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
|
||||
@@ -0,0 +1,261 @@
|
||||
package sshx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/arescom/docker-migrate/internal/spec"
|
||||
)
|
||||
|
||||
// RemoteDocker drives the docker CLI on a target host over SSH. It assumes
|
||||
// nothing beyond sshd, docker and a POSIX shell; gzip is used only when
|
||||
// compression is enabled and is probed for first.
|
||||
type RemoteDocker struct {
|
||||
c *Client
|
||||
binary string
|
||||
sudo bool
|
||||
}
|
||||
|
||||
// NewRemoteDocker wraps a connection.
|
||||
func NewRemoteDocker(c *Client) *RemoteDocker {
|
||||
cfg := c.Config()
|
||||
bin := cfg.DockerCmd
|
||||
if bin == "" {
|
||||
bin = "docker"
|
||||
}
|
||||
return &RemoteDocker{c: c, binary: bin, sudo: cfg.Sudo}
|
||||
}
|
||||
|
||||
// Cmd renders a docker invocation as a shell command line, correctly quoted.
|
||||
func (r *RemoteDocker) Cmd(args ...string) string {
|
||||
var b strings.Builder
|
||||
if r.sudo {
|
||||
b.WriteString("sudo -n ")
|
||||
}
|
||||
b.WriteString(spec.ShellQuote(r.binary))
|
||||
if len(args) > 0 {
|
||||
b.WriteString(" ")
|
||||
b.WriteString(spec.ShellQuoteAll(args))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Run executes a docker command and returns its stdout, failing on non-zero.
|
||||
func (r *RemoteDocker) Run(ctx context.Context, args ...string) (string, error) {
|
||||
return r.c.RunCheck(ctx, r.Cmd(args...))
|
||||
}
|
||||
|
||||
// Try executes a docker command and reports success without treating a
|
||||
// non-zero exit as an error. Used for existence probes.
|
||||
func (r *RemoteDocker) Try(ctx context.Context, args ...string) (string, bool, error) {
|
||||
res, err := r.c.Run(ctx, r.Cmd(args...))
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return res.Stdout, res.ExitCode == 0, nil
|
||||
}
|
||||
|
||||
// Feed pipes a local reader into a docker command's stdin. When decompress is
|
||||
// set the remote side runs `gzip -dc` ahead of docker, so the bytes on the
|
||||
// wire are compressed.
|
||||
func (r *RemoteDocker) Feed(ctx context.Context, src io.Reader, decompress bool, args ...string) error {
|
||||
cmd := r.Cmd(args...)
|
||||
if decompress {
|
||||
cmd = "gzip -dc | " + cmd
|
||||
}
|
||||
_, err := r.c.Stream(ctx, cmd, src, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Preflight is what the target host was found to support.
|
||||
type Preflight struct {
|
||||
DockerVersion string `json:"dockerVersion"`
|
||||
ServerVersion string `json:"serverVersion"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
HasGzip bool `json:"hasGzip"`
|
||||
DiskFreeBytes int64 `json:"diskFreeBytes"`
|
||||
DockerRoot string `json:"dockerRoot"`
|
||||
Problems []string `json:"problems,omitempty"`
|
||||
}
|
||||
|
||||
// Preflight checks everything the migration depends on before any data moves.
|
||||
func (r *RemoteDocker) Preflight(ctx context.Context) (*Preflight, error) {
|
||||
p := &Preflight{}
|
||||
|
||||
out, ok, err := r.Try(ctx, "version", "--format", "{{.Client.Version}}|{{.Server.Version}}|{{.Server.Os}}|{{.Server.Arch}}")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
res, _ := r.c.Run(ctx, r.Cmd("version"))
|
||||
msg := strings.TrimSpace(res.Stderr)
|
||||
if strings.Contains(msg, "permission denied") {
|
||||
p.Problems = append(p.Problems,
|
||||
"the login user cannot talk to the docker daemon; add it to the docker group or enable sudo for this connection")
|
||||
} else if msg != "" {
|
||||
p.Problems = append(p.Problems, "docker is not usable on the target: "+firstLine(msg))
|
||||
} else {
|
||||
p.Problems = append(p.Problems, "docker is not installed or not on PATH on the target")
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
parts := strings.Split(strings.TrimSpace(out), "|")
|
||||
if len(parts) == 4 {
|
||||
p.DockerVersion, p.ServerVersion, p.OS, p.Arch = parts[0], parts[1], parts[2], parts[3]
|
||||
}
|
||||
if p.OS != "" && p.OS != "linux" {
|
||||
p.Problems = append(p.Problems, "target daemon runs "+p.OS+" containers; only linux targets are supported")
|
||||
}
|
||||
|
||||
res, err := r.c.Run(ctx, "command -v gzip >/dev/null 2>&1")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.HasGzip = res.ExitCode == 0
|
||||
if !p.HasGzip {
|
||||
p.Problems = append(p.Problems, "gzip is missing on the target; transfers will run uncompressed")
|
||||
}
|
||||
|
||||
if root, err2 := r.Run(ctx, "info", "--format", "{{.DockerRootDir}}"); err2 == nil {
|
||||
p.DockerRoot = strings.TrimSpace(root)
|
||||
if p.DockerRoot != "" {
|
||||
// POSIX df in 1K blocks; the fourth column is available space.
|
||||
cmd := "df -Pk " + spec.ShellQuote(p.DockerRoot) + " | awk 'NR==2 {print $4}'"
|
||||
if dres, derr := r.c.Run(ctx, cmd); derr == nil && dres.ExitCode == 0 {
|
||||
var kb int64
|
||||
if _, serr := fmt.Sscanf(strings.TrimSpace(dres.Stdout), "%d", &kb); serr == nil {
|
||||
p.DiskFreeBytes = kb * 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// TargetContainer is a container that already exists on the target host.
|
||||
type TargetContainer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
State string `json:"state"`
|
||||
Status string `json:"status"`
|
||||
Ports string `json:"ports"`
|
||||
}
|
||||
|
||||
// TargetInventory is the current state of the target host, used to show it
|
||||
// side by side with the source and to detect name conflicts up front.
|
||||
type TargetInventory struct {
|
||||
Host string `json:"host"`
|
||||
Containers []TargetContainer `json:"containers"`
|
||||
Volumes []string `json:"volumes"`
|
||||
Networks []string `json:"networks"`
|
||||
Preflight *Preflight `json:"preflight"`
|
||||
}
|
||||
|
||||
// Inventory reads the target host's containers, volumes and networks.
|
||||
func (r *RemoteDocker) Inventory(ctx context.Context) (*TargetInventory, error) {
|
||||
pre, err := r.Preflight(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inv := &TargetInventory{Preflight: pre}
|
||||
if pre.ServerVersion == "" {
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
if host, err := r.c.RunCheck(ctx, "hostname"); err == nil {
|
||||
inv.Host = strings.TrimSpace(host)
|
||||
}
|
||||
|
||||
out, err := r.Run(ctx, "ps", "-a", "--no-trunc", "--format", "{{json .}}")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var raw struct {
|
||||
ID, Names, Image, State, Status, Ports string
|
||||
}
|
||||
if json.Unmarshal([]byte(line), &raw) != nil {
|
||||
continue
|
||||
}
|
||||
// A container attached to several networks is reported with a
|
||||
// comma-separated name list; the first is its real name.
|
||||
name := raw.Names
|
||||
if i := strings.Index(name, ","); i >= 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
inv.Containers = append(inv.Containers, TargetContainer{
|
||||
ID: raw.ID, Name: name, Image: raw.Image,
|
||||
State: raw.State, Status: raw.Status, Ports: raw.Ports,
|
||||
})
|
||||
}
|
||||
|
||||
if out, err := r.Run(ctx, "volume", "ls", "--format", "{{.Name}}"); err == nil {
|
||||
inv.Volumes = nonEmptyLines(out)
|
||||
}
|
||||
if out, err := r.Run(ctx, "network", "ls", "--format", "{{.Name}}"); err == nil {
|
||||
inv.Networks = nonEmptyLines(out)
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// Exists reports whether an object of the given kind is present on the target.
|
||||
func (r *RemoteDocker) Exists(ctx context.Context, kind, name string) (bool, error) {
|
||||
var args []string
|
||||
switch kind {
|
||||
case "container":
|
||||
args = []string{"container", "inspect", name}
|
||||
case "volume":
|
||||
args = []string{"volume", "inspect", name}
|
||||
case "network":
|
||||
args = []string{"network", "inspect", name}
|
||||
case "image":
|
||||
args = []string{"image", "inspect", name}
|
||||
default:
|
||||
return false, fmt.Errorf("unknown object kind %q", kind)
|
||||
}
|
||||
res, err := r.c.Run(ctx, r.Cmd(args...)+" >/dev/null 2>&1")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return res.ExitCode == 0, nil
|
||||
}
|
||||
|
||||
// MkdirAll creates a directory on the target host, for bind mount sources that
|
||||
// need to exist with the right ownership before the container starts.
|
||||
func (r *RemoteDocker) MkdirAll(ctx context.Context, path string) error {
|
||||
cmd := "mkdir -p " + spec.ShellQuote(path)
|
||||
if r.sudo {
|
||||
cmd = "sudo -n " + cmd
|
||||
}
|
||||
_, err := r.c.RunCheck(ctx, cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
// Client exposes the underlying SSH connection for raw shell work.
|
||||
func (r *RemoteDocker) Client() *Client { return r.c }
|
||||
|
||||
func nonEmptyLines(s string) []string {
|
||||
var out []string
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package sshx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
)
|
||||
|
||||
// KnownHosts is the trust store for target host keys. It behaves like OpenSSH:
|
||||
// an unknown key is refused until the operator confirms the fingerprint, and a
|
||||
// changed key is refused outright.
|
||||
type KnownHosts struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewKnownHosts opens (and creates if needed) the store at path.
|
||||
func NewKnownHosts(path string) (*KnownHosts, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create key store directory: %w", err)
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open known hosts file: %w", err)
|
||||
}
|
||||
f.Close()
|
||||
return &KnownHosts{path: path}, nil
|
||||
}
|
||||
|
||||
// Path returns the on-disk location of the store.
|
||||
func (k *KnownHosts) Path() string { return k.path }
|
||||
|
||||
// Check implements the ssh.HostKeyCallback contract.
|
||||
func (k *KnownHosts) Check(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
|
||||
cb, err := knownhosts.New(k.path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read known hosts: %w", err)
|
||||
}
|
||||
err = cb(hostname, remote, key)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var keyErr *knownhosts.KeyError
|
||||
if errors.As(err, &keyErr) {
|
||||
return &HostKeyError{
|
||||
Host: hostname,
|
||||
Fingerprint: ssh.FingerprintSHA256(key),
|
||||
KeyType: key.Type(),
|
||||
Changed: len(keyErr.Want) > 0,
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Trust records a host key so later connections succeed.
|
||||
func (k *KnownHosts) Trust(hostname string, key ssh.PublicKey) error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
|
||||
f, err := os.OpenFile(k.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open known hosts for write: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
line := knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key)
|
||||
if _, err := f.WriteString(line + "\n"); err != nil {
|
||||
return fmt.Errorf("record host key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Forget removes every entry for a host, so a changed key can be re-approved.
|
||||
func (k *KnownHosts) Forget(hostname string) error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
|
||||
b, err := os.ReadFile(k.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
want := knownhosts.Normalize(hostname)
|
||||
var kept []byte
|
||||
for _, line := range splitLines(b) {
|
||||
if len(line) == 0 || line[0] == '#' {
|
||||
kept = append(kept, line...)
|
||||
kept = append(kept, '\n')
|
||||
continue
|
||||
}
|
||||
_, hosts, _, _, _, perr := ssh.ParseKnownHosts(append(line, '\n'))
|
||||
if perr == nil && containsHost(hosts, want) {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, line...)
|
||||
kept = append(kept, '\n')
|
||||
}
|
||||
return os.WriteFile(k.path, kept, 0o600)
|
||||
}
|
||||
|
||||
// HostKeyInfo is the fingerprint presented by a host, shown to the operator
|
||||
// before they decide to trust it.
|
||||
type HostKeyInfo struct {
|
||||
Host string `json:"host"`
|
||||
KeyType string `json:"keyType"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Trusted bool `json:"trusted"`
|
||||
Changed bool `json:"changed"`
|
||||
}
|
||||
|
||||
// Probe opens a TCP connection just far enough to read the host key, without
|
||||
// authenticating. Used by the "check fingerprint" step in the UI.
|
||||
func Probe(ctx context.Context, cfg Config, hk *KnownHosts) (*HostKeyInfo, error) {
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 15 * time.Second
|
||||
}
|
||||
var captured ssh.PublicKey
|
||||
clientCfg := &ssh.ClientConfig{
|
||||
User: cfg.User,
|
||||
Timeout: timeout,
|
||||
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
|
||||
captured = key
|
||||
// Stop the handshake here: reading the key is all this needs.
|
||||
return errProbeDone
|
||||
},
|
||||
}
|
||||
d := net.Dialer{Timeout: timeout}
|
||||
conn, err := d.DialContext(ctx, "tcp", cfg.addr())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to %s: %w", cfg.addr(), err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_, _, _, err = ssh.NewClientConn(conn, cfg.addr(), clientCfg)
|
||||
if captured == nil {
|
||||
return nil, fmt.Errorf("read host key from %s: %w", cfg.addr(), err)
|
||||
}
|
||||
|
||||
info := &HostKeyInfo{
|
||||
Host: cfg.addr(),
|
||||
KeyType: captured.Type(),
|
||||
Fingerprint: ssh.FingerprintSHA256(captured),
|
||||
}
|
||||
switch checkErr := hk.Check(cfg.addr(), conn.RemoteAddr(), captured).(type) {
|
||||
case nil:
|
||||
info.Trusted = true
|
||||
case *HostKeyError:
|
||||
info.Changed = checkErr.Changed
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// TrustFromProbe re-reads the host key and stores it. Taking the key from a
|
||||
// fresh handshake rather than from client-supplied input means the UI can only
|
||||
// approve a fingerprint it actually saw.
|
||||
func TrustFromProbe(ctx context.Context, cfg Config, hk *KnownHosts, expectFingerprint string) error {
|
||||
info, err := Probe(ctx, cfg, hk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if expectFingerprint != "" && info.Fingerprint != expectFingerprint {
|
||||
return fmt.Errorf("host key changed between check and approval (%s vs %s); aborting",
|
||||
expectFingerprint, info.Fingerprint)
|
||||
}
|
||||
var captured ssh.PublicKey
|
||||
clientCfg := &ssh.ClientConfig{
|
||||
User: cfg.User,
|
||||
Timeout: 15 * time.Second,
|
||||
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
|
||||
captured = key
|
||||
return errProbeDone
|
||||
},
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", cfg.addr(), 15*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
_, _, _, _ = ssh.NewClientConn(conn, cfg.addr(), clientCfg)
|
||||
if captured == nil {
|
||||
return errors.New("could not read host key")
|
||||
}
|
||||
if ssh.FingerprintSHA256(captured) != info.Fingerprint {
|
||||
return errors.New("host key is unstable; aborting")
|
||||
}
|
||||
if info.Changed {
|
||||
if err := hk.Forget(cfg.addr()); err != nil {
|
||||
return fmt.Errorf("drop previous host key: %w", err)
|
||||
}
|
||||
}
|
||||
return hk.Trust(cfg.addr(), captured)
|
||||
}
|
||||
|
||||
var errProbeDone = errors.New("host key captured")
|
||||
|
||||
func splitLines(b []byte) [][]byte {
|
||||
var out [][]byte
|
||||
start := 0
|
||||
for i := 0; i < len(b); i++ {
|
||||
if b[i] == '\n' {
|
||||
line := b[start:i]
|
||||
if n := len(line); n > 0 && line[n-1] == '\r' {
|
||||
line = line[:n-1]
|
||||
}
|
||||
out = append(out, line)
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(b) {
|
||||
out = append(out, b[start:])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containsHost(hosts []string, want string) bool {
|
||||
for _, h := range hosts {
|
||||
if knownhosts.Normalize(h) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user