hub v0.38.0: offsite SLICE 2 — capture the box host-key fingerprint

Descriptor.HostFingerprint (SHA256, non-secret), captured at provision via an
x/crypto/ssh keyscan (SSHHostKeyScanner — dials :23, grabs the host key from the
handshake, no ssh binary). Fail-closed: nil scanner or scan failure → error (don't
serve a descriptor the controller can't verify). Pairs with controller v0.106.0
which re-scans + refuses on mismatch (no blind TOFU).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-09 19:14:36 +02:00
parent cff3a30996
commit 229650b4ee
6 changed files with 148 additions and 6 deletions
+10
View File
@@ -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
+12
View File
@@ -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
+1 -1
View File
@@ -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)
}
+33 -4
View File
@@ -35,6 +35,14 @@ type Descriptor struct {
RepoPath string `json:"repo_path,omitempty"` // /home/<repo>
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) {
+35 -1
View File
@@ -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)
+57
View File
@@ -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)
}