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),
|
> 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`.
|
> `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
|
- **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 +
|
on operator enable: `internal/hetznerapi` (typed client, base **api.hetzner.com/v1**, `CloudAPI` interface +
|
||||||
exported `Fake`, `WaitAction`), `internal/offsite` (`Provisioner.ProvisionOffsite` — idempotent by label
|
exported `Fake`, `WaitAction`), `internal/offsite` (`Provisioner.ProvisionOffsite` — idempotent by label
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# Felhom Hub — Changelog
|
# 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)
|
## 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
|
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") })
|
client := hetznerapi.NewClient(func() string { return os.Getenv("HETZNER_TOKEN") })
|
||||||
webServer.SetOffsiteProvisioner(&offsite.Provisioner{
|
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)
|
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>
|
RepoPath string `json:"repo_path,omitempty"` // /home/<repo>
|
||||||
QuotaGB int `json:"quota_gb,omitempty"` // shared soft-quota (Felhom-enforced; no native lever)
|
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)
|
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.
|
// Input is the operator's offsite choice.
|
||||||
@@ -49,8 +57,9 @@ type Input struct {
|
|||||||
type Provisioner struct {
|
type Provisioner struct {
|
||||||
API hetznerapi.CloudAPI
|
API hetznerapi.CloudAPI
|
||||||
Store *store.Store
|
Store *store.Store
|
||||||
PoolBoxID int64 // the shared-pool storage-box id (e.g. 611421)
|
Scanner HostKeyScanner // captures the box host-key fingerprint (fail-closed if nil/scan-fails)
|
||||||
Location string // dedicated-box location, e.g. "fsn1"
|
PoolBoxID int64 // the shared-pool storage-box id (e.g. 611421)
|
||||||
|
Location string // dedicated-box location, e.g. "fsn1"
|
||||||
Logger *log.Logger
|
Logger *log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,14 +84,34 @@ func (p *Provisioner) ProvisionOffsite(ctx context.Context, customerID string, i
|
|||||||
if !in.Enabled {
|
if !in.Enabled {
|
||||||
return &Descriptor{Enabled: false}, nil
|
return &Descriptor{Enabled: false}, nil
|
||||||
}
|
}
|
||||||
|
var d *Descriptor
|
||||||
|
var err error
|
||||||
switch in.Type {
|
switch in.Type {
|
||||||
case "shared":
|
case "shared":
|
||||||
return p.provisionShared(ctx, customerID, in)
|
d, err = p.provisionShared(ctx, customerID, in)
|
||||||
case "dedicated":
|
case "dedicated":
|
||||||
return p.provisionDedicated(ctx, customerID, in)
|
d, err = p.provisionDedicated(ctx, customerID, in)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("offsite: unknown type %q (want shared|dedicated)", in.Type)
|
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) {
|
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() })
|
t.Cleanup(func() { st.Close() })
|
||||||
fake := hetznerapi.NewFake()
|
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
|
// 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.
|
// Scenario B — enable dedicated → box provisioned.
|
||||||
func TestProvision_Dedicated(t *testing.T) {
|
func TestProvision_Dedicated(t *testing.T) {
|
||||||
p, fake, st := newTestProvisioner(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