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:
12 files changed
+1695
-29
No files matched your search
+34
-5
@@ -49,11 +49,35 @@ the same build runs on KDE, GNOME or a bare window manager.
|
|||||||
## Blocking
|
## Blocking
|
||||||
|
|
||||||
The rules (which sites, which apps, when) live on the backend so the web app can
|
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
|
* **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
|
policy. Writing it needs root, which a desktop app must not have, so it calls
|
||||||
`kadence-helper` through **pkexec**.
|
`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
|
* **Applications** — `/proc` is scanned every few seconds for your own processes
|
||||||
matching the list; they are terminated (SIGTERM, then SIGKILL) or merely
|
matching the list; they are terminated (SIGTERM, then SIGKILL) or merely
|
||||||
reported, and every attempt is sent to the backend for the evening report.
|
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
|
### The privileged helper
|
||||||
|
|
||||||
`helper/kadence-helper` is the entire privileged surface, and it is deliberately
|
`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;
|
* 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
|
* 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 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
|
* the write is atomic (temp file + `rename`), and the first run backs up
|
||||||
`/etc/hosts` to `/etc/hosts.kadence-backup`;
|
`/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
|
```bash
|
||||||
sudo ./helper/install.sh # polkit asks once per session
|
sudo ./helper/install.sh # polkit asks once per session
|
||||||
sudo ./helper/install.sh --passwordless # or never, for your user only
|
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
|
config.py # ~/.config/kadence/desktop.json
|
||||||
tray.py # the painted countdown icon and its menu
|
tray.py # the painted countdown icon and its menu
|
||||||
integrations/ # notifications, inhibit/DND, MPRIS, launcher, logind, D-Bus, autostart
|
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
|
qml/ # Material UI: Timer, Tasks, Blocker, Report, Settings
|
||||||
helper/ # the privileged helper + polkit action + installer
|
helper/ # the privileged helper + polkit action + installer
|
||||||
packaging/ # .desktop entry, icon, user installer
|
packaging/ # .desktop entry, icon, user installer
|
||||||
|
|||||||
@@ -36,8 +36,10 @@ if [[ $EUID -ne 0 ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $UNINSTALL -eq 1 ]]; then
|
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" clear || true
|
||||||
|
[[ -x "$HELPER_DEST" ]] && "$HELPER_DEST" resolved-off || true
|
||||||
rm -f "$HELPER_DEST" "$POLICY_DEST" "$RULE_DEST"
|
rm -f "$HELPER_DEST" "$POLICY_DEST" "$RULE_DEST"
|
||||||
echo "Removed the helper, its polkit action and any passwordless rule."
|
echo "Removed the helper, its polkit action and any passwordless rule."
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Kadence's privileged helper: the only part that runs as root.
|
"""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 does two things, both of them small: maintain a marked region inside
|
||||||
it is written to be boring on purpose:
|
`/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
|
* 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
|
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;
|
closes the "point the helper at a symlink" trick;
|
||||||
* the write is atomic (temp file in `/etc` + `rename`), so an interrupted run
|
* the write is atomic (temp file in `/etc` + `rename`), so an interrupted run
|
||||||
cannot leave the machine with half a hosts file;
|
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
|
Installed by `install.sh` together with a polkit action, so the desktop app can
|
||||||
call it through `pkexec` without ever running as root itself.
|
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:
|
Usage:
|
||||||
kadence-helper apply --ip 127.0.0.1 --file /tmp/list
|
kadence-helper apply --ip 127.0.0.1 --file /tmp/list
|
||||||
kadence-helper clear
|
kadence-helper clear
|
||||||
|
kadence-helper resolved-on --port 5353 --domains youtube.com,t.me
|
||||||
|
kadence-helper resolved-off
|
||||||
kadence-helper status
|
kadence-helper status
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -42,6 +49,11 @@ BEGIN_MARKER = "# >>> kadence block >>>"
|
|||||||
END_MARKER = "# <<< kadence block <<<"
|
END_MARKER = "# <<< kadence block <<<"
|
||||||
MAX_DOMAINS = 5000
|
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}$")
|
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.
|
# Blocking these would break the machine, not your concentration.
|
||||||
@@ -169,6 +181,104 @@ def command_clear() -> None:
|
|||||||
print("cleared")
|
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:
|
def command_status() -> None:
|
||||||
inside = False
|
inside = False
|
||||||
count = 0
|
count = 0
|
||||||
@@ -192,6 +302,10 @@ def main() -> int:
|
|||||||
apply_parser.add_argument("--ip", default="127.0.0.1")
|
apply_parser.add_argument("--ip", default="127.0.0.1")
|
||||||
apply_parser.add_argument("--file", required=True, help="newline-separated hostnames")
|
apply_parser.add_argument("--file", required=True, help="newline-separated hostnames")
|
||||||
sub.add_parser("clear", help="remove the managed block")
|
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")
|
sub.add_parser("status", help="how many domains are blocked")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -202,6 +316,10 @@ def main() -> int:
|
|||||||
command_apply(args.ip, args.file)
|
command_apply(args.ip, args.file)
|
||||||
elif args.command == "clear":
|
elif args.command == "clear":
|
||||||
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:
|
else:
|
||||||
command_status()
|
command_status()
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
<vendor_url>https://github.com/</vendor_url>
|
<vendor_url>https://github.com/</vendor_url>
|
||||||
|
|
||||||
<action id="org.kadence.helper.manage-hosts">
|
<action id="org.kadence.helper.manage-hosts">
|
||||||
<description>Manage the Kadence website block list</description>
|
<description>Manage the Kadence website block list and its DNS routing</description>
|
||||||
<description xml:lang="ru">Управление списком заблокированных сайтов Kadence</description>
|
<description xml:lang="ru">Управление списком заблокированных сайтов Kadence и их DNS-маршрутизацией</description>
|
||||||
<message>Authentication is required to change which websites are blocked.</message>
|
<message>Authentication is required to change which websites are blocked.</message>
|
||||||
<message xml:lang="ru">Для изменения списка заблокированных сайтов требуется аутентификация.</message>
|
<message xml:lang="ru">Для изменения списка заблокированных сайтов требуется аутентификация.</message>
|
||||||
<icon_name>kadence</icon_name>
|
<icon_name>kadence</icon_name>
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
|
|
||||||
The *rules* live on the server (so the web UI can edit them and a second machine
|
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
|
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.
|
||||||
"""
|
"""
|
||||||
@@ -97,6 +97,19 @@ class HostsBlocker:
|
|||||||
def apply(self, domains: list[str], redirect_ip: str = "127.0.0.1") -> bool:
|
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."""
|
"""Make the managed region exactly `domains`. True when it already was."""
|
||||||
wanted = sorted({d.strip().lower() for d in domains if valid_hostname(d)})
|
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:
|
if sorted(set(current_block())) == wanted:
|
||||||
return True
|
return True
|
||||||
if not wanted:
|
if not wanted:
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
"""Turning the server's policy into what this machine actually does.
|
"""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
|
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
|
file, the DNS routes and the process list in step with it, reports every
|
||||||
back to the API (that is what makes the daily report fun), and — importantly —
|
turned-away attempt back to the API (that is what makes the daily report fun),
|
||||||
puts everything back the moment the policy says blocking is off, including when
|
and — importantly — puts everything back the moment the policy says blocking is
|
||||||
the app exits or crashes mid-interval.
|
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
|
from __future__ import annotations
|
||||||
@@ -14,6 +29,7 @@ from typing import Callable
|
|||||||
|
|
||||||
from kadence_desktop.blocking import processes
|
from kadence_desktop.blocking import processes
|
||||||
from kadence_desktop.blocking.hosts import HostsBlocker
|
from kadence_desktop.blocking.hosts import HostsBlocker
|
||||||
|
from kadence_desktop.blocking.resolver import ResolvedRoute, StubResolver, split_routable
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,18 +41,35 @@ class BlockerManager:
|
|||||||
helper_path: str,
|
helper_path: str,
|
||||||
report: Callable[[str, str, str, str | None], None],
|
report: Callable[[str, str, str, str | None], None],
|
||||||
notify: Callable[[str, str], None] | None = None,
|
notify: Callable[[str, str], None] | None = None,
|
||||||
|
stub: StubResolver | None = None,
|
||||||
|
route: ResolvedRoute | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._hosts = HostsBlocker(helper_path)
|
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._report = report
|
||||||
self._notify = notify
|
self._notify = notify
|
||||||
self._active = False
|
self._active = False
|
||||||
self._applied: list[str] = []
|
self._applied: list[str] = []
|
||||||
self._announced: set[int] = set()
|
self._announced: set[int] = set()
|
||||||
|
self._redirect_ip = "127.0.0.1"
|
||||||
self.enforce_hosts = True
|
self.enforce_hosts = True
|
||||||
self.enforce_apps = True
|
self.enforce_apps = True
|
||||||
self.last_error: str | None = None
|
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
|
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 -------------------------------------------------------------
|
# ---- state -------------------------------------------------------------
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -49,8 +82,14 @@ class BlockerManager:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def blocked_domains(self) -> list[str]:
|
def blocked_domains(self) -> list[str]:
|
||||||
|
"""What is really in `/etc/hosts` right now — not what the policy wants."""
|
||||||
return list(self._applied)
|
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 ----------------------------------------------
|
# ---- the one entry point ----------------------------------------------
|
||||||
|
|
||||||
def sync(self, policy: dict) -> None:
|
def sync(self, policy: dict) -> None:
|
||||||
@@ -58,11 +97,19 @@ class BlockerManager:
|
|||||||
should_block = bool(policy.get("active")) and bool(policy.get("enabled"))
|
should_block = bool(policy.get("active")) and bool(policy.get("enabled"))
|
||||||
domains = [str(item) for item in policy.get("domains", [])]
|
domains = [str(item) for item in policy.get("domains", [])]
|
||||||
apps = [str(item) for item in policy.get("apps", [])]
|
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 should_block:
|
||||||
if self.enforce_hosts and domains != self._applied:
|
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._applied = domains
|
||||||
self.last_error = None
|
self.last_error = None
|
||||||
log.info("blocking %s domain(s)", len(domains))
|
log.info("blocking %s domain(s)", len(domains))
|
||||||
@@ -74,6 +121,41 @@ class BlockerManager:
|
|||||||
else:
|
else:
|
||||||
self.release()
|
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:
|
def release(self) -> None:
|
||||||
"""Unblock everything. Called when the interval ends, and on shutdown."""
|
"""Unblock everything. Called when the interval ends, and on shutdown."""
|
||||||
if self._applied or self._hosts_dirty():
|
if self._applied or self._hosts_dirty():
|
||||||
@@ -84,6 +166,18 @@ class BlockerManager:
|
|||||||
self.last_error = self._hosts.last_error
|
self.last_error = self._hosts.last_error
|
||||||
self._active = False
|
self._active = False
|
||||||
self._announced.clear()
|
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:
|
def _hosts_dirty(self) -> bool:
|
||||||
from kadence_desktop.blocking.hosts import current_block
|
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
|
||||||
@@ -289,19 +289,52 @@ class Controller(QObject):
|
|||||||
|
|
||||||
@pyqtProperty("QVariantList", notify=blockerChanged)
|
@pyqtProperty("QVariantList", notify=blockerChanged)
|
||||||
def blockedDomains(self) -> list[str]:
|
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", [])]
|
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)
|
@pyqtProperty("QVariantList", notify=blockerChanged)
|
||||||
def blockedApps(self) -> list[str]:
|
def blockedApps(self) -> list[str]:
|
||||||
return [str(item) for item in self._policy.get("all_apps", [])]
|
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)
|
@pyqtProperty(bool, notify=blockerChanged)
|
||||||
def helperInstalled(self) -> bool:
|
def helperInstalled(self) -> bool:
|
||||||
return self._blocker.helper_installed
|
return self._blocker.helper_installed
|
||||||
|
|
||||||
@pyqtProperty(str, notify=blockerChanged)
|
@pyqtProperty(str, notify=blockerChanged)
|
||||||
def blockerError(self) -> str:
|
def blockerError(self) -> str:
|
||||||
return self._blocker.last_error or ""
|
return self._blocker.error or ""
|
||||||
|
|
||||||
@pyqtProperty(bool, notify=miniChanged)
|
@pyqtProperty(bool, notify=miniChanged)
|
||||||
def miniVisible(self) -> bool:
|
def miniVisible(self) -> bool:
|
||||||
@@ -510,7 +543,8 @@ class Controller(QObject):
|
|||||||
elif key in ("mini_timer_corner", "mini_timer_margin"):
|
elif key in ("mini_timer_corner", "mini_timer_margin"):
|
||||||
self.miniPlacementChanged.emit()
|
self.miniPlacementChanged.emit()
|
||||||
elif key == "enforce_blocking" and not value:
|
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()
|
self.configChanged.emit()
|
||||||
|
|
||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
@@ -781,14 +815,14 @@ class Controller(QObject):
|
|||||||
# ---- shutdown ----------------------------------------------------------
|
# ---- shutdown ----------------------------------------------------------
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
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._tick.stop()
|
||||||
self._idle_check.stop()
|
self._idle_check.stop()
|
||||||
self._screensaver.release()
|
self._screensaver.release()
|
||||||
self._dnd.release()
|
self._dnd.release()
|
||||||
self._launcher.clear()
|
self._launcher.clear()
|
||||||
try:
|
try:
|
||||||
self._blocker.release()
|
self._blocker.shutdown()
|
||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
log.exception("could not clear the block on exit")
|
log.exception("could not clear the block on exit")
|
||||||
self._thread.quit()
|
self._thread.quit()
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// The focus guard: what is blocked, when, and what it caught.
|
// 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
|
// 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
|
// expanded server-side into real hostnames, and the patterns that leaves over
|
||||||
// plainly when a pattern cannot be expressed that way.
|
// 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
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
@@ -42,6 +44,26 @@ ScrollView {
|
|||||||
visible: controller.blockerError !== ""
|
visible: controller.blockerError !== ""
|
||||||
text: 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 {
|
Panel {
|
||||||
padding: 0
|
padding: 0
|
||||||
@@ -50,8 +72,21 @@ ScrollView {
|
|||||||
spacing: 0
|
spacing: 0
|
||||||
Repeater {
|
Repeater {
|
||||||
model: [
|
model: [
|
||||||
{ label: "Hostnames", value: "" + controller.blockedDomains.length, caption: "after expansion" },
|
{ label: "Hostnames",
|
||||||
{ label: "Apps", value: "" + controller.blockedApps.length, caption: "closed on sight" },
|
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",
|
{ label: "Attempts today",
|
||||||
value: controller.stats.today ? "" + controller.stats.today.blocked_attempts : "0",
|
value: controller.stats.today ? "" + controller.stats.today.blocked_attempts : "0",
|
||||||
caption: "turned away" },
|
caption: "turned away" },
|
||||||
@@ -125,8 +160,9 @@ ScrollView {
|
|||||||
Repeater {
|
Repeater {
|
||||||
model: [
|
model: [
|
||||||
{ pattern: "youtube.com", meaning: "the site itself, plus www. and m." },
|
{ pattern: "youtube.com", meaning: "the site itself, plus www. and m." },
|
||||||
{ pattern: "*.google.com", meaning: "every subdomain in the expansion list" },
|
{ pattern: "*.google.com", meaning: "the bare domain plus every subdomain in the expansion list" },
|
||||||
{ pattern: "*gram*", meaning: "matches apps; a hosts file cannot express it" }
|
{ 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 {
|
delegate: RowLayout {
|
||||||
required property var modelData
|
required property var modelData
|
||||||
@@ -260,7 +296,7 @@ ScrollView {
|
|||||||
TextField {
|
TextField {
|
||||||
id: subdomainField
|
id: subdomainField
|
||||||
Layout.preferredWidth: 420
|
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
|
text: (controller.settings.blocker && controller.settings.blocker.subdomain_expansions
|
||||||
? controller.settings.blocker.subdomain_expansions : []).join(", ")
|
? controller.settings.blocker.subdomain_expansions : []).join(", ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
Reference in new issue
Block a user