#!/usr/bin/python3 """felhom-priv-apply — the ROOT half of every file the agent writes into a root-read place (R-861, `03` §3.1). Install as /usr/local/sbin/felhom-priv-apply (0755 root:root) — it rides the signed config bundle (R-840). WHY IT EXISTS. Until agent v0.146.0 the agent's sudoers let it `install` a file it had written itself into a place a root program reads: a systemd .mount unit (a bind mount of an agent-owned directory over /etc/sudoers.d is a root shell), a dnsmasq drop-in (`dhcp-script=` runs as root), the WireGuard config (`PostUp=` runs as root) and the OOB sshd config (`AuthorizedKeysFile` + `StrictModes no`). A compromised agent PROCESS was therefore root on its host. Now the agent stages the file and this wrapper — root-owned, delivered only by an operator-signed bundle — checks the CONTENT against the exact grammar the agent's own renderers produce, and refuses anything else. The agent can no longer name the destination: each verb has a fixed source and a fixed (or strictly named) destination. Verbs (each one sudoers line, exact-match pattern): unit /var/lib/felhom-agent/units/ -> /etc/systemd/system/ (.mount | .automount) dnsmasq /tmp/felhom-resolver-.conf -> /etc/dnsmasq.d/felhom-<...>.conf 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 the ref on STDIN -> /etc/felhom-controller-image INSIDE guest (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. Every refusal is logged to the journal (tag felhom-priv-apply) with its rule; file CONTENT is never logged. Pinned by configs/test_felhom_priv_apply.py (one test per rule, red-proofs in the R-861 audit). """ import ipaddress import os import re import stat import subprocess import sys AGENT_USER = "felhom-agent" STATE = "/var/lib/felhom-agent" UNITS_SRC = STATE + "/units" UNIT_DIR = "/etc/systemd/system" DNSMASQ_DIR = "/etc/dnsmasq.d" 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", "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$") DNSMASQ_NAME_RE = re.compile(r"^felhom-[a-z0-9][a-z0-9._-]*\.conf$") SEG = r"[A-Za-z0-9_-][A-Za-z0-9_.-]*" WHERE_RE = re.compile(r"^/mnt/(felhom-drives/)?" + SEG + r"$") UUID_RE = re.compile(r"^[A-Fa-f0-9]{4,}(-[A-Fa-f0-9]+){0,4}$") HOST_RE = re.compile(r"^[A-Za-z0-9._:-]{1,255}$") NET_PATH_RE = re.compile(r"^[A-Za-z0-9._/@+-]{1,512}$") OPT_RE = re.compile(r"^[A-Za-z0-9_.:/@+-]+(=[A-Za-z0-9_.:/@+-]+)?$") LOCAL_TYPES = {"ext4", "xfs", "btrfs", "exfat", "vfat", "ntfs3", "ntfs"} NET_TYPES = {"nfs", "nfs4", "cifs"} # Options that turn a device mount into something else, or let set-uid/device files act on the host. FORBIDDEN_OPTS = {"bind", "rbind", "move", "rmove", "remount", "suid", "dev", "user", "users", "owner", "group", "x-mount.mkdir", "helper"} DESC_RE = re.compile(r"^[^\x00-\x1f\x7f]{0,200}$") WG_KEY_RE = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=$") KEY_LINE_RE = re.compile(r"^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh\.com) " r"[A-Za-z0-9+/]+={0,3}( [ -~]{0,200})?$") class Refused(Exception): def __init__(self, rule, reason): super().__init__(reason) self.rule, self.reason = rule, reason class Host: """Every filesystem / process effect, so the tests can play the box in memory.""" def agent_uid(self): import pwd return pwd.getpwnam(AGENT_USER).pw_uid def read_source(self, path): """The staged file: a REGULAR file owned by the agent, never a symlink, at most MAX_BYTES.""" try: fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) except OSError as e: raise Refused("P1", f"cannot open the staged file {path}: {e.strerror}") try: st = os.fstat(fd) if not stat.S_ISREG(st.st_mode): raise Refused("P1", f"{path} is not a regular file") if st.st_uid != self.agent_uid(): raise Refused("P1", f"{path} is not owned by {AGENT_USER}") if st.st_size > MAX_BYTES: raise Refused("P1", f"{path} is larger than {MAX_BYTES} bytes") with os.fdopen(fd, "rb") as f: fd = -1 return f.read(MAX_BYTES + 1) finally: if fd >= 0: os.close(fd) def read_dest(self, path): try: with open(path, "rb") as f: return f.read() except OSError: return None def install(self, dest, data, mode): """Atomic, root-owned: a temp file beside the destination, fsync, rename.""" d = os.path.dirname(dest) os.makedirs(d, mode=0o755, exist_ok=True) tmp = os.path.join(d, f".{os.path.basename(dest)}.felhom-new.{os.getpid()}") fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) try: with os.fdopen(fd, "wb") as f: f.write(data) f.flush() os.fchown(f.fileno(), 0, 0) os.fchmod(f.fileno(), mode) os.fsync(f.fileno()) os.replace(tmp, dest) except BaseException: try: os.remove(tmp) except OSError: 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 -- tee ` 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: subprocess.run(["logger", "-t", "felhom-priv-apply", "--", line], timeout=10, check=False) except (OSError, subprocess.SubprocessError): pass def systemd_escape_path(path): """`systemd-escape --path`: strip the slashes at both ends, `/` -> `-`, every byte outside [A-Za-z0-9:_.] (and a leading `.`) -> `\\xNN`.""" p = path.strip("/") out = [] for i, ch in enumerate(p): if ch == "/": out.append("-") elif (ch.isascii() and (ch.isalnum() or ch in ":_.")) and not (i == 0 and ch == "."): out.append(ch) else: out.extend("\\x%02x" % b for b in ch.encode()) return "".join(out) def text_of(data, what): if len(data) > MAX_BYTES: raise Refused("P1", f"{what} is too large") try: text = data.decode("utf-8") except UnicodeDecodeError: raise Refused("P2", f"{what} is not UTF-8 text") if "\x00" in text or "\r" in text: raise Refused("P2", f"{what} carries a NUL or CR byte") return text def parse_ini(text, what): """[Section] / Key=Value / comments / blank lines. A key outside a section or a repeated key is refused.""" sections, cur = {}, None for n, raw in enumerate(text.split("\n"), 1): line = raw.strip() if line.endswith("\\"): # systemd joins a line ending in a backslash with the next one; this parser does not. Refused, so the two # can never read the same bytes differently (review 2026-10-05). raise Refused("U2", f"{what}: line {n} ends with a backslash (a continuation)") if not line or line.startswith("#") or line.startswith(";"): continue m = re.match(r"^\[([A-Za-z]+)\]$", line) if m: cur = m.group(1) if cur in sections: raise Refused("U2", f"{what}: section [{cur}] twice") sections[cur] = {} continue if cur is None or "=" not in line: raise Refused("U2", f"{what}: line {n} is not Key=Value inside a section") k, v = line.split("=", 1) k, v = k.strip(), v.strip() if k in sections[cur]: raise Refused("U2", f"{what}: {cur}.{k} given twice") sections[cur][k] = v return sections # ---------- the unit verb ---------- def check_unit(name, text): if not UNIT_NAME_RE.match(name) or "/" in name: raise Refused("U1", f"unit name {name!r} is not mnt-.mount|.automount") kind = "automount" if name.endswith(".automount") else "mount" s = parse_ini(text, name) body = "Automount" if kind == "automount" else "Mount" # [Unit] holds ONLY what the renderers write: Description, and After=local-fs-pre.target on a local mount. A # Wants=/Requires=/Before= naming any unit would start it with the mount (Wants=reboot.target — found by review # 2026-10-05), so none of them is accepted. allowed = {"Unit": {"Description", "After"}, body: {"Where", "TimeoutIdleSec"} if kind == "automount" else {"What", "Where", "Type", "Options"}, "Install": {"WantedBy"}} for sec, keys in s.items(): if sec not in allowed: raise Refused("U2", f"{name}: section [{sec}] is not allowed") bad = set(keys) - allowed[sec] if bad: raise Refused("U2", f"{name}: [{sec}] key(s) {sorted(bad)} not allowed") u = s.get("Unit", {}) if not DESC_RE.match(u.get("Description", "")): raise Refused("U2", f"{name}: Description has control characters") if "After" in u and u["After"] != "local-fs-pre.target": raise Refused("U2", f"{name}: After= may only be local-fs-pre.target") inst = s.get("Install", {}) if inst and inst.get("WantedBy") != "multi-user.target": raise Refused("U2", f"{name}: WantedBy must be multi-user.target") m = s.get(body) if not m or "Where" not in m: raise Refused("U3", f"{name}: no [{body}] Where=") where = m["Where"] if not WHERE_RE.match(where): raise Refused("U3", f"{name}: Where={where} is not /mnt/ or /mnt/felhom-drives/") if systemd_escape_path(where) + "." + kind != name: raise Refused("U3", f"{name}: the unit name does not match Where={where}") if kind == "automount": t = m.get("TimeoutIdleSec", "") if t and not re.match(r"^[0-9]{1,6}$", t): raise Refused("U2", f"{name}: TimeoutIdleSec must be seconds") return what, typ = m.get("What", ""), m.get("Type", "") opts = [o for o in m.get("Options", "").split(",") if o] net = False mu = re.match(r"^/dev/disk/by-uuid/(.+)$", what) if mu: if not UUID_RE.match(mu.group(1)): raise Refused("U4", f"{name}: What= is not a filesystem UUID") if typ and typ not in LOCAL_TYPES: raise Refused("U4", f"{name}: Type={typ} is not a local filesystem") else: net = True if typ not in NET_TYPES: raise Refused("U4", f"{name}: What= is neither /dev/disk/by-uuid/ nor a network source with Type=nfs/nfs4/cifs") if typ == "cifs": mm = re.match(r"^//([^/]+)/(.+)$", what) else: mm = re.match(r"^([^/:][^:]*):(/.*)$", what) if not mm or not HOST_RE.match(mm.group(1)) or not NET_PATH_RE.match(mm.group(2)) or ".." in mm.group(2).split("/"): raise Refused("U4", f"{name}: What= is not a clean {typ} source") if not where.startswith("/mnt/felhom-drives/"): raise Refused("U3", f"{name}: a network share mounts only under /mnt/felhom-drives/") for o in opts: if not OPT_RE.match(o): raise Refused("U5", f"{name}: mount option {o!r} has characters a mount option never needs") if o.split("=", 1)[0].lower() in FORBIDDEN_OPTS or o.lower().startswith("x-mount."): raise Refused("U5", f"{name}: mount option {o.split('=', 1)[0]!r} is not allowed") if net and not {"nosuid", "nodev"} <= set(opts): # A network server is outside the box: a set-uid file on it must never run as root here. raise Refused("U5", f"{name}: a network share must carry nosuid,nodev") # ---------- dnsmasq ---------- def _ip(v, v6=True): try: a = ipaddress.ip_address(v) except ValueError: return False return v6 or a.version == 4 DOMAIN_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9-]{0,62})(\.[A-Za-z0-9]([A-Za-z0-9-]{0,62}))*$") def check_dnsmasq(text): for n, raw in enumerate(text.split("\n"), 1): line = raw.strip() if not line or line.startswith("#"): continue if line in ("bind-interfaces", "no-resolv"): continue k, _, v = line.partition("=") if k == "listen-address" and _ip(v, v6=False): continue if k == "server" and (_ip(v) or (v.count("#") == 1 and _ip(v.split("#")[0]) and v.split("#")[1].isdigit())): continue m = re.match(r"^/([^/]+)/$", v) if k == "local" and m and DOMAIN_RE.match(m.group(1)): continue m = re.match(r"^/([^/]+)/([^/]+)$", v) if k == "address" and m and DOMAIN_RE.match(m.group(1)) and _ip(m.group(2), v6=False): continue raise Refused("D1", f"dnsmasq line {n} ({k or line[:20]!r}) is not one the resolver writes") # ---------- WireGuard ---------- def check_wg(text): s = parse_ini(text, "wg-felhom.conf") if set(s) != {"Interface", "Peer"}: raise Refused("W1", "wg-felhom.conf must hold exactly [Interface] and [Peer]") i, p = s["Interface"], s["Peer"] if set(i) - {"PrivateKey", "Address", "MTU"} or set(p) - {"PublicKey", "Endpoint", "AllowedIPs", "PersistentKeepalive"}: raise Refused("W1", "wg-felhom.conf carries a key the agent never writes (PostUp/PreUp/... run as root)") if not WG_KEY_RE.match(i.get("PrivateKey", "")) or not WG_KEY_RE.match(p.get("PublicKey", "")): raise Refused("W2", "a WireGuard key is not 32 bytes of base64") try: a = ipaddress.ip_network(i.get("Address", ""), strict=False) if a.version != 4 or a.prefixlen != 32: raise ValueError if not (1280 <= int(i.get("MTU", "1280")) <= 1500): raise ValueError host, _, port = p.get("Endpoint", "").rpartition(":") if ipaddress.ip_address(host).version != 4 or not (1 <= int(port) <= 65535): raise ValueError for n in p.get("AllowedIPs", "").split(","): if ipaddress.ip_network(n.strip(), strict=True).prefixlen != 32: raise ValueError if not (0 <= int(p.get("PersistentKeepalive", "25")) <= 3600): raise ValueError except ValueError: raise Refused("W2", "an Address/MTU/Endpoint/AllowedIPs/PersistentKeepalive value is not what the agent renders") # ---------- OOB sshd ---------- def render_sshd(port): """Byte-identical to felhomsshd.renderConfig (internal/felhomsshd/config.go) — pinned by a Go test.""" return ("# felhom OOB sshd — agent-managed (H1); DO NOT EDIT\n" f"Port {port}\n" "ListenAddress 0.0.0.0\n" "ListenAddress ::\n" "HostKey /etc/felhom-sshd/ssh_host_ed25519_key\n" "PidFile /run/felhom-sshd.pid\n" "AuthorizedKeysFile /etc/felhom-sshd/authorized_keys/%u\n" "PasswordAuthentication no\n" "PermitRootLogin prohibit-password\n" "PubkeyAuthentication yes\n" "KbdInteractiveAuthentication no\n" "UsePAM yes\n" "AllowUsers root felhom-op\n" "X11Forwarding no\n" "Subsystem sftp internal-sftp\n") def check_sshd(text): m = re.search(r"^Port ([0-9]{1,5})$", text, re.M) if not m or not (1 <= int(m.group(1)) <= 65535) or int(m.group(1)) == 22: raise Refused("S1", "sshd_config has no Port (or claims :22, the household's sshd)") if text != render_sshd(int(m.group(1))): raise Refused("S1", "sshd_config differs from the one fixed template (only the Port may vary)") def check_key(text): lines = [l for l in text.split("\n") if l.strip()] if len(lines) > 1: raise Refused("S2", "felhom-op's authorized_keys holds more than one key") if lines and not KEY_LINE_RE.match(lines[0]): raise Refused("S2", "the key line is not a plain public key (no options such as command= or from=)") # ---------- main ---------- def plan(argv): """(verb, source, dest, mode, checker) for an argv, or Refused("A1").""" if not argv or argv[0] not in VERBS: raise Refused("A1", "usage: felhom-priv-apply unit | dnsmasq | wg | sshd-config | sshd-key") v, rest = argv[0], argv[1:] if v == "unit" and len(rest) == 1: if not UNIT_NAME_RE.match(rest[0]): raise Refused("U1", f"unit name {rest[0]!r} is not mnt-.mount|.automount") return v, os.path.join(UNITS_SRC, rest[0]), os.path.join(UNIT_DIR, rest[0]), 0o644, lambda t: check_unit(rest[0], t) if v == "dnsmasq" and len(rest) == 2: if not DNSMASQ_TMP_RE.match(rest[0]) or not DNSMASQ_NAME_RE.match(rest[1]): raise Refused("D2", "dnsmasq wants /tmp/felhom-resolver-.conf and felhom-.conf") return v, rest[0], os.path.join(DNSMASQ_DIR, rest[1]), 0o644, check_dnsmasq if v == "wg" and not rest: return v, WG_SRC, WG_DEST, 0o600, check_wg if v == "sshd-config" and not rest: return v, SSHD_SRC, SSHD_DEST, 0o644, check_sshd if v == "sshd-key" and not rest: return v, KEY_SRC, KEY_DEST, 0o644, check_key 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 (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:") 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"]: print("felhom-priv-apply ok verbs=" + ",".join(VERBS)) return 0 if len(argv) >= 3 and argv[0] == "--check": # CHECK ONLY (tests and the Go contract tests): `--check [] ` validates as that # verb would and installs nothing. Not in sudoers. Prints OK or the rule; never the content. verb, file = argv[1], argv[-1] try: _, _, _, _, checker = plan([verb] + argv[2:-1] if verb != "dnsmasq" else [verb, "/tmp/felhom-resolver-1.conf", argv[2]]) with open(file, "rb") as f: checker(text_of(f.read(), file)) except Refused as e: print(f"REFUSED [{e.rule}] {e.reason}") 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) checker(text_of(data, src)) except Refused as e: host.log(f"felhom-priv-apply: REFUSED [{e.rule}] {' '.join(argv)[:160]}: {e.reason}") return 2 if e.rule == "A1" else 3 if host.read_dest(dest) == data: host.log(f"felhom-priv-apply: SAME {verb} {dest}") return 0 try: host.install(dest, data, mode) except OSError as e: host.log(f"felhom-priv-apply: FAILED {verb} {dest}: {e}") return 4 host.log(f"felhom-priv-apply: INSTALLED {verb} {dest} ({len(data)} bytes)") return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))