v0.88.0: controller-driven escrow ceremony — --output=json machine mode (escrowCeremony extraction, text mode byte-identical), the ONE fixed argv (escrow.CeremonyArgs, shared by exec+manifest+FELHOM_ESCROW sudoers, pin-tested), localapi ceremony job (single-flight, 60s) + one-shot in-memory R claim (10min TTL, unclaimed_void) + preflight; escrow-ceremony capability (Critical, pbs_dr-gated)

This commit is contained in:
2026-07-13 19:01:11 +02:00
parent adf7882f7d
commit 1c3a3ef9ad
11 changed files with 1196 additions and 52 deletions
+41
View File
@@ -9,6 +9,8 @@ import (
"log/slog"
"net"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"sync"
@@ -99,6 +101,10 @@ type Options struct {
// controller-pushed restic repo password (fork-4). "" → escrow.StagedResticPasswordPath() (the
// canonical path the escrow-create ceremony reads). Injectable so the stage handler is testable.
EscrowStagePath string
// EscrowCeremony wires the controller-driven ceremony endpoints (v0.88.0): POST /escrow/ceremony
// (+/status, /claim one-shot R) and GET /escrow/preflight. OPTIONAL — when nil, those endpoints
// report "not configured".
EscrowCeremony *EscrowCeremonyConfig
// ControllerSwap runs guest commands (pct exec) for the agentic controller-update swap (Phase 1).
// OPTIONAL — when nil, POST /controller/swap reports "not configured". Satisfied by *GuestBinder.
ControllerSwap GuestExecutor
@@ -240,6 +246,26 @@ type Server struct {
// netReachable is the 2 s TCP endpoint pre-probe (sync fast-fail + classification tiebreak).
netReachable func(proto storage.NetworkProtocol, server string) bool
// Controller-driven escrow ceremony (v0.88.0): the single job slot + the ONE-SHOT in-memory R
// holder. R lives ONLY in escrowR (never in the job struct — snapshots must be structurally
// incapable of carrying it) and is zeroed on claim, supersede, or TTL expiry. See
// escrow_ceremony.go for the custody rules.
escrowCeremony *EscrowCeremonyConfig
escrowMu sync.Mutex
escrowJob *escrowCeremonyJob
escrowR []byte
escrowRClaimed bool
escrowRExpiry time.Time
escrowDone <-chan struct{} // closes when the detached job finishes (tests wait on it)
// ceremonyRun executes the fixed-argv sudo self-invocation (tests inject canned JSON).
ceremonyRun ceremonyRunner
// escrowSudoCheck is the preflight's list-mode grant probe (`sudo -n -l -- <argv>`).
escrowSudoCheck func(ctx context.Context) error
// escrowLookPath resolves a binary on PATH for preflight (tests inject).
escrowLookPath func(file string) (string, error)
// statFile reports whether a path exists (preflight's staged-secret item; tests inject).
statFile func(path string) bool
baseCtx context.Context // for fire-and-forget backups; set in Run
}
@@ -300,6 +326,14 @@ func NewServer(o Options) (*Server, error) {
s.netMounted = storage.NetworkMountedAt
s.netJournal = readUnitJournal
s.netReachable = storage.NetworkEndpointReachable
// Controller-driven escrow ceremony (v0.88.0): production seams; tests inject fakes.
s.escrowCeremony = o.EscrowCeremony
if s.escrowCeremony != nil {
s.ceremonyRun = runCeremonySubprocess(s.escrowCeremony.SudoPath)
s.escrowSudoCheck = checkCeremonySudoGrant(s.escrowCeremony.SudoPath)
}
s.escrowLookPath = exec.LookPath
s.statFile = func(path string) bool { _, err := os.Stat(path); return err == nil }
if o.ControllerSwap != nil {
s.swap = NewControllerSwapper(o.ControllerSwap, o.ControllerSwapStateDir, o.Logger)
}
@@ -350,6 +384,13 @@ func (s *Server) Handler() http.Handler {
// fork-4 hygiene: wipe the staged secret once escrowed (controller calls this on confirm). Idempotent.
mux.HandleFunc("DELETE /escrow/stage-secret", s.withGuest(s.handleWipeStagedEscrowSecret))
// Controller-driven escrow ceremony (v0.88.0): preflight checklist, the detached root ceremony
// job (fixed-argv sudo self-invocation), its status, and the ONE-SHOT in-memory R claim.
mux.HandleFunc("GET /escrow/preflight", s.withGuest(s.handleEscrowPreflight))
mux.HandleFunc("POST /escrow/ceremony", s.withGuest(s.handleEscrowCeremonyStart))
mux.HandleFunc("GET /escrow/ceremony/status", s.withGuest(s.handleEscrowCeremonyStatus))
mux.HandleFunc("POST /escrow/ceremony/claim", s.withGuest(s.handleEscrowCeremonyClaim))
// v0.83.0 observability: the agent's always-DEBUG capture ring, for the controller's
// Debug page agent tab (same auth/self-scoping wrap as every sibling route).
mux.HandleFunc("GET /debug/logs", s.withGuest(s.handleDebugLogs))