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
+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) {