#!/usr/bin/env python3
"""Kadence's privileged helper: the only part that runs as root.

It does two things, both of them small: maintain a marked region inside
`/etc/hosts`, and keep one file in `/etc/systemd/resolved.conf.d/` that points
the zones Kadence routes at the app's local DNS stub. It is written to be boring
on purpose:

* it never trusts its input: every hostname is matched against a strict pattern
  and the redirect address must parse as an IP, so nothing else can be smuggled
  into the file;
* it only ever rewrites the region between its own markers, so hand-written
  entries above and below are preserved byte for byte;
* the input list is opened with `O_NOFOLLOW` and must be a regular file, which
  closes the "point the helper at a symlink" trick;
* the write is atomic (temp file in `/etc` + `rename`), so an interrupted run
  cannot leave the machine with half a hosts file;
* the first run keeps a backup at `/etc/hosts.kadence-backup`;
* the DNS route is a file it owns outright, whose every line it composes: the
  zones are validated as hostnames and the port as a port, so no other setting
  can be smuggled in, and removing the file restores plain DNS.

Installed by `install.sh` together with a polkit action, so the desktop app can
call it through `pkexec` without ever running as root itself.

Usage:
    kadence-helper apply --ip 127.0.0.1 --file /tmp/list
    kadence-helper clear
    kadence-helper resolved-on --port 5353 --domains youtube.com,t.me
    kadence-helper resolved-off
    kadence-helper status
"""

from __future__ import annotations

import argparse
import ipaddress
import os
import re
import shutil
import stat as stat_module
import subprocess
import sys
import tempfile

HOSTS_PATH = "/etc/hosts"
BACKUP_PATH = "/etc/hosts.kadence-backup"
BEGIN_MARKER = "# >>> kadence block >>>"
END_MARKER = "# <<< kadence block <<<"
MAX_DOMAINS = 5000

DROPIN_DIR = "/etc/systemd/resolved.conf.d"
DROPIN_PATH = os.path.join(DROPIN_DIR, "kadence.conf")
STUB_ADDRESS = "127.0.0.1"
MIN_STUB_PORT = 1024

HOSTNAME_RE = re.compile(r"^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$")

# Blocking these would break the machine, not your concentration.
FORBIDDEN = {"localhost", "localhost.localdomain"}


def fail(message: str) -> None:
    print(f"kadence-helper: {message}", file=sys.stderr)
    raise SystemExit(2)


def read_hosts() -> list[str]:
    try:
        with open(HOSTS_PATH, "r", encoding="utf-8") as handle:
            return handle.read().splitlines()
    except OSError as exc:
        fail(f"cannot read {HOSTS_PATH}: {exc}")
        return []


def strip_region(lines: list[str]) -> list[str]:
    """Everything except our managed region (and a stale, unterminated one)."""
    output: list[str] = []
    inside = False
    for line in lines:
        stripped = line.strip()
        if stripped == BEGIN_MARKER:
            inside = True
            continue
        if stripped == END_MARKER:
            inside = False
            continue
        if not inside:
            output.append(line)
    while output and not output[-1].strip():
        output.pop()
    return output


def write_hosts(lines: list[str]) -> None:
    if not os.path.exists(BACKUP_PATH):
        try:
            shutil.copy2(HOSTS_PATH, BACKUP_PATH)
        except OSError:
            pass  # a missing backup must not stop us unblocking later

    directory = os.path.dirname(HOSTS_PATH) or "/"
    descriptor, temp_path = tempfile.mkstemp(dir=directory, prefix=".hosts.kadence.")
    try:
        with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
            handle.write("\n".join(lines) + "\n")
        os.chmod(temp_path, 0o644)
        os.chown(temp_path, 0, 0)
        os.replace(temp_path, HOSTS_PATH)
    except OSError as exc:
        os.unlink(temp_path)
        fail(f"cannot write {HOSTS_PATH}: {exc}")


def flush_dns() -> None:
    """Best effort: a resolver cache would otherwise keep serving the old answer."""
    for command in (
        ["resolvectl", "flush-caches"],
        ["systemd-resolve", "--flush-caches"],
        ["nscd", "-i", "hosts"],
    ):
        if shutil.which(command[0]):
            subprocess.run(command, check=False, capture_output=True, timeout=10)


def load_domains(path: str) -> list[str]:
    """Read the list the client wrote, refusing anything surprising."""
    try:
        descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
    except OSError as exc:
        fail(f"cannot open the domain list: {exc}")
        return []
    with os.fdopen(descriptor, "r", encoding="utf-8", errors="replace") as handle:
        info = os.fstat(handle.fileno())
        if not stat_module.S_ISREG(info.st_mode):
            fail("the domain list must be a regular file")
        if info.st_size > 1_000_000:
            fail("the domain list is implausibly large")
        raw = handle.read()

    domains: list[str] = []
    for line in raw.splitlines():
        name = line.strip().lower()
        if not name or name.startswith("#"):
            continue
        if name in FORBIDDEN:
            continue
        if not HOSTNAME_RE.match(name):
            fail(f"refusing a hostname that does not look like one: {name[:80]!r}")
        if name not in domains:
            domains.append(name)
        if len(domains) > MAX_DOMAINS:
            fail(f"more than {MAX_DOMAINS} domains")
    return domains


def command_apply(ip: str, path: str) -> None:
    try:
        address = ipaddress.ip_address(ip)
    except ValueError:
        fail(f"not an IP address: {ip!r}")
        return
    domains = load_domains(path)
    lines = strip_region(read_hosts())
    if domains:
        lines.append("")
        lines.append(BEGIN_MARKER)
        lines.append("# Managed by Kadence. Edit the list in the app, not here.")
        for domain in domains:
            lines.append(f"{address}\t{domain}")
        lines.append(END_MARKER)
    write_hosts(lines)
    flush_dns()
    print(f"blocking {len(domains)} domain(s) -> {address}")


def command_clear() -> None:
    write_hosts(strip_region(read_hosts()))
    flush_dns()
    print("cleared")


def load_zones(raw: str) -> list[str]:
    """Validate the zone list the client sent. One bad name refuses the lot.

    These names go into a resolved configuration file, so they must be plain
    hostnames and nothing else — that is what makes it safe to compose the
    `Domains=` line around them.
    """
    zones: list[str] = []
    for part in raw.split(","):
        zone = part.strip().lower().strip(".")
        if not zone:
            continue
        if zone in FORBIDDEN:
            fail(f"refusing to route {zone!r}: it is a name this machine needs")
        if not HOSTNAME_RE.match(zone):
            fail(f"refusing a zone that does not look like a hostname: {zone[:80]!r}")
        if len(zone.split(".")) < 2:
            fail(f"refusing to route a whole top-level domain: {zone!r}")
        if zone not in zones:
            zones.append(zone)
        if len(zones) > MAX_DOMAINS:
            fail(f"more than {MAX_DOMAINS} zones")
    if not zones:
        fail("no zones given; use resolved-off to stop routing")
    return zones


def resolved_dropin_text(port: int, zones: list[str]) -> str:
    """The exact contents of ours, assembled from validated pieces."""
    return "\n".join(
        [
            "# Managed by Kadence. Edit the block lists in the app, not here.",
            "#",
            "# These zones are answered by the app's own DNS stub, which redirects",
            "# blocked names and forwards everything else to the real upstreams.",
            "# Deleting this file and restarting systemd-resolved restores plain",
            "# DNS; `kadence-helper resolved-off` does both.",
            "[Resolve]",
            f"DNS={STUB_ADDRESS}:{port}",
            "Domains=" + " ".join(f"~{zone}" for zone in zones),
            "",
        ]
    )


def write_dropin(text: str) -> None:
    try:
        os.makedirs(DROPIN_DIR, mode=0o755, exist_ok=True)
    except OSError as exc:
        fail(f"cannot create {DROPIN_DIR}: {exc}")
    descriptor, temp_path = tempfile.mkstemp(dir=DROPIN_DIR, prefix=".kadence.")
    try:
        with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
            handle.write(text)
        os.chmod(temp_path, 0o644)
        os.chown(temp_path, 0, 0)
        os.replace(temp_path, DROPIN_PATH)
    except OSError as exc:
        os.unlink(temp_path)
        fail(f"cannot write {DROPIN_PATH}: {exc}")


def restart_resolved() -> None:
    """Make resolved pick the file up. Its cache goes with it, which is wanted."""
    if not os.path.isdir("/run/systemd/resolve"):
        fail("this machine does not run systemd-resolved, so Kadence cannot route zones")
    if not shutil.which("systemctl"):
        fail("systemctl is not available to reload systemd-resolved")
    result = subprocess.run(
        ["systemctl", "restart", "systemd-resolved"], capture_output=True, text=True, timeout=60, check=False
    )
    if result.returncode != 0:
        detail = (result.stderr or result.stdout).strip()[:300]
        fail(f"could not restart systemd-resolved: {detail or result.returncode}")
    flush_dns()


def command_resolved_on(port: int, domains: str) -> None:
    if not MIN_STUB_PORT <= port <= 65535:
        fail(f"refusing port {port}: the stub has to bind an unprivileged port")
    zones = load_zones(domains)
    write_dropin(resolved_dropin_text(port, zones))
    restart_resolved()
    print(f"routing {len(zones)} zone(s) to {STUB_ADDRESS}:{port}")


def command_resolved_off() -> None:
    if not os.path.exists(DROPIN_PATH):
        print("no zones were routed")
        return
    try:
        os.unlink(DROPIN_PATH)
    except OSError as exc:
        fail(f"cannot remove {DROPIN_PATH}: {exc}")
    restart_resolved()
    print("stopped routing zones")


def command_status() -> None:
    inside = False
    count = 0
    for line in read_hosts():
        stripped = line.strip()
        if stripped == BEGIN_MARKER:
            inside = True
            continue
        if stripped == END_MARKER:
            inside = False
            continue
        if inside and stripped and not stripped.startswith("#"):
            count += 1
    print(f"{count} domain(s) blocked")


def main() -> int:
    parser = argparse.ArgumentParser(prog="kadence-helper", description=__doc__)
    sub = parser.add_subparsers(dest="command", required=True)
    apply_parser = sub.add_parser("apply", help="replace the managed block")
    apply_parser.add_argument("--ip", default="127.0.0.1")
    apply_parser.add_argument("--file", required=True, help="newline-separated hostnames")
    sub.add_parser("clear", help="remove the managed block")
    resolved_on = sub.add_parser("resolved-on", help="route DNS zones through the local stub")
    resolved_on.add_argument("--port", type=int, required=True, help="the stub's port")
    resolved_on.add_argument("--domains", required=True, help="comma-separated zone names")
    sub.add_parser("resolved-off", help="stop routing DNS zones through the stub")
    sub.add_parser("status", help="how many domains are blocked")

    args = parser.parse_args()
    if os.geteuid() != 0:
        fail("must run as root (it is meant to be called through pkexec)")

    if args.command == "apply":
        command_apply(args.ip, args.file)
    elif args.command == "clear":
        command_clear()
    elif args.command == "resolved-on":
        command_resolved_on(args.port, args.domains)
    elif args.command == "resolved-off":
        command_resolved_off()
    else:
        command_status()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
