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

It does exactly one thing — maintain a marked region inside `/etc/hosts` — and
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`.

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 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

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 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")
    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()
    else:
        command_status()
    return 0


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