From 7b0a8b234b0f9381d36ec5c3f20a30c07197cd3b Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 7 Oct 2026 10:02:19 +0200 Subject: [PATCH] R-105 (decision 169): remove the escrow-create -directive flag and the upload's directive field Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 3 ++ cmd/felhom-agent/main.go | 99 ++++++++++++++++++---------------------- 2 files changed, 47 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fecde6b..75fd337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ updates (and the old binary's capability probe would read `controllerswap-write` - `internal/pvegate` (new): the agent's own writes to /etc/pve wait while a pve step runs (pmxcfs restarts); the step waits for writes in flight (bounded, 2 min — then it fails and does not run). Wired at `proxmox.Client.doBody` (every non-GET) and `ExecRunner.RunStdin` (`WritesEtcPVE`: pct config verbs, pvesm, pveum, felhom-pbs-apply create/reconcile). - `internal/osupdate`: `LayerPVE`; the night leg runs the pve step in ring 0 after a healthy host step (an appliance; ring 1 never in the night leg); `PVEHealthVerdict` = the host rule + every running container keeps its id + pveversion reads the installed pve-manager; the pve report carries Proxmox userspace only (the hub's candidate set). `PVEStepExecutor` (signed `os_pve_step`, ring 1, under the heavy-op gate and the /etc/pve gate); `reconcile.ClassOSPVEStep` (destructive-class); `felhom-opsign -op os_pve_step` (params by `-params`). - Tests: wrapper `PVELane` (17; red first — the `pve-manager` plan was refused R12 on the old code), `pvegate` (5), `TestPVEGate_*` + `TestWritesEtcPVE`, `TestPVE_*`, `TestPVEHealthVerdict`, `TestPVEStepExecutor_*`. Red-proofs: `felhom.eu/documentation/audits/day-2026-10-07/B/`. +## Unreleased (2026-10-07) + +- R-105 option A (`09` §3 decision 169): the `--selftest=escrow-create -directive ` flag and the escrow upload's `directive` field are removed — nothing read the directive; the DR path reads the recipe, tenantsync and the escrow blob. The hub ignores a `directive` from an older agent. ## v0.150.0 — the Docker step proves the engine reports a memory kill; after a restart the agent remembers the last backup per tier; three more SMART counters on the wire (R-528, R-894, R-330; `09` §3 decisions 157, 161) (2026-10-07) diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index ae0bd75..3eb5a85 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -135,39 +135,38 @@ func main() { return } var ( - cfgPath string - selftest selftestFlag - vmid int - watch time.Duration - archive string - mode string - hostname string - keep bool - rootfsGrow int - dataVolGrow int - dataVolMount string - sysDataGrow int - sysDataMount string - cores int - memoryMB int - pbsStorage string - paperkey bool - offline bool - upload bool - custID string - custDomain string - custName string - custEmail string - hubPassword string - blobPath string - expectedFP string - keyDest string - installWGKey bool - idBundlePath string - directivePath string - swapImage string - outputMode string - showVersion bool + cfgPath string + selftest selftestFlag + vmid int + watch time.Duration + archive string + mode string + hostname string + keep bool + rootfsGrow int + dataVolGrow int + dataVolMount string + sysDataGrow int + sysDataMount string + cores int + memoryMB int + pbsStorage string + paperkey bool + offline bool + upload bool + custID string + custDomain string + custName string + custEmail string + hubPassword string + blobPath string + expectedFP string + keyDest string + installWGKey bool + idBundlePath string + swapImage string + outputMode string + showVersion bool ) flag.StringVar(&cfgPath, "config", envOr("FELHOM_AGENT_CONFIG", "/etc/felhom-agent/agent.json"), "path to the agent config file (JSON)") flag.Var(&selftest, "selftest", "run a self-test and exit: bare/`read` = read-only queries; `task` = reversible mutating exercise (needs -vmid); `hub` = one collect+report; `storage` = observe storage (+ -watch); `backup` = one-shot backup of -vmid; `restore-test` = restore→boot→verify→teardown of -archive (or newest backup); `restore-test-due` = READ-ONLY: print the per-tier due verdict the scheduler would act on, with its cost; `pbs-verify` = trigger a PBS verify + print snapshot records; `bring-up` = restore→reset identity→size→start link-up of -archive into -vmid (needs -mode/-archive/-vmid; optional -cores/-memory cap; tears down unless -keep); `provision` = full slice-8A chain: bring-up provision + mint token + populate bootstrap config mount (needs -archive/-vmid/-customer-id/-hub-password; optional -rootfs-grow/-datavol-grow/-cores/-memory (-sysdata-grow is deprecated: folded into -datavol-grow); keeps the guest)") @@ -198,7 +197,6 @@ func main() { 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") flag.StringVar(&hubPassword, "hub-password", "", "for --selftest=provision: the customer's hub RETRIEVAL PASSPHRASE (SECRET) — baked into bootstrap.json so the controller pulls its config (and the customer-scoped hub key) from the hub. The customer must already exist in the hub.") flag.StringVar(&swapImage, "image", "", "for --selftest=controller-swap: the target controller image ref (gitea.dooplex.hu/admin/felhom-controller:) — must already be pulled in the guest") @@ -269,7 +267,7 @@ func main() { SysDataGrowGB: sysDataGrow, SysDataMount: sysDataMount, Cores: cores, MemoryMB: memoryMB}, })) case "escrow-create": - os.Exit(runSelftestEscrowCreate(context.Background(), cfg, logger, pbsStorage, paperkey, offline, upload, idBundlePath, directivePath, outputMode)) + os.Exit(runSelftestEscrowCreate(context.Background(), cfg, logger, pbsStorage, paperkey, offline, upload, idBundlePath, outputMode)) case "escrow-consume": os.Exit(runSelftestEscrowConsume(context.Background(), logger, blobPath, expectedFP, keyDest)) case "identity-consume": @@ -2699,7 +2697,6 @@ type escrowCeremonyOpts struct { offline bool upload bool identityBundlePath string - directivePath string } // escrowCeremonyOutcome is the shared core's result. R is the ONLY secret; Sum mirrors the @@ -2758,11 +2755,10 @@ func escrowCeremony(ctx context.Context, cfg config.Config, logger *slog.Logger, return out, &escrowCeremonyErr{kind: "setup", err: fmt.Errorf("PBS key for %q not found (%s): %v", storage, keyPath, err)} } - // Slice 10D.1: optionally ALSO wrap the identity bundle under the same R, and carry the non-secret - // directive for the hub. The bundle file is a 0600 secret (tunnel/pbs tokens); the directive is - // non-secret (pbs repo/ns, expected fingerprint, tunnel id). + // Slice 10D.1: optionally ALSO wrap the identity bundle under the same R. The bundle file is a 0600 secret + // (tunnel/pbs tokens). The non-secret "directive" that used to ride along is retired (R-105, `09` §3 decision 169: + // nothing read it; the DR path reads the recipe, tenantsync and the escrow blob). var identity *escrow.IdentityBundle - var directive json.RawMessage if opts.identityBundlePath != "" { raw, err := os.ReadFile(opts.identityBundlePath) if err != nil { @@ -2773,11 +2769,6 @@ func escrowCeremony(ctx context.Context, cfg config.Config, logger *slog.Logger, return out, &escrowCeremonyErr{kind: "setup", err: fmt.Errorf("identity bundle is not valid JSON {tunnel_token,pbs_token}: %v", err)} } identity = &b - if opts.directivePath != "" { - if d, err := os.ReadFile(opts.directivePath); err == nil && json.Valid(d) { - directive = d - } - } } // S3: auto-inject the offsite WG private key into the escrowed identity when the key file // exists — a NEW escrow run should always capture the live tunnel identity. Field NAME only @@ -2856,7 +2847,7 @@ func escrowCeremony(ctx context.Context, cfg config.Config, logger *slog.Logger, ResticPwSealed: resticStaged, } if opts.upload { - if err := uploadEscrowBlob(ctx, cfg, res, directive, resticPwSHA256); err != nil { + if err := uploadEscrowBlob(ctx, cfg, res, resticPwSHA256); err != nil { // R is minted and the blob self-verified — only the hub leg failed. kind "upload" lets // the text shell keep the pre-extraction order (R surfaced, THEN the failure). return out, &escrowCeremonyErr{kind: "upload", err: err} @@ -2913,7 +2904,7 @@ func printEscrowTextRBlock(out *escrowCeremonyOutcome) { // NOTHING else there; every human/info line goes to stderr; failures exit non-zero with no // partial JSON. This is the controller-driven ceremony's parse surface (spike §2.3: the text // banner is positionally brittle). -func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slog.Logger, storage string, paperkey, offline, upload bool, identityBundlePath, directivePath, outputMode string) int { +func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slog.Logger, storage string, paperkey, offline, upload bool, identityBundlePath, outputMode string) int { switch outputMode { case "", "text", "json": default: @@ -2931,7 +2922,7 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo out, cerr := escrowCeremony(ctx, cfg, logger, escrowCeremonyOpts{ storage: storage, paperkey: paperkey, offline: offline, upload: upload, - identityBundlePath: identityBundlePath, directivePath: directivePath, + identityBundlePath: identityBundlePath, }) if cerr != nil { switch cerr.kind { @@ -3114,20 +3105,19 @@ type escrowUploadRequest struct { BlobB64 string `json:"blob_b64"` // base64 of the opaque R-wrapped blob (ciphertext) KeyFingerprint string `json:"key_fingerprint"` // for operator display only Posture string `json:"posture"` // e.g. "zero_knowledge" - // Slice 10D.1 — optional DR bundle (identity escrow + non-secret directive). Omitted in slice-7. - IdentityBlobB64 string `json:"identity_blob_b64,omitempty"` - DirectiveJSON json.RawMessage `json:"directive,omitempty"` - CreatedAt string `json:"created_at"` // RFC3339 + // Slice 10D.1 — optional identity escrow. Omitted in slice-7. (The `directive` is retired — R-105.) + IdentityBlobB64 string `json:"identity_blob_b64,omitempty"` + CreatedAt string `json:"created_at"` // RFC3339 // SLICE 3 — sha256 hex of the offsite restic repo password sealed in the identity blob (present only // when a staged password was folded in). Non-reversible hash of a 256-bit random secret — safe to // store/serve; lets the controller VERIFY "the escrow covers the CURRENT key" and auto-confirm. ResticPwSHA256 string `json:"restic_pw_sha256,omitempty"` } -// uploadEscrowBlob PUTs the opaque blob (and, for 10D, the identity blob + non-secret directive) to +// uploadEscrowBlob PUTs the opaque blob (and, for 10D, the identity blob) to // the hub, authed with the per-host key. The hub stores ciphertext + non-secret fields; no usable // secret leaves the agent. -func uploadEscrowBlob(ctx context.Context, cfg config.Config, res escrow.CreateResult, directive json.RawMessage, resticPwSHA256 string) error { +func uploadEscrowBlob(ctx context.Context, cfg config.Config, res escrow.CreateResult, resticPwSHA256 string) error { if cfg.Hub.URL == "" || cfg.Hub.HostID == "" || cfg.Hub.APIKey == "" { return fmt.Errorf("hub not configured (url/host_id/api_key)") } @@ -3140,7 +3130,6 @@ func uploadEscrowBlob(ctx context.Context, cfg config.Config, res escrow.CreateR } if len(res.IdentityBlob) > 0 { upReq.IdentityBlobB64 = base64.StdEncoding.EncodeToString(res.IdentityBlob) - upReq.DirectiveJSON = directive } body, _ := json.Marshal(upReq) url := strings.TrimRight(cfg.Hub.URL, "/") + "/api/v1/hosts/" + cfg.Hub.HostID + "/escrow"