golink: 一条命令在远端执行,退出码原样透传,从不提示、不分配终端。 goscp: 用 SCP 协议上传,-files 一个参数同时接受文件和目录。 - 纯 Go,唯一依赖 golang.org/x/crypto,可离线交叉编译到六个桌面平台 - internal/sshx 统一建连、认证(password + keyboard-interactive)与主机密钥策略 - smoketest/ 自带迷你 sshd,57 项端到端测试(golink 38 + goscp 19)
260 lines
7.5 KiB
Go
260 lines
7.5 KiB
Go
// 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)
|
|
}
|
|
}
|