Files
orca/internal/sshpush/transport_test.go
T
Jon Chery e92b18197c feat(P01): SSH-push transport layer — connection pool, retry, fan-out, idempotent writes (REQ-073)
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---
2026-08-05 17:35:11 +00:00

665 lines
17 KiB
Go

package sshpush
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
// --- fakeSSHServer: a minimal in-process SSH server for hermetic tests.
type fakeSSHServer struct {
listener net.Listener
config *ssh.ServerConfig
done chan struct{}
mu sync.Mutex
files map[string]string
hostKey ssh.Signer
cmdCount int64
execDelay time.Duration
}
func newFakeSSHServer(t *testing.T) *fakeSSHServer {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519 gen: %v", err)
}
signer, err := ssh.NewSignerFromKey(priv)
if err != nil {
t.Fatalf("ssh signer: %v", err)
}
config := &ssh.ServerConfig{
NoClientAuth: true,
}
config.AddHostKey(signer)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
srv := &fakeSSHServer{
listener: ln,
config: config,
done: make(chan struct{}),
files: make(map[string]string),
hostKey: signer,
}
go srv.serve()
return srv
}
func (s *fakeSSHServer) addr() string { return s.listener.Addr().String() }
func (s *fakeSSHServer) hostPublicKey() ssh.PublicKey { return s.hostKey.PublicKey() }
func (s *fakeSSHServer) close() {
_ = s.listener.Close()
<-s.done
}
func (s *fakeSSHServer) setExecDelay(d time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
s.execDelay = d
}
func (s *fakeSSHServer) serve() {
for {
conn, err := s.listener.Accept()
if err != nil {
close(s.done)
return
}
go s.handle(conn)
}
}
func (s *fakeSSHServer) handle(netConn net.Conn) {
defer netConn.Close()
_, chans, reqs, err := ssh.NewServerConn(netConn, s.config)
if err != nil {
return
}
go ssh.DiscardRequests(reqs)
for newChan := range chans {
if newChan.ChannelType() != "session" {
newChan.Reject(ssh.UnknownChannelType, "only session")
continue
}
go s.handleSession(newChan)
}
}
func (s *fakeSSHServer) handleSession(newChan ssh.NewChannel) {
ch, reqs, err := newChan.Accept()
if err != nil {
return
}
defer ch.Close()
for req := range reqs {
if req.Type != "exec" {
req.Reply(false, nil)
continue
}
var execReq struct{ Command string }
if err := ssh.Unmarshal(req.Payload, &execReq); err != nil {
req.Reply(false, nil)
continue
}
req.Reply(true, nil)
atomic.AddInt64(&s.cmdCount, 1)
s.mu.Lock()
delay := s.execDelay
s.mu.Unlock()
if delay > 0 {
time.Sleep(delay)
}
out, code := s.runCommand(execReq.Command)
_, _ = ch.Write(out)
_, _ = ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Code uint32 }{uint32(code)}))
_ = ch.Close()
return
}
}
// runCommand implements the minimal command surface the transport uses:
// echo (for exec tests), sha256sum (for idempotency), cat (read), and the
// heredoc-based write (cat > tmp <<EOF ... EOF && chmod ... && mv ...).
func (s *fakeSSHServer) runCommand(cmd string) ([]byte, int) {
s.mu.Lock()
defer s.mu.Unlock()
trimmed := strings.TrimSpace(cmd)
switch {
case trimmed == "echo hello":
return []byte("hello\n"), 0
case strings.HasPrefix(trimmed, "sha256sum "):
// Format: sha256sum '/path' 2>/dev/null
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "sha256sum "))
rest = strings.TrimSuffix(rest, " 2>/dev/null")
rest = strings.TrimSpace(rest)
path := unquote(rest)
content, ok := s.files[path]
if !ok {
// `2>/dev/null` swallows the error; sha256sum exits 1 but
// stderr is suppressed. The transport treats empty output as
// "file missing" (no hash), so return ("", 0).
return []byte(""), 0
}
sum := sha256HexStr([]byte(content))
return []byte(sum + " " + path + "\n"), 0
case strings.HasPrefix(trimmed, "cat '"):
path := unquote(strings.TrimPrefix(trimmed, "cat "))
content, ok := s.files[path]
if !ok {
return []byte("cat: " + path + ": No such file or directory\n"), 1
}
return []byte(content), 0
case strings.HasPrefix(trimmed, "mkdir -p ") && strings.Contains(trimmed, "cat >"):
return s.handleWrite(trimmed)
default:
return []byte("sh: command not found\n"), 127
}
}
// handleWrite parses the heredoc write command produced by writeFile.
// Command format:
//
// mkdir -p '<dir>' && cat > '<tmp>' <<'ORCA_PUSH_EOF_a1b2c3'
// <content>
// ORCA_PUSH_EOF_a1b2c3
// chmod <mode> '<tmp>' && mv -f '<tmp>' '<path>'
func (s *fakeSSHServer) handleWrite(cmd string) ([]byte, int) {
const eof = "ORCA_PUSH_EOF_a1b2c3"
// Find the opening heredoc line: ... <<'EOF'\n
openerIdx := strings.Index(cmd, "<<'"+eof+"'")
if openerIdx < 0 {
return []byte("sh: no heredoc opener\n"), 1
}
// Body starts after the opener line's newline.
rest := cmd[openerIdx+len("<<'"+eof+"'"):]
nl := strings.Index(rest, "\n")
if nl < 0 {
return []byte("sh: no body start\n"), 1
}
body := rest[nl+1:]
// Body ends at the closing EOF marker on its own line.
closeIdx := strings.Index(body, "\n"+eof+"\n")
if closeIdx < 0 {
// Maybe EOF is at the end without trailing newline.
closeIdx = strings.Index(body, "\n"+eof)
if closeIdx < 0 {
return []byte("sh: no heredoc close\n"), 1
}
body = body[:closeIdx]
} else {
body = body[:closeIdx]
}
// Find the mv target: last quoted arg of "mv -f 'tmp' 'path'".
mvIdx := strings.LastIndex(cmd, "mv -f ")
if mvIdx < 0 {
return []byte("sh: no mv\n"), 1
}
tail := cmd[mvIdx+len("mv -f "):]
parts := splitQuoted(tail)
if len(parts) < 2 {
return []byte("sh: bad mv args\n"), 1
}
target := parts[1]
s.files[target] = body
return nil, 0
}
// splitQuoted splits a string of the form "'a' 'b'" into ["a","b"].
func splitQuoted(s string) []string {
var out []string
var cur strings.Builder
in := false
for _, r := range s {
if r == '\'' {
if in {
out = append(out, cur.String())
cur.Reset()
}
in = !in
continue
}
if in {
cur.WriteRune(r)
}
}
return out
}
func unquote(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 2 && s[0] == '\'' && s[len(s)-1] == '\'' {
return s[1 : len(s)-1]
}
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
return s[1 : len(s)-1]
}
return s
}
// sha256HexStr is a test-local copy of the sha256Hex helper.
func sha256HexStr(b []byte) string {
sum := sha256.Sum256(b)
return fmt.Sprintf("%x", sum[:])
}
// --- test helpers for transport setup ---
// setupORCAHome creates a temp ORCA_HOME with an empty known_hosts (at
// the v0.8 flat location $ORCA_HOME/known_hosts, which is where
// proxmox.TOFUHostKeyCallback reads via certpaths.KnownHostsPath()) and a
// generated Ed25519 SSH key, returns the key path.
func setupORCAHome(t *testing.T) (keyPath string) {
t.Helper()
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
// certpaths.KnownHostsPath() = paths.Root()/known_hosts = $ORCA_HOME/known_hosts.
knownHosts := filepath.Join(dir, "known_hosts")
if err := os.WriteFile(knownHosts, []byte{}, 0o600); err != nil {
t.Fatalf("create known_hosts: %v", err)
}
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519 gen: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
keyPath = filepath.Join(dir, "orca_ssh_key")
if err := os.WriteFile(keyPath, pemBytes, 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
return keyPath
}
// realTransport returns a Transport wired to use the real SSH dialer
// against a fake SSH server, with the server's host key pre-populated in
// known_hosts (so the TOFU callback matches on first dial — no first-
// connect write race in tests).
func realTransport(t *testing.T, srv *fakeSSHServer) *Transport {
t.Helper()
keyPath := setupORCAHome(t)
tr := NewTransport(keyPath, "")
tr.SetUser("root")
addr := srv.addr()
line := knownhosts.Line([]string{knownhosts.Normalize(addr)}, srv.hostPublicKey())
home := os.Getenv("ORCA_HOME")
kh := filepath.Join(home, "known_hosts")
if err := os.WriteFile(kh, []byte(line+"\n"), 0o600); err != nil {
t.Fatalf("pre-pop known_hosts: %v", err)
}
return tr
}
// --- mock dialer + mock session for pure-logic tests (no real SSH) ---
type mockDialer struct {
client *ssh.Client
err error
calls int
}
func (m *mockDialer) DialContext(ctx context.Context, network, addr string, cfg *ssh.ClientConfig) (*ssh.Client, error) {
m.calls++
if m.err != nil {
return nil, m.err
}
return m.client, nil
}
type mockSession struct {
out []byte
err error
cmd string
}
func (m *mockSession) CombinedOutput(cmd string) ([]byte, error) {
m.cmd = cmd
return m.out, m.err
}
func (m *mockSession) Close() error { return nil }
// --- tests ---
func TestNewTransport_Defaults(t *testing.T) {
tr := NewTransport("/tmp/key", "/tmp/kh")
if tr.keyPath != "/tmp/key" {
t.Errorf("keyPath = %q", tr.keyPath)
}
if tr.user != "orca" {
t.Errorf("default user = %q, want orca", tr.user)
}
if tr.dialer == nil {
t.Error("dialer is nil")
}
}
func TestSetUser(t *testing.T) {
tr := NewTransport("/tmp/key", "/tmp/kh")
tr.SetUser("root")
if tr.user != "root" {
t.Errorf("user = %q, want root", tr.user)
}
tr.SetUser("")
if tr.user != "root" {
t.Errorf("user = %q, want root", tr.user)
}
}
func TestBackoff(t *testing.T) {
// Without jitter, attempt 1 -> 100ms, 2 -> 200ms, ... up to 5s cap.
// The jittered result is in [d/4, 3d/4) where d is the capped base,
// so for high attempts the result can reach 3*d/4 < 1.5*d. We bound
// the upper end at 2x the cap to allow jitter headroom.
for _, tc := range []struct {
attempt int
max time.Duration
}{
{1, 200 * time.Millisecond},
{2, 400 * time.Millisecond},
{6, 2 * RetryMax},
{10, 2 * RetryMax},
} {
got := backoff(RetryInitial, RetryMax, tc.attempt)
if got < 0 || got > tc.max {
t.Errorf("backoff(%d) = %s, want in [0, %s]", tc.attempt, got, tc.max)
}
}
}
func TestIsTransient(t *testing.T) {
if isTransient(nil) {
t.Error("nil should not be transient")
}
if !isTransient(ErrTransient) {
t.Error("ErrTransient should be transient")
}
if isTransient(ErrPermanent) {
t.Error("ErrPermanent should not be transient")
}
if !isTransient(errors.New("connection refused")) {
t.Error("connection refused should be transient")
}
if !isTransient(errors.New("i/o timeout")) {
t.Error("i/o timeout should be transient")
}
if isTransient(errors.New("some other error")) {
t.Error("unknown error should not be transient")
}
}
func TestClassifyDialErr(t *testing.T) {
if got := classifyDialErr(nil); got != nil {
t.Errorf("nil -> nil, got %v", got)
}
perm := classifyDialErr(errors.New("ssh: unable to authenticate"))
if !errors.Is(perm, ErrPermanent) {
t.Errorf("auth failure should be permanent, got %v", perm)
}
perm2 := classifyDialErr(errors.New("host key mismatch"))
if !errors.Is(perm2, ErrPermanent) {
t.Errorf("host key mismatch should be permanent, got %v", perm2)
}
trans := classifyDialErr(errors.New("connection refused"))
if !errors.Is(trans, ErrTransient) {
t.Errorf("connection refused should be transient, got %v", trans)
}
}
func TestClassifyExecErr(t *testing.T) {
exitErr := ssh.ExitError{}
perm := classifyExecErr(&exitErr)
if !errors.Is(perm, ErrPermanent) {
t.Errorf("ExitError should be permanent, got %v", perm)
}
trans := classifyExecErr(errors.New("session closed"))
if !errors.Is(trans, ErrTransient) {
t.Errorf("session closed should be transient, got %v", trans)
}
}
func TestShellQuote(t *testing.T) {
got := shellQuote("/etc/orca/foo.conf")
if got != "'/etc/orca/foo.conf'" {
t.Errorf("shellQuote = %q", got)
}
got = shellQuote("it's a path")
if got != "'it'\\''s a path'" {
t.Errorf("shellQuote with quote = %q", got)
}
}
func TestTransport_ExecSuccess_RealSSH(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
tr := realTransport(t, srv)
defer tr.Close()
out, err := tr.Exec(context.Background(), srv.addr(), "echo hello")
if err != nil {
t.Fatalf("Exec: %v", err)
}
if strings.TrimSpace(string(out)) != "hello" {
t.Errorf("out = %q, want hello", out)
}
}
func TestTransport_ExecRetry_TransientFailure(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
tr := realTransport(t, srv)
defer tr.Close()
var calls int32
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
n := atomic.AddInt32(&calls, 1)
if n < 3 {
return &mockSession{err: errors.New("EOF")}, nil
}
return &mockSession{out: []byte("ok\n")}, nil
})
client, err := tr.dial(srv.addr())
if err != nil {
t.Fatalf("dial: %v", err)
}
tr.pool.Store(srv.addr(), client)
out, err := tr.Exec(context.Background(), srv.addr(), "echo ok")
if err != nil {
t.Fatalf("Exec: %v (calls=%d)", err, atomic.LoadInt32(&calls))
}
if string(out) != "ok\n" {
t.Errorf("out = %q, want ok\\n", out)
}
if got := atomic.LoadInt32(&calls); got < 3 {
t.Errorf("calls = %d, want >= 3 (retried)", got)
}
}
func TestTransport_ExecPermanentError_NoRetry(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
tr := realTransport(t, srv)
defer tr.Close()
var calls int32
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
atomic.AddInt32(&calls, 1)
return &mockSession{err: &ssh.ExitError{}}, nil
})
client, err := tr.dial(srv.addr())
if err != nil {
t.Fatalf("dial: %v", err)
}
tr.pool.Store(srv.addr(), client)
_, err = tr.Exec(context.Background(), srv.addr(), "exit 1")
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrPermanent) {
t.Errorf("err should be permanent, got %v", err)
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("calls = %d, want 1 (no retry on permanent)", got)
}
}
func TestTransport_ExecTimeout(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
srv.setExecDelay(500 * time.Millisecond)
tr := realTransport(t, srv)
defer tr.Close()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_, err := tr.Exec(ctx, srv.addr(), "echo hello")
if err == nil {
t.Fatal("expected timeout error, got nil")
}
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, ErrTransient) {
t.Errorf("err should be timeout/transient, got %v", err)
}
}
func TestTransport_ConnectionPoolReuse(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
tr := realTransport(t, srv)
defer tr.Close()
addr := srv.addr()
c1, err := tr.dial(addr)
if err != nil {
t.Fatalf("first dial: %v", err)
}
c2, err := tr.dial(addr)
if err != nil {
t.Fatalf("second dial: %v", err)
}
if c1 != c2 {
t.Error("pool did not reuse client for same peer")
}
}
func TestTransport_CloseClosesAllClients(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
tr := realTransport(t, srv)
if _, err := tr.dial(srv.addr()); err != nil {
t.Fatalf("dial: %v", err)
}
count := 0
tr.pool.Range(func(_, _ any) bool {
count++
return true
})
if count != 1 {
t.Fatalf("pool has %d entries, want 1", count)
}
if err := tr.Close(); err != nil {
t.Errorf("Close: %v", err)
}
_, err := tr.dial(srv.addr())
if !errors.Is(err, ErrPermanent) {
t.Errorf("dial after Close should be ErrPermanent, got %v", err)
}
if err := tr.Close(); err != nil {
t.Errorf("second Close: %v", err)
}
}
func TestTransport_ReadFile_RealSSH(t *testing.T) {
srv := newFakeSSHServer(t)
defer srv.close()
srv.mu.Lock()
srv.files["/etc/orca/test.conf"] = "content-line\n"
srv.mu.Unlock()
tr := realTransport(t, srv)
defer tr.Close()
out, err := tr.ReadFile(context.Background(), srv.addr(), "/etc/orca/test.conf")
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(out) != "content-line\n" {
t.Errorf("out = %q", out)
}
}
func TestTransport_DialKeyParseFailure(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
if err := os.WriteFile(filepath.Join(dir, "known_hosts"), []byte{}, 0o600); err != nil {
t.Fatalf("kh: %v", err)
}
keyPath := filepath.Join(dir, "bad_key")
if err := os.WriteFile(keyPath, []byte("not a key"), 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
tr := NewTransport(keyPath, "")
tr.SetUser("root")
_, err := tr.dial("127.0.0.1:1")
if err == nil {
t.Fatal("expected parse error, got nil")
}
if !strings.Contains(err.Error(), "parse key") {
t.Errorf("err should mention parse key, got %v", err)
}
}
func TestTransport_DialKeyMissing(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
if err := os.WriteFile(filepath.Join(dir, "known_hosts"), []byte{}, 0o600); err != nil {
t.Fatalf("kh: %v", err)
}
tr := NewTransport(filepath.Join(dir, "missing_key"), "")
tr.SetUser("root")
_, err := tr.dial("127.0.0.1:1")
if err == nil {
t.Fatal("expected read error, got nil")
}
if !strings.Contains(err.Error(), "read key") {
t.Errorf("err should mention read key, got %v", err)
}
}
func TestTransport_DialMockFailure(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
if err := os.WriteFile(filepath.Join(dir, "known_hosts"), []byte{}, 0o600); err != nil {
t.Fatalf("kh: %v", err)
}
_, priv, _ := ed25519.GenerateKey(rand.Reader)
der, _ := x509.MarshalPKCS8PrivateKey(priv)
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
keyPath := filepath.Join(dir, "orca_ssh_key")
_ = os.WriteFile(keyPath, pemBytes, 0o600)
tr := NewTransport(keyPath, "")
tr.SetUser("root")
tr.SetDialer(&mockDialer{err: errors.New("connection refused")})
_, err := tr.dial("127.0.0.1:1")
if err == nil {
t.Fatal("expected dial error")
}
if !errors.Is(err, ErrTransient) {
t.Errorf("connection refused should be transient, got %v", err)
}
}