655f5f66f8
gates / gates (push) Successful in 6m0s
- S4 / R-242: GoldenVouchChecker - a published golden unvouched for 2 h raises golden_not_vouched (operator only) - S6 / R-244: the delete cascade de-references app_log_issues; one-time sweep flag -sweep-app-log-residue=count|apply - S10 / R-266: read_failed storage entry shows "read failed", not 0 % - S29 / R-250: host-key scan IPv4 first, ~2 min ladder, "safe to press Save again" error - S30 / R-246: stale_at retired (reader, setter, badge) - S32 / R-521: storage_reconnected ends the storage_disconnected cooldown on both channels - S33 / R-402: host page "Last full check (off-site)" line; two wire-gate allowlist entries removed Each with a test seen failing first. Not built or deployed yet (operator-attended deploy). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
71 lines
2.3 KiB
Go
71 lines
2.3 KiB
Go
package offsite
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
// SSHHostKeyScanner captures a box's SSH host-key fingerprint by dialing port 23 and grabbing the host key
|
|
// from the handshake — BEFORE any auth (there is no credential; the callback aborts once the key is seen).
|
|
// This is the hub-side equivalent of `ssh-keyscan`, using x/crypto/ssh so no ssh binary is needed in the
|
|
// container. The captured fingerprint is non-secret (a public host-key identifier).
|
|
type SSHHostKeyScanner struct {
|
|
Timeout time.Duration
|
|
// dial is a test seam (nil = a net.Dialer with Timeout). TestScannerDialsIPv4First.
|
|
dial func(ctx context.Context, network, addr string) (net.Conn, error)
|
|
}
|
|
|
|
// errCaptured aborts the handshake once we have the host key (we never intended to authenticate).
|
|
var errCaptured = errors.New("host key captured")
|
|
|
|
func (s SSHHostKeyScanner) Fingerprint(ctx context.Context, host string, port int) (string, error) {
|
|
timeout := s.Timeout
|
|
if timeout == 0 {
|
|
timeout = 10 * time.Second
|
|
}
|
|
var fp string
|
|
cfg := &ssh.ClientConfig{
|
|
User: "felhom-keyscan",
|
|
Auth: nil, // no auth — we abort in the host-key callback
|
|
Timeout: timeout,
|
|
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
|
|
fp = ssh.FingerprintSHA256(key)
|
|
return errCaptured
|
|
},
|
|
}
|
|
dial := s.dial
|
|
if dial == nil {
|
|
d := net.Dialer{Timeout: timeout}
|
|
dial = d.DialContext
|
|
}
|
|
// R-250 (S29, decision 224): IPv4 FIRST. A fresh Storage Box name answers AAAA before the hub pod
|
|
// can route IPv6 (measured 2026-08-07: "network is unreachable" on the v6 address while v4 worked).
|
|
// Any family only as the fallback; the IPv4 error is the one reported when both fail.
|
|
addr := fmt.Sprintf("%s:%d", host, port)
|
|
conn, err := dial(ctx, "tcp4", addr)
|
|
if err != nil {
|
|
var err2 error
|
|
if conn, err2 = dial(ctx, "tcp", addr); err2 != nil {
|
|
return "", fmt.Errorf("dial: %w", err)
|
|
}
|
|
}
|
|
defer conn.Close()
|
|
// ssh.NewClientConn runs the handshake; our callback captures the key then returns errCaptured, so this
|
|
// returns an error — but fp is set. Any OTHER error (or no key) is a real failure.
|
|
c, chans, reqs, herr := ssh.NewClientConn(conn, host, cfg)
|
|
if c != nil {
|
|
c.Close()
|
|
}
|
|
_ = chans
|
|
_ = reqs
|
|
if fp != "" {
|
|
return fp, nil
|
|
}
|
|
return "", fmt.Errorf("host-key handshake: %w", herr)
|
|
}
|