dr: recovered WG-key install + host_loss directive→restore-PLAN (S5 safe halves)

wgtunnel.InstallRecoveredKey: write an escrow-recovered WG private key (create-
only, refuse-overwrite) so the tunnel re-establishes with the same identity/pubkey
(same /32), no keygen. Wired into identity-consume -install-wg-key (opt-in;
pre-S3 blob → logged fresh-keygen fallback). Value never logged.

internal/dr (new): consume the host_loss restore_directive (was logged-ignored)
into an inspectable RestorePlan via the AddConsumer raw seam — per guest
{vmid,archive,target,sizing} + per drive {durable_id→mount} + offsite PBS coord.
DERIVE-AND-SURFACE only; the Consumer has no restore/destroy dependency (execute-
nothing is structural). guest_loss/absent → no plan.

Tests + red-proofs (WG create-only overwrite; plan mode-gate). No secrets on
argv/stdout/logs. The destructive in-place restore is a separate operator-present
STOP-gated drill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-04 21:06:31 +02:00
parent 567cf9f401
commit bd4bced771
7 changed files with 360 additions and 3 deletions
+36 -2
View File
@@ -29,6 +29,7 @@ import (
"gitea.dooplex.hu/admin/felhom-agent/internal/capability"
"gitea.dooplex.hu/admin/felhom-agent/internal/config"
"gitea.dooplex.hu/admin/felhom-agent/internal/desired"
"gitea.dooplex.hu/admin/felhom-agent/internal/dr"
"gitea.dooplex.hu/admin/felhom-agent/internal/escrow"
"gitea.dooplex.hu/admin/felhom-agent/internal/guesthook"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
@@ -106,6 +107,7 @@ func main() {
blobPath string
expectedFP string
keyDest string
installWGKey bool
idBundlePath string
directivePath string
swapImage string
@@ -133,6 +135,7 @@ func main() {
flag.StringVar(&blobPath, "blob", "", "for --selftest=escrow-consume: path to the R-wrapped escrow blob file")
flag.StringVar(&expectedFP, "fingerprint", "", "for --selftest=escrow-consume: the EXPECTED key fingerprint (the gate target)")
flag.StringVar(&keyDest, "keydest", "", "for --selftest=escrow-consume: where to install the recovered key (0600)")
flag.BoolVar(&installWGKey, "install-wg-key", false, "for --selftest=identity-consume: ALSO install the recovered wg_private_key into wgtunnel's key file (S5 DR; create-only, refuses to overwrite)")
flag.StringVar(&idBundlePath, "identity-bundle", "", "for --selftest=escrow-create: a 0600 JSON file {tunnel_token,pbs_token} to ALSO escrow under R (10D)")
flag.StringVar(&directivePath, "directive", "", "for --selftest=escrow-create: a JSON file with the non-secret DR directive (pbs repo/ns, expected fingerprint, tunnel id)")
flag.StringVar(&custID, "customer-id", "", "for --selftest=provision: the customer id — the hub config-pull target, baked into the guest's bootstrap")
@@ -198,7 +201,7 @@ func main() {
case "escrow-consume":
os.Exit(runSelftestEscrowConsume(context.Background(), logger, blobPath, expectedFP, keyDest))
case "identity-consume":
os.Exit(runSelftestIdentityConsume(context.Background(), logger, blobPath, keyDest))
os.Exit(runSelftestIdentityConsume(context.Background(), cfg, logger, blobPath, keyDest, installWGKey))
case "controller-swap":
os.Exit(runSelftestControllerSwap(context.Background(), cfg, logger, vmid, swapImage))
}
@@ -428,6 +431,15 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
// The "Down" channel sync hook: on each heartbeat, fetch desired-state when the generation
// advances. The loop calls it via the EnvelopeObserver seam (hub does not import desired).
desiredSyncer := desired.NewSyncer(client, desiredProvider, logger)
// S5: consume a host_loss restore_directive into an inspectable restore PLAN (derive + surface,
// execute nothing). The recipe is fetched on-demand (rare directive) via a fresh Collect.
desiredSyncer.AddConsumer(dr.NewConsumer(func(ctx context.Context) *hub.DRRecipeHostHalf {
r, err := collector.Collect(ctx)
if err != nil || r == nil {
return nil
}
return r.DRRecipe
}, cfg.Backup.RestoreStorage, logger))
// The signed-jobs runner (slice 10B) is wired as a SECOND envelope observer below (after the
// gate is built) — when the heartbeat flags pending signed ops, it fetches + verifies + executes.
@@ -1702,7 +1714,7 @@ func runSelftestEscrowConsume(ctx context.Context, logger *slog.Logger, blobPath
// and writes the recovered {tunnel_token, pbs_token} JSON to -keydest (0600). R is taken BY HAND from
// FELHOM_RECOVERY_CODE (off the command line); the recovered tokens are never logged. The drill then
// uses the tunnel token to re-establish the tunnel + the pbs token for steady-state.
func runSelftestIdentityConsume(ctx context.Context, logger *slog.Logger, blobPath, keyDest string) int {
func runSelftestIdentityConsume(ctx context.Context, cfg config.Config, logger *slog.Logger, blobPath, keyDest string, installWGKey bool) int {
if blobPath == "" || keyDest == "" {
fmt.Fprintln(os.Stderr, "selftest=identity-consume requires -blob and -keydest (R via env FELHOM_RECOVERY_CODE)")
return 2
@@ -1732,6 +1744,28 @@ func runSelftestIdentityConsume(ctx context.Context, logger *slog.Logger, blobPa
return 1
}
fmt.Printf(" [OK] identity recovered (tunnel_token + pbs_token) → %s (0600) — never printed\n", keyDest)
// S5 DR: install the recovered WG private key so the tunnel re-establishes with the SAME
// identity/pubkey (→ the same hub /32), no fresh keygen. Create-only (refuses to overwrite a
// present key). The VALUE is never printed — field NAME only.
if installWGKey {
stateDir := cfg.WGTunnel.WithDefaults().StateDir
switch {
case bundle.WGPrivateKey == "":
fmt.Printf(" [WARN] -install-wg-key set but the recovered bundle has NO wg_private_key (pre-S3 blob) — "+
"DR falls back to fresh keygen + re-register (keeps the /32 via hub re-key-in-place). key path: %s\n",
wgtunnel.KeyFilePath(stateDir))
default:
if err := wgtunnel.InstallRecoveredKey(stateDir, bundle.WGPrivateKey); err != nil {
fmt.Fprintln(os.Stderr, " [FAIL] install recovered wg key:", err) // never contains the key value
return 1
}
logger.Info("escrow: installed recovered identity field", "field", "wg_private_key",
"key_path", wgtunnel.KeyFilePath(stateDir)) // NAME only, never the value
fmt.Printf(" [OK] recovered wg_private_key installed at %s (0600, create-only) — tunnel will re-establish with the same identity\n",
wgtunnel.KeyFilePath(stateDir))
}
}
fmt.Println("=== selftest=identity-consume OK ===")
return 0
}