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