#!/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 <name>              /var/lib/felhom-agent/units/<name> -> /etc/systemd/system/<name>   (.mount | .automount)
  dnsmasq <tmp> <name>     /tmp/felhom-resolver-<digits>.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
  --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")

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"}
UNIT_TOKEN_RE = re.compile(r"^[A-Za-z0-9@_.\\:-]+$")
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 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 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-<escaped path>.mount|.automount")
    kind = "automount" if name.endswith(".automount") else "mount"
    s = parse_ini(text, name)
    body = "Automount" if kind == "automount" else "Mount"
    allowed = {"Unit": {"Description", "After", "Before", "Wants", "Requires"},
               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")
    for k in ("After", "Before", "Wants", "Requires"):
        if k in u and not all(UNIT_TOKEN_RE.match(t) for t in u[k].split()):
            raise Refused("U2", f"{name}: {k}= names something that is not a unit")
    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/<name> or /mnt/felhom-drives/<name>")
    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/<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 <name> | dnsmasq <tmp> <name> | 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-<escaped path>.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-<digits>.conf and felhom-<name>.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 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 <verb> [<name>] <file>` validates <file> 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
    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:]))
