// 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. Fails if the install or the verify fails. KeyInstaller interface { Install(ctx context.Context, host, user string, port int, password, privPEM, pubAuthorized 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 } ) // 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 MarkerPath string // where the applied-descriptor-hash is persisted (e.g. /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) } // 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. if err := b.Installer.Install(ctx, o.Host, o.User, port, password, privPEM, pubAuthorized); 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 }