Files
felhom.eu/hub/internal/offsite/scanner.go
T
admin 655f5f66f8
gates / gates (push) Successful in 6m0s
hub v0.147.0 (code): the operator's answers S4, S6, S10, S29, S30, S32, S33
- 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
2026-10-12 14:54:14 +02:00

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)
}