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
@@ -1,10 +1,25 @@
|
||||
"""Turning the server's policy into what this machine actually does.
|
||||
|
||||
Called on every poll with the policy the backend computed. It keeps the hosts
|
||||
file and the process list in step with it, reports every turned-away attempt
|
||||
back to the API (that is what makes the daily report fun), and — importantly —
|
||||
puts everything back the moment the policy says blocking is off, including when
|
||||
the app exits or crashes mid-interval.
|
||||
file, the DNS routes and the process list in step with it, reports every
|
||||
turned-away attempt back to the API (that is what makes the daily report fun),
|
||||
and — importantly — puts everything back the moment the policy says blocking is
|
||||
off, including when the app exits or crashes mid-interval.
|
||||
|
||||
Two mechanisms do the website blocking, because one cannot do it all:
|
||||
|
||||
* `/etc/hosts` answers the concrete hostnames the server expanded — exact, cheap,
|
||||
and readable with `cat`;
|
||||
* the patterns a hosts file cannot express (`*.youtube.com`, `*gram.com`) are
|
||||
answered by the local DNS stub, with `systemd-resolved` sent the affected zones
|
||||
through the helper's drop-in.
|
||||
|
||||
The stub and the drop-in are kept for as long as the app runs, not per interval:
|
||||
repointing resolved costs a restart and a dropped DNS cache, and turning the
|
||||
block on and off is instantaneous — the stub answers or forwards, per query.
|
||||
That is why `release()` does not touch either of them, and why `shutdown()` is
|
||||
its own method: the drop-in has to be gone *before* the stub stops, or the
|
||||
machine spends a moment asking a dead port.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,6 +29,7 @@ from typing import Callable
|
||||
|
||||
from kadence_desktop.blocking import processes
|
||||
from kadence_desktop.blocking.hosts import HostsBlocker
|
||||
from kadence_desktop.blocking.resolver import ResolvedRoute, StubResolver, split_routable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,18 +41,35 @@ class BlockerManager:
|
||||
helper_path: str,
|
||||
report: Callable[[str, str, str, str | None], None],
|
||||
notify: Callable[[str, str], None] | None = None,
|
||||
stub: StubResolver | None = None,
|
||||
route: ResolvedRoute | None = None,
|
||||
) -> None:
|
||||
self._hosts = HostsBlocker(helper_path)
|
||||
# Injectable so tests can drive the ordering without binding ports or
|
||||
# asking a real systemd-resolved for anything.
|
||||
self._stub = stub if stub is not None else StubResolver()
|
||||
self._route = route if route is not None else ResolvedRoute(helper_path)
|
||||
self._report = report
|
||||
self._notify = notify
|
||||
self._active = False
|
||||
self._applied: list[str] = []
|
||||
self._announced: set[int] = set()
|
||||
self._redirect_ip = "127.0.0.1"
|
||||
self.enforce_hosts = True
|
||||
self.enforce_apps = True
|
||||
self.last_error: str | None = None
|
||||
self.route_error: str | None = None
|
||||
#: Patterns no machine can enforce at DNS level (`*gram*`); the UI says so.
|
||||
self.unroutable: list[str] = []
|
||||
#: Zones currently handed to `systemd-resolved`.
|
||||
self.routed_zones: list[str] = []
|
||||
self.killed_today = 0
|
||||
|
||||
if self._route.applied is not None:
|
||||
# A drop-in survived a crash: the zones point at this stub, so it has
|
||||
# to be listening *now*, before the first policy even arrives.
|
||||
self._stub.start()
|
||||
|
||||
# ---- state -------------------------------------------------------------
|
||||
|
||||
@property
|
||||
@@ -49,8 +82,14 @@ class BlockerManager:
|
||||
|
||||
@property
|
||||
def blocked_domains(self) -> list[str]:
|
||||
"""What is really in `/etc/hosts` right now — not what the policy wants."""
|
||||
return list(self._applied)
|
||||
|
||||
@property
|
||||
def error(self) -> str | None:
|
||||
"""The first thing that went wrong, hosts or DNS, for the UI to show."""
|
||||
return self.last_error or self.route_error
|
||||
|
||||
# ---- the one entry point ----------------------------------------------
|
||||
|
||||
def sync(self, policy: dict) -> None:
|
||||
@@ -58,11 +97,19 @@ class BlockerManager:
|
||||
should_block = bool(policy.get("active")) and bool(policy.get("enabled"))
|
||||
domains = [str(item) for item in policy.get("domains", [])]
|
||||
apps = [str(item) for item in policy.get("apps", [])]
|
||||
redirect_ip = str(policy.get("redirect_ip") or "127.0.0.1")
|
||||
self._redirect_ip = str(policy.get("redirect_ip") or "127.0.0.1")
|
||||
patterns = [str(item) for item in policy.get("unexpandable", [])]
|
||||
allow = [str(item) for item in policy.get("allowed", [])]
|
||||
|
||||
routable, unroutable, zones = split_routable(patterns)
|
||||
self.unroutable = unroutable
|
||||
if not self.enforce_hosts:
|
||||
routable, zones = [], []
|
||||
self._sync_dns(routable, allow, zones, active=should_block)
|
||||
|
||||
if should_block:
|
||||
if self.enforce_hosts and domains != self._applied:
|
||||
if self._hosts.apply(domains, redirect_ip):
|
||||
if self._hosts.apply(domains, self._redirect_ip):
|
||||
self._applied = domains
|
||||
self.last_error = None
|
||||
log.info("blocking %s domain(s)", len(domains))
|
||||
@@ -74,6 +121,41 @@ class BlockerManager:
|
||||
else:
|
||||
self.release()
|
||||
|
||||
def _sync_dns(
|
||||
self,
|
||||
patterns: list[str],
|
||||
allow: list[str],
|
||||
zones: list[str],
|
||||
*,
|
||||
active: bool,
|
||||
) -> None:
|
||||
"""Put the stub and the resolved route in step, in a safe order."""
|
||||
if not zones:
|
||||
if self._route.applied is not None:
|
||||
self.route_error = None if self._route.off() else self._route.last_error
|
||||
else:
|
||||
self.route_error = None
|
||||
# Nothing points at the stub any more, so it can go.
|
||||
if self._stub.running:
|
||||
self._stub.stop()
|
||||
self.routed_zones = []
|
||||
return
|
||||
|
||||
# The stub first. Writing the drop-in before anything listens on its port
|
||||
# would break resolution for the very zones we are trying to protect.
|
||||
if not self._stub.running and not self._stub.start():
|
||||
self.route_error = self._stub.error
|
||||
if self._route.applied is not None:
|
||||
self._route.off()
|
||||
self.routed_zones = []
|
||||
return
|
||||
if self._route.on(zones, self._stub.port or 0):
|
||||
self.route_error = None
|
||||
else:
|
||||
self.route_error = self._route.last_error
|
||||
self._stub.set_rules(patterns, allow, self._redirect_ip, active=active)
|
||||
self.routed_zones = zones
|
||||
|
||||
def release(self) -> None:
|
||||
"""Unblock everything. Called when the interval ends, and on shutdown."""
|
||||
if self._applied or self._hosts_dirty():
|
||||
@@ -84,6 +166,18 @@ class BlockerManager:
|
||||
self.last_error = self._hosts.last_error
|
||||
self._active = False
|
||||
self._announced.clear()
|
||||
# The stub keeps running and keeps its route: the zones are still pointed
|
||||
# at it, so it has to answer — it just forwards everything again.
|
||||
self._stub.set_rules([], [], self._redirect_ip, active=False)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Give the machine's DNS back, then everything else. Called on exit."""
|
||||
if self._route.applied is not None and not self._route.off():
|
||||
self.route_error = self._route.last_error
|
||||
if self._stub.running:
|
||||
self._stub.stop()
|
||||
self.routed_zones = []
|
||||
self.release()
|
||||
|
||||
def _hosts_dirty(self) -> bool:
|
||||
from kadence_desktop.blocking.hosts import current_block
|
||||
|
||||
Reference in new issue
Block a user