`youtube.com` on the block list catches `music.youtube.com` — a plain entry covers its subtree. `youtube.com` on the *allow* list caught only itself, because exceptions were glob-matched whatever they looked like and a plain name has no metacharacters. The same text in two boxes of the same form meant a subtree in one and a single hostname in the other, with nothing in the UI saying so. A plain exception now covers its subdomains; a masked one is glob-matched exactly as before, which is still how you spare something narrower than a whole subtree (`docs.*`, `*.docs.google.com`). Three enforcers decide this and all three had to move together, or an exception would work in the browser and not at the resolver: `blocker_service.is_allowed`, `:core`'s `isAllowed`, and the desktop DNS stub's own allow check, which was reusing the generic glob matcher. Parity tests on both sides, plus the two sentences in the web UI and the README that promised the old behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
792 lines
29 KiB
Python
792 lines
29 KiB
Python
"""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<token>\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
|