e92b18197c
P01 — Load-bearing replacement for v0.8 mTLS transport (R-001). Transport (internal/sshpush/transport.go, REQ-073): - Transport struct with sync.Map connection pool (reuse *ssh.Client per peer). - Exec with context timeout (10s default) + retry (100ms x2 cap 5s max 5 attempts, +/-25% jitter — same backoff as v0.8 transport/retry.go). - ReadFile, WriteFile (atomic heredoc + mv), Close. - sshDialer + sshSession seams for testability. TOFU host-key verification reuses proxmox.TOFUHostKeyCallback. security.Flock for known_hosts. Fan-out (internal/sshpush/fanout.go): - ExecAll, WriteAll with errgroup + SetLimit semaphore (default 8 per I-B-001). Per-peer errors collected, don't cancel the group. Idempotency (internal/sshpush/idempotency.go, C-18): - WriteFileIdempotent: SHA-256 compare via ssh sha256sum; skip if content matches (written=false). Content-addressed idempotency replaces the v0.8 X-Orca-Idempotency-Key header (C-18 capability map). Tests: in-process fake SSH server (ssh.NewServerConn NoClientAuth ed25519) for e2e + interface seams for pure-logic. 93.0% coverage. 20 packages pass. ---ci--- project: orca phase: P01 milestone: v0.9 status: execute ---/ci---
484 lines
15 KiB
Go
484 lines
15 KiB
Go
package sshpush
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"math/rand"
|
||
"net"
|
||
"os"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"golang.org/x/crypto/ssh"
|
||
|
||
"git.cloudinit.dev/coreci/orca/internal/proxmox"
|
||
)
|
||
|
||
// Default timeouts and retry parameters (REQ-073, I-B-001).
|
||
const (
|
||
// ExecTimeout is the default per-exec timeout for a single SSH
|
||
// command (I-B-001).
|
||
ExecTimeout = 10 * time.Second
|
||
// SCPTimeout is the default per-SCP timeout for a single file
|
||
// transfer (I-B-001).
|
||
SCPTimeout = 30 * time.Second
|
||
// DialTimeout is the default SSH dial timeout.
|
||
DialTimeout = 15 * time.Second
|
||
// RetryInitial is the first backoff interval (v0.8 transport/retry.go).
|
||
RetryInitial = 100 * time.Millisecond
|
||
// RetryMax is the cap on backoff between attempts.
|
||
RetryMax = 5 * time.Second
|
||
// RetryMaxAttempts is the total attempt count (including the first).
|
||
RetryMaxAttempts = 5
|
||
)
|
||
|
||
// Sentinel errors. ErrTransient marks a transient failure worth
|
||
// retrying; ErrPermanent marks a non-retryable failure (auth, host-key
|
||
// mismatch, validation). These mirror the v0.8 transport sentinels
|
||
// (reimplemented here since internal/transport is not imported).
|
||
var (
|
||
ErrTransient = errors.New("sshpush: transient error")
|
||
ErrPermanent = errors.New("sshpush: permanent error")
|
||
ErrNotConnected = errors.New("sshpush: not connected")
|
||
)
|
||
|
||
// Transport is the SSH-push transport (REQ-073). It reuses one
|
||
// *ssh.Client per peer across multiple operations within a single CLI
|
||
// invocation (I-B-001). The zero value is NOT usable; construct one with
|
||
// NewTransport.
|
||
type Transport struct {
|
||
// pool caches *ssh.Client per peer address ("host:port").
|
||
pool sync.Map
|
||
// keyPath is the SSH private key path (Ed25519, D-037).
|
||
keyPath string
|
||
// knownHostsPath is the v0.9 known_hosts path (paths.KnownHostsPath()
|
||
// = ClusterDir()/known_hosts). It is stored for the v0.10-P14 migration
|
||
// when proxmox.TOFUHostKeyCallback will accept a path parameter; today
|
||
// the callback reads certpaths.KnownHostsPath() (the v0.8 flat layout)
|
||
// directly, so this field is not yet read by dial(). Tests set
|
||
// $ORCA_HOME so certpaths.KnownHostsPath() resolves under the temp dir.
|
||
knownHostsPath string
|
||
// user is the remote SSH user (default "orca", D-037).
|
||
user string
|
||
// signer is the parsed SSH private key signer, set lazily on first
|
||
// dial.
|
||
signer ssh.Signer
|
||
signErr error
|
||
// signerOnce guards signer initialization.
|
||
signerOnce sync.Once
|
||
|
||
// dialer is the SSH dialer. Tests override it to inject a mock
|
||
// server. The default uses ssh.DialContext via the context-aware
|
||
// wrapper.
|
||
dialer sshDialer
|
||
|
||
// sessionFactory returns a new session for a given client. Tests
|
||
// override it to inject mock sessions without a real *ssh.Client.
|
||
// When nil, the default (*ssh.Client).NewSession is used.
|
||
sessionFactory func(*ssh.Client) (sshSession, error)
|
||
|
||
// mu guards the closed flag (pool iteration is sync.Map.Range).
|
||
closed bool
|
||
mu sync.Mutex
|
||
}
|
||
|
||
// sshSession is the minimal *ssh.Session surface the transport uses.
|
||
// It lets tests substitute a mock without a real SSH server.
|
||
type sshSession interface {
|
||
CombinedOutput(cmd string) ([]byte, error)
|
||
Close() error
|
||
}
|
||
|
||
// sshDialer is the SSH dialer interface (mirrors proxmox.sshDialerType).
|
||
// The default uses ssh.Dial; tests inject mocks that return a fake
|
||
// *ssh.Client or an error.
|
||
type sshDialer interface {
|
||
DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error)
|
||
}
|
||
|
||
// defaultSSHDialer wraps ssh.Dial with a context-aware connect timeout.
|
||
type defaultSSHDialer struct{}
|
||
|
||
func (defaultSSHDialer) DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
|
||
d := net.Dialer{Timeout: config.Timeout}
|
||
if d.Timeout == 0 {
|
||
d.Timeout = DialTimeout
|
||
}
|
||
conn, err := d.DialContext(ctx, network, addr)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
|
||
if err != nil {
|
||
_ = conn.Close()
|
||
return nil, err
|
||
}
|
||
return ssh.NewClient(sshConn, chans, reqs), nil
|
||
}
|
||
|
||
// NewTransport returns a Transport configured with the given SSH
|
||
// private key path and known_hosts path. The known_hosts path is the v0.9
|
||
// location (paths.KnownHostsPath); it is stored for the v0.10-P14
|
||
// migration when the TOFU callback will accept a path parameter. Today
|
||
// dial() delegates host-key verification to proxmox.TOFUHostKeyCallback,
|
||
// which reads certpaths.KnownHostsPath() (the v0.8 flat layout under
|
||
// $ORCA_HOME) directly — so callers must ensure $ORCA_HOME points at the
|
||
// cluster root (the CLI sets this up). The remote user defaults to
|
||
// "orca" (D-037); override with SetUser. The dialer defaults to the
|
||
// real ssh.Dial-based dialer; tests call SetDialer to inject a mock.
|
||
func NewTransport(keyPath, knownHostsPath string) *Transport {
|
||
return &Transport{
|
||
keyPath: keyPath,
|
||
knownHostsPath: knownHostsPath,
|
||
user: "orca",
|
||
dialer: defaultSSHDialer{},
|
||
}
|
||
}
|
||
|
||
// SetUser overrides the remote SSH user (default "orca").
|
||
func (t *Transport) SetUser(user string) {
|
||
if user != "" {
|
||
t.user = user
|
||
}
|
||
}
|
||
|
||
// SetDialer overrides the SSH dialer (for tests).
|
||
func (t *Transport) SetDialer(d sshDialer) {
|
||
if d != nil {
|
||
t.dialer = d
|
||
}
|
||
}
|
||
|
||
// SetSessionFactory overrides the session factory (for tests). The
|
||
// factory is called per-exec/write/read to obtain a fresh session; it
|
||
// must close the session when the test mock is done, or the transport
|
||
// will call Close on the returned session.
|
||
func (t *Transport) SetSessionFactory(f func(*ssh.Client) (sshSession, error)) {
|
||
t.sessionFactory = f
|
||
}
|
||
|
||
// dial returns the cached *ssh.Client for peer, dialing and caching on
|
||
// first use (I-B-001 connection pooling). Returns an error if the dial
|
||
// fails or the transport is closed.
|
||
func (t *Transport) dial(peer string) (*ssh.Client, error) {
|
||
t.mu.Lock()
|
||
if t.closed {
|
||
t.mu.Unlock()
|
||
return nil, ErrPermanent
|
||
}
|
||
t.mu.Unlock()
|
||
if c, ok := t.pool.Load(peer); ok {
|
||
return c.(*ssh.Client), nil
|
||
}
|
||
// Lazily parse the private key signer (once across all dials).
|
||
t.signerOnce.Do(func() {
|
||
keyBytes, err := os.ReadFile(t.keyPath)
|
||
if err != nil {
|
||
t.signErr = fmt.Errorf("sshpush: read key %s: %w", t.keyPath, err)
|
||
return
|
||
}
|
||
s, err := ssh.ParsePrivateKey(keyBytes)
|
||
if err != nil {
|
||
t.signErr = fmt.Errorf("sshpush: parse key: %w", err)
|
||
return
|
||
}
|
||
t.signer = s
|
||
})
|
||
if t.signErr != nil {
|
||
return nil, t.signErr
|
||
}
|
||
// Host-key verification reuses the v0.8 TOFU wrapper (D-035). The
|
||
// known_hosts file is flock-protected inside the callback on
|
||
// first-connect capture, so we do NOT re-lock here.
|
||
cb, err := proxmox.TOFUHostKeyCallback(peer, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("sshpush: host-key callback: %w", err)
|
||
}
|
||
config := &ssh.ClientConfig{
|
||
User: t.user,
|
||
Auth: []ssh.AuthMethod{ssh.PublicKeys(t.signer)},
|
||
HostKeyCallback: cb,
|
||
Timeout: DialTimeout,
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), DialTimeout)
|
||
defer cancel()
|
||
client, err := t.dialer.DialContext(ctx, "tcp", peer, config)
|
||
if err != nil {
|
||
return nil, classifyDialErr(err)
|
||
}
|
||
// Race: two goroutines dialing the same peer concurrently both
|
||
// create a client. Last-wins; the loser is closed. This is rare
|
||
// (dial is rare and the pool hit short-circuits) and harmless.
|
||
if existing, loaded := t.pool.LoadOrStore(peer, client); loaded {
|
||
_ = client.Close()
|
||
return existing.(*ssh.Client), nil
|
||
}
|
||
return client, nil
|
||
}
|
||
|
||
// Exec runs cmd on peer over SSH and returns its combined output. The
|
||
// default per-exec timeout is ExecTimeout (I-B-001); override by
|
||
// passing a context with a shorter deadline. Transient failures are
|
||
// retried with exponential backoff (100ms ×2, cap 5s, max 5 attempts —
|
||
// the v0.8 transport/retry.go pattern, reimplemented here).
|
||
func (t *Transport) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) {
|
||
return t.execWithRetry(ctx, peer, cmd, true)
|
||
}
|
||
|
||
// execWithRetry runs the exec with retry. exec is treated as
|
||
// idempotent (read-only) for retry purposes; the idempotency helpers
|
||
// (WriteFileIdempotent) handle writes.
|
||
func (t *Transport) execWithRetry(ctx context.Context, peer string, cmd string, idempotent bool) ([]byte, error) {
|
||
var lastErr error
|
||
for attempt := 1; attempt <= RetryMaxAttempts; attempt++ {
|
||
if err := ctx.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
out, err := t.execOnce(ctx, peer, cmd)
|
||
if err == nil {
|
||
return out, nil
|
||
}
|
||
if errors.Is(err, ErrPermanent) {
|
||
return nil, err
|
||
}
|
||
lastErr = err
|
||
if attempt == RetryMaxAttempts {
|
||
break
|
||
}
|
||
if !isTransient(err) {
|
||
return nil, err
|
||
}
|
||
wait := backoff(RetryInitial, RetryMax, attempt)
|
||
timer := time.NewTimer(wait)
|
||
select {
|
||
case <-ctx.Done():
|
||
timer.Stop()
|
||
return nil, ctx.Err()
|
||
case <-timer.C:
|
||
}
|
||
}
|
||
return nil, lastErr
|
||
}
|
||
|
||
// execOnce runs the command a single time against peer.
|
||
func (t *Transport) execOnce(ctx context.Context, peer string, cmd string) ([]byte, error) {
|
||
client, err := t.dial(peer)
|
||
if err != nil {
|
||
return nil, classifyDialErr(err)
|
||
}
|
||
sess, err := t.newSession(client)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("sshpush: new session: %w", err)
|
||
}
|
||
defer sess.Close()
|
||
type result struct {
|
||
out []byte
|
||
err error
|
||
}
|
||
ch := make(chan result, 1)
|
||
go func() {
|
||
out, err := sess.CombinedOutput(cmd)
|
||
ch <- result{out, err}
|
||
}()
|
||
timeout := ExecTimeout
|
||
if dl, ok := ctx.Deadline(); ok {
|
||
if remaining := time.Until(dl); remaining > 0 && remaining < timeout {
|
||
timeout = remaining
|
||
}
|
||
}
|
||
select {
|
||
case <-ctx.Done():
|
||
return nil, ctx.Err()
|
||
case <-time.After(timeout):
|
||
return nil, fmt.Errorf("sshpush: exec timeout after %s: %w", timeout, ErrTransient)
|
||
case r := <-ch:
|
||
if r.err != nil {
|
||
return r.out, classifyExecErr(r.err)
|
||
}
|
||
return r.out, nil
|
||
}
|
||
}
|
||
|
||
// newSession returns a session for client, using the override factory
|
||
// when set (tests), otherwise the real *ssh.Client.NewSession.
|
||
func (t *Transport) newSession(client *ssh.Client) (sshSession, error) {
|
||
if t.sessionFactory != nil {
|
||
return t.sessionFactory(client)
|
||
}
|
||
s, err := client.NewSession()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &realSession{Session: s}, nil
|
||
}
|
||
|
||
// realSession wraps *ssh.Session to satisfy the sshSession interface.
|
||
type realSession struct {
|
||
*ssh.Session
|
||
}
|
||
|
||
func (r *realSession) CombinedOutput(cmd string) ([]byte, error) {
|
||
return r.Session.CombinedOutput(cmd)
|
||
}
|
||
|
||
// WriteFile SCPs content to peer:path atomically (write-to-tmp + mv,
|
||
// REQ-074). The default per-SCP timeout is SCPTimeout (I-B-001).
|
||
// Idempotency: if the file already exists with the same SHA-256, the
|
||
// write is skipped (C-18). Use WriteFileIdempotent for the explicit
|
||
// written/skipped result.
|
||
func (t *Transport) WriteFile(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) error {
|
||
_, err := t.WriteFileIdempotent(ctx, peer, path, content, mode)
|
||
return err
|
||
}
|
||
|
||
// ReadFile reads the file at peer:path via SSH cat.
|
||
func (t *Transport) ReadFile(ctx context.Context, peer string, path string) ([]byte, error) {
|
||
cmd := fmt.Sprintf("cat %s", shellQuote(path))
|
||
out, err := t.Exec(ctx, peer, cmd)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// Close closes all pooled SSH clients (REQ-073). Safe to call
|
||
// multiple times; subsequent calls are no-ops.
|
||
func (t *Transport) Close() error {
|
||
t.mu.Lock()
|
||
if t.closed {
|
||
t.mu.Unlock()
|
||
return nil
|
||
}
|
||
t.closed = true
|
||
t.mu.Unlock()
|
||
var firstErr error
|
||
t.pool.Range(func(key, value any) bool {
|
||
if c, ok := value.(*ssh.Client); ok {
|
||
if err := c.Close(); err != nil && firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
}
|
||
t.pool.Delete(key)
|
||
return true
|
||
})
|
||
return firstErr
|
||
}
|
||
|
||
// backoff returns the wait duration for the n-th attempt (1-indexed).
|
||
// Formula: min(Initial * 2^(n-1), Max), with up to 25% jitter (matches
|
||
// v0.8 transport/retry.go).
|
||
func backoff(initial, max time.Duration, n int) time.Duration {
|
||
d := initial
|
||
for i := 1; i < n; i++ {
|
||
d *= 2
|
||
if d > max {
|
||
d = max
|
||
break
|
||
}
|
||
}
|
||
if d <= 0 {
|
||
return 0
|
||
}
|
||
jitter := time.Duration(rand.Int63n(int64(d) / 2))
|
||
d = d - d/4 + jitter
|
||
if d < 0 {
|
||
d = 0
|
||
}
|
||
return d
|
||
}
|
||
|
||
// isTransient reports whether err looks like a transient failure worth
|
||
// retrying (mirrors v0.8 transport.IsTransient, reimplemented here).
|
||
func isTransient(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
if errors.Is(err, ErrTransient) {
|
||
return true
|
||
}
|
||
if errors.Is(err, ErrPermanent) {
|
||
return false
|
||
}
|
||
s := err.Error()
|
||
for _, sub := range []string{
|
||
"connection refused", "i/o timeout", "EOF",
|
||
"no such host", "connection reset", "timeout",
|
||
"deadline exceeded", "temporarily unavailable",
|
||
} {
|
||
if strings.Contains(s, sub) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// classifyDialErr converts a raw ssh.Dial error into a transport error
|
||
// (transient vs permanent). Auth failures and host-key mismatches are
|
||
// permanent; everything else is transient.
|
||
func classifyDialErr(err error) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
s := err.Error()
|
||
if strings.Contains(s, "unable to authenticate") || strings.Contains(s, "handshake failed") {
|
||
return fmt.Errorf("%w: %v", ErrPermanent, err)
|
||
}
|
||
if strings.Contains(s, "host key") && strings.Contains(s, "mismatch") {
|
||
return fmt.Errorf("%w: %v", ErrPermanent, err)
|
||
}
|
||
if strings.Contains(s, "knownhosts") {
|
||
return fmt.Errorf("%w: %v", ErrPermanent, err)
|
||
}
|
||
return fmt.Errorf("%w: %v", ErrTransient, err)
|
||
}
|
||
|
||
// classifyExecErr converts a raw session exec error into a transport
|
||
// error. Non-zero exit codes are NOT transient (the command ran; the
|
||
// failure is logical, not network). Session-creation failures and
|
||
// network-level errors are transient.
|
||
func classifyExecErr(err error) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
var exitErr *ssh.ExitError
|
||
if errors.As(err, &exitErr) {
|
||
return fmt.Errorf("%w: exit %d", ErrPermanent, exitErr.ExitStatus())
|
||
}
|
||
s := err.Error()
|
||
for _, sub := range []string{"EOF", "session closed", "channel closed"} {
|
||
if strings.Contains(s, sub) {
|
||
return fmt.Errorf("%w: %v", ErrTransient, err)
|
||
}
|
||
}
|
||
return fmt.Errorf("%w: %v", ErrPermanent, err)
|
||
}
|
||
|
||
// shellQuote single-quotes a path for safe shell interpolation. It
|
||
// escapes embedded single-quotes via the standard '\” idiom.
|
||
func shellQuote(s string) string {
|
||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||
}
|
||
|
||
// remoteSHA256 returns the SHA-256 of the file at peer:path via SSH
|
||
// `sha256sum`, or ("", error) if the file is missing or the command
|
||
// fails. The returned hash is the hex digest (lowercase, no filename).
|
||
func (t *Transport) remoteSHA256(ctx context.Context, peer string, path string) (string, error) {
|
||
cmd := fmt.Sprintf("sha256sum %s 2>/dev/null", shellQuote(path))
|
||
out, err := t.execWithRetry(ctx, peer, cmd, true)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
out = bytes.TrimSpace(out)
|
||
if len(out) == 0 {
|
||
return "", nil
|
||
}
|
||
fields := strings.Fields(string(out))
|
||
if len(fields) == 0 {
|
||
return "", nil
|
||
}
|
||
return fields[0], nil
|
||
}
|