Files
felhom-controller/controller/internal/agentapi/escrow.go
T

120 lines
4.4 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
}