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:
+10
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user