diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ec6d7e..2ec921d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,14 @@ -## Unreleased (2026-10-22 night) — root SSH from the LAN by key only (R-982, operator ruling D4); the tunnel's future-stamped handshake is reported (R-1204) — COMMITTED, NOT RELEASED (no agent release tonight) +## Unreleased (2026-10-22 night) — the tunnel's future-stamped handshake is reported (R-1204) — COMMITTED, NOT RELEASED (no agent release tonight); root SSH by key only (R-982, D4) is a READY PATCH, not on main -- **R-982 / D4 — the host's own sshd: root by key only.** New bundle file `/etc/ssh/sshd_config.d/10-felhom-root-key-only.conf` - (`PermitRootLogin prohibit-password`; source `configs/felhom-root-ssh-key-only.conf`, policy `replace`). Debian's - `sshd_config` reads that folder before its own `PermitRootLogin yes`, and the first value wins (read on demo-hp). - The root password still works on the console; the operator's door (felhom-sshd, port 8822) reads only - `/etc/felhom-sshd/sshd_config`, which includes nothing from there. `felhom-os-apply`: a new content check `sshd` - (`sshd -t -f` on the snippet, and the snippet must set exactly `PermitRootLogin prohibit-password` — `yes` undoes the - ruling, `no` would shut the root KEY out too), and after an install that wrote it: `sshd -t` (the whole config; a - failure is a refusal and the bundle undo puts the old files back), then `systemctl reload ssh` (open sessions stay). - A new box gets it from the installer's `--install-bundle` (the same bundle). Tests `RootKeyOnly` (3) in - `configs/test_felhom_config_bundle.py`, red-proved both ways. +- **R-982 / D4 — a ready patch, NOT on main.** Built and proven the same night (felhom.eu + `audits/night-2026-10-22/P2-D4/`): a bundle drop-in `/etc/ssh/sshd_config.d/10-felhom-root-key-only.conf` + (`PermitRootLogin prohibit-password`), a `sshd` content check (exactly `prohibit-password`; `sshd -t -f`), and after + an install that wrote it `sshd -t` + `systemctl reload ssh`; tests `RootKeyOnly` (3), red-proved. Live on a fresh + nested box from ISO 1.30.0: root password over SSH refused, root key works, the console and the web page still take + the password. It was committed (`a0c88e5`) and moved back out (this commit): the installer's uninstall must remove + every bundle file (felhom.eu `scripts/test_hostinstall.py`), and a push of `felhom-host-install.sh` publishes the + installer to every new box — forbidden tonight. Both halves are patches: `felhom-agent-bundle-D4.patch` and + `felhom.eu-installer-D4.patch` in that folder; apply them together with the agent release. - **R-1204 — `wireguard.handshake_future_s`.** A box that ran once with its clock ahead keeps a handshake stamped in the future; ep0 refuses every newer one, and `last_handshake_age_s` (clamped at 0) read „just now" for ever. The heartbeat now carries how many seconds the handshake lies ahead (> 60 s only); the age keeps its clamp. The hub alarms on it diff --git a/configs/felhom-os-apply b/configs/felhom-os-apply index 3b6fc9a..81565fb 100755 --- a/configs/felhom-os-apply +++ b/configs/felhom-os-apply @@ -165,8 +165,7 @@ KERNEL_ACTIVE = ("staged", "oneshot", "judging", "reverting") # dest: (source file under configs/, mode, check, policy) # check: the content check BEFORE anything is written (R18): sudoers → visudo -cf; sh / bash → -n; python → compile; # unit → a [Unit]/[Service]/[Timer] section; unit-nort → that, and never RuntimeDirectory= (the G1 incident); -# agent-unit → User=felhom-agent; dropin → a [Unit] section (SF-3); nft → nft -c -f; plain → none; -# sshd → `sshd -t -f` on the snippet alone (R-982). +# agent-unit → User=felhom-agent; dropin → a [Unit] section (SF-3); nft → nft -c -f; plain → none. # policy: replace → always written when it differs; if-absent → only when the box has none (a setting the operator may # have tuned); oob → only on a box with the OOB belt (/etc/felhom-sshd exists — the installer's --no-oob leaves none). # Order matters: the sudoers files come LAST, so a referenced wrapper is in place before the line that allows it. @@ -222,9 +221,6 @@ BUNDLE_FILES = [ ("/etc/systemd/system/felhom-sshd.service", "felhom-sshd.service", 0o644, "unit-nort", "oob"), ("/etc/felhom-oob.nft", "felhom-oob.nft", 0o644, "nft", "oob"), ("/etc/systemd/system/felhom-oob-nft.service", "felhom-oob-nft.service", 0o644, "unit", "oob"), - # R-982, operator ruling D4 (2026-10-22): the host's own sshd (port 22, the LAN): root by key only. The console - # password stays; felhom-sshd (the operator's door) reads nothing from sshd_config.d. Reloaded in after_install. - ("/etc/ssh/sshd_config.d/10-felhom-root-key-only.conf", "felhom-root-ssh-key-only.conf", 0o644, "sshd", "replace"), ("/etc/sudoers.d/felhom-op", "felhom-op.sudoers", 0o440, "sudoers", "oob"), ("/etc/sudoers.d/felhom-agent", "felhom-agent.sudoers", 0o440, "sudoers", "replace"), ] @@ -438,8 +434,7 @@ class Runner: def check_content(self, kind, data): """Run an external syntax check on a root-only temp copy. Returns (rc, message).""" import tempfile - cmd = {"sudoers": ["visudo", "-cf"], "sh": ["sh", "-n"], "bash": ["bash", "-n"], "nft": ["nft", "-c", "-f"], - "sshd": ["/usr/sbin/sshd", "-t", "-f"]}[kind] + cmd = {"sudoers": ["visudo", "-cf"], "sh": ["sh", "-n"], "bash": ["bash", "-n"], "nft": ["nft", "-c", "-f"]}[kind] with tempfile.TemporaryDirectory(prefix="felhom-bundle-") as d: p = os.path.join(d, "f") with open(p, "wb") as f: @@ -1937,16 +1932,10 @@ class Bundle: text = content.decode("utf-8") except UnicodeDecodeError: raise Refused("R18", f"{dest} is not UTF-8 text") - if check in ("sudoers", "sh", "bash", "nft", "sshd"): + if check in ("sudoers", "sh", "bash", "nft"): rc, msg = self.r.check_content(check, content) if rc != 0: raise Refused("R18", f"{dest} fails its {check} check: {msg}") - if check == "sshd": - # R-982: the drop-in may only ever make root KEY-ONLY. `no` would also shut the root key out (Proxmox's own - # node-to-node SSH and the LAN key route), `yes` would undo the ruling — both refused. - vals = re.findall(r"^\s*PermitRootLogin\s+(\S+)\s*$", text, re.M | re.I) - if vals != ["prohibit-password"]: - raise Refused("R18", f"{dest} must set exactly `PermitRootLogin prohibit-password` (found {vals})") elif check == "python": try: compile(text, dest, "exec") @@ -2084,12 +2073,6 @@ class Bundle: "enable the crash guard") if "/etc/systemd/system/felhom-mgmt-watchdog.timer" in dests: self.must(["systemctl", "enable", "--now", "felhom-mgmt-watchdog.timer"], "enable the mgmt watchdog timer") - # R-982: the host's sshd takes the root-key-only drop-in. The WHOLE config is tested first (a failure is a - # refusal, and the bundle undo puts the old files back); `reload` keeps every open session, including the - # operator's. felhom-sshd is not touched. - if "/etc/ssh/sshd_config.d/10-felhom-root-key-only.conf" in written: - self.must(["/usr/sbin/sshd", "-t"], "sshd -t (the host's whole sshd config)") - self.must(["systemctl", "reload", "ssh"], "systemctl reload ssh") def must(self, argv, what): rc, out, err = self.r.host(argv, 120) diff --git a/configs/felhom-root-ssh-key-only.conf b/configs/felhom-root-ssh-key-only.conf deleted file mode 100644 index f2ff547..0000000 --- a/configs/felhom-root-ssh-key-only.conf +++ /dev/null @@ -1,7 +0,0 @@ -# MANAGED BY FELHOM (the agent's config bundle) — R-982, operator ruling D4 (2026-10-22). -# The Proxmox host's OWN sshd — the listener the household network reaches on port 22: root logs in with a KEY only. -# The root password still works on the console (the break-glass password, `03` §8a). The operator's door -# (felhom-sshd, port 8822, /etc/felhom-sshd/sshd_config) is a separate daemon whose config includes nothing from -# here, so it is not changed. Debian's sshd_config reads this folder BEFORE its own `PermitRootLogin yes`, and for -# sshd the first value wins. -PermitRootLogin prohibit-password diff --git a/configs/test_felhom_config_bundle.py b/configs/test_felhom_config_bundle.py index 74f983d..1c5a6ab 100644 --- a/configs/test_felhom_config_bundle.py +++ b/configs/test_felhom_config_bundle.py @@ -429,48 +429,6 @@ class SelfCheckUndo(unittest.TestCase): self.assert_restored(box, before, rep) - -class RootKeyOnly(unittest.TestCase): - """R-982, operator ruling D4 (2026-10-22): the bundle gives the host's own sshd a root-key-only drop-in, tests the - whole config and reloads sshd — only when the file was written; a drop-in that says anything but - `prohibit-password` is refused before anything is written. - RED-PROOF: with the after_install block removed, test_fresh_box_takes_the_dropin_and_reloads fails (no reload); - with the content rule removed, test_a_dropin_that_says_yes_or_no_is_refused fails — seen failing 2026-10-22.""" - DEST = "/etc/ssh/sshd_config.d/10-felhom-root-key-only.conf" - - def test_fresh_box_takes_the_dropin_and_reloads(self): - data, sha = real_bundle() - box = Box(data, signed_job(sha)) - rc, rep = run(box) - self.assertEqual(rc, 0, rep) - self.assertIn(b"PermitRootLogin prohibit-password", box.files[self.DEST]) - self.assertEqual(box.modes[self.DEST], 0o644) - self.assertIn(["/usr/sbin/sshd", "-t"], box.calls) - self.assertIn(["systemctl", "reload", "ssh"], box.calls) - self.assertLess(box.calls.index(["/usr/sbin/sshd", "-t"]), box.calls.index(["systemctl", "reload", "ssh"])) - self.assertFalse(any("felhom-sshd" in " ".join(c) for c in box.calls), "the operator's door must not be touched") - - def test_an_unchanged_dropin_is_not_reloaded(self): - data, sha = real_bundle() - box = Box(data, signed_job(sha)) - box.put(self.DEST, (REPO / "configs" / "felhom-root-ssh-key-only.conf").read_bytes(), 0o644) - rc, rep = run(box) - self.assertEqual(rc, 0, rep) - self.assertNotIn(["systemctl", "reload", "ssh"], box.calls) - - def test_a_dropin_that_says_yes_or_no_is_refused(self): - for bad in (b"PermitRootLogin yes\n", b"PermitRootLogin no\n", b"# nothing\n", - b"PermitRootLogin prohibit-password\nPermitRootLogin yes\n"): - def edit(files, bad=bad): - for e in files: - if e["path"] == self.DEST: - e["content_b64"] = base64.b64encode(bad).decode() - data, sha = edited_bundle(edit) - box = Box(data, signed_job(sha)) - rc, rep = run(box) - self.assertNotEqual(rc, 0, bad) - self.assertNotIn(self.DEST, box.files, bad) - class TrustBootstrap(unittest.TestCase): def test_missing_signers_verifies_against_the_pinned_key_and_creates_it(self): data, sha = real_bundle()