diff --git a/CONTEXT.md b/CONTEXT.md index 66e06b5..ba1924e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -3,6 +3,16 @@ > Created with the REUSE.md rollout (2026-07-03). Authoritative history: `hub/CHANGELOG.md` (hub), > `website/CHANGELOG.md`, `scripts/CHANGELOG.md`; end-of-task detail in `REPORT.md`. +- **2026-07-09 — offsite provisioning SLICE 2 (hub v0.38.0 + controller v0.106.0).** The controller apply-bridge: + on startup it reconciles the hub-served `offsite:` descriptor into a key-only offbox target + (`controller/internal/offsiteapply.Bridge`) — **verify-pin the box host key against the hub-captured + `host_fingerprint` (no blind TOFU)** → consume the one-time password (single-use) → `sshpass ssh-copy-id -s -f` + install → configure offbox → `EscrowState="pending"` → persist a descriptor-hash marker. Idempotent + + fail-safe; both red-proofs green. Hub v0.38.0 adds `Descriptor.HostFingerprint` captured via an + `x/crypto/ssh` keyscan (fail-closed). **NOT yet live-applied** — supervised end-to-end (hub provisions on + the new pool box → controller apply) is the next runbook, gated on the hub's new scoped `HETZNER_TOKEN`. + NEXT: SLICE 3 (escrow auto-confirm), SLICE 4 (soft-quota). + - **2026-07-09 — hub offsite provisioning SLICE 1 (hub v0.37.0).** The hub can now provision the offsite tier on operator enable: `internal/hetznerapi` (typed client, base **api.hetzner.com/v1**, `CloudAPI` interface + exported `Fake`, `WaitAction`), `internal/offsite` (`Provisioner.ProvisionOffsite` — idempotent by label diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index d32b974..8319ba5 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,17 @@ # Felhom Hub — Changelog +## v0.38.0 — offsite provisioning SLICE 2 (hub side): capture the box host-key fingerprint (2026-07-09) + +Pairs with controller v0.106.0. So the controller can VERIFY the box identity instead of blind-TOFU, the hub +captures the box's SSH host-key fingerprint at provision and serves it in the descriptor. + +- `internal/offsite`: `Descriptor.HostFingerprint` (SHA256:…, non-secret). `ProvisionOffsite` now captures it + after the resource is ready via a `HostKeyScanner` seam (`SSHHostKeyScanner`, x/crypto/ssh — dials port 23 + and grabs the host key from the handshake, no ssh binary needed). **Fail-closed:** a nil scanner or a scan + failure returns an error (don't serve a descriptor the controller can't verify). The controller re-scans and + refuses on mismatch (v0.106.0). +- Tests: descriptor carries the fingerprint from a faked scanner; a scan failure fails-closed. + ## v0.37.0 — offsite provisioning SLICE 1: Hetzner Cloud-API client + provisioning core (2026-07-09) Slice 1 of the offsite-provisioning epic. On operator enable, the hub provisions a Hetzner storage-box diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index 11f766a..245b963 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -279,7 +279,7 @@ func main() { } client := hetznerapi.NewClient(func() string { return os.Getenv("HETZNER_TOKEN") }) webServer.SetOffsiteProvisioner(&offsite.Provisioner{ - API: client, Store: dataStore, PoolBoxID: poolBoxID, Location: location, Logger: logger, + API: client, Store: dataStore, Scanner: offsite.SSHHostKeyScanner{}, PoolBoxID: poolBoxID, Location: location, Logger: logger, }) logger.Printf("[INFO] Offsite provisioning enabled (pool_box=%d, location=%s)", poolBoxID, location) } diff --git a/hub/internal/offsite/offsite.go b/hub/internal/offsite/offsite.go index d82c731..aef97cb 100644 --- a/hub/internal/offsite/offsite.go +++ b/hub/internal/offsite/offsite.go @@ -35,6 +35,14 @@ type Descriptor struct { RepoPath string `json:"repo_path,omitempty"` // /home/ QuotaGB int `json:"quota_gb,omitempty"` // shared soft-quota (Felhom-enforced; no native lever) BoxType string `json:"box_type,omitempty"` // dedicated (Hetzner-hard quota via the type) + // HostFingerprint is the box's SSH host-key fingerprint (SHA256:…), captured at provision so the + // controller VERIFIES the box identity instead of blind-TOFU (SLICE 2). Non-secret. + HostFingerprint string `json:"host_fingerprint,omitempty"` +} + +// HostKeyScanner returns a box's SSH host-key fingerprint (SHA256:…). Seam'd so tests inject a fake. +type HostKeyScanner interface { + Fingerprint(ctx context.Context, host string, port int) (string, error) } // Input is the operator's offsite choice. @@ -49,8 +57,9 @@ type Input struct { type Provisioner struct { API hetznerapi.CloudAPI Store *store.Store - PoolBoxID int64 // the shared-pool storage-box id (e.g. 611421) - Location string // dedicated-box location, e.g. "fsn1" + Scanner HostKeyScanner // captures the box host-key fingerprint (fail-closed if nil/scan-fails) + PoolBoxID int64 // the shared-pool storage-box id (e.g. 611421) + Location string // dedicated-box location, e.g. "fsn1" Logger *log.Logger } @@ -75,14 +84,34 @@ func (p *Provisioner) ProvisionOffsite(ctx context.Context, customerID string, i if !in.Enabled { return &Descriptor{Enabled: false}, nil } + var d *Descriptor + var err error switch in.Type { case "shared": - return p.provisionShared(ctx, customerID, in) + d, err = p.provisionShared(ctx, customerID, in) case "dedicated": - return p.provisionDedicated(ctx, customerID, in) + d, err = p.provisionDedicated(ctx, customerID, in) default: return nil, fmt.Errorf("offsite: unknown type %q (want shared|dedicated)", in.Type) } + if err != nil { + return nil, err + } + // Capture the box host-key fingerprint so the controller verifies (no blind TOFU). Fail-closed: don't + // serve a descriptor the controller can't verify. Applies to both fresh and idempotent paths. + if p.Scanner == nil { + return nil, fmt.Errorf("offsite: no host-key scanner configured (cannot capture the pin)") + } + port := d.Port + if port == 0 { + port = sftpPort + } + fp, err := p.Scanner.Fingerprint(ctx, d.Host, port) + if err != nil { + return nil, fmt.Errorf("offsite: host-key scan %s: %w", d.Host, err) + } + d.HostFingerprint = fp + return d, nil } func (p *Provisioner) provisionShared(ctx context.Context, customerID string, in Input) (*Descriptor, error) { diff --git a/hub/internal/offsite/offsite_test.go b/hub/internal/offsite/offsite_test.go index 68b693c..18292d2 100644 --- a/hub/internal/offsite/offsite_test.go +++ b/hub/internal/offsite/offsite_test.go @@ -23,7 +23,17 @@ func newTestProvisioner(t *testing.T) (*Provisioner, *hetznerapi.Fake, *store.St } t.Cleanup(func() { st.Close() }) fake := hetznerapi.NewFake() - return &Provisioner{API: fake, Store: st, PoolBoxID: 611421, Location: "fsn1", Logger: log.New(io.Discard, "", 0)}, fake, st + return &Provisioner{API: fake, Store: st, Scanner: &fakeScanner{fp: "SHA256:testfp"}, PoolBoxID: 611421, Location: "fsn1", Logger: log.New(io.Discard, "", 0)}, fake, st +} + +// fakeScanner returns a fixed fingerprint (or an error) — no live SSH in tests. +type fakeScanner struct { + fp string + err error +} + +func (f *fakeScanner) Fingerprint(_ context.Context, _ string, _ int) (string, error) { + return f.fp, f.err } // Scenario A — enable shared → sub-account provisioned, descriptor built, one-time password stored (NOT in @@ -62,6 +72,30 @@ func TestProvision_Shared(t *testing.T) { } } +// Part 0 — the descriptor carries the box host-key fingerprint (captured at provision). +func TestProvision_HostFingerprint(t *testing.T) { + p, _, _ := newTestProvisioner(t) + d, err := p.ProvisionOffsite(context.Background(), "cust-fp", Input{Enabled: true, Type: "shared", QuotaGB: 10}) + if err != nil { + t.Fatal(err) + } + if d.HostFingerprint != "SHA256:testfp" { + t.Fatalf("descriptor must carry the host fingerprint, got %q", d.HostFingerprint) + } +} + +// Part 0 — a host-key scan failure is fail-closed (no descriptor served). +func TestProvision_ScanFailClosed(t *testing.T) { + p, _, st := newTestProvisioner(t) + p.Scanner = &fakeScanner{err: errors.New("keyscan timeout")} + d, err := p.ProvisionOffsite(context.Background(), "cust-sf", Input{Enabled: true, Type: "shared", QuotaGB: 10}) + if err == nil || d != nil { + t.Fatalf("a keyscan failure must fail-closed, got d=%+v err=%v", d, err) + } + // the resource may have been created + password stored, but no verifiable descriptor is served + _ = st +} + // Scenario B — enable dedicated → box provisioned. func TestProvision_Dedicated(t *testing.T) { p, fake, st := newTestProvisioner(t) diff --git a/hub/internal/offsite/scanner.go b/hub/internal/offsite/scanner.go new file mode 100644 index 0000000..b48f030 --- /dev/null +++ b/hub/internal/offsite/scanner.go @@ -0,0 +1,57 @@ +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 +} + +// 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 + }, + } + d := net.Dialer{Timeout: timeout} + conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", host, port)) + if err != 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) +}