diff --git a/CHANGELOG.md b/CHANGELOG.md index b34eb42..0e3fc97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## 2026-10-25 (night, unattended) — a BYO host loses our SSH drop-in in ONE update (R-1285) — committed, NOT released + +- **The cause, measured 2026-10-25 on a BYO test host:** the bundle that brings a new `felhom-os-apply` is planned by + the OLD one on disk, which did not know D43's appliance-only policy — so a BYO host upgraded from 0.164.0 kept our + drop-in until its next bundle. A change inside the wrapper alone cannot fix the run that delivers it. +- **The fix — the new AGENT asks the new wrapper:** `internal/osupdate/bundle.go` runs a policy pass right after every + bundle install (`mode: "bundle-policy"`, host layer, no envelope); the wrapper on disk is the bundle's own by then. + `configs/felhom-os-apply` `Bundle.policy_pass`: on a root-owned BYO record it removes the appliance-only files WE + wrote (our header), keeps the previous copy beside the bundle's, `sshd -t` then reloads ssh — a failing `sshd -t` puts + the file back. It writes nothing, so it needs no signature; an appliance and the owner's own file are left alone. Never + fatal for the agent: an older wrapper refuses the unknown mode and the next bundle does it. Report key `bundle_policy`. +- Tests: `TestR1285_AnInstalledBundleIsFollowedByThePolicyPass` (a refused bundle runs no pass); + `ByoKeepsItsSSH.test_the_policy_pass_*` (three). Red-proofs: felhom.eu + `documentation/audits/night-2026-10-25/redproof-r1285.txt`. + ## 2026-10-25 (night, unattended) — a signed job's window is at most 24 h (R-1280, D44 #22) — committed, NOT released (rides the next agent release; the vouch is the operator's) - `internal/authz/verifier.go`: `MaxWindow = 24h`; a blob whose `expires_at - issued_at` is longer is refused diff --git a/configs/felhom-os-apply b/configs/felhom-os-apply index 86666b5..4a2582e 100755 --- a/configs/felhom-os-apply +++ b/configs/felhom-os-apply @@ -491,7 +491,8 @@ class Apply: def check_plan(self, plan): mode = plan.get("mode", "apply") - if mode not in ("apply", "inventory", "health", "facts", "live-restore-on", "bundle", "agent_update", "oom-check") \ + if mode not in ("apply", "inventory", "health", "facts", "live-restore-on", "bundle", "bundle-policy", "agent_update", + "oom-check") \ + KERNEL_MODES: raise Refused("R11", f"unknown mode {mode!r}") if mode == "agent_update": @@ -502,6 +503,10 @@ class Apply: if plan.get("layer") != "host": raise Refused("R11", "bundle is a host-layer mode") return mode, "host", 0, "bundle" + if mode == "bundle-policy": + if plan.get("layer") != "host": + raise Refused("R11", "bundle-policy is a host-layer mode") + return mode, "host", 0, "bundle-policy" layer = plan.get("layer") if layer not in ("guest", "host", "docker", "pve", "kernel"): raise Refused("R12", f"layer {layer!r} is not guest, host, docker, pve or kernel") @@ -1074,6 +1079,8 @@ class Apply: return self.facts() if self.mode == "bundle": return Bundle(self).from_plan(plan) + if self.mode == "bundle-policy": + return Bundle(self).policy_pass() if self.mode == "agent_update": return self.agent_update(plan) if self.layer in ("host", "pve", "kernel"): @@ -2064,6 +2071,42 @@ class Bundle: ver, files = self.parse(data, sha) return self.install(ver, sha, files, "installer", False) + def policy_pass(self): + """R-1285 (D43 = A): the appliance-only files' policy, applied by THIS wrapper. A bundle that brings a new wrapper + is planned by the OLD one, which did not know the policy — so on a BYO host upgraded from 0.164.0 our SSH drop-in + stayed until the next bundle. The agent runs this pass right after every bundle install (the wrapper on disk is + then the new one). It only REMOVES a file WE wrote (our header) from a box whose root-owned install record says + byo, keeps the previous copy beside the bundle's, and reloads sshd; it writes nothing, so it needs no signature. + A failing `sshd -t` puts the file back (R-1257 X4's rule). Report key `bundle_policy`.""" + log = self.r.log + install_mode = self.install_mode() + removed = [] + if install_mode == "byo": + stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime(self.r.now())) + prev = os.path.join(BUNDLE_PREV_DIR, f"{stamp}-policy") + for dest, _src, _mode, _check, policy in BUNDLE_FILES: + if policy != "appliance": + continue + old, old_mode, _ = self.live(dest) + if old is None or not old.startswith(SSHD_DROPIN_HEADER): + continue + self.r.put_file(prev + dest, old, 0o600) + self.r.remove(dest) + removed.append((dest, old, old_mode)) + log(f"os-apply: BUNDLE POLICY REMOVED {dest} (ours, on a BYO host — D43, R-1285)") + if any(d == SSHD_DROPIN for d, _, _ in removed): + rc, out, err = self.r.host(["/usr/sbin/sshd", "-t"], 120) + if rc != 0: + for d, old, old_mode in removed: + self.r.put_file(d, old, old_mode) + log(f"os-apply: BUNDLE POLICY WARNING sshd -t failed without our drop-in (rc={rc}) — put back") + removed = [] + else: + self.must(["systemctl", "try-reload-or-restart", "ssh"], "systemctl try-reload-or-restart ssh") + self.report["bundle_policy"] = {"install_mode": install_mode, "removed": [d for d, _, _ in removed]} + log(f"os-apply: BUNDLE POLICY DONE install_mode={install_mode} removed={len(removed)}") + return 0 + # ---------- install, self-check, undo ---------- def install(self, ver, sha, files, authority, bootstrap): log = self.r.log diff --git a/configs/test_felhom_config_bundle.py b/configs/test_felhom_config_bundle.py index 3bdcf9c..95f264b 100644 --- a/configs/test_felhom_config_bundle.py +++ b/configs/test_felhom_config_bundle.py @@ -588,6 +588,47 @@ class ByoKeepsItsSSH(unittest.TestCase): return orig(argv, timeout, stdin) box.host = host + # R-1285: the policy pass the agent runs right after every bundle install, with the wrapper NOW on disk. No + # signature (it only removes a file we wrote). RED-PROOF: make policy_pass return before the loop → our copy stays. + POLICY_PLAN = {"release_id": "bundle-policy-0.166.0", "layer": "host", "mode": "bundle-policy"} + + def test_the_policy_pass_removes_our_copy_from_a_byo_host(self): + data, sha = real_bundle() + box = Box(data, None, install_mode="byo") + box.put(PLAN, json.dumps(self.POLICY_PLAN).encode(), 0o600, uid=999) + box.put(self.DEST, self.OURS, 0o644) # the old wrapper's bundle run left it + rc, rep = run(box) + self.assertEqual(rc, 0, rep) + self.assertNotIn(self.DEST, box.files) + self.assertEqual(rep["bundle_policy"], {"install_mode": "byo", "removed": [self.DEST]}, rep) + self.assertTrue(any(k.endswith(self.DEST) and k.startswith(osapply.BUNDLE_PREV_DIR) for k in box.files)) + self.assertLess(box.calls.index(["/usr/sbin/sshd", "-t"]), box.calls.index(["systemctl", "try-reload-or-restart", "ssh"])) + + def test_the_policy_pass_leaves_an_appliance_and_the_owners_file(self): + for mode, content in (("appliance", None), ("byo", b"# my own\nPermitRootLogin yes\n")): + data, sha = real_bundle() + box = Box(data, None, install_mode=mode) + box.put(PLAN, json.dumps(self.POLICY_PLAN).encode(), 0o600, uid=999) + body = content if content is not None else self.OURS + box.put(self.DEST, body, 0o644) + rc, rep = run(box) + self.assertEqual(rc, 0, rep) + self.assertEqual(box.files.get(self.DEST), body, mode) + self.assertEqual(rep["bundle_policy"]["removed"], [], mode) + self.assertNotIn(["systemctl", "try-reload-or-restart", "ssh"], box.calls, mode) + + def test_the_policy_pass_puts_our_copy_back_when_sshd_t_fails(self): + data, sha = real_bundle() + box = Box(data, None, install_mode="byo") + box.put(PLAN, json.dumps(self.POLICY_PLAN).encode(), 0o600, uid=999) + box.put(self.DEST, self.OURS, 0o644) + self._fake(box, sshd_t_rc=1) + rc, rep = run(box) + self.assertEqual(rc, 0, rep) + self.assertEqual(box.files.get(self.DEST), self.OURS) + self.assertEqual(rep["bundle_policy"]["removed"], []) + self.assertNotIn(["systemctl", "try-reload-or-restart", "ssh"], box.calls) + def test_a_match_block_for_the_lan_is_not_in_force(self): data, sha = real_bundle() box = Box(data, signed_job(sha)) diff --git a/internal/osupdate/bundle.go b/internal/osupdate/bundle.go index b9b6fb6..0676316 100644 --- a/internal/osupdate/bundle.go +++ b/internal/osupdate/bundle.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" "os" "path/filepath" @@ -114,12 +115,30 @@ func (e ConfigUpdateExecutor) Execute(ctx context.Context, op string, params jso return fmt.Errorf("agent_config_update: failed (previous files restored): %s", wr.Failed) } lg.Warn("osupdate: config bundle INSTALLED", "bundle", string(wr.Bundle), "pass_seconds", wr.PassSeconds) + e.policyPass(ctx, lg, p.AgentVersion) if e.AfterInstall != nil { e.AfterInstall(ctx) } return nil } +// policyPass (R-1285) asks the wrapper NOW on disk — after this install, the bundle's own — to apply the +// appliance-only files' policy (mode "bundle-policy"): on a BYO host it removes the SSH drop-in we wrote. The bundle run +// itself was planned by the wrapper that was on disk BEFORE it, which may predate the policy (0.164.0). Never fatal: +// the bundle is installed either way; an older wrapper refuses the unknown mode and the next bundle does it. +func (e ConfigUpdateExecutor) policyPass(ctx context.Context, lg *slog.Logger, ver string) { + runID := "bundle-policy-" + e.Leg.now().UTC().Format("20060102T150405Z") + wr, err := e.Leg.call(ctx, runID, map[string]any{"release_id": "bundle-policy-" + ver, "layer": LayerHost, "mode": "bundle-policy"}) + switch { + case err != nil: + lg.Warn("osupdate: the bundle policy pass did not run", "err", err) + case wr.refused(): + lg.Info("osupdate: the bundle policy pass was refused (a wrapper before 0.166.0?) — the next bundle applies it", "refused", string(wr.Refused)) + default: + lg.Info("osupdate: bundle policy pass done", "bundle_policy", string(wr.BundlePolicy)) + } +} + func (e ConfigUpdateExecutor) download(ctx context.Context, url, dest string) (string, error) { hc := e.HTTPClient if hc == nil { diff --git a/internal/osupdate/bundle_test.go b/internal/osupdate/bundle_test.go index 81ed399..e56cf36 100644 --- a/internal/osupdate/bundle_test.go +++ b/internal/osupdate/bundle_test.go @@ -38,6 +38,9 @@ func (b *bundleWrapper) Run(_ context.Context, name string, args ...string) ([]b var plan map[string]any _ = json.Unmarshal(raw, &plan) b.plans = append(b.plans, plan) + if plan["mode"] == "bundle-policy" { // R-1285: the pass after an install carries no bundle + return []byte(`OSAPPLY-REPORT {"mode":"bundle-policy","bundle_policy":{"install_mode":"byo","removed":[]}}` + "\n"), nil, nil + } body, _ := os.ReadFile(plan["bundle"].(string)) b.bodies = append(b.bodies, body) return []byte("OSAPPLY-REPORT " + b.rep + "\n"), nil, nil @@ -153,3 +156,27 @@ func TestBundleURL(t *testing.T) { func (b *bundleWrapper) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) { return b.Run(ctx, name, args...) } + +// R-1285: right after an install the agent asks the wrapper now on disk for the appliance-only policy pass — the bundle +// run itself was planned by the OLD wrapper (on a BYO host upgraded from 0.164.0 our SSH drop-in stayed one bundle +// longer). A refused or failed bundle runs no pass. +// RED-PROOF: drop the policyPass call → one plan only. +func TestR1285_AnInstalledBundleIsFollowedByThePolicyPass(t *testing.T) { + body := []byte(`{"format":1,"agent_version":"0.143.0","files":[]}`) + srv, sha := serveBundle(t, body) + w := &bundleWrapper{t: t, rep: `{"mode":"bundle","bundle":{"agent_version":"0.143.0","written":["/usr/local/sbin/felhom-os-apply"]}}`} + e, _ := bundleExec(t, w, srv.URL) + if err := e.Execute(signedCtx(), OpConfigUpdate, params("0.143.0", sha)); err != nil { + t.Fatal(err) + } + if len(w.plans) != 2 || w.plans[1]["mode"] != "bundle-policy" || w.plans[1]["layer"] != "host" || w.plans[1]["signed"] != nil { + t.Fatalf("after the install the agent must run the policy pass (host layer, no envelope): %v", w.plans) + } + + w2 := &bundleWrapper{t: t, rep: `{"mode":"bundle","refused":{"code":"R3","reason":"x"}}`} + e2, _ := bundleExec(t, w2, srv.URL) + _ = e2.Execute(signedCtx(), OpConfigUpdate, params("0.143.0", sha)) + if len(w2.plans) != 1 { + t.Fatalf("a refused bundle must run no policy pass: %v", w2.plans) + } +} diff --git a/internal/osupdate/leg.go b/internal/osupdate/leg.go index 80116f0..92496db 100644 --- a/internal/osupdate/leg.go +++ b/internal/osupdate/leg.go @@ -123,7 +123,8 @@ type WrapperReport struct { Undo bool `json:"undo"` LiveRestore json.RawMessage `json:"live_restore"` Facts json.RawMessage `json:"facts"` - Bundle json.RawMessage `json:"bundle"` // the config bundle's result (R-840, mode "bundle") + Bundle json.RawMessage `json:"bundle"` // the config bundle's result (R-840, mode "bundle") + BundlePolicy json.RawMessage `json:"bundle_policy"` // the appliance-only policy pass (R-1285, mode "bundle-policy") // OOMCheck (R-528, `09` decision 157): the docker layer's memory-kill check, {result, oom_killed, oom_event, // exit_code, image, detail}. Carried to the hub UNCHANGED; the agent never reads it. OOMCheck json.RawMessage `json:"oom_check"`