D4 (R-982): root SSH from the LAN by key only — the bundle's sshd drop-in, try-reload-or-restart, an sshd -T read-back reported when not in force, a reload in undo (the reviewed v2 patch); unreleased
This commit is contained in:
@@ -21,6 +21,14 @@
|
||||
`TestD30_TheHourBoundSurvivesARestart`, red-proved. **Live (felhom.eu `audits/night-2026-10-23/P2-D30/`):** the D30
|
||||
re-key ran on a test box (before these two changes): clock corrected → 15 min → new key registered → the box switched
|
||||
at its next desired-state fetch → a handshake with the new key.
|
||||
- **D4 (operator ruling 2026-10-22, R-982) — root SSH from the LAN by key only, on `main` now (2026-10-23 day brief).** The
|
||||
config bundle carries `/etc/ssh/sshd_config.d/10-felhom-root-key-only.conf` (`PermitRootLogin prohibit-password`;
|
||||
content check: exactly that line, `sshd -t -f`). After an install that wrote it: `sshd -t` (the whole config), then
|
||||
`systemctl try-reload-or-restart ssh` (a stopped or socket-activated sshd no longer refuses the bundle), then `sshd -T`
|
||||
is read back and a drop-in that is NOT in force (no Include, an earlier drop-in, a Match block) is said loudly and
|
||||
reported as `sshd_dropin_not_in_force` — never a refusal; an undone bundle reloads sshd again. The console and the
|
||||
web page keep the root password; felhom-sshd (8822) is not touched. Tests `RootKeyOnly` (6), red-proved; the
|
||||
installer's uninstall removes the file (felhom.eu installer 1.36.0). Reviewed 2026-10-23 night (A1–A7).
|
||||
- **Known gap, unchanged (filed):** after a reboot the kernel forgets the future stamp (latest handshake 0), so neither
|
||||
this re-key nor the hub's alarm sees a box whose clock was corrected and which then restarted (R-1223).
|
||||
|
||||
|
||||
+41
-3
@@ -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.
|
||||
@@ -184,6 +185,9 @@ BUNDLE_KEEP = 3
|
||||
BUNDLE_MAX_BYTES = 4 * 1024 * 1024
|
||||
BUNDLE_RE = re.compile(r"^bundle-[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?\.json$")
|
||||
OOB_DIR = "/etc/felhom-sshd"
|
||||
# R-982: the host's own sshd drop-in (after_install reloads sshd for it; undo reloads after putting it back).
|
||||
SSHD_DROPIN = "/etc/ssh/sshd_config.d/10-felhom-root-key-only.conf"
|
||||
|
||||
BUNDLE_FILES = [
|
||||
("/usr/local/sbin/felhom-mkfs-guarded", "felhom-mkfs-guarded.sh", 0o755, "bash", "replace"),
|
||||
("/usr/local/sbin/felhom-selfupdate-guarded", "felhom-selfupdate-guarded", 0o755, "sh", "replace"),
|
||||
@@ -221,6 +225,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 +441,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:
|
||||
@@ -1864,6 +1872,7 @@ class Bundle:
|
||||
def __init__(self, apply):
|
||||
self.a, self.r = apply, apply.r
|
||||
self.report = apply.report
|
||||
self.sshd_dropin_not_in_force = False
|
||||
|
||||
# ---------- reading and checking ----------
|
||||
def load_file(self, path):
|
||||
@@ -1932,10 +1941,17 @@ 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's PermitRootLogin must be exactly `prohibit-password`. `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.
|
||||
# Only that line is pinned: the file is signed like every bundle file (review A7).
|
||||
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")
|
||||
@@ -2044,6 +2060,8 @@ class Bundle:
|
||||
self.report["failed"] = {"rc": 3, "step": "bundle", "reason": reason[:300]}
|
||||
return 3
|
||||
rep["signers_created"] = False
|
||||
if self.sshd_dropin_not_in_force:
|
||||
rep["sshd_dropin_not_in_force"] = True # R-982 A2: written and reloaded, but sshd -T shows root passwords allowed
|
||||
if bootstrap and not self.r.lexists(TRUST_SIGNERS):
|
||||
self.r.put_file(TRUST_SIGNERS, pinned_signers_line().encode(), 0o644)
|
||||
rep["signers_created"] = True
|
||||
@@ -2073,6 +2091,23 @@ 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); `try-reload-or-restart` keeps every open session and
|
||||
# does nothing on a box whose ssh.service is not running (a socket-activated or stopped sshd reads the file
|
||||
# at its next start) — a failure there must not freeze the bundle route (review 2026-10-22 night, A1).
|
||||
# felhom-sshd is not touched. Then the POSITIVE check: sshd's own effective view for root. A box whose
|
||||
# sshd_config has no Include of sshd_config.d, an earlier drop-in or a Match block can keep the password route
|
||||
# — that is said loudly in the log and the report, never silently recorded as done (A2).
|
||||
if SSHD_DROPIN in written:
|
||||
self.must(["/usr/sbin/sshd", "-t"], "sshd -t (the host's whole sshd config)")
|
||||
self.must(["systemctl", "try-reload-or-restart", "ssh"], "systemctl try-reload-or-restart ssh")
|
||||
rc, out, err = self.r.host(["/usr/sbin/sshd", "-T", "-C", "user=root,host=felhom-check,addr=192.0.2.1"], 60)
|
||||
eff = re.findall(r"^permitrootlogin\s+(\S+)", out, re.M | re.I)
|
||||
if rc != 0 or eff != ["without-password"]:
|
||||
self.r.log(f"os-apply: BUNDLE WARNING the root-key-only drop-in is NOT in force: sshd -T says "
|
||||
f"permitrootlogin={eff or '?'} (rc={rc}) — check sshd_config's Include, an earlier drop-in "
|
||||
f"or a Match block (R-982)")
|
||||
self.sshd_dropin_not_in_force = True
|
||||
|
||||
def must(self, argv, what):
|
||||
rc, out, err = self.r.host(argv, 120)
|
||||
@@ -2133,6 +2168,9 @@ class Bundle:
|
||||
self.r.log(f"os-apply: BUNDLE UNDO could not restore {dest}: {e}")
|
||||
if any(d.startswith("/etc/systemd/system/") for d in back):
|
||||
self.r.host(["systemctl", "daemon-reload"], 120)
|
||||
if SSHD_DROPIN in back:
|
||||
# A3: after_install may already have reloaded sshd with the drop-in; the restored state takes effect now.
|
||||
self.r.host(["systemctl", "try-reload-or-restart", "ssh"], 120)
|
||||
rc, _, _ = self.r.host(["visudo", "-c"], 60)
|
||||
self.r.log(f"os-apply: BUNDLE UNDO restored={len(back)} visudo-after={'ok' if rc == 0 else 'FAILED'}")
|
||||
return back
|
||||
|
||||
@@ -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: felhom.eu runbooks/break-glass.md). 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 settings, and for
|
||||
# sshd the first value wins; the bundle checks the effective value with `sshd -T` after the reload.
|
||||
PermitRootLogin prohibit-password
|
||||
@@ -429,6 +429,90 @@ 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", "try-reload-or-restart", "ssh"], box.calls)
|
||||
self.assertLess(box.calls.index(["/usr/sbin/sshd", "-t"]), box.calls.index(["systemctl", "try-reload-or-restart", "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", "try-reload-or-restart", "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)
|
||||
|
||||
# Review 2026-10-22 night. A2: the effective value is read back with `sshd -T`; a drop-in that is not in force is
|
||||
# said in the log and the report (not refused — a refusal would freeze the bundle route). A3: a bundle undone
|
||||
# after the reload reloads sshd again with the old state. RED-PROOF: with the sshd -T check removed,
|
||||
# test_a_dropin_not_in_force_is_reported fails; with the undo reload removed, test_an_undone_bundle_reloads_sshd
|
||||
# fails — seen failing 2026-10-22 night.
|
||||
def _sshd_T(self, box, value):
|
||||
orig = box.host
|
||||
def host(argv, timeout=600, stdin=None):
|
||||
if argv[:2] == ["/usr/sbin/sshd", "-T"]:
|
||||
box.calls.append(argv)
|
||||
return 0, f"port 22\npermitrootlogin {value}\npasswordauthentication yes\n", ""
|
||||
return orig(argv, timeout, stdin)
|
||||
box.host = host
|
||||
|
||||
def test_the_effective_value_is_checked(self):
|
||||
data, sha = real_bundle()
|
||||
box = Box(data, signed_job(sha))
|
||||
self._sshd_T(box, "without-password")
|
||||
rc, rep = run(box)
|
||||
self.assertEqual(rc, 0, rep)
|
||||
self.assertTrue(any(c[:2] == ["/usr/sbin/sshd", "-T"] for c in box.calls))
|
||||
self.assertNotIn("sshd_dropin_not_in_force", rep["bundle"])
|
||||
|
||||
def test_a_dropin_not_in_force_is_reported(self):
|
||||
data, sha = real_bundle()
|
||||
box = Box(data, signed_job(sha))
|
||||
self._sshd_T(box, "yes") # e.g. no Include of sshd_config.d
|
||||
rc, rep = run(box)
|
||||
self.assertEqual(rc, 0, rep)
|
||||
self.assertTrue(rep["bundle"].get("sshd_dropin_not_in_force"), rep)
|
||||
self.assertTrue(any("NOT in force" in l for l in box.logs), box.logs)
|
||||
|
||||
def test_an_undone_bundle_reloads_sshd(self):
|
||||
data, sha = real_bundle()
|
||||
box = Box(data, signed_job(sha))
|
||||
box.visudo_fail = True # the self-check fails AFTER the install → undo
|
||||
rc, rep = run(box)
|
||||
self.assertEqual(rc, 3, rep)
|
||||
self.assertNotIn(self.DEST, box.files)
|
||||
reloads = [i for i, c in enumerate(box.calls) if c == ["systemctl", "try-reload-or-restart", "ssh"]]
|
||||
self.assertEqual(len(reloads), 2, box.calls) # after_install, then the undo
|
||||
|
||||
class TrustBootstrap(unittest.TestCase):
|
||||
def test_missing_signers_verifies_against_the_pinned_key_and_creates_it(self):
|
||||
data, sha = real_bundle()
|
||||
|
||||
Reference in New Issue
Block a user