R-861 (a) A1 + (b) B2: the image ref goes to a root verb that checks it; the agent's in-guest tee grant is gone; felhom-op's pct lines are exact (09 §3 decision 165)
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:
@@ -111,15 +111,17 @@ Cmnd_Alias FELHOM_INTERMEDIARY = \
|
||||
# docker inspect -f * — container running/health/image (read-only; `*` spans the -f template
|
||||
# + container across spaces, spike-confirmed)
|
||||
# systemctl restart <fixed unit> — re-run the golden's bootstrap (the only state change)
|
||||
# tee <FIXED image file> — WRITE the ref; content is fed on STDIN (no shell, no interpolation),
|
||||
# the agent strict-validates the ref (controllerImageRe) before the write.
|
||||
# felhom-priv-apply controller-image <vmid> — WRITE the ref (R-861 (a) A1, `09` §3 decision 165): the ref goes on
|
||||
# STDIN to the ROOT wrapper, which requires our registry + repository + an x.y.z tag
|
||||
# and writes the guest file itself. The agent's own `tee` grant is GONE: before, a
|
||||
# compromised agent could hand the guest's bootstrap ANY image (sudo cannot see stdin).
|
||||
# Validated GO: felhom.eu/documentation/audits/SPIKE-controllerswap-narrow-grants-2026-06-29.md.
|
||||
Cmnd_Alias FELHOM_CONTROLLERSWAP = \
|
||||
/usr/sbin/pct ^exec [0-9]+ -- cat /etc/felhom-controller-image$, \
|
||||
/usr/sbin/pct ^exec [0-9]+ -- docker image inspect gitea\.dooplex\.hu/admin/felhom-controller\:[0-9]+\.[0-9]+\.[0-9]+$, \
|
||||
/usr/sbin/pct ^exec [0-9]+ -- docker inspect -f .+ (felhom-controller|cloudflared)$, \
|
||||
/usr/sbin/pct ^exec [0-9]+ -- systemctl restart felhom-controller-bootstrap\.service$, \
|
||||
/usr/sbin/pct ^exec [0-9]+ -- tee /etc/felhom-controller-image$
|
||||
/usr/local/sbin/felhom-priv-apply ^controller-image [0-9]+$
|
||||
|
||||
# Stale-lock recovery (F2-b, v0.49.0). A host reboot DURING a vzdump backup leaves the guest with a
|
||||
# `snapshot-delete`/`backup` lock + `onboot:1` then can't start it → the customer box stays DOWN. The
|
||||
|
||||
@@ -16,8 +16,10 @@ Cmnd_Alias FELHOM_OP_REPAIR = \
|
||||
/usr/bin/systemctl reset-failed felhom-sshd, \
|
||||
/usr/bin/systemctl restart felhom-sshd, \
|
||||
/usr/sbin/pct list, \
|
||||
/usr/sbin/pct start [0-9]*, \
|
||||
/usr/sbin/pct stop [0-9]*, \
|
||||
/usr/sbin/pct unlock [0-9]*
|
||||
/usr/sbin/pct ^start [0-9]+$, \
|
||||
/usr/sbin/pct ^stop [0-9]+$, \
|
||||
/usr/sbin/pct ^unlock [0-9]+$
|
||||
|
||||
# R-861 (b) B2 (`09` §3 decision 165, hygiene): one numeric vmid per pct verb, anchored — the old glob `[0-9]*` also
|
||||
# matched spaces, so `pct stop 9201 --skiplock 1` passed. Pinned by TestFelhomOpSudoersPctIsExact.
|
||||
felhom-op ALL=(root) NOPASSWD: FELHOM_OP_REPAIR
|
||||
|
||||
@@ -17,6 +17,8 @@ Verbs (each one sudoers line, exact-match pattern):
|
||||
wg /var/lib/felhom-agent/wg/wg-felhom.conf -> /etc/wireguard/wg-felhom.conf (0600)
|
||||
sshd-config /var/lib/felhom-agent/felhom-sshd/sshd_config -> /etc/felhom-sshd/sshd_config
|
||||
sshd-key /var/lib/felhom-agent/felhom-sshd/authorized_keys.felhom-op -> /etc/felhom-sshd/authorized_keys/felhom-op
|
||||
controller-image <vmid> the ref on STDIN -> /etc/felhom-controller-image INSIDE guest <vmid> (R-861 (a) A1): only
|
||||
our registry + our repository + an x.y.z tag; the agent no longer has a `tee` grant
|
||||
--self-check prints "felhom-priv-apply ok verbs=..." (the bundle's self-check)
|
||||
|
||||
Exit codes: 0 installed (or already identical), 2 usage, 3 refused (content or source), 4 install failed.
|
||||
@@ -39,7 +41,14 @@ WG_SRC, WG_DEST = STATE + "/wg/wg-felhom.conf", "/etc/wireguard/wg-felhom.conf"
|
||||
SSHD_SRC, SSHD_DEST = STATE + "/felhom-sshd/sshd_config", "/etc/felhom-sshd/sshd_config"
|
||||
KEY_SRC, KEY_DEST = STATE + "/felhom-sshd/authorized_keys.felhom-op", "/etc/felhom-sshd/authorized_keys/felhom-op"
|
||||
MAX_BYTES = 64 * 1024
|
||||
VERBS = ("unit", "dnsmasq", "wg", "sshd-config", "sshd-key")
|
||||
VERBS = ("unit", "dnsmasq", "wg", "sshd-config", "sshd-key", "controller-image")
|
||||
# R-861 (a) A1 (`09` §3 decision 165): the SAME pattern as the agent's controllerImageRe (internal/localapi/
|
||||
# controllerswap.go) — a compromised agent cannot hand the guest's bootstrap any other image. Pinned by
|
||||
# configs/test_felhom_priv_apply.py ControllerImage.
|
||||
CONTROLLER_IMAGE_RE = re.compile(r"^gitea\.dooplex\.hu/admin/felhom-controller:[0-9]+\.[0-9]+\.[0-9]+$")
|
||||
CONTROLLER_IMAGE_FILE = "/etc/felhom-controller-image"
|
||||
CONTROLLER_IMAGE_MAX = 256
|
||||
VMID_RE = re.compile(r"^[0-9]{1,9}$")
|
||||
|
||||
UNIT_NAME_RE = re.compile(r"^mnt-[A-Za-z0-9_.\\-]+\.(mount|automount)$")
|
||||
DNSMASQ_TMP_RE = re.compile(r"^/tmp/felhom-resolver-[0-9]+\.conf$")
|
||||
@@ -123,6 +132,16 @@ class Host:
|
||||
pass
|
||||
raise
|
||||
|
||||
def read_stdin(self, limit):
|
||||
return sys.stdin.buffer.read(limit + 1)
|
||||
|
||||
def write_guest_image(self, vmid, data):
|
||||
"""As root: `pct exec <vmid> -- tee <the fixed file>` with the checked ref on stdin (no shell)."""
|
||||
r = subprocess.run(["/usr/sbin/pct", "exec", str(vmid), "--", "tee", CONTROLLER_IMAGE_FILE],
|
||||
input=data, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=60)
|
||||
if r.returncode != 0:
|
||||
raise OSError(f"pct exec {vmid} tee exited {r.returncode}: {r.stderr.decode(errors='replace').strip()[:200]}")
|
||||
|
||||
def log(self, line):
|
||||
print(line, file=sys.stderr)
|
||||
try:
|
||||
@@ -377,6 +396,34 @@ def plan(argv):
|
||||
raise Refused("A1", f"wrong arguments for {v}")
|
||||
|
||||
|
||||
def controller_image(rest, host):
|
||||
"""R-861 (a) A1: read the ref on stdin, check it, write it INSIDE the guest as root."""
|
||||
try:
|
||||
if len(rest) != 1 or not VMID_RE.match(rest[0]):
|
||||
raise Refused("A1", "usage: felhom-priv-apply controller-image <vmid> (the ref on stdin)")
|
||||
raw = host.read_stdin(CONTROLLER_IMAGE_MAX)
|
||||
if len(raw) > CONTROLLER_IMAGE_MAX:
|
||||
raise Refused("I1", f"the image ref is longer than {CONTROLLER_IMAGE_MAX} bytes")
|
||||
try:
|
||||
text = raw.decode("ascii")
|
||||
except UnicodeDecodeError:
|
||||
raise Refused("I1", "the image ref is not ASCII")
|
||||
ref = text[:-1] if text.endswith("\n") else text
|
||||
if not CONTROLLER_IMAGE_RE.match(ref) or "\n" in ref:
|
||||
raise Refused("I1", "the image ref is not gitea.dooplex.hu/admin/felhom-controller:<x.y.z>")
|
||||
except Refused as e:
|
||||
host.log(f"felhom-priv-apply: REFUSED [{e.rule}] controller-image {' '.join(rest)[:40]}: {e.reason}")
|
||||
return 2 if e.rule == "A1" else 3
|
||||
vmid = int(rest[0])
|
||||
try:
|
||||
host.write_guest_image(vmid, (ref + "\n").encode())
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
host.log(f"felhom-priv-apply: FAILED controller-image {vmid}: {e}")
|
||||
return 4
|
||||
host.log(f"felhom-priv-apply: WROTE controller-image {vmid} {ref}")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv, host=None):
|
||||
host = host or Host()
|
||||
if argv == ["--self-check"]:
|
||||
@@ -396,6 +443,8 @@ def main(argv, host=None):
|
||||
return 3
|
||||
print("OK")
|
||||
return 0
|
||||
if argv and argv[0] == "controller-image":
|
||||
return controller_image(argv[1:], host)
|
||||
try:
|
||||
verb, src, dest, mode, checker = plan(argv)
|
||||
data = host.read_source(src)
|
||||
|
||||
@@ -56,6 +56,18 @@ class FakeHost:
|
||||
def log(self, line):
|
||||
self.logs.append(line)
|
||||
|
||||
# controller-image (R-861 (a) A1): the ref arrives on stdin and is written INSIDE the guest by root.
|
||||
stdin = b""
|
||||
guest_writes = None
|
||||
|
||||
def read_stdin(self, limit):
|
||||
return self.stdin[:limit + 1]
|
||||
|
||||
def write_guest_image(self, vmid, data):
|
||||
if self.guest_writes is None:
|
||||
self.guest_writes = []
|
||||
self.guest_writes.append((vmid, data))
|
||||
|
||||
|
||||
LOCAL_UNIT = """# Managed by felhom-agent — do not edit by hand.
|
||||
[Unit]
|
||||
@@ -316,5 +328,53 @@ class Refuses(unittest.TestCase):
|
||||
self.refused(h, ["wg", "/etc/shadow"], "A1")
|
||||
|
||||
|
||||
class ControllerImage(unittest.TestCase):
|
||||
"""R-861 (a) A1 (`09` §3 decision 165): the agent can no longer `tee` any image ref into the guest. The root verb
|
||||
reads the ref on stdin, requires our registry + our repository + an x.y.z tag, and writes the guest file itself.
|
||||
RED-PROOF: on the pre-A1 wrapper `controller-image` is not a verb (A1 usage, rc 2) — the accepted case fails."""
|
||||
|
||||
def go(self, ref, *argv):
|
||||
h = FakeHost()
|
||||
h.stdin = ref.encode() if isinstance(ref, str) else ref
|
||||
return h, run(h, *(argv or ("controller-image", "9201")))
|
||||
|
||||
def test_our_controller_ref_is_written_in_the_guest(self):
|
||||
h, rc = self.go("gitea.dooplex.hu/admin/felhom-controller:0.301.0\n")
|
||||
self.assertEqual(rc, 0, h.logs)
|
||||
self.assertEqual(h.guest_writes, [(9201, b"gitea.dooplex.hu/admin/felhom-controller:0.301.0\n")])
|
||||
|
||||
def test_a_foreign_image_is_refused(self):
|
||||
for ref in ("docker.io/library/alpine:latest\n", "alpine\n",
|
||||
"gitea.dooplex.hu/admin/felhom-controller:latest\n",
|
||||
"gitea.dooplex.hu/admin/other:0.1.0\n",
|
||||
"evil.example/admin/felhom-controller:0.301.0\n",
|
||||
"gitea.dooplex.hu/admin/felhom-controller:0.301.0\nalpine\n",
|
||||
"gitea.dooplex.hu/admin/felhom-controller:0.301.0 x\n",
|
||||
"", "\n"):
|
||||
h, rc = self.go(ref)
|
||||
self.assertEqual(rc, 3, f"{ref!r} was accepted")
|
||||
self.assertFalse(h.guest_writes, f"{ref!r} wrote the guest file")
|
||||
self.assertTrue(any("[I1]" in l for l in h.logs), h.logs)
|
||||
|
||||
def test_oversize_stdin_is_refused(self):
|
||||
h, rc = self.go("gitea.dooplex.hu/admin/felhom-controller:0.301.0" + " " * 300)
|
||||
self.assertEqual(rc, 3)
|
||||
self.assertFalse(h.guest_writes)
|
||||
|
||||
def test_vmid_must_be_numeric(self):
|
||||
for argv in (("controller-image", "9201;id"), ("controller-image", "-1"), ("controller-image",),
|
||||
("controller-image", "9201", "9202")):
|
||||
h, rc = self.go("gitea.dooplex.hu/admin/felhom-controller:0.301.0\n", *argv)
|
||||
self.assertIn(rc, (2, 3), argv)
|
||||
self.assertFalse(h.guest_writes, argv)
|
||||
|
||||
def test_self_check_names_the_verb(self):
|
||||
import io, contextlib
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
pa.main(["--self-check"])
|
||||
self.assertIn("controller-image", buf.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
Reference in New Issue
Block a user