`[tool.ruff]` set a line length and a target version and nothing else, so which rules ran depended on the installed ruff's defaults — fine while nobody ran it, not fine for a CI job that can turn a push red because a tool changed its mind. The set is named explicitly (E, F, I, UP, SIM, B, RUF) with three ignores that are each a real decision rather than a shrug: B008, because FastAPI's whole dependency idiom is a call in a default argument; E501, because wrapping prose is a formatter's business; and RUF001, because the reports and the bot speak Russian and "ambiguous character" is the wrong complaint about a string the user is meant to read. The 73 findings that remained are fixed — mostly `datetime.timezone.utc` → `datetime.UTC`, `typing.Callable` → `collections.abc.Callable`, import order, and unused `noqa` markers — plus seven by hand: `contextlib.suppress` for two try/except/pass pairs, unpacking instead of list concatenation, one negated return. `ruff format` is deliberately *not* adopted: it would reformat 51 files, and some of the layout here is hand-set (the comment tables, the DNS wire-format structs) in ways a formatter flattens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
791 lines
29 KiB
Python
791 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 contextlib
|
|
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
|
|
with contextlib.suppress(OSError):
|
|
sock.close() # the blocking recv/accept raises, ending the loop
|
|
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 TimeoutError:
|
|
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 TimeoutError:
|
|
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, 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, 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
|