sshtools: 非交互 SSH 工具集(golink / goscp)
golink: 一条命令在远端执行,退出码原样透传,从不提示、不分配终端。 goscp: 用 SCP 协议上传,-files 一个参数同时接受文件和目录。 - 纯 Go,唯一依赖 golang.org/x/crypto,可离线交叉编译到六个桌面平台 - internal/sshx 统一建连、认证(password + keyboard-interactive)与主机密钥策略 - smoketest/ 自带迷你 sshd,57 项端到端测试(golink 38 + goscp 19)
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
// Package cli holds small command-line helpers shared by the sshtools binaries.
|
||||
package cli
|
||||
|
||||
import "strings"
|
||||
|
||||
// Split reorders a raw argument list so that Go's flag package can parse it
|
||||
// even when flags follow positional arguments, as in:
|
||||
//
|
||||
// golink user@host -p secret -port 22 -command uptime
|
||||
//
|
||||
// flag.Parse stops at the first non-flag argument, so that raw order would
|
||||
// otherwise leave every flag unset and silently do the wrong thing. Split walks
|
||||
// the arguments once and collects flags (together with the value of any flag
|
||||
// named in takesValue) into flags, and everything else into positional.
|
||||
//
|
||||
// The caller is expected to concatenate the results:
|
||||
//
|
||||
// flags, positional := cli.Split(os.Args[1:], valueFlags)
|
||||
// fs.Parse(append(flags, positional...))
|
||||
//
|
||||
// A literal "--" ends flag processing; everything after it is positional.
|
||||
func Split(args []string, takesValue map[string]bool) (flags, positional []string) {
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if arg == "--" {
|
||||
positional = append(positional, args[i+1:]...)
|
||||
break
|
||||
}
|
||||
if !isFlag(arg) {
|
||||
positional = append(positional, arg)
|
||||
continue
|
||||
}
|
||||
flags = append(flags, arg)
|
||||
name := strings.TrimLeft(arg, "-")
|
||||
if strings.ContainsRune(name, '=') {
|
||||
continue // -port=22 carries its value inline
|
||||
}
|
||||
if takesValue[name] && i+1 < len(args) {
|
||||
i++
|
||||
flags = append(flags, args[i])
|
||||
}
|
||||
}
|
||||
return flags, positional
|
||||
}
|
||||
|
||||
// isFlag reports whether arg looks like a flag rather than a positional value.
|
||||
// A bare "-" and negative numbers are treated as positional.
|
||||
func isFlag(arg string) bool {
|
||||
if len(arg) < 2 || arg[0] != '-' {
|
||||
return false
|
||||
}
|
||||
return arg[1] < '0' || arg[1] > '9'
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
// Package scp implements the client half of the SCP wire protocol on top of an
|
||||
// SSH session.
|
||||
//
|
||||
// It exists so the sshtools binaries stay dependency free: the protocol is
|
||||
// small, and every normal OpenSSH server still ships the scp binary that the
|
||||
// remote side of this protocol talks to.
|
||||
package scp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// Options describes one upload batch.
|
||||
type Options struct {
|
||||
// Dest is the remote directory that receives the paths. Empty means the
|
||||
// remote user's home directory.
|
||||
Dest string
|
||||
// Paths are local files and directories, uploaded in order. Each one is
|
||||
// handled according to what it is: a file is sent as one entry, a directory
|
||||
// is walked recursively. Callers do not have to distinguish them.
|
||||
Paths []string
|
||||
// Logf reports each entry as it is sent. nil disables reporting.
|
||||
Logf func(format string, args ...any)
|
||||
}
|
||||
|
||||
// Stats summarises an upload.
|
||||
type Stats struct {
|
||||
Files int
|
||||
Dirs int
|
||||
Bytes int64
|
||||
}
|
||||
|
||||
// Upload copies opts.Paths into opts.Dest on the remote host. The session must
|
||||
// not have been started yet.
|
||||
func Upload(sess *ssh.Session, opts Options) (Stats, error) {
|
||||
var stats Stats
|
||||
dest := opts.Dest
|
||||
if dest == "" {
|
||||
dest = "."
|
||||
}
|
||||
|
||||
stdin, err := sess.StdinPipe()
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stdout, err := sess.StdoutPipe()
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
sess.Stderr = os.Stderr
|
||||
|
||||
// -d tells scp the target is a directory, -r that directories may arrive;
|
||||
// mkdir -p makes sure the target exists in the first place.
|
||||
cmd := "mkdir -p " + shellQuote(dest) + " && scp -t -r -d " + shellQuote(dest)
|
||||
if err := sess.Start(cmd); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
|
||||
writer := bufio.NewWriter(stdin)
|
||||
reader := bufio.NewReader(stdout)
|
||||
if err := readAck(reader); err != nil {
|
||||
stdin.Close()
|
||||
return stats, err
|
||||
}
|
||||
|
||||
for _, path := range opts.Paths {
|
||||
if err := sendPath(writer, reader, path, opts, &stats); err != nil {
|
||||
stdin.Close()
|
||||
return stats, err
|
||||
}
|
||||
}
|
||||
if err := writer.Flush(); err != nil {
|
||||
stdin.Close()
|
||||
return stats, err
|
||||
}
|
||||
if err := stdin.Close(); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
return stats, sess.Wait()
|
||||
}
|
||||
|
||||
func sendPath(w *bufio.Writer, r *bufio.Reader, local string, opts Options, stats *Stats) error {
|
||||
// Stat, not Lstat: a symlink named on the command line is uploaded as the
|
||||
// file or directory it points at.
|
||||
info, err := os.Stat(local)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case info.IsDir():
|
||||
return sendDir(w, r, local, opts, stats)
|
||||
case info.Mode().IsRegular():
|
||||
return sendFile(w, r, local, info.Size(), opts, stats)
|
||||
default:
|
||||
return fmt.Errorf("%s: not a regular file or directory", local)
|
||||
}
|
||||
}
|
||||
|
||||
func sendFile(w *bufio.Writer, r *bufio.Reader, local string, size int64, opts Options, stats *Stats) error {
|
||||
name := filepath.Base(local)
|
||||
if err := checkName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
mode := fs.FileMode(0644)
|
||||
if isExecutable(local) {
|
||||
mode = 0755
|
||||
}
|
||||
f, err := os.Open(local)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := fmt.Fprintf(w, "C%04o %d %s\n", mode, size, name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := readAck(r); err != nil {
|
||||
return fmt.Errorf("%s: %w", local, err)
|
||||
}
|
||||
if _, err := io.CopyN(w, f, size); err != nil {
|
||||
return fmt.Errorf("%s: %w", local, err)
|
||||
}
|
||||
if err := w.WriteByte(0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := readAck(r); err != nil {
|
||||
return fmt.Errorf("%s: %w", local, err)
|
||||
}
|
||||
stats.Files++
|
||||
stats.Bytes += size
|
||||
if opts.Logf != nil {
|
||||
opts.Logf("%-40s %s", name, humanBytes(size))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendDir(w *bufio.Writer, r *bufio.Reader, local string, opts Options, stats *Stats) error {
|
||||
name := filepath.Base(local)
|
||||
if err := checkName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "D%04o 0 %s\n", 0755, name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := readAck(r); err != nil {
|
||||
return fmt.Errorf("%s: %w", local, err)
|
||||
}
|
||||
stats.Dirs++
|
||||
if opts.Logf != nil {
|
||||
opts.Logf("%-40s dir", name+"/")
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(local)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
child := filepath.Join(local, entry.Name())
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case info.IsDir():
|
||||
if err := sendDir(w, r, child, opts, stats); err != nil {
|
||||
return err
|
||||
}
|
||||
case info.Mode().IsRegular():
|
||||
if err := sendFile(w, r, child, info.Size(), opts, stats); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "goscp: skipping %s (not a regular file)\n", child)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := w.WriteString("E\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := readAck(r); err != nil {
|
||||
return fmt.Errorf("%s: %w", local, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readAck consumes the single-byte reply that follows every SCP message.
|
||||
func readAck(r *bufio.Reader) error {
|
||||
b, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return fmt.Errorf("scp: %w", err)
|
||||
}
|
||||
switch b {
|
||||
case 0:
|
||||
return nil
|
||||
case 1, 2:
|
||||
msg, _ := r.ReadString('\n')
|
||||
msg = strings.TrimSpace(msg)
|
||||
if msg == "" {
|
||||
msg = "unspecified error"
|
||||
}
|
||||
return errors.New("scp: " + msg)
|
||||
default:
|
||||
return fmt.Errorf("scp: unexpected reply %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
func checkName(name string) error {
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return fmt.Errorf("invalid file name %q", name)
|
||||
}
|
||||
if strings.ContainsAny(name, "\n\r/") {
|
||||
return fmt.Errorf("unsupported file name %q", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isExecutable(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode().Perm()&0111 != 0
|
||||
}
|
||||
|
||||
// shellQuote wraps s for the remote shell, escaping embedded single quotes.
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
func humanBytes(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for m := n / unit; m >= unit; m /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// Package sshx implements the connection, authentication and host key policy
|
||||
// layer shared by the sshtools command-line clients.
|
||||
//
|
||||
// It is non-interactive by design: credentials are supplied up front and host
|
||||
// keys are either accepted, pinned or verified against a known_hosts file.
|
||||
// Nothing here ever prompts.
|
||||
package sshx
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
)
|
||||
|
||||
// DefaultPort is used when neither the target nor the port flag sets one.
|
||||
const DefaultPort = 22
|
||||
|
||||
// Target is a parsed [user@]host[:port] destination.
|
||||
type Target struct {
|
||||
User string
|
||||
Host string
|
||||
Port int
|
||||
}
|
||||
|
||||
// Addr returns the host:port address to dial.
|
||||
func (t Target) Addr() string {
|
||||
return net.JoinHostPort(t.Host, strconv.Itoa(t.Port))
|
||||
}
|
||||
|
||||
// ParseTarget parses raw as [user@]host[:port]. A non-empty user (from -l)
|
||||
// overrides the user embedded in raw. A port embedded in raw overrides port.
|
||||
func ParseTarget(raw, user string, port int) (Target, error) {
|
||||
t := Target{Port: port}
|
||||
if t.Port == 0 {
|
||||
t.Port = DefaultPort
|
||||
}
|
||||
host := strings.TrimSpace(raw)
|
||||
if host == "" {
|
||||
return t, errors.New("missing target: expected [user@]host")
|
||||
}
|
||||
if i := strings.LastIndex(host, "@"); i >= 0 {
|
||||
if user == "" {
|
||||
user = host[:i]
|
||||
}
|
||||
host = host[i+1:]
|
||||
}
|
||||
if user == "" {
|
||||
return t, fmt.Errorf("target %q has no user: use user@host or -l user", raw)
|
||||
}
|
||||
t.User = user
|
||||
|
||||
if strings.HasPrefix(host, "[") {
|
||||
end := strings.Index(host, "]")
|
||||
if end < 0 {
|
||||
return t, fmt.Errorf("invalid target %q: missing ]", raw)
|
||||
}
|
||||
rest := host[end+1:]
|
||||
host = host[1:end]
|
||||
switch {
|
||||
case rest == "":
|
||||
case strings.HasPrefix(rest, ":"):
|
||||
p, err := parsePort(rest[1:], raw)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
t.Port = p
|
||||
default:
|
||||
return t, fmt.Errorf("invalid target %q: unexpected %q after ]", raw, rest)
|
||||
}
|
||||
} else if i := strings.LastIndex(host, ":"); i >= 0 {
|
||||
if strings.Count(host, ":") > 1 {
|
||||
return t, fmt.Errorf("invalid target %q: bracket IPv6 literals like [::1]:22", raw)
|
||||
}
|
||||
p, err := parsePort(host[i+1:], raw)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
t.Port = p
|
||||
host = host[:i]
|
||||
}
|
||||
if host == "" {
|
||||
return t, fmt.Errorf("invalid target %q: missing host", raw)
|
||||
}
|
||||
if t.Port < 1 || t.Port > 65535 {
|
||||
return t, fmt.Errorf("invalid port %d", t.Port)
|
||||
}
|
||||
t.Host = host
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func parsePort(s, raw string) (int, error) {
|
||||
p, err := strconv.Atoi(s)
|
||||
if err != nil || p < 1 || p > 65535 {
|
||||
return 0, fmt.Errorf("invalid target %q: bad port %q", raw, s)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Config describes a connection attempt. Zero values are usable: an empty
|
||||
// KnownHosts and HostKey means host keys are accepted without verification.
|
||||
type Config struct {
|
||||
Target Target
|
||||
Password string
|
||||
KeyFile string
|
||||
KnownHosts string
|
||||
HostKey string
|
||||
Timeout time.Duration
|
||||
Logf func(format string, args ...any)
|
||||
}
|
||||
|
||||
func (c Config) logf(format string, args ...any) {
|
||||
if c.Logf != nil {
|
||||
c.Logf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Dial connects to the target, completes the SSH handshake and authenticates.
|
||||
// The caller owns the returned client and must close it. Dial never prompts.
|
||||
func Dial(cfg Config) (*ssh.Client, error) {
|
||||
auth, err := authMethods(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hostKey, err := hostKeyCallback(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addr := cfg.Target.Addr()
|
||||
clientConfig := &ssh.ClientConfig{
|
||||
User: cfg.Target.User,
|
||||
Auth: auth,
|
||||
HostKeyCallback: hostKey,
|
||||
Timeout: cfg.Timeout,
|
||||
}
|
||||
cfg.logf("connecting to %s", addr)
|
||||
dialer := net.Dialer{Timeout: cfg.Timeout}
|
||||
conn, err := dialer.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to %s: %w", addr, err)
|
||||
}
|
||||
if cfg.Timeout > 0 {
|
||||
// Bound the handshake and authentication too, then clear the deadline:
|
||||
// the remote command itself may legitimately run for hours.
|
||||
_ = conn.SetDeadline(time.Now().Add(cfg.Timeout))
|
||||
}
|
||||
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, clientConfig)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("ssh handshake with %s: %w", addr, err)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
cfg.logf("authenticated as %s", cfg.Target.User)
|
||||
return ssh.NewClient(sshConn, chans, reqs), nil
|
||||
}
|
||||
|
||||
// authMethods builds the credential list. Public keys come first; the password
|
||||
// is offered both as the "password" method and as keyboard-interactive, because
|
||||
// plenty of servers (PAM setups in particular) only offer the latter.
|
||||
func authMethods(cfg Config) ([]ssh.AuthMethod, error) {
|
||||
var methods []ssh.AuthMethod
|
||||
if cfg.KeyFile != "" {
|
||||
signer, err := loadSigner(cfg.KeyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
methods = append(methods, ssh.PublicKeys(signer))
|
||||
}
|
||||
if cfg.Password != "" {
|
||||
password := cfg.Password
|
||||
methods = append(methods, ssh.Password(password))
|
||||
methods = append(methods, ssh.KeyboardInteractive(
|
||||
func(name, instruction string, questions []string, echos []bool) ([]string, error) {
|
||||
answers := make([]string, len(questions))
|
||||
for i := range answers {
|
||||
answers[i] = password
|
||||
}
|
||||
return answers, nil
|
||||
}))
|
||||
}
|
||||
if len(methods) == 0 {
|
||||
return nil, errors.New("no credentials: pass -p <password> (or set GOLINK_PASSWORD) or -i <keyfile>")
|
||||
}
|
||||
return methods, nil
|
||||
}
|
||||
|
||||
func loadSigner(path string) (ssh.Signer, error) {
|
||||
pemBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read key %s: %w", path, err)
|
||||
}
|
||||
signer, err := ssh.ParsePrivateKey(pemBytes)
|
||||
if err != nil {
|
||||
var missing *ssh.PassphraseMissingError
|
||||
if errors.As(err, &missing) {
|
||||
return nil, fmt.Errorf("key %s is passphrase protected: golink is non-interactive, decrypt it with 'ssh-keygen -p' or use -p", path)
|
||||
}
|
||||
return nil, fmt.Errorf("parse key %s: %w", path, err)
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
// hostKeyCallback selects the host key policy: pinned fingerprint, known_hosts
|
||||
// file, or accept anything (the default, since a prompt is not an option).
|
||||
func hostKeyCallback(cfg Config) (ssh.HostKeyCallback, error) {
|
||||
var cb ssh.HostKeyCallback
|
||||
switch {
|
||||
case cfg.HostKey != "":
|
||||
cb = pinnedHostKey(cfg.HostKey)
|
||||
case cfg.KnownHosts != "":
|
||||
verify, err := knownhosts.New(cfg.KnownHosts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load known_hosts %s: %w", cfg.KnownHosts, err)
|
||||
}
|
||||
cb = func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
err := verify(hostname, remote, key)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var keyErr *knownhosts.KeyError
|
||||
if errors.As(err, &keyErr) {
|
||||
if len(keyErr.Want) == 0 {
|
||||
return fmt.Errorf("host %s is not in %s: add it with 'ssh-keyscan -p %d %s' or pass -hostkey", hostname, cfg.KnownHosts, cfg.Target.Port, cfg.Target.Host)
|
||||
}
|
||||
return fmt.Errorf("host key mismatch for %s against %s (possible MITM): %w", hostname, cfg.KnownHosts, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
default:
|
||||
cb = ssh.InsecureIgnoreHostKey()
|
||||
}
|
||||
if cfg.Logf != nil {
|
||||
inner := cb
|
||||
cb = func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
cfg.logf("host key %s (%s)", ssh.FingerprintSHA256(key), key.Type())
|
||||
return inner(hostname, remote, key)
|
||||
}
|
||||
}
|
||||
return cb, nil
|
||||
}
|
||||
|
||||
func pinnedHostKey(want string) ssh.HostKeyCallback {
|
||||
want = strings.TrimSpace(want)
|
||||
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
sha256 := ssh.FingerprintSHA256(key)
|
||||
if strings.EqualFold(want, sha256) ||
|
||||
strings.EqualFold(want, strings.TrimPrefix(sha256, "SHA256:")) ||
|
||||
strings.EqualFold(want, ssh.FingerprintLegacyMD5(key)) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("host key for %s does not match -hostkey:\n offered: %s\n wanted: %s", hostname, sha256, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user