Initial push

This commit is contained in:
2026-08-11 09:00:01 +02:00
commit fe8b354adc
54 changed files with 12640 additions and 0 deletions
+298
View File
@@ -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
+261
View File
@@ -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
}
+231
View File
@@ -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
}