desktop: route wildcard zones to a local DNS stub

A hosts file cannot express a wildcard, so '*.youtube.com' was only ever the
base domain plus the guessed labels. The patterns the server now hands over
as unexpandable are answered by a stub on 127.0.0.1, and the helper owns one
systemd-resolved drop-in that points just those zones at it:

  * the stub is listening before the drop-in is written, and the drop-in is
    removed before the stub stops, so a zone's queries never land on a dead
    port;
  * stub and route live as long as the app, not the interval: between
    intervals they forward everything, instead of restarting resolved (and
    dropping its cache) on every pomodoro;
  * a crashed run leaves a world-readable drop-in the next start adopts, and
    the stub comes back before anything else;
  * masks with nothing routable after their last '*' ('*gram*') are reported
    as unenforceable, not silently dropped.

The Blocker page now reports what is really in place — the hosts region, the
routed zones, and the masks this machine cannot do — rather than the whole
expansion. 39 desktop tests, including the ordering invariants.
This commit is contained in:
timka123 committed 2026-10-19 18:45:42 +03:00
1 parent a531a62afc
commit 36af1e9e83
12 files changed
+1695 -29

No files matched your search

+34 -5
View File
@@ -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
+3 -1
View File
@@ -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
+121 -3
View File
@@ -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
+2 -2
View File
@@ -15,8 +15,8 @@
<vendor_url>https://github.com/</vendor_url>
<action id="org.kadence.helper.manage-hosts">
<description>Manage the Kadence website block list</description>
<description xml:lang="ru">Управление списком заблокированных сайтов Kadence</description>
<description>Manage the Kadence website block list and its DNS routing</description>
<description xml:lang="ru">Управление списком заблокированных сайтов Kadence и их DNS-маршрутизацией</description>
<message>Authentication is required to change which websites are blocked.</message>
<message xml:lang="ru">Для изменения списка заблокированных сайтов требуется аутентификация.</message>
<icon_name>kadence</icon_name>
+2 -1
View File
@@ -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.
"""
+13
View File
@@ -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:
+100 -6
View File
@@ -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
@@ -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<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
+38 -4
View File
@@ -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()
+43 -7
View File
@@ -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(", ")
}
+188
View File
@@ -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
+377
View File
@@ -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")