a38c743926
Key-auth-first: a KeyAuthProber seam lets the bridge skip consume+install when the already-installed key still authenticates (pinned to the freshly verified host key) — descriptor changes on provisioned guests no longer loop on consume-404. Fingerprint verify still precedes everything. Wipe-on-escrowed: confirm-escrow now calls the agent's new DELETE /escrow/stage-secret (v0.78.0) best-effort, closing the hygiene gap where a ceremony-less confirm left the staged password file behind. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
186 lines
8.4 KiB
Go
186 lines
8.4 KiB
Go
// Package offsiteapply is the controller-side apply-bridge (SLICE 2): it turns the hub-served offsite
|
|
// descriptor + the one-time password into a working key-only offbox target. On config apply it consumes the
|
|
// one-time password, VERIFIES the box host key against the hub-captured fingerprint (no blind TOFU), pins it,
|
|
// installs the controller's own key, and configures the offbox target → EscrowState="pending" (the fork-4
|
|
// enable path). Idempotent (a descriptor hash marker prevents re-consuming a spent password) and fail-safe
|
|
// (any step fails → nothing persisted, retried next cycle; never a half-configured offbox).
|
|
package offsiteapply
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
)
|
|
|
|
// The apply-bridge seams (tests inject fakes — no live SSH / hub calls in unit tests).
|
|
type (
|
|
// PasswordConsumer fetches the one-time transient password from the hub (single-use).
|
|
PasswordConsumer interface {
|
|
Consume(ctx context.Context) (string, error)
|
|
}
|
|
// HostKeyScanner returns the box's host-key fingerprint (SHA256:…) + the known_hosts line to pin.
|
|
HostKeyScanner interface {
|
|
Scan(ctx context.Context, host string, port int) (fingerprint, knownHostsLine string, err error)
|
|
}
|
|
// KeyGenerator produces a fresh keypair: the private key (PEM) and the authorized_keys pub line.
|
|
KeyGenerator interface {
|
|
Generate() (privPEM, pubAuthorized string, err error)
|
|
}
|
|
// KeyInstaller installs the pub line on the box using the one-time password, then verifies passwordless
|
|
// key auth with the private key. It MUST pin the VERIFIED knownHosts line (from the scan) on the
|
|
// connection — never blind-TOFU — so a MITM cannot swap the key between the scan and the install.
|
|
KeyInstaller interface {
|
|
Install(ctx context.Context, host, user string, port int, password, privPEM, pubAuthorized, knownHosts string) error
|
|
}
|
|
// OffboxEnabler configures the offbox target (key + known_hosts + target) and goes EscrowState="pending"
|
|
// (the fork-4 enable path).
|
|
OffboxEnabler interface {
|
|
ConfigureOffbox(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string) error
|
|
}
|
|
// KeyAuthProber checks whether an ALREADY-INSTALLED key authenticates to the target (pinned to the
|
|
// freshly-scanned knownHosts). ok=true returns that key's PEM so the descriptor change is applied by
|
|
// re-pinning + reconfiguring WITHOUT consuming a one-time password (key-auth-first — kills the
|
|
// stale-descriptor consume-404 loop and shrinks the re-issue blast radius to genuinely-fresh guests).
|
|
// ok=false (no key / auth refused) → the caller falls through to the full consume+install path.
|
|
KeyAuthProber interface {
|
|
Probe(ctx context.Context, host, user string, port int, knownHosts string) (privPEM string, ok bool)
|
|
}
|
|
)
|
|
|
|
// Bridge reconciles the offsite descriptor into a configured offbox target.
|
|
type Bridge struct {
|
|
Cfg *config.Config
|
|
Consumer PasswordConsumer
|
|
Scanner HostKeyScanner
|
|
KeyGen KeyGenerator
|
|
Installer KeyInstaller
|
|
Enabler OffboxEnabler
|
|
Prober KeyAuthProber // optional: key-auth-first (nil → always the full consume+install path)
|
|
MarkerPath string // where the applied-descriptor-hash is persisted (e.g. <dataDir>/offbox/applied_marker)
|
|
Logger *log.Logger
|
|
}
|
|
|
|
func (b *Bridge) logf(f string, a ...any) {
|
|
if b.Logger != nil {
|
|
b.Logger.Printf(f, a...)
|
|
}
|
|
}
|
|
|
|
// descriptorHash is the applied-marker key: a hash of the identity-bearing descriptor fields. A change
|
|
// (re-provision → new host/user/fingerprint) yields a new hash → the bridge re-applies (new password).
|
|
func descriptorHash(o config.OffsiteConfig) string {
|
|
s := fmt.Sprintf("%s|%s|%s|%d|%s|%s", o.Type, o.Host, o.User, o.Port, o.RepoPath, o.HostFingerprint)
|
|
sum := sha256.Sum256([]byte(s))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func (b *Bridge) readMarker() string {
|
|
data, err := os.ReadFile(b.MarkerPath)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func (b *Bridge) writeMarker(h string) error {
|
|
if err := os.MkdirAll(filepath.Dir(b.MarkerPath), 0o700); err != nil {
|
|
return err
|
|
}
|
|
tmp := b.MarkerPath + ".tmp"
|
|
if err := os.WriteFile(tmp, []byte(h), 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, b.MarkerPath)
|
|
}
|
|
|
|
// Reconcile applies the offsite descriptor. Safe to call repeatedly (idempotent) and on any error leaves
|
|
// nothing half-configured (fail-safe). Returns an error for logging; callers run it async and retry.
|
|
func (b *Bridge) Reconcile(ctx context.Context) error {
|
|
o := b.Cfg.Offsite
|
|
if !o.Enabled {
|
|
return nil // disabled → the fork-4 gate blocks runs; nothing to apply
|
|
}
|
|
port := o.Port
|
|
if port == 0 {
|
|
port = 23
|
|
}
|
|
h := descriptorHash(o)
|
|
if b.readMarker() == h {
|
|
return nil // already applied for this descriptor (idempotent) — do NOT re-consume a spent password
|
|
}
|
|
if o.HostFingerprint == "" {
|
|
return fmt.Errorf("offsite-apply: descriptor has no host_fingerprint — refusing (no blind TOFU)")
|
|
}
|
|
if o.Host == "" || o.User == "" || o.RepoPath == "" {
|
|
return fmt.Errorf("offsite-apply: descriptor missing host/user/repo_path")
|
|
}
|
|
|
|
// 1) Scan + VERIFY the host key BEFORE consuming the password (don't waste it on a mismatch).
|
|
scannedFP, knownHostsLine, err := b.Scanner.Scan(ctx, o.Host, port)
|
|
if err != nil {
|
|
return fmt.Errorf("offsite-apply: host-key scan: %w", err)
|
|
}
|
|
if scannedFP != o.HostFingerprint {
|
|
return fmt.Errorf("offsite-apply: host-key MISMATCH for %s (got %s, want %s) — refusing to pin/install (possible MITM)", o.Host, scannedFP, o.HostFingerprint)
|
|
}
|
|
|
|
// 1b) Key-auth-first: if an already-installed key still authenticates (pinned to the key we JUST
|
|
// verified — the probe never weakens the identity check), the descriptor change is applied by
|
|
// re-pinning + reconfiguring alone. NO one-time password is consumed — a stale/re-scanned descriptor
|
|
// on an already-provisioned guest no longer loops on consume-404.
|
|
if b.Prober != nil {
|
|
if privPEM, ok := b.Prober.Probe(ctx, o.Host, o.User, port, knownHostsLine); ok {
|
|
if err := b.Enabler.ConfigureOffbox(ctx, o.Host, o.User, port, o.RepoPath, privPEM, knownHostsLine); err != nil {
|
|
return fmt.Errorf("offsite-apply: reconfigure (key-auth-first): %w", err)
|
|
}
|
|
if err := b.writeMarker(h); err != nil {
|
|
b.logf("[WARN] [offsite-apply] key-auth-first applied for %s but failed to persist the marker: %v", o.Host, err)
|
|
return err
|
|
}
|
|
b.logf("[INFO] [offsite-apply] existing key still authenticates to %s@%s — re-pinned + reconfigured without consuming a password", o.User, o.Host)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// 2) Generate the controller keypair.
|
|
privPEM, pubAuthorized, err := b.KeyGen.Generate()
|
|
if err != nil {
|
|
return fmt.Errorf("offsite-apply: keygen: %w", err)
|
|
}
|
|
|
|
// 3) Consume the one-time password (single-use). After this the password is SPENT.
|
|
password, err := b.Consumer.Consume(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("offsite-apply: consume one-time password: %w", err)
|
|
}
|
|
|
|
// 4) Install the pubkey using the password (proven ssh-copy-id -s -f), verify key auth. Pin the
|
|
// scanner-VERIFIED known_hosts line on the install/verify connections — never accept-new — so a MITM
|
|
// cannot substitute a different key in the gap between the scan and the install.
|
|
if err := b.Installer.Install(ctx, o.Host, o.User, port, password, privPEM, pubAuthorized, knownHostsLine); err != nil {
|
|
// The password is now SPENT but install failed — a loud, distinct signal: the operator must reset
|
|
// the box password on the hub and let the bridge retry. Do NOT mark applied.
|
|
b.logf("[ERROR] [offsite-apply] key install FAILED after consuming the one-time password for %s@%s — the password is spent; reset it on the hub to retry: %v", o.User, o.Host, err)
|
|
return fmt.Errorf("offsite-apply: install key (password spent — needs hub reset): %w", err)
|
|
}
|
|
|
|
// 5) Configure the offbox target + go EscrowState="pending" (fork-4 enable path).
|
|
if err := b.Enabler.ConfigureOffbox(ctx, o.Host, o.User, port, o.RepoPath, privPEM, knownHostsLine); err != nil {
|
|
return fmt.Errorf("offsite-apply: configure offbox: %w", err)
|
|
}
|
|
|
|
// 6) Persist the marker LAST — only a fully-applied descriptor is recorded (fail-safe).
|
|
if err := b.writeMarker(h); err != nil {
|
|
b.logf("[WARN] [offsite-apply] applied offsite for %s but failed to persist the marker (will re-apply next cycle — the password is spent, needs reset): %v", o.Host, err)
|
|
return err
|
|
}
|
|
b.logf("[INFO] [offsite-apply] offsite configured for %s@%s:%s (pending key escrow)", o.User, o.Host, o.RepoPath)
|
|
return nil
|
|
}
|