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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -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 <file>` 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)
|
||||
|
||||
|
||||
+44
-55
@@ -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:<semver>) — 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"
|
||||
|
||||
Reference in New Issue
Block a user