"""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) def _allowed(name: str, entries: list[str]) -> bool: """Whether an exception covers this name. Not [_matches]: a plain exception covers its subdomains, the same way a plain block-list entry does. Parity with `blocker_service.is_allowed` and `:core`'s `isAllowed` — the stub is the third place that decides this, and an exception that works in the browser but not at the resolver is worse than neither. """ for entry in entries: if any(char in entry for char in "*?["): if fnmatch.fnmatchcase(name, entry): return True elif name == entry or name.endswith(f".{entry}"): return True return False # ========================================================================== # 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 _allowed(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