Initial push
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
// Package sshx provides the SSH transport used to drive a target host that has
|
||||
// nothing installed but sshd and the docker CLI.
|
||||
package sshx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/agent"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
)
|
||||
|
||||
// AuthMethod selects how to authenticate against the target host.
|
||||
type AuthMethod string
|
||||
|
||||
const (
|
||||
AuthPassword AuthMethod = "password"
|
||||
AuthKey AuthMethod = "key"
|
||||
AuthAgent AuthMethod = "agent"
|
||||
)
|
||||
|
||||
// Config describes one target host.
|
||||
type Config struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
|
||||
Auth AuthMethod `json:"auth"`
|
||||
// Password is used with AuthPassword, and as the passphrase fallback when
|
||||
// a key is encrypted.
|
||||
Password string `json:"password,omitempty"`
|
||||
// PrivateKey holds PEM key material for AuthKey. PrivateKeyPath is read
|
||||
// from disk instead when PrivateKey is empty.
|
||||
PrivateKey string `json:"privateKey,omitempty"`
|
||||
PrivateKeyPath string `json:"privateKeyPath,omitempty"`
|
||||
Passphrase string `json:"passphrase,omitempty"`
|
||||
|
||||
// Sudo prefixes every docker command with sudo -n, for hosts where the
|
||||
// login user is not in the docker group.
|
||||
Sudo bool `json:"sudo"`
|
||||
// DockerCmd overrides the docker binary, e.g. "podman" or an absolute path.
|
||||
DockerCmd string `json:"dockerCmd,omitempty"`
|
||||
|
||||
// SaveSecrets persists the password and key material to the connection
|
||||
// store. When false the secrets live only for the current process.
|
||||
SaveSecrets bool `json:"saveSecrets"`
|
||||
|
||||
// Timeout is the TCP/handshake timeout. Zero means 20s.
|
||||
Timeout time.Duration `json:"-"`
|
||||
}
|
||||
|
||||
func (c Config) addr() string {
|
||||
port := c.Port
|
||||
if port == 0 {
|
||||
port = 22
|
||||
}
|
||||
return net.JoinHostPort(c.Host, strconv.Itoa(port))
|
||||
}
|
||||
|
||||
// Client is a live SSH connection to a target host.
|
||||
type Client struct {
|
||||
cfg Config
|
||||
conn *ssh.Client
|
||||
}
|
||||
|
||||
// HostKeyError reports that the target's host key is unknown or has changed.
|
||||
// The UI shows the fingerprint and asks the operator to confirm before the key
|
||||
// is written to the known-hosts store.
|
||||
type HostKeyError struct {
|
||||
Host string
|
||||
Fingerprint string
|
||||
KeyType string
|
||||
Changed bool // true when a different key was already trusted
|
||||
}
|
||||
|
||||
func (e *HostKeyError) Error() string {
|
||||
if e.Changed {
|
||||
return fmt.Sprintf("host key for %s CHANGED (%s %s); refusing to connect", e.Host, e.KeyType, e.Fingerprint)
|
||||
}
|
||||
return fmt.Sprintf("host key for %s is not trusted yet (%s %s)", e.Host, e.KeyType, e.Fingerprint)
|
||||
}
|
||||
|
||||
// Dial opens a connection, verifying the host key against the known-hosts
|
||||
// store. It returns a *HostKeyError when the operator has to make a trust
|
||||
// decision first.
|
||||
func Dial(ctx context.Context, cfg Config, hk *KnownHosts) (*Client, error) {
|
||||
auths, err := authMethods(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 20 * time.Second
|
||||
}
|
||||
|
||||
var hkErr *HostKeyError
|
||||
clientCfg := &ssh.ClientConfig{
|
||||
User: cfg.User,
|
||||
Auth: auths,
|
||||
Timeout: timeout,
|
||||
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
err := hk.Check(hostname, remote, key)
|
||||
var he *HostKeyError
|
||||
if errors.As(err, &he) {
|
||||
hkErr = he
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
d := net.Dialer{Timeout: timeout}
|
||||
rawConn, err := d.DialContext(ctx, "tcp", cfg.addr())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to %s: %w", cfg.addr(), err)
|
||||
}
|
||||
sshConn, chans, reqs, err := ssh.NewClientConn(rawConn, cfg.addr(), clientCfg)
|
||||
if err != nil {
|
||||
rawConn.Close()
|
||||
if hkErr != nil {
|
||||
return nil, hkErr
|
||||
}
|
||||
return nil, fmt.Errorf("ssh handshake with %s: %w", cfg.addr(), err)
|
||||
}
|
||||
return &Client{cfg: cfg, conn: ssh.NewClient(sshConn, chans, reqs)}, nil
|
||||
}
|
||||
|
||||
// Close terminates the connection.
|
||||
func (c *Client) Close() error { return c.conn.Close() }
|
||||
|
||||
// Config returns the configuration this client was dialled with.
|
||||
func (c *Client) Config() Config { return c.cfg }
|
||||
|
||||
// Result is the outcome of a remote command.
|
||||
type Result struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
ExitCode int
|
||||
}
|
||||
|
||||
// Run executes a command line on the remote host and collects its output.
|
||||
// The command is passed to the remote login shell, so it may contain pipes.
|
||||
func (c *Client) Run(ctx context.Context, cmdline string) (*Result, error) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code, err := c.run(ctx, cmdline, nil, &stdout, &stderr)
|
||||
res := &Result{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: code}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// RunCheck executes a command and turns a non-zero exit into an error that
|
||||
// carries the remote stderr, which is what the operator needs to see.
|
||||
func (c *Client) RunCheck(ctx context.Context, cmdline string) (string, error) {
|
||||
res, err := c.Run(ctx, cmdline)
|
||||
if err != nil {
|
||||
return res.Stdout, err
|
||||
}
|
||||
if res.ExitCode != 0 {
|
||||
msg := strings.TrimSpace(res.Stderr)
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(res.Stdout)
|
||||
}
|
||||
return res.Stdout, fmt.Errorf("remote command failed (exit %d): %s", res.ExitCode, msg)
|
||||
}
|
||||
return res.Stdout, nil
|
||||
}
|
||||
|
||||
// Stream executes a command, feeding it stdin and writing its stdout to out.
|
||||
// This is how bulk data crosses the wire: the tar stream produced locally is
|
||||
// piped straight into a remote `docker cp` without ever touching disk.
|
||||
func (c *Client) Stream(ctx context.Context, cmdline string, stdin io.Reader, stdout io.Writer) (*Result, error) {
|
||||
var stderr bytes.Buffer
|
||||
if stdout == nil {
|
||||
stdout = io.Discard
|
||||
}
|
||||
code, err := c.run(ctx, cmdline, stdin, stdout, &stderr)
|
||||
res := &Result{Stderr: stderr.String(), ExitCode: code}
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if code != 0 {
|
||||
return res, fmt.Errorf("remote command failed (exit %d): %s", code, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (c *Client) run(ctx context.Context, cmdline string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
|
||||
sess, err := c.conn.NewSession()
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("open ssh session: %w", err)
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
sess.Stdout = stdout
|
||||
sess.Stderr = stderr
|
||||
if stdin != nil {
|
||||
sess.Stdin = stdin
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- sess.Run(cmdline) }()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = sess.Signal(ssh.SIGTERM)
|
||||
_ = sess.Close()
|
||||
return -1, ctx.Err()
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
return 0, nil
|
||||
}
|
||||
var ee *ssh.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
return ee.ExitStatus(), nil
|
||||
}
|
||||
return -1, err
|
||||
}
|
||||
}
|
||||
|
||||
func authMethods(cfg Config) ([]ssh.AuthMethod, error) {
|
||||
var methods []ssh.AuthMethod
|
||||
switch cfg.Auth {
|
||||
case AuthPassword:
|
||||
if cfg.Password == "" {
|
||||
return nil, errors.New("password authentication selected but no password supplied")
|
||||
}
|
||||
methods = append(methods,
|
||||
ssh.Password(cfg.Password),
|
||||
// Many sshd setups answer with keyboard-interactive instead of the
|
||||
// plain password method.
|
||||
ssh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) {
|
||||
answers := make([]string, len(questions))
|
||||
for i := range answers {
|
||||
answers[i] = cfg.Password
|
||||
}
|
||||
return answers, nil
|
||||
}),
|
||||
)
|
||||
case AuthKey:
|
||||
pem := []byte(cfg.PrivateKey)
|
||||
if len(pem) == 0 {
|
||||
if cfg.PrivateKeyPath == "" {
|
||||
return nil, errors.New("key authentication selected but no key supplied")
|
||||
}
|
||||
b, err := os.ReadFile(cfg.PrivateKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read private key: %w", err)
|
||||
}
|
||||
pem = b
|
||||
}
|
||||
var signer ssh.Signer
|
||||
var err error
|
||||
passphrase := cfg.Passphrase
|
||||
if passphrase == "" {
|
||||
passphrase = cfg.Password
|
||||
}
|
||||
if passphrase != "" {
|
||||
signer, err = ssh.ParsePrivateKeyWithPassphrase(pem, []byte(passphrase))
|
||||
} else {
|
||||
signer, err = ssh.ParsePrivateKey(pem)
|
||||
}
|
||||
if err != nil {
|
||||
var pm *ssh.PassphraseMissingError
|
||||
if errors.As(err, &pm) {
|
||||
return nil, errors.New("private key is encrypted; supply the passphrase")
|
||||
}
|
||||
return nil, fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
methods = append(methods, ssh.PublicKeys(signer))
|
||||
case AuthAgent:
|
||||
sock := os.Getenv("SSH_AUTH_SOCK")
|
||||
if sock == "" {
|
||||
return nil, errors.New("agent authentication selected but SSH_AUTH_SOCK is not set")
|
||||
}
|
||||
conn, err := net.Dial("unix", sock)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to ssh agent: %w", err)
|
||||
}
|
||||
methods = append(methods, ssh.PublicKeysCallback(agent.NewClient(conn).Signers))
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown auth method %q", cfg.Auth)
|
||||
}
|
||||
return methods, nil
|
||||
}
|
||||
|
||||
// Fingerprint renders a public key the way OpenSSH shows it.
|
||||
func Fingerprint(key ssh.PublicKey) string { return ssh.FingerprintSHA256(key) }
|
||||
|
||||
var _ = knownhosts.Normalize
|
||||
Reference in New Issue
Block a user