Files
felhom-controller/controller/internal/agentapi/escrow.go
T
admin 9640e51321
gates / gates (push) Successful in 10s
controller v0.195.0: prove the offsite key comes back (R-200 plumbing half) -- MinAgent 0.125.0
--recover-offsite-check is a docker exec diagnostic in the shape of --print-reset-code: it
reads the customer's recovery code from STDIN, asks the agent to fetch this host's sealed
bundle and open it, and reports whether the recovered key matches the one on disk BY SHA256.
Two hashes and a verdict; never a password, never R, never a blob.

R comes from stdin and not a flag because a flag value is visible in ps, in shell history, in
a container's command line and in any transcript of the session that ran it.

IT COMPARES; IT DOES NOT INSTALL. The recovered password is never written to
offbox/repo_password -- installing changes a live box on a path nobody has walked, and that
link is next session's, with the drill around it. A test asserts the data dir is byte-unchanged
after a check; its red-proof (adding the install call) fails it.

Exit codes: 0 match, 2 clean MISMATCH, 1 a step failed -- "it failed" and "it worked and
disagreed" must never share a status. A box with no local password reports distinctly: that is
the rebuilt-box shape, where the next step is to install rather than compare.

Nothing customer-reachable ships here: no card, no form, no preview.
2026-08-04 13:42:50 +02:00

150 lines
5.9 KiB
Go

package agentapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
// Controller-driven escrow ceremony client methods (v0.127.0, agent ≥ v0.88.0). The claim call
// is the ONLY place the recovery code R crosses this client — its response body must never be
// logged (the shared helpers log path/status/duration only, never bodies) and the caller hands
// R straight to the wizard's claim XHR, nowhere else.
// EscrowPreflightItem mirrors one agent preflight checklist row.
type EscrowPreflightItem struct {
ID string `json:"id"`
OK bool `json:"ok"`
Detail string `json:"detail"`
}
// EscrowPreflightResponse mirrors GET /escrow/preflight.
type EscrowPreflightResponse struct {
VMID int `json:"vmid"`
OK bool `json:"ok"`
Items []EscrowPreflightItem `json:"items"`
}
// EscrowPreflight fetches the agent's ceremony prerequisite checklist.
func (c *Client) EscrowPreflight(ctx context.Context) (EscrowPreflightResponse, error) {
var out EscrowPreflightResponse
body, err := c.get(ctx, "/escrow/preflight")
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /escrow/preflight: %w", err)
}
return out, nil
}
// EscrowCeremonyStartResponse mirrors the POST /escrow/ceremony 202 payload.
type EscrowCeremonyStartResponse struct {
JobID string `json:"job_id"`
Phase string `json:"phase"`
}
// EscrowCeremonyStart triggers the agent's detached root ceremony job. Status-aware: the HTTP
// status is returned so the caller can map the agent's 409 (a ceremony already running) to its
// own house-style refusal.
func (c *Client) EscrowCeremonyStart(ctx context.Context) (EscrowCeremonyStartResponse, int, error) {
var out EscrowCeremonyStartResponse
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony", struct{}{})
if err != nil {
return out, status, err
}
if err := refusalError("/escrow/ceremony", status, env); err != nil {
return out, status, err
}
if err := json.Unmarshal(env.Data, &out); err != nil {
return out, status, fmt.Errorf("agentapi: decode /escrow/ceremony: %w", err)
}
return out, status, nil
}
// EscrowCeremonyStatusResponse mirrors GET /escrow/ceremony/status — the NON-SECRET job view
// (R is structurally absent from the agent's payload).
type EscrowCeremonyStatusResponse struct {
Phase string `json:"phase"` // none | running | done | failed | unclaimed_void
JobID string `json:"job_id"`
KeyFingerprint string `json:"key_fingerprint"`
EntropyBits float64 `json:"entropy_bits"`
ResticPwSealed bool `json:"restic_pw_sealed"`
Uploaded bool `json:"uploaded"`
Claimable bool `json:"claimable"`
Claimed bool `json:"claimed"`
ClaimExpiresInSec int `json:"claim_expires_in_sec"`
Detail string `json:"detail"`
}
// EscrowCeremonyStatus polls the ceremony job.
func (c *Client) EscrowCeremonyStatus(ctx context.Context) (EscrowCeremonyStatusResponse, error) {
var out EscrowCeremonyStatusResponse
body, err := c.get(ctx, "/escrow/ceremony/status")
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /escrow/ceremony/status: %w", err)
}
return out, nil
}
// EscrowCeremonyClaim performs the ONE-SHOT R claim. Returns the recovery code + the agent's
// HTTP status (410 = already claimed / expired — the wizard's void state). The code must never
// be logged, persisted, or placed anywhere but the claim XHR response; the error path carries
// the agent's reason text, never the code.
func (c *Client) EscrowCeremonyClaim(ctx context.Context) (string, int, error) {
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/claim", struct{}{})
if err != nil {
return "", status, err
}
if status == http.StatusGone {
return "", status, fmt.Errorf("agentapi: POST /escrow/ceremony/claim: gone (claimed or expired)")
}
if err := refusalError("/escrow/ceremony/claim", status, env); err != nil {
return "", status, err
}
var out struct {
RecoveryCode string `json:"recovery_code"`
}
if err := json.Unmarshal(env.Data, &out); err != nil {
return "", status, fmt.Errorf("agentapi: decode /escrow/ceremony/claim: %w", err)
}
if out.RecoveryCode == "" {
return "", status, fmt.Errorf("agentapi: /escrow/ceremony/claim returned no code")
}
return out.RecoveryCode, status, nil
}
// RecoverOffsiteRepoPassword asks the agent to open this host's hub-held sealed bundle with the
// customer's recovery code and return ONLY the offsite restic repository password, plus its sha256
// (R-199, agent >= v0.125.0).
//
// R CROSSES HERE, AND NOWHERE ELSE IN THIS DIRECTION. It travels in the request body over the pinned
// local-API channel (the operator's 2026-08-04 acceptance) and is not retained by this client. The
// shared POST helper logs path/status/duration and never bodies — do not add a body log, on either
// the request or the response side: the request carries R and the response carries the password.
func (c *Client) RecoverOffsiteRepoPassword(ctx context.Context, recoveryCode string) (password, sha256hex string, err error) {
env, status, perr := c.postWithStatus(ctx, "/escrow/recover-offsite-password",
map[string]string{"recovery_code": recoveryCode})
if perr != nil {
return "", "", perr
}
if rerr := refusalError("/escrow/recover-offsite-password", status, env); rerr != nil {
return "", "", rerr
}
var out struct {
ResticRepoPassword string `json:"restic_repo_password"`
ResticPwSHA256 string `json:"restic_pw_sha256"`
}
if uerr := json.Unmarshal(env.Data, &out); uerr != nil {
return "", "", fmt.Errorf("agentapi: decode /escrow/recover-offsite-password: %w", uerr)
}
if out.ResticRepoPassword == "" || out.ResticPwSHA256 == "" {
return "", "", fmt.Errorf("agentapi: the agent returned an empty recovery result")
}
return out.ResticRepoPassword, out.ResticPwSHA256, nil
}