diff --git a/desktop/README.md b/desktop/README.md index 53a1c83..b478a54 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -49,11 +49,35 @@ the same build runs on KDE, GNOME or a bare window manager. ## Blocking The rules (which sites, which apps, when) live on the backend so the web app can -edit them and a second machine obeys the same list. This app enforces them: +edit them and a second machine obeys the same list. This app enforces them with +two mechanisms, because no single one covers the masks people actually write: * **Websites** — the managed region of `/etc/hosts` is kept in sync with the policy. Writing it needs root, which a desktop app must not have, so it calls `kadence-helper` through **pkexec**. +* **Wildcards a hosts file cannot express** — a hosts file is a list of exact + names, so `*.google.com` is expanded server-side into real hostnames. A bare + `*` in the *subdomain expansion* list changes what that means: `*.google.com` + then covers **every** subdomain, not just the configured labels, and those are + answered by a small DNS stub on `127.0.0.1` (port 5353, falling back to + 5453/5553/5354) which the helper points `systemd-resolved` at with one drop-in + for just the zones that need it (`~google.com`). Every other name flows to the + machine's normal resolver untouched. The stub is unprivileged, speaks plain DNS + over UDP and TCP, answers blocked A records with the redirect address and other + record types with NODATA (so `HTTPS`/SVCB hints cannot smuggle an address past + it), and returns SERVFAIL when no upstream can answer. +* **What cannot be routed** — the zone is whatever follows the last `*`, and it + needs at least two labels: `*gram.com` routes `gram.com`, while `*gram*` has + nothing after its last `*` and `*.site` has a single label. Those stay honestly + reported as "cannot be blocked on this computer" instead of pretending; the + phone still matches them against the URLs it sees. +* **Ordering** — the stub is listening *before* the drop-in is written, and the + drop-in is removed *before* the stub stops, so resolved never has a moment with + nowhere to send a zone's queries. The stub and the route belong to the app's + lifetime, not to one interval: between intervals they stay up and forward + everything, because repointing resolved costs a restart and a dropped DNS cache. + If a crash left the drop-in behind, the next start sees it (the file is + world-readable), brings its stub back first, and carries on. * **Applications** — `/proc` is scanned every few seconds for your own processes matching the list; they are terminated (SIGTERM, then SIGKILL) or merely reported, and every attempt is sent to the backend for the evening report. @@ -65,7 +89,7 @@ edit them and a second machine obeys the same list. This app enforces them: ### The privileged helper `helper/kadence-helper` is the entire privileged surface, and it is deliberately -dull: +dull. It has two jobs — the hosts region and the resolved drop-in: * every hostname must match a strict pattern, the redirect must parse as an IP; * only the region between its own markers is rewritten — your own entries above @@ -73,12 +97,17 @@ dull: * the input list is opened `O_NOFOLLOW` and must be a regular file; * the write is atomic (temp file + `rename`), and the first run backs up `/etc/hosts` to `/etc/hosts.kadence-backup`; -* `localhost` can never be blocked. +* `localhost` can never be blocked; +* `resolved-on` accepts only real zone names (two or more labels, no `*`, no + ports or junk), deduplicates them, refuses privileged ports, and writes the + drop-in `0644` atomically; `resolved-off` removes it. Both then restart + systemd-resolved and flush its cache; on a machine that does not run resolved, + they say so and the hosts half carries on alone. ```bash sudo ./helper/install.sh # polkit asks once per session sudo ./helper/install.sh --passwordless # or never, for your user only -sudo ./helper/install.sh --uninstall # unblocks first, then removes itself +sudo ./helper/install.sh --uninstall # unblocks, removes the drop-in, then removes itself ``` --- @@ -190,7 +219,7 @@ kadence_desktop/ config.py # ~/.config/kadence/desktop.json tray.py # the painted countdown icon and its menu integrations/ # notifications, inhibit/DND, MPRIS, launcher, logind, D-Bus, autostart - blocking/ # hosts file, /proc scanning, and the manager that syncs policy + blocking/ # hosts region, DNS stub + resolved route, /proc scanning, the manager that syncs policy qml/ # Material UI: Timer, Tasks, Blocker, Report, Settings helper/ # the privileged helper + polkit action + installer packaging/ # .desktop entry, icon, user installer diff --git a/desktop/helper/install.sh b/desktop/helper/install.sh index 345d919..8407a5e 100755 --- a/desktop/helper/install.sh +++ b/desktop/helper/install.sh @@ -36,8 +36,10 @@ if [[ $EUID -ne 0 ]]; then fi if [[ $UNINSTALL -eq 1 ]]; then - # Unblock first — leaving a block behind after uninstalling would be rude. + # Unblock first — leaving a block behind after uninstalling would be rude, + # and so would leaving the machine's DNS pointed at a stub that is gone. [[ -x "$HELPER_DEST" ]] && "$HELPER_DEST" clear || true + [[ -x "$HELPER_DEST" ]] && "$HELPER_DEST" resolved-off || true rm -f "$HELPER_DEST" "$POLICY_DEST" "$RULE_DEST" echo "Removed the helper, its polkit action and any passwordless rule." exit 0 diff --git a/desktop/helper/kadence-helper b/desktop/helper/kadence-helper index d3d18ba..7b3681c 100755 --- a/desktop/helper/kadence-helper +++ b/desktop/helper/kadence-helper @@ -1,8 +1,10 @@ #!/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 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 @@ -13,7 +15,10 @@ it is written to be boring on purpose: 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 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. @@ -21,6 +26,8 @@ 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 """ @@ -42,6 +49,11 @@ 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. @@ -169,6 +181,104 @@ def command_clear() -> None: 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 @@ -192,6 +302,10 @@ def main() -> int: 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() @@ -202,6 +316,10 @@ def main() -> int: 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 diff --git a/desktop/helper/org.kadence.helper.policy b/desktop/helper/org.kadence.helper.policy index 6917726..7c6a1a8 100644 --- a/desktop/helper/org.kadence.helper.policy +++ b/desktop/helper/org.kadence.helper.policy @@ -15,8 +15,8 @@ https://github.com/ - Manage the Kadence website block list - Управление списком заблокированных сайтов Kadence + Manage the Kadence website block list and its DNS routing + Управление списком заблокированных сайтов Kadence и их DNS-маршрутизацией Authentication is required to change which websites are blocked. Для изменения списка заблокированных сайтов требуется аутентификация. kadence diff --git a/desktop/kadence_desktop/blocking/__init__.py b/desktop/kadence_desktop/blocking/__init__.py index dbfeea0..2867464 100644 --- a/desktop/kadence_desktop/blocking/__init__.py +++ b/desktop/kadence_desktop/blocking/__init__.py @@ -2,5 +2,6 @@ The *rules* live on the server (so the web UI can edit them and a second machine obeys the same list); this package is what turns a rule into a redirected -hostname and a terminated process on this particular computer. +hostname, a routed DNS zone and a terminated process on this particular +computer. """ diff --git a/desktop/kadence_desktop/blocking/hosts.py b/desktop/kadence_desktop/blocking/hosts.py index e3accbd..ea2526e 100644 --- a/desktop/kadence_desktop/blocking/hosts.py +++ b/desktop/kadence_desktop/blocking/hosts.py @@ -97,6 +97,19 @@ class HostsBlocker: def apply(self, domains: list[str], redirect_ip: str = "127.0.0.1") -> bool: """Make the managed region exactly `domains`. True when it already was.""" wanted = sorted({d.strip().lower() for d in domains if valid_hostname(d)}) + + # Never drop a hostname without saying so. The server expands masks for us, + # and a bad `subdomain_expansions` entry (`*` is the one people try) used to + # arrive here as a non-hostname and vanish into this filter — the mask looked + # applied and blocked nothing. The server refuses those labels now; this is + # the backstop, and it is loud. + refused = sorted({d.strip().lower() for d in domains if d.strip() and not valid_hostname(d)}) + if refused: + log.warning( + "not a hostname, so not blocked: %s — check subdomain_expansions", + ", ".join(refused[:10]), + ) + if sorted(set(current_block())) == wanted: return True if not wanted: diff --git a/desktop/kadence_desktop/blocking/manager.py b/desktop/kadence_desktop/blocking/manager.py index 3c13237..fee2823 100644 --- a/desktop/kadence_desktop/blocking/manager.py +++ b/desktop/kadence_desktop/blocking/manager.py @@ -1,10 +1,25 @@ """Turning the server's policy into what this machine actually does. Called on every poll with the policy the backend computed. It keeps the hosts -file and the process list in step with it, reports every turned-away attempt -back to the API (that is what makes the daily report fun), and — importantly — -puts everything back the moment the policy says blocking is off, including when -the app exits or crashes mid-interval. +file, the DNS routes and the process list in step with it, reports every +turned-away attempt back to the API (that is what makes the daily report fun), +and — importantly — puts everything back the moment the policy says blocking is +off, including when the app exits or crashes mid-interval. + +Two mechanisms do the website blocking, because one cannot do it all: + +* `/etc/hosts` answers the concrete hostnames the server expanded — exact, cheap, + and readable with `cat`; +* the patterns a hosts file cannot express (`*.youtube.com`, `*gram.com`) are + answered by the local DNS stub, with `systemd-resolved` sent the affected zones + through the helper's drop-in. + +The stub and the drop-in are kept for as long as the app runs, not per interval: +repointing resolved costs a restart and a dropped DNS cache, and turning the +block on and off is instantaneous — the stub answers or forwards, per query. +That is why `release()` does not touch either of them, and why `shutdown()` is +its own method: the drop-in has to be gone *before* the stub stops, or the +machine spends a moment asking a dead port. """ from __future__ import annotations @@ -14,6 +29,7 @@ from typing import Callable from kadence_desktop.blocking import processes from kadence_desktop.blocking.hosts import HostsBlocker +from kadence_desktop.blocking.resolver import ResolvedRoute, StubResolver, split_routable log = logging.getLogger(__name__) @@ -25,18 +41,35 @@ class BlockerManager: helper_path: str, report: Callable[[str, str, str, str | None], None], notify: Callable[[str, str], None] | None = None, + stub: StubResolver | None = None, + route: ResolvedRoute | None = None, ) -> None: self._hosts = HostsBlocker(helper_path) + # Injectable so tests can drive the ordering without binding ports or + # asking a real systemd-resolved for anything. + self._stub = stub if stub is not None else StubResolver() + self._route = route if route is not None else ResolvedRoute(helper_path) self._report = report self._notify = notify self._active = False self._applied: list[str] = [] self._announced: set[int] = set() + self._redirect_ip = "127.0.0.1" self.enforce_hosts = True self.enforce_apps = True self.last_error: str | None = None + self.route_error: str | None = None + #: Patterns no machine can enforce at DNS level (`*gram*`); the UI says so. + self.unroutable: list[str] = [] + #: Zones currently handed to `systemd-resolved`. + self.routed_zones: list[str] = [] self.killed_today = 0 + if self._route.applied is not None: + # A drop-in survived a crash: the zones point at this stub, so it has + # to be listening *now*, before the first policy even arrives. + self._stub.start() + # ---- state ------------------------------------------------------------- @property @@ -49,8 +82,14 @@ class BlockerManager: @property def blocked_domains(self) -> list[str]: + """What is really in `/etc/hosts` right now — not what the policy wants.""" return list(self._applied) + @property + def error(self) -> str | None: + """The first thing that went wrong, hosts or DNS, for the UI to show.""" + return self.last_error or self.route_error + # ---- the one entry point ---------------------------------------------- def sync(self, policy: dict) -> None: @@ -58,11 +97,19 @@ class BlockerManager: should_block = bool(policy.get("active")) and bool(policy.get("enabled")) domains = [str(item) for item in policy.get("domains", [])] apps = [str(item) for item in policy.get("apps", [])] - redirect_ip = str(policy.get("redirect_ip") or "127.0.0.1") + self._redirect_ip = str(policy.get("redirect_ip") or "127.0.0.1") + patterns = [str(item) for item in policy.get("unexpandable", [])] + allow = [str(item) for item in policy.get("allowed", [])] + + routable, unroutable, zones = split_routable(patterns) + self.unroutable = unroutable + if not self.enforce_hosts: + routable, zones = [], [] + self._sync_dns(routable, allow, zones, active=should_block) if should_block: if self.enforce_hosts and domains != self._applied: - if self._hosts.apply(domains, redirect_ip): + if self._hosts.apply(domains, self._redirect_ip): self._applied = domains self.last_error = None log.info("blocking %s domain(s)", len(domains)) @@ -74,6 +121,41 @@ class BlockerManager: else: self.release() + def _sync_dns( + self, + patterns: list[str], + allow: list[str], + zones: list[str], + *, + active: bool, + ) -> None: + """Put the stub and the resolved route in step, in a safe order.""" + if not zones: + if self._route.applied is not None: + self.route_error = None if self._route.off() else self._route.last_error + else: + self.route_error = None + # Nothing points at the stub any more, so it can go. + if self._stub.running: + self._stub.stop() + self.routed_zones = [] + return + + # The stub first. Writing the drop-in before anything listens on its port + # would break resolution for the very zones we are trying to protect. + if not self._stub.running and not self._stub.start(): + self.route_error = self._stub.error + if self._route.applied is not None: + self._route.off() + self.routed_zones = [] + return + if self._route.on(zones, self._stub.port or 0): + self.route_error = None + else: + self.route_error = self._route.last_error + self._stub.set_rules(patterns, allow, self._redirect_ip, active=active) + self.routed_zones = zones + def release(self) -> None: """Unblock everything. Called when the interval ends, and on shutdown.""" if self._applied or self._hosts_dirty(): @@ -84,6 +166,18 @@ class BlockerManager: self.last_error = self._hosts.last_error self._active = False self._announced.clear() + # The stub keeps running and keeps its route: the zones are still pointed + # at it, so it has to answer — it just forwards everything again. + self._stub.set_rules([], [], self._redirect_ip, active=False) + + def shutdown(self) -> None: + """Give the machine's DNS back, then everything else. Called on exit.""" + if self._route.applied is not None and not self._route.off(): + self.route_error = self._route.last_error + if self._stub.running: + self._stub.stop() + self.routed_zones = [] + self.release() def _hosts_dirty(self) -> bool: from kadence_desktop.blocking.hosts import current_block diff --git a/desktop/kadence_desktop/blocking/resolver.py b/desktop/kadence_desktop/blocking/resolver.py new file mode 100644 index 0000000..ac4ddb4 --- /dev/null +++ b/desktop/kadence_desktop/blocking/resolver.py @@ -0,0 +1,774 @@ +"""Website blocking at DNS level, for the patterns a hosts file cannot express. + +`/etc/hosts` can only answer with a fixed list of hostnames, so a mask like +`*.youtube.com` — *every* subdomain — cannot be written into it. The server +expands the labels the user listed and hands the rest over in the policy's +`unexpandable` field; this module is what makes those patterns real here: + +* [StubResolver] is a small DNS server bound to `127.0.0.1` on a high port. It + needs no privileges, it is only reachable from this machine, and it is not a + resolver of its own: anything it does not block is forwarded verbatim to the + machine's real upstreams, in parallel, and the first answer is passed back. + The forwarding matters — an allow-listed subdomain of a blocked zone + (`music.youtube.com` under `*.youtube.com`) has to keep resolving. +* [ResolvedRoute] tells `systemd-resolved` to send the affected zones here, via + a drop-in file the privileged helper writes. `systemd-resolved` routes a name + to the *most specific* matching route-only domain, so a `~youtube.com` route + beats a VPN's catch-all `~.` for that zone — and because the route is the + whole scope, those queries go to this stub and nowhere else. + +Two invariants keep this from taking the machine's DNS down, and both are the +reason `BlockerManager` starts and stops this pair in a fixed order: + +* the stub is listening *before* the drop-in that points zones at it exists; +* the drop-in is gone *before* the stub stops. + +A crash between those steps leaves zones pointing at a dead port until the app +runs again — the honest failure mode, and the reason the stub is started at +startup whenever a drop-in is found lying around. +""" + +from __future__ import annotations + +import concurrent.futures +import fnmatch +import ipaddress +import logging +import os +import re +import shutil +import socket +import struct +import subprocess +import threading +import time +from dataclasses import dataclass, field + +log = logging.getLogger(__name__) + +# The stub binds the first of these ports that is free. 5353 is not just some +# high port: it is mDNS's, hence "the obvious one" — but it can be taken, so the +# fallbacks are tried in turn and the *actual* port is what gets routed. +STUB_ADDRESS = "127.0.0.1" +STUB_PORTS = (5353, 5453, 5553, 5354) + +# systemd-resolved's own listener, and the extra one it may listen on. Sending a +# query there would come straight back to this stub (resolved asks us, we ask +# resolved, …), so they are never used as upstreams. +RESOLVED_LISTENERS = {"127.0.0.53", "127.0.0.54"} + +# The file listing the uplink DNS servers resolved actually uses. In stub mode +# (the normal one) it holds the real upstreams, not 127.0.0.53. +UPSTREAM_PATH = "/run/systemd/resolve/resolv.conf" + +DROPIN_PATH = "/etc/systemd/resolved.conf.d/kadence.conf" + +# Short on purpose: an answer must not outlive the interval that produced it by +# much, or a site stays unreachable from a cache after the block lifts. +ANSWER_TTL = 60 + +TYPE_A, TYPE_AAAA, CLASS_IN = 1, 28, 1 +ROOT_TIMEOUT = 2.5 + + +# ========================================================================== +# Which patterns *can* be routed at DNS level +# ========================================================================== + + +def _zone_of(pattern: str) -> str | None: + """The zone a mask anchors on, or None when no zone can be routed for it. + + `*.youtube.com` anchors on `youtube.com`: every name under it matches the + mask, so the whole zone can be sent to the stub. `*gram.com` anchors on + `gram.com` the same way — the suffix is literal, only the front is not. + `*gram*` has nothing after the last `*` — no anchor, no route, and it is + reported as unenforceable rather than silently ignored. A zone of a single + label (`*.site`) is refused too: routing a whole top-level domain is not what + the user meant. + """ + zone = pattern.rsplit("*", 1)[-1].strip(".").lower() + return zone if len(zone.split(".")) >= 2 else None + + +def split_routable(patterns: list[str]) -> tuple[list[str], list[str], list[str]]: + """Sort `unexpandable` masks into (routable, unroutable, zones to route). + + The three lists answer three different questions: what the stub answers, + what no machine can answer (the phone's URL matcher still can), and what + goes into the resolved drop-in. + """ + routable: list[str] = [] + unroutable: list[str] = [] + zones: list[str] = [] + for pattern in patterns: + zone = _zone_of(pattern) + if zone is None: + unroutable.append(pattern) + continue + routable.append(pattern) + if zone not in zones: + zones.append(zone) + return routable, unroutable, zones + + +def _matches(name: str, patterns: list[str]) -> bool: + return any(fnmatch.fnmatchcase(name, pattern) for pattern in patterns) + + +# ========================================================================== +# The DNS wire format, as little of it as a blocker needs +# ========================================================================== + +_HEADER = struct.Struct("!HHHHHH") +_FLAG_QR, _FLAG_RD, _FLAG_RA = 0x8000, 0x0100, 0x0080 +_RCODE_SERVFAIL = 2 + + +@dataclass(frozen=True) +class Question: + qid: int + flags: int + name: str + qtype: int + qclass: int + end: int # offset just past the question section + + +def _read_name(packet: bytes, offset: int) -> tuple[str, int] | None: + """Read a possibly-compressed domain name; `(lowercased name, next offset)`. + + Compression pointers are followed with a small hop budget, because a query + is attacker-controlled data like any other packet on a socket. + """ + labels: list[str] = [] + end = offset + jumped = False + hops = 0 + while True: + if offset >= len(packet): + return None + length = packet[offset] + if length == 0: + offset += 1 + if not jumped: + end = offset + return ".".join(labels).lower(), end + if length & 0xC0 == 0xC0: + if offset + 1 >= len(packet): + return None + if not jumped: + end = offset + 2 + offset = ((length & 0x3F) << 8) | packet[offset + 1] + jumped = True + hops += 1 + if hops > 10: + return None + continue + if length & 0xC0: + return None + offset += 1 + labels.append(packet[offset : offset + length].decode("ascii", errors="replace")) + offset += length + if not jumped: + end = offset + + +def parse_question(packet: bytes) -> Question | None: + """The single question of a standard query, or None for anything else.""" + if len(packet) < _HEADER.size: + return None + qid, flags, qdcount, _, _, _ = _HEADER.unpack_from(packet) + if flags & _FLAG_QR or qdcount != 1 or flags & 0x7800: # a reply, or not a query + return None + read = _read_name(packet, _HEADER.size) + if read is None: + return None + name, end = read + if end + 4 > len(packet): + return None + qtype, qclass = struct.unpack_from("!HH", packet, end) + return Question(qid, flags, name, qtype, qclass, end + 4) + + +def _build_response( + request: bytes, + question: Question, + *, + rdata: bytes | None = None, + record_type: int = TYPE_A, + rcode: int = 0, +) -> bytes: + """Answer `question`: one record when `rdata` is given, NODATA otherwise.""" + flags = _FLAG_QR | (question.flags & _FLAG_RD) | _FLAG_RA | rcode + answers = 1 if rdata is not None else 0 + header = _HEADER.pack(question.qid, flags, 1, answers, 0, 0) + body = request[_HEADER.size : question.end] + if rdata is None: + return header + body + answer = b"\xc0\x0c" + struct.pack("!HHIH", record_type, CLASS_IN, ANSWER_TTL, len(rdata)) + rdata + return header + body + answer + + +# ========================================================================== +# The stub itself +# ========================================================================== + + +@dataclass(frozen=True) +class _Rules: + """What the stub is currently doing. Swapped whole, under the GIL.""" + + active: bool = False + patterns: tuple[str, ...] = () + allow: tuple[str, ...] = () + redirect: ipaddress.IPv4Address | ipaddress.IPv6Address | None = None + + def blocks(self, name: str) -> bool: + return ( + self.active + and bool(self.patterns) + and not _matches(name, list(self.allow)) + and _matches(name, list(self.patterns)) + ) + + +class StubResolver: + """The local DNS server the routed zones are pointed at. + + Deliberately small: it answers the address queries for names it blocks and + forwards everything else, including the whole allow list. It has no cache of + its own — caching here would mean answers that outlive the interval — so the + upstreams and `systemd-resolved` stay the source of truth. + """ + + def __init__( + self, + *, + address: str = STUB_ADDRESS, + ports: tuple[int, ...] = STUB_PORTS, + upstream_path: str = UPSTREAM_PATH, + timeout: float = ROOT_TIMEOUT, + ) -> None: + self.address = address + self.ports = ports + self.upstream_path = upstream_path + self.timeout = timeout + + self._rules = _Rules() + self._lock = threading.Lock() + self._running = False + self._port: int | None = None + self._error: str | None = None + self._udp: socket.socket | None = None + self._tcp: socket.socket | None = None + self._threads: list[threading.Thread] = [] + self._handlers: concurrent.futures.ThreadPoolExecutor | None = None + self._forwards: concurrent.futures.ThreadPoolExecutor | None = None + self._upstream_cache: tuple[float, list[tuple[str, int]]] | None = None + + # ---- lifecycle --------------------------------------------------------- + + @property + def running(self) -> bool: + return self._running + + @property + def port(self) -> int | None: + """The port actually bound — what the drop-in has to name.""" + return self._port + + @property + def error(self) -> str | None: + return self._error + + def start(self) -> bool: + """Bind the first free port. True when the stub is listening.""" + if self._running: + return True + for port in self.ports: + bound = self._bind(port) + if bound is not None: + return True + self._error = ( + f"the local DNS stub could not bind {self.address} on any of " + f"{', '.join(str(p) for p in self.ports)} ({self._error})" + ) + log.warning(self._error) + return False + + def _bind(self, port: int) -> socket.socket | None: + udp = tcp = None + try: + # TCP first: with port 0 it picks a free port, and UDP then takes the + # same one — the two protocols have separate namespaces, so this is + # how both end up on one number even when the kernel chooses. + tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + tcp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + tcp.bind((self.address, port)) + tcp.listen(16) + bound = tcp.getsockname()[1] + udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + udp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + udp.bind((self.address, bound)) + # A timeout on the blocking calls below, so `stop()` can actually + # interrupt them: closing a socket from another thread does not wake + # a recvfrom() that is already parked on it. + udp.settimeout(0.5) + tcp.settimeout(0.5) + except OSError as exc: + self._error = f"{self.address}:{port}: {exc}" + for sock in (udp, tcp): + if sock is not None: + sock.close() + return None + + self._udp, self._tcp = udp, tcp + self._port = udp.getsockname()[1] + self._handlers = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix="kadence-dns") + self._forwards = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix="kadence-fwd") + self._running = True + self._error = None + self._threads = [ + threading.Thread(target=self._serve_udp, name="kadence-dns-udp", daemon=True), + threading.Thread(target=self._serve_tcp, name="kadence-dns-tcp", daemon=True), + ] + for thread in self._threads: + thread.start() + log.info("DNS stub listening on %s:%s", self.address, self._port) + return udp + + def stop(self) -> None: + """Stop answering and give the port back. Only safe once unrouted.""" + if not self._running: + return + self._running = False + for sock in (self._udp, self._tcp): + if sock is None: + continue + try: + sock.close() # the blocking recv/accept raises, ending the loop + except OSError: + pass + for thread in self._threads: + thread.join(timeout=1) + for pool in (self._handlers, self._forwards): + if pool is not None: + pool.shutdown(wait=False) + self._handlers = self._forwards = None + self._udp = self._tcp = None + self._threads = [] + self._port = None + log.info("DNS stub stopped") + + def set_rules( + self, + patterns: list[str], + allow: list[str], + redirect_ip: str, + *, + active: bool, + ) -> None: + """What to block until told otherwise. Never touches the sockets.""" + try: + redirect = ipaddress.ip_address(redirect_ip) + except ValueError: + log.warning("redirect_ip %r is not an IP address; falling back to 127.0.0.1", redirect_ip) + redirect = ipaddress.ip_address("127.0.0.1") + rules = _Rules( + active=bool(active), + patterns=tuple(p.strip().lower() for p in patterns if p.strip()), + allow=tuple(a.strip().lower() for a in allow if a.strip()), + redirect=redirect, + ) + with self._lock: + self._rules = rules + + # ---- serving ----------------------------------------------------------- + + def _serve_udp(self) -> None: + while self._running and self._udp is not None: + try: + data, address = self._udp.recvfrom(4096) + except socket.timeout: + continue + except OSError: + break + handlers = self._handlers + if handlers is None: + break + try: + handlers.submit(self._answer_udp, data, address) + except RuntimeError: + break + + def _answer_udp(self, data: bytes, address: tuple[str, int]) -> None: + try: + response = self._resolve(data, tcp=False) + if response and self._udp is not None: + self._udp.sendto(response, address) + except OSError: + pass + + def _serve_tcp(self) -> None: + while self._running and self._tcp is not None: + try: + connection, _ = self._tcp.accept() + except socket.timeout: + continue + except OSError: + break + handlers = self._handlers + if handlers is None: + break + try: + handlers.submit(self._answer_tcp, connection) + except RuntimeError: + break + + def _answer_tcp(self, connection: socket.socket) -> None: + try: + with connection: + connection.settimeout(5) + header = _recv_exact(connection, 2) + if header is None: + return + (length,) = struct.unpack("!H", header) + data = _recv_exact(connection, length) + if data is None: + return + response = self._resolve(data, tcp=True) + if response is None: + response = _servfail(data) + connection.sendall(struct.pack("!H", len(response)) + response) + except OSError: + pass + + def _resolve(self, data: bytes, *, tcp: bool) -> bytes | None: + question = parse_question(data) + if question is not None and question.qclass == CLASS_IN: + rules = self._rules + if rules.blocks(question.name): + return self._blocked_answer(data, question, rules) + return self._forward(data, tcp=tcp) + + def _blocked_answer(self, data: bytes, question: Question, rules: _Rules) -> bytes: + """Redirect the address family we have an address for, drop the rest. + + Dropping the others is the point: an HTTPS/SVCB record carries address + hints, and answering those would hand the browser a real address to + connect to without ever asking the A record. + """ + redirect = rules.redirect + if question.qtype == TYPE_A and redirect is not None and redirect.version == 4: + return _build_response(data, question, rdata=redirect.packed, record_type=TYPE_A) + if question.qtype == TYPE_AAAA and redirect is not None and redirect.version == 6: + return _build_response(data, question, rdata=redirect.packed, record_type=TYPE_AAAA) + return _build_response(data, question) + + # ---- forwarding -------------------------------------------------------- + + def _upstreams(self) -> list[tuple[str, int]]: + """The machine's real DNS servers, read from resolved's uplink file. + + Cached until the file changes: resolved rewrites it whenever the network + does, and this is on the path of every query. + """ + try: + stat = os.stat(self.upstream_path) + stamp = stat.st_mtime + except OSError: + stamp = 0.0 + cached = self._upstream_cache + if cached is not None and cached[0] == stamp and stamp != 0.0: + return cached[1] + + servers = _parse_resolv_conf(self.upstream_path) + if not servers: + servers = _parse_resolv_conf("/etc/resolv.conf") + servers = [ + server + for server in servers + if server[0] not in RESOLVED_LISTENERS and server != (self.address, self._port) + ] + if cached is None or cached[1] != servers: + log.info("forwarding DNS to %s", ", ".join(f"{host}:{port}" for host, port in servers) or "nowhere") + self._upstream_cache = (stamp, servers) + return servers + + def _forward(self, query: bytes, *, tcp: bool) -> bytes | None: + upstreams = self._upstreams() + pool = self._forwards + if not upstreams or pool is None: + return _servfail(query) + deadline = time.monotonic() + self.timeout + pending = { + pool.submit(self._ask_tcp if tcp else self._ask_udp, server, query, self.timeout): server + for server in upstreams + } + try: + while pending: + done, pending = concurrent.futures.wait( + pending, timeout=max(0.05, deadline - time.monotonic()), + return_when=concurrent.futures.FIRST_COMPLETED, + ) + if not done: + break + for future in done: + try: + response = future.result() + except (OSError, concurrent.futures.CancelledError): + continue + if response: + return response + if time.monotonic() >= deadline: + break + finally: + for future in pending: + future.cancel() + # No upstream spoke: SERVFAIL, so the client retries rather than caching + # a lie about a domain we did not mean to block. + return _servfail(query) + + def _ask_udp(self, server: tuple[str, int], query: bytes, timeout: float) -> bytes | None: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.settimeout(timeout) + try: + sock.sendto(query, server) + response, _ = sock.recvfrom(4096) + except OSError: + return None + return _plausible(query, response) + + def _ask_tcp(self, server: tuple[str, int], query: bytes, timeout: float) -> bytes | None: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(timeout) + try: + sock.connect(server) + sock.sendall(struct.pack("!H", len(query)) + query) + header = _recv_exact(sock, 2) + if header is None: + return None + (length,) = struct.unpack("!H", header) + response = _recv_exact(sock, length) + except OSError: + return None + return _plausible(query, response) + + +def _recv_exact(sock: socket.socket, count: int) -> bytes | None: + chunks = b"" + while len(chunks) < count: + try: + chunk = sock.recv(count - len(chunks)) + except OSError: + return None + if not chunk: + return None + chunks += chunk + return chunks + + +def _plausible(query: bytes, response: bytes | None) -> bytes | None: + """A response is only worth passing on if it answers *this* query.""" + if not response or len(response) < _HEADER.size: + return None + if response[:2] != query[:2]: # the transaction id must come back intact + return None + return response + + +_NAMESERVER_RE = re.compile(r"^nameserver\s+(?P\S+)") + + +def _parse_nameserver(token: str) -> tuple[str, int] | None: + """`nameserver` line → `(address, port)`, tolerating the odd shapes. + + resolv.conf itself never carries a port, but systemd's generated file can, + and so can hand-written ones: `1.2.3.4:5353`, `[::1]:5353`, a bare IPv6 + address, or an address with a comment bolted on. + """ + token = token.split("#", 1)[0].strip() + port = 53 + if token.startswith("["): + host, bracket, rest = token[1:].partition("]") + if not bracket: + return None + if rest.startswith(":") and rest[1:].isdigit(): + port = int(rest[1:]) + elif rest: + return None + elif token.count(":") == 1: + host, text = token.split(":") + if not text.isdigit(): + return None + port = int(text) + else: + host = token + try: + ipaddress.ip_address(host) + except ValueError: + return None + return (host, port) if 1 <= port <= 65535 else None + + +def _parse_resolv_conf(path: str) -> list[tuple[str, int]]: + servers: list[tuple[str, int]] = [] + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + lines = handle.read().splitlines() + except OSError: + return servers + for line in lines: + match = _NAMESERVER_RE.match(line.strip()) + if match is None: + continue + entry = _parse_nameserver(match.group("token")) + if entry is not None and entry not in servers: + servers.append(entry) + return servers + + +def _servfail(query: bytes) -> bytes: + """A response that says "try again", for a query we cannot answer at all.""" + if len(query) < _HEADER.size: + return b"" + qid = struct.unpack_from("!H", query)[0] + flags = _FLAG_QR | (struct.unpack_from("!H", query, 2)[0] & _FLAG_RD) | _FLAG_RA | _RCODE_SERVFAIL + question = parse_question(query) + body = query[_HEADER.size : question.end] if question is not None else b"" + return _HEADER.pack(qid, flags, 1, 0, 0, 0) + body + + +# ========================================================================== +# Pointing systemd-resolved at the stub +# ========================================================================== + + +@dataclass +class ResolvedRoute: + """The resolved drop-in, kept in step through the privileged helper. + + Like the hosts blocker, this diffs against what it last applied and treats a + no-op as a no-op — a restart of `systemd-resolved` drops the whole machine's + DNS cache for a moment, so it should only happen when the routed zones + genuinely changed. The applied state is re-read from the drop-in on first + use, so starting the app against an unchanged file does not restart + anything. + """ + + helper_path: str = "/usr/local/bin/kadence-helper" + dropin_path: str = DROPIN_PATH + _applied: tuple[tuple[str, ...], int] | None = None + _error: str | None = None + _loaded: bool = field(default=False, repr=False) + + @property + def helper_installed(self) -> bool: + return os.path.isfile(self.helper_path) and os.access(self.helper_path, os.X_OK) + + @property + def last_error(self) -> str | None: + return self._error + + @property + def applied(self) -> tuple[tuple[str, ...], int] | None: + self._load() + return self._applied + + def _load(self) -> None: + if self._loaded: + return + self._loaded = True + if self._applied is not None: + return + self._applied = self._read_dropin() + + def _read_dropin(self) -> tuple[tuple[str, ...], int] | None: + """What the drop-in currently says, if it is one of ours.""" + try: + with open(self.dropin_path, "r", encoding="utf-8") as handle: + text = handle.read() + except OSError: + return None + port: int | None = None + zones: tuple[str, ...] = () + for line in text.splitlines(): + line = line.strip() + if line.startswith("DNS="): + for address in line[4:].split(): + if address.startswith(f"{STUB_ADDRESS}:"): + try: + port = int(address.rsplit(":", 1)[1]) + except ValueError: + port = None + elif line.startswith("Domains="): + zones = tuple( + sorted(part[1:].lower() for part in line[8:].split() if part.startswith("~") and len(part) > 1) + ) + if port is None: + return None + return zones, port + + def on(self, zones: list[str], port: int) -> bool: + """Route `zones` to a stub on `127.0.0.1:port`. True when it is so.""" + self._load() + wanted = tuple(sorted({zone.strip().lower().strip(".") for zone in zones if zone.strip()})) + if not wanted: + return self.off() + if self._applied == (wanted, port): + return True + + runner = self._runner() + if runner is None: + return False + command = runner + ["resolved-on", "--port", str(port), "--domains", ",".join(wanted)] + if not self._run(command, f"route {len(wanted)} zone(s) through the local stub"): + return False + self._applied = (wanted, port) + return True + + def off(self) -> bool: + """Stop routing anything to the stub. True when nothing is routed.""" + self._load() + if self._applied is None and not os.path.exists(self.dropin_path): + return True + runner = self._runner() + if runner is None: + return False + if not self._run(runner + ["resolved-off"], "stop routing DNS through the local stub"): + return False + self._applied = None + return True + + def _runner(self) -> list[str] | None: + if not self.helper_installed: + self._error = ( + f"The privileged helper is not installed at {self.helper_path}. " + "Run desktop/helper/install.sh once." + ) + return None + if shutil.which("pkexec") and os.environ.get("DISPLAY", os.environ.get("WAYLAND_DISPLAY")): + return ["pkexec", self.helper_path] + if shutil.which("sudo"): + return ["sudo", "-n", self.helper_path] + self._error = "Neither pkexec nor sudo is available to run the helper." + return None + + def _run(self, command: list[str], what: str) -> bool: + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=90, check=False) + except (OSError, subprocess.SubprocessError) as exc: + self._error = f"Could not {what}: {exc}" + log.warning(self._error) + return False + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip()[:300] + if result.returncode in (126, 127): + detail = detail or "the authorisation prompt was dismissed" + self._error = f"Could not {what}: {detail}" + log.warning(self._error) + return False + self._error = None + log.info("helper: %s", result.stdout.strip() or what) + return True diff --git a/desktop/kadence_desktop/controller.py b/desktop/kadence_desktop/controller.py index 55aa010..13bca46 100644 --- a/desktop/kadence_desktop/controller.py +++ b/desktop/kadence_desktop/controller.py @@ -289,19 +289,52 @@ class Controller(QObject): @pyqtProperty("QVariantList", notify=blockerChanged) def blockedDomains(self) -> list[str]: + """Every hostname the policy wants blocked, server-expanded. + + This is the *intent*: what the rules add up to. What is actually in this + machine's hosts file is [appliedDomains] — reporting this one as the + state was the page lying about a machine it had not touched. + """ return [str(item) for item in self._policy.get("all_domains", [])] + @pyqtProperty("QVariantList", notify=blockerChanged) + def appliedDomains(self) -> list[str]: + """What is really inside the managed region of `/etc/hosts` right now.""" + return self._blocker.blocked_domains + + @pyqtProperty("QVariantList", notify=blockerChanged) + def routedZones(self) -> list[str]: + """DNS zones currently pointed at this machine's local resolver stub.""" + return list(self._blocker.routed_zones) + + @pyqtProperty("QVariantList", notify=blockerChanged) + def unroutableDomains(self) -> list[str]: + """Masks no machine can enforce: no DNS zone can be routed for them.""" + if not self._policy.get("active"): + return [] + return list(self._blocker.unroutable) + @pyqtProperty("QVariantList", notify=blockerChanged) def blockedApps(self) -> list[str]: return [str(item) for item in self._policy.get("all_apps", [])] + @pyqtProperty("QVariantList", notify=blockerChanged) + def invalidExpansions(self) -> list[str]: + """Subdomain expansions the server refused, e.g. `not a label`. + + Worth a banner rather than a log line: a mistyped label silently reduces + every `*.site` mask to the bare domain, which looks like "blocking is on + and does nothing". + """ + return [str(item) for item in self._policy.get("invalid_expansions", [])] + @pyqtProperty(bool, notify=blockerChanged) def helperInstalled(self) -> bool: return self._blocker.helper_installed @pyqtProperty(str, notify=blockerChanged) def blockerError(self) -> str: - return self._blocker.last_error or "" + return self._blocker.error or "" @pyqtProperty(bool, notify=miniChanged) def miniVisible(self) -> bool: @@ -510,7 +543,8 @@ class Controller(QObject): elif key in ("mini_timer_corner", "mini_timer_margin"): self.miniPlacementChanged.emit() elif key == "enforce_blocking" and not value: - self._blocker.release() + # Takes the DNS route down with it, in the safe order. + self._blocker.shutdown() self.configChanged.emit() @pyqtSlot() @@ -781,14 +815,14 @@ class Controller(QObject): # ---- shutdown ---------------------------------------------------------- def shutdown(self) -> None: - """Hand everything back: inhibits, DND, and the hosts file.""" + """Hand everything back: inhibits, DND, the hosts file and the DNS route.""" self._tick.stop() self._idle_check.stop() self._screensaver.release() self._dnd.release() self._launcher.clear() try: - self._blocker.release() + self._blocker.shutdown() except Exception: # pragma: no cover log.exception("could not clear the block on exit") self._thread.quit() diff --git a/desktop/kadence_desktop/qml/BlockerPage.qml b/desktop/kadence_desktop/qml/BlockerPage.qml index f7ed725..50aa05c 100644 --- a/desktop/kadence_desktop/qml/BlockerPage.qml +++ b/desktop/kadence_desktop/qml/BlockerPage.qml @@ -1,8 +1,10 @@ // The focus guard: what is blocked, when, and what it caught. // // Lists accept masks. A hosts file has no wildcards of its own, so `*.x.com` is -// expanded server-side into real hostnames; this page shows that count and says -// plainly when a pattern cannot be expressed that way. +// expanded server-side into real hostnames, and the patterns that leaves over +// are answered by the local DNS stub for the zones it can route. The stats show +// what is *really* in place on this computer — the hosts region and the routed +// zones — and every banner says plainly what could not be enforced here. import QtQuick import QtQuick.Controls import QtQuick.Layouts @@ -42,6 +44,26 @@ ScrollView { visible: controller.blockerError !== "" text: controller.blockerError } + Banner { + visible: controller.invalidExpansions.length > 0 + text: controller.invalidExpansions.join(", ") + + " is not a subdomain label, so it was ignored. Each entry is one " + + "label — web, m, mobile. A bare `*` is kept and means every subdomain." + } + Banner { + visible: controller.blockerActive && controller.unroutableDomains.length > 0 + text: controller.unroutableDomains.join(", ") + + " cannot be blocked on this computer: no DNS zone can be routed for " + + "a mask with nothing after its last `*`. The phone still matches it " + + "against the URLs you open." + } + Banner { + visible: controller.blockerActive && controller.blockedDomains.length > 0 + && controller.appliedDomains.length === 0 + text: "Blocking is on, but this machine's hosts file has none of the " + + controller.blockedDomains.length + " hostnames in it yet. Either the " + + "write has not run, or it was refused — a message above would say which." + } Panel { padding: 0 @@ -50,8 +72,21 @@ ScrollView { spacing: 0 Repeater { model: [ - { label: "Hostnames", value: "" + controller.blockedDomains.length, caption: "after expansion" }, - { label: "Apps", value: "" + controller.blockedApps.length, caption: "closed on sight" }, + { label: "Hostnames", + value: "" + controller.appliedDomains.length, + caption: controller.config.enforce_blocking && controller.config.block_hosts_file + ? "in /etc/hosts" + : "not written (enforcement off)" }, + { label: "Zones", + value: "" + controller.routedZones.length, + caption: "routed to the stub" }, + { label: "Apps", + value: "" + controller.blockedApps.length, + caption: !controller.config.enforce_blocking || !controller.config.block_apps + ? "not enforced" + : (controller.settings.blocker + && controller.settings.blocker.kill_blocked_apps + ? "closed on sight" : "warned only") }, { label: "Attempts today", value: controller.stats.today ? "" + controller.stats.today.blocked_attempts : "0", caption: "turned away" }, @@ -125,8 +160,9 @@ ScrollView { Repeater { model: [ { pattern: "youtube.com", meaning: "the site itself, plus www. and m." }, - { pattern: "*.google.com", meaning: "every subdomain in the expansion list" }, - { pattern: "*gram*", meaning: "matches apps; a hosts file cannot express it" } + { pattern: "*.google.com", meaning: "the bare domain plus every subdomain in the expansion list" }, + { pattern: "*gram*", meaning: "matches apps, and URLs on the phone; no DNS zone can be routed for it" }, + { pattern: "* in the list below", meaning: "makes *.site cover every subdomain — answered by the DNS stub" } ] delegate: RowLayout { required property var modelData @@ -260,7 +296,7 @@ ScrollView { TextField { id: subdomainField Layout.preferredWidth: 420 - placeholderText: "*.site expands to: www, m, app…" + placeholderText: "*.site expands to: www, m, web… (`*` = every subdomain)" text: (controller.settings.blocker && controller.settings.blocker.subdomain_expansions ? controller.settings.blocker.subdomain_expansions : []).join(", ") } diff --git a/desktop/tests/test_blocker_manager.py b/desktop/tests/test_blocker_manager.py new file mode 100644 index 0000000..57af556 --- /dev/null +++ b/desktop/tests/test_blocker_manager.py @@ -0,0 +1,188 @@ +"""The blocker's lifecycle: which mechanism comes up when, and in which order. + +`BlockerManager` is the one place that knows both mechanisms exist, and the +invariants are all about *ordering*: the DNS stub has to be listening before the +drop-in points zones at it, and the drop-in has to be gone before the stub stops. +Both rules are cheap to state and easy to break in a refactor, so they are +pinned here with stand-ins that do nothing but write down what was asked of them. +""" + +from __future__ import annotations + +from kadence_desktop.blocking.manager import BlockerManager + + +class RecordingStub: + """Stands in for the DNS stub: a flag, a port, and a list of events.""" + + def __init__(self, events: list, *, start_ok: bool = True) -> None: + self.events = events + self.start_ok = start_ok + self.running = False + self.port = 5353 + self.error = None if start_ok else "the local DNS stub could not bind 127.0.0.1" + self.rules: tuple[list[str], list[str], str, bool] | None = None + + def start(self) -> bool: + self.events.append("stub.start") + if not self.start_ok: + return False + self.running = True + return True + + def stop(self) -> None: + self.events.append("stub.stop") + self.running = False + + def set_rules(self, patterns: list[str], allow: list[str], redirect_ip: str, *, active: bool) -> None: + self.events.append(("rules", tuple(patterns), active)) + self.rules = (list(patterns), list(allow), redirect_ip, active) + + +class RecordingRoute: + """Stands in for the resolved drop-in.""" + + def __init__(self, events: list, *, ok: bool = True, applied=None) -> None: + self.events = events + self.ok = ok + self.applied = applied + self.last_error = None if ok else "the authorisation prompt was dismissed" + + def on(self, zones: list[str], port: int) -> bool: + self.events.append(("route.on", tuple(zones), port)) + if not self.ok: + return False + self.applied = (tuple(zones), port) + return True + + def off(self) -> bool: + self.events.append("route.off") + if not self.ok: + return False + self.applied = None + return True + + +def policy(*, active: bool = True, domains=("youtube.com",), unexpandable=(), allowed=()) -> dict: + return { + "active": active, + "enabled": True, + "domains": list(domains), + "apps": [], + "kill_apps": False, + "redirect_ip": "127.0.0.1", + "unexpandable": list(unexpandable), + "allowed": list(allowed), + } + + +def manager(events: list, *, stub: RecordingStub | None = None, route: RecordingRoute | None = None) -> BlockerManager: + # A helper path that leads nowhere: the hosts half then refuses every write + # without touching anything, which is exactly what these tests do not need. + return BlockerManager( + helper_path="/nonexistent/kadence-helper", + report=lambda *args: None, + stub=stub if stub is not None else RecordingStub(events), + route=route if route is not None else RecordingRoute(events), + ) + + +def test_the_stub_comes_up_before_the_route_and_the_route_before_the_rules(): + events: list = [] + blocker = manager(events) + blocker.sync(policy(unexpandable=["*.youtube.com"])) + + assert events.index("stub.start") < events.index(("route.on", ("youtube.com",), 5353)) + assert events.index(("route.on", ("youtube.com",), 5353)) < events.index( + ("rules", ("*.youtube.com",), True) + ) + assert blocker.routed_zones == ["youtube.com"] + assert blocker.active + + +def test_the_route_stays_up_between_intervals(): + # A restart of systemd-resolved drops the machine's DNS cache, so the route + # belongs to the app's lifetime, not to one interval: between intervals the + # stub keeps the port and simply forwards everything. + events: list = [] + blocker = manager(events) + # The server keeps reporting `unexpandable` while nothing is blocking — it is + # a property of the rules, not of the interval — so the route survives. + blocker.sync(policy(unexpandable=["*.youtube.com"])) + events.clear() + + blocker.sync(policy(active=False, unexpandable=["*.youtube.com"])) + + assert "route.off" not in events + assert "stub.stop" not in events + assert ("rules", (), False) in events + assert not blocker.active + + +def test_shutdown_takes_the_dropin_down_before_the_stub(): + events: list = [] + blocker = manager(events) + blocker.sync(policy(unexpandable=["*.youtube.com"])) + events.clear() + + blocker.shutdown() + + assert events.index("route.off") < events.index("stub.stop") + assert ("rules", (), False) in events + assert not blocker.active + + +def test_a_mask_without_a_zone_never_touches_the_route(): + # `*gram*` has nothing after its last `*`: no zone to route, so the route is + # not opened just to say nothing. + events: list = [] + blocker = manager(events) + blocker.sync(policy(domains=("instagram.com",), unexpandable=["*gram*"])) + + assert blocker.unroutable == ["*gram*"] + assert not [event for event in events if isinstance(event, tuple) and event[0] == "route.on"] + assert blocker.routed_zones == [] + + +def test_switching_hosts_file_enforcement_off_takes_the_route_with_it(): + events: list = [] + blocker = manager(events) + blocker.sync(policy(unexpandable=["*.youtube.com"])) + events.clear() + + blocker.enforce_hosts = False + blocker.sync(policy(unexpandable=["*.youtube.com"])) + + assert events.index("route.off") < events.index("stub.stop") + assert blocker.routed_zones == [] + + +def test_a_stub_that_cannot_bind_leaves_no_route_behind(): + # The invariant, upside down: if nothing is listening, the drop-in must not + # be written — that is the state where the zones' DNS simply dies. + events: list = [] + blocker = manager(events, stub=RecordingStub(events, start_ok=False)) + blocker.sync(policy(unexpandable=["*.youtube.com"])) + + assert not [event for event in events if isinstance(event, tuple) and event[0] == "route.on"] + assert "route.off" not in events + assert blocker.route_error and "could not bind" in blocker.route_error + + +def test_a_dropin_left_by_a_crash_gets_its_stub_back_at_startup(): + events: list = [] + route = RecordingRoute(events, applied=(("youtube.com",), 5353)) + manager(events, route=route) # construction alone must do it + + assert events == ["stub.start"] + + +def test_a_refused_authorisation_is_reported_alongside_the_hosts_error(): + events: list = [] + blocker = manager(events, route=RecordingRoute(events, ok=False)) + blocker.sync(policy(unexpandable=["*.youtube.com"])) + + assert blocker.route_error == "the authorisation prompt was dismissed" + # The hosts half failed too (there is no helper), and it speaks first. + assert blocker.error == blocker.last_error + assert blocker.error is not None diff --git a/desktop/tests/test_resolver.py b/desktop/tests/test_resolver.py new file mode 100644 index 0000000..f8fcc7e --- /dev/null +++ b/desktop/tests/test_resolver.py @@ -0,0 +1,377 @@ +"""The DNS side of website blocking: routing, the stub, and the drop-in file. + +Everything here runs without root and without touching the real machine: the +stub binds a port the kernel picks, "upstream" is a socket in this process, the +resolved drop-in goes to a temp directory, and the helper is a shell script that +only writes down what it was called with. The one thing worth testing obsessively +is the order of operations — the failure mode is a machine whose DNS points at a +stub that is not listening, and that is not a thing to discover in production. +""" + +from __future__ import annotations + +import importlib.machinery +import importlib.util +import socket +import struct +import threading +from pathlib import Path + +import pytest + +from kadence_desktop.blocking.resolver import ( + ResolvedRoute, + StubResolver, + _parse_resolv_conf, + split_routable, +) + +HELPER_SOURCE = Path(__file__).resolve().parents[1] / "helper" / "kadence-helper" + + +@pytest.fixture(scope="module") +def helper(): + """The privileged helper, imported as a module — it is all functions. + + It has no `.py` suffix, so the loader has to be named explicitly. + """ + loader = importlib.machinery.SourceFileLoader("kadence_helper", str(HELPER_SOURCE)) + spec = importlib.util.spec_from_loader("kadence_helper", loader) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +# ========================================================================== +# Which masks can be routed +# ========================================================================== + + +def test_a_mask_anchored_on_a_zone_is_routable(): + routable, unroutable, zones = split_routable( + ["*.youtube.com", "*gram.com", "*.t.me", "*.site", "*gram*", "*"] + ) + assert routable == ["*.youtube.com", "*gram.com", "*.t.me"] + assert unroutable == ["*.site", "*gram*", "*"] + assert zones == ["youtube.com", "gram.com", "t.me"] + + +def test_zones_are_reported_once_even_for_several_masks(): + _, _, zones = split_routable(["*.youtube.com", "*.music.youtube.com", "*.video.youtube.com"]) + assert zones == ["youtube.com", "music.youtube.com", "video.youtube.com"] + + +# ========================================================================== +# The DNS wire format, from the test's side +# ========================================================================== + +TYPE_A, TYPE_AAAA, TYPE_HTTPS = 1, 28, 65 + + +def query(name: str, qtype: int = TYPE_A, qid: int = 0x1234) -> bytes: + labels = b"".join(bytes([len(label)]) + label.encode() for label in name.split(".")) + b"\x00" + return struct.pack("!HHHHHH", qid, 0x0100, 1, 0, 0, 0) + labels + struct.pack("!HH", qtype, 1) + + +def _question_end(packet: bytes) -> int | None: + """Where the question section ends, or None for anything malformed.""" + offset = 12 + while offset < len(packet) and packet[offset]: + offset += 1 + packet[offset] + offset += 1 + 4 + return offset if offset <= len(packet) else None + + +def _answer(rdata: bytes, record_type: int = TYPE_A) -> bytes: + return b"\xc0\x0c" + struct.pack("!HHIH", record_type, 1, 60, len(rdata)) + rdata + + +def upstream_answer(request: bytes, address: str) -> bytes | None: + """What a real upstream would say: an A record with a routable address.""" + end = _question_end(request) + if end is None: + return None + rdata = socket.inet_aton(address) + flags = struct.unpack_from("!H", request, 2)[0] | 0x8000 + return struct.pack("!HHHHHH", 0x1234, flags, 1, 1, 0, 0) + request[12:end] + _answer(rdata) + + +def read_answer(response: bytes) -> list[tuple[int, bytes]]: + """The answer records of a response, as `(type, rdata)` pairs.""" + count = struct.unpack_from("!H", response, 6)[0] + offset = _question_end(response) + assert offset is not None + records: list[tuple[int, bytes]] = [] + for _ in range(count): + offset += 2 # the name, always a pointer to the question here + record_type, _, _, length = struct.unpack_from("!HHIH", response, offset) + offset += 10 + records.append((record_type, response[offset : offset + length])) + offset += length + return records + + +def read_ip(response: bytes) -> str | None: + for record_type, rdata in read_answer(response): + if record_type == TYPE_A: + return socket.inet_ntoa(rdata) + return None + + +class Upstream: + """A stand-in for the machine's real DNS server, answering one address.""" + + def __init__(self, address: str = "203.0.113.9") -> None: + self.answer = address + self.queries: list[bytes] = [] + self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.socket.bind(("127.0.0.1", 0)) + self.address = self.socket.getsockname() + self.thread = threading.Thread(target=self._serve, daemon=True) + self.thread.start() + + def _serve(self) -> None: + while True: + try: + request, peer = self.socket.recvfrom(4096) + except OSError: + return + self.queries.append(request) + response = upstream_answer(request, self.answer) + if response is not None: + self.socket.sendto(response, peer) + + def stop(self) -> None: + self.socket.close() + + +# ========================================================================== +# The stub +# ========================================================================== + + +@pytest.fixture +def stub(tmp_path): + """A stub on a kernel-chosen port, forwarding to [Upstream] by default.""" + upstream = Upstream() + conf = tmp_path / "resolv.conf" + conf.write_text(f"nameserver {upstream.address[0]}:{upstream.address[1]}\n") + resolver = StubResolver(ports=(0,), upstream_path=str(conf)) + assert resolver.start() + resolver.set_rules( + ["*.youtube.com"], ["music.youtube.com"], "127.0.0.1", active=True + ) + yield resolver + resolver.stop() + upstream.stop() + + +def ask(stub: StubResolver, packet: bytes, *, tcp: bool = False) -> bytes: + if tcp: + with socket.create_connection(("127.0.0.1", stub.port), timeout=2) as sock: + sock.sendall(struct.pack("!H", len(packet)) + packet) + length = struct.unpack("!H", sock.recv(2))[0] + return sock.recv(length) + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.settimeout(2) + sock.sendto(packet, ("127.0.0.1", stub.port)) + response, _ = sock.recvfrom(4096) + return response + + +def test_a_blocked_name_is_answered_with_the_redirect_address(stub): + response = ask(stub, query("web.youtube.com")) + assert read_ip(response) == "127.0.0.1" + + +def test_ipv6_and_service_records_get_no_answer_at_all(stub): + # The address hints in an HTTPS record would hand the browser a real address + # without it ever asking the A record, so every non-A query is NODATA. + for qtype in (TYPE_AAAA, TYPE_HTTPS): + response = ask(stub, query("web.youtube.com", qtype=qtype)) + assert struct.unpack_from("!H", response, 6)[0] == 0 + + +def test_the_bare_zone_is_not_matched_by_a_wildcard(stub): + # `*.youtube.com` never covered `youtube.com` itself; the hosts file has that + # one, and the stub must not start disagreeing with it. + response = ask(stub, query("youtube.com")) + assert read_ip(response) == "203.0.113.9" + + +def test_an_allow_listed_subdomain_is_forwarded(stub): + response = ask(stub, query("music.youtube.com")) + assert read_ip(response) == "203.0.113.9" + + +def test_an_unrelated_name_is_forwarded(stub): + response = ask(stub, query("example.com")) + assert read_ip(response) == "203.0.113.9" + + +def test_when_the_block_is_off_everything_is_forwarded(stub): + stub.set_rules(["*.youtube.com"], [], "127.0.0.1", active=False) + assert read_ip(ask(stub, query("web.youtube.com"))) == "203.0.113.9" + + +def test_the_same_answer_comes_over_tcp(stub): + response = ask(stub, query("web.youtube.com"), tcp=True) + assert read_ip(response) == "127.0.0.1" + + +def test_junk_on_the_socket_does_not_take_the_stub_down(stub): + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.sendto(b"\x01\x02 not a dns packet", ("127.0.0.1", stub.port)) + sock.sendto(b"", ("127.0.0.1", stub.port)) + sock.settimeout(0.2) + with pytest.raises(socket.timeout): + sock.recvfrom(4096) + # …and it is still answering the real questions. + assert read_ip(ask(stub, query("web.youtube.com"))) == "127.0.0.1" + + +def test_a_dead_upstream_becomes_servfail(tmp_path): + resolver = StubResolver(ports=(0,), upstream_path=str(tmp_path / "empty.conf"), timeout=0.3) + assert resolver.start() + try: + # The file does not exist and /etc/resolv.conf points at resolved, which + # is filtered out — so there is nowhere to forward to. + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.settimeout(2) + sock.sendto(query("example.com"), ("127.0.0.1", resolver.port)) + response, _ = sock.recvfrom(4096) + assert response[3] & 0x0F == 2 # SERVFAIL, not a lie about the address + finally: + resolver.stop() + + +def test_upstreams_are_read_from_resolv_conf_and_loops_are_dropped(tmp_path): + conf = tmp_path / "resolv.conf" + conf.write_text( + "# comment\n" + "search .\n" + "nameserver 127.0.0.53\n" + "nameserver 127.0.0.54\n" + "nameserver 192.168.3.1\n" + "nameserver 198.18.0.2:5353\n" + "nameserver [fe80::1]:53\n" + "nameserver not an address\n" + ) + assert _parse_resolv_conf(str(conf)) == [ + ("127.0.0.53", 53), + ("127.0.0.54", 53), + ("192.168.3.1", 53), + ("198.18.0.2", 5353), + ("fe80::1", 53), + ] + + resolver = StubResolver(ports=(0,), upstream_path=str(conf)) + resolver.set_rules([], [], "127.0.0.1", active=False) + servers = resolver._upstreams() + # resolved's own listeners are what would bounce a query straight back. + assert ("127.0.0.53", 53) not in servers + assert ("127.0.0.54", 53) not in servers + assert ("192.168.3.1", 53) in servers + + +# ========================================================================== +# The resolved route +# ========================================================================== + + +class FakeHelper: + """Writes down its argv instead of asking polkit for anything.""" + + def __init__(self, path: Path) -> None: + self.log = path / "helper.log" + self.path = path / "kadence-helper" + self.path.write_text(f'#!/bin/sh\necho "$@" >> "{self.log}"\nexit 0\n') + self.path.chmod(0o755) + + def calls(self) -> list[str]: + return self.log.read_text().splitlines() if self.log.exists() else [] + + +class LocalRoute(ResolvedRoute): + """The route, pointed straight at the fake helper. + + How to become root is the environment's business (pkexec, sudo, whatever a + developer's machine offers); what these tests are about is *what gets asked*, + so the elevation layer is skipped rather than simulated. + """ + + def _runner(self) -> list[str]: + return [self.helper_path] + + +def test_routing_zones_names_the_stub_and_the_helper(helper, tmp_path): + fake = FakeHelper(tmp_path) + route = LocalRoute(helper_path=str(fake.path), dropin_path=str(tmp_path / "kadence.conf")) + + assert route.on(["T.Me", "youtube.com", "youtube.com"], 5353) + assert fake.calls() == ["resolved-on --port 5353 --domains t.me,youtube.com"] + + # Same zones, same port: resolved is not restarted for nothing. + assert route.on(["youtube.com", "t.me"], 5353) + assert len(fake.calls()) == 1 + + # A different port means the drop-in has grown stale, so it goes again. + assert route.on(["youtube.com", "t.me"], 5453) + assert fake.calls()[1] == "resolved-on --port 5453 --domains t.me,youtube.com" + + +def test_stopping_the_route_is_a_no_op_when_nothing_was_routed(tmp_path): + fake = FakeHelper(tmp_path) + route = ResolvedRoute(helper_path=str(fake.path), dropin_path=str(tmp_path / "absent.conf")) + assert route.off() + assert fake.calls() == [] + + +def test_the_dropin_written_by_the_helper_is_read_back(helper, tmp_path): + # The two halves have to agree on the file's shape, or every app start would + # restart resolved "because nothing changed". + dropin = tmp_path / "kadence.conf" + dropin.write_text(helper.resolved_dropin_text(5353, ["youtube.com", "t.me"])) + fake = FakeHelper(tmp_path) + route = LocalRoute(helper_path=str(fake.path), dropin_path=str(dropin)) + + assert route.applied == (("t.me", "youtube.com"), 5353) + assert route.on(["t.me", "youtube.com"], 5353) + assert fake.calls() == [] + + assert route.off() + assert fake.calls() == ["resolved-off"] + assert route.applied is None + + +def test_a_missing_helper_is_reported_not_ignored(tmp_path): + route = ResolvedRoute(helper_path=str(tmp_path / "gone"), dropin_path=str(tmp_path / "kadence.conf")) + assert not route.on(["youtube.com"], 5353) + assert "not installed" in (route.last_error or "") + + +# ========================================================================== +# The helper's own validation — the part that runs as root +# ========================================================================== + + +def test_zone_names_are_validated_before_they_go_into_the_file(helper): + assert helper.load_zones("youtube.com, t.me ,YouTube.com") == ["youtube.com", "t.me"] + for bad in ["", " ", "localhost", "com", "youtube.com\nDNS=6.6.6.6", "127.0.0.1", "*.youtube.com"]: + with pytest.raises(SystemExit): + helper.load_zones(bad) + + +def test_the_dropin_says_exactly_what_it_means(helper): + text = helper.resolved_dropin_text(5353, ["youtube.com"]) + assert "[Resolve]" in text + assert "DNS=127.0.0.1:5353" in text + assert "Domains=~youtube.com" in text + assert "systemd-resolved" in text + + +def test_a_priviliged_port_is_refused(helper): + for port in (53, 80, 1023, 70000): + with pytest.raises(SystemExit): + helper.command_resolved_on(port, "youtube.com")