138 lines
8.4 KiB
Diff
138 lines
8.4 KiB
Diff
commit a0c88e5183838ebad04d5aad4acb3e05e1c1c153
|
|
Author: kisfenyo <nagyfenyvesi.viktor@gmail.com>
|
|
Date: Thu Oct 22 16:58:45 2026 +0200
|
|
|
|
R-982 (D4): root SSH from the LAN by key only — a bundle drop-in, checked and reloaded by felhom-os-apply; R-1204: the heartbeat reports a future-stamped tunnel handshake; unreleased
|
|
|
|
diff --git a/configs/felhom-os-apply b/configs/felhom-os-apply
|
|
index 81565fb..3b6fc9a 100755
|
|
--- a/configs/felhom-os-apply
|
|
+++ b/configs/felhom-os-apply
|
|
@@ -165,7 +165,8 @@ 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.
|
|
+# 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).
|
|
# 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.
|
|
@@ -221,6 +222,9 @@ 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"),
|
|
]
|
|
@@ -434,7 +438,8 @@ 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"]}[kind]
|
|
+ cmd = {"sudoers": ["visudo", "-cf"], "sh": ["sh", "-n"], "bash": ["bash", "-n"], "nft": ["nft", "-c", "-f"],
|
|
+ "sshd": ["/usr/sbin/sshd", "-t", "-f"]}[kind]
|
|
with tempfile.TemporaryDirectory(prefix="felhom-bundle-") as d:
|
|
p = os.path.join(d, "f")
|
|
with open(p, "wb") as f:
|
|
@@ -1932,10 +1937,16 @@ 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"):
|
|
+ if check in ("sudoers", "sh", "bash", "nft", "sshd"):
|
|
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")
|
|
@@ -2073,6 +2084,12 @@ 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
|
|
new file mode 100644
|
|
index 0000000..f2ff547
|
|
--- /dev/null
|
|
+++ b/configs/felhom-root-ssh-key-only.conf
|
|
@@ -0,0 +1,7 @@
|
|
+# 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 1c5a6ab..74f983d 100644
|
|
--- a/configs/test_felhom_config_bundle.py
|
|
+++ b/configs/test_felhom_config_bundle.py
|
|
@@ -429,6 +429,48 @@ 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()
|