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.
204 lines
8.2 KiB
Python
204 lines
8.2 KiB
Python
"""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, 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
|
|
|
|
import logging
|
|
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__)
|
|
|
|
|
|
class BlockerManager:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
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
|
|
def active(self) -> bool:
|
|
return self._active
|
|
|
|
@property
|
|
def helper_installed(self) -> bool:
|
|
return self._hosts.helper_installed
|
|
|
|
@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:
|
|
"""Bring the machine in line with `policy` (from /api/blocker/policy)."""
|
|
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", [])]
|
|
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, self._redirect_ip):
|
|
self._applied = domains
|
|
self.last_error = None
|
|
log.info("blocking %s domain(s)", len(domains))
|
|
else:
|
|
self.last_error = self._hosts.last_error
|
|
if self.enforce_apps and apps:
|
|
self._enforce_apps(apps, kill=bool(policy.get("kill_apps", True)))
|
|
self._active = True
|
|
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():
|
|
if self._hosts.clear():
|
|
self._applied = []
|
|
self.last_error = None
|
|
else:
|
|
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
|
|
|
|
return bool(current_block())
|
|
|
|
# ---- apps --------------------------------------------------------------
|
|
|
|
def _enforce_apps(self, patterns: list[str], *, kill: bool) -> None:
|
|
for app in processes.scan(patterns):
|
|
if app.pid in self._announced:
|
|
continue
|
|
self._announced.add(app.pid)
|
|
|
|
if kill and processes.terminate(app):
|
|
self.killed_today += 1
|
|
self._report("app", app.matched, "killed", f"{app.name} (pid {app.pid})")
|
|
if self._notify:
|
|
self._notify("Closed a distraction", f"{app.name} is blocked during focus.")
|
|
else:
|
|
self._report("app", app.matched, "warned", f"{app.name} (pid {app.pid})")
|
|
if self._notify:
|
|
self._notify("That app is blocked", f"{app.name} is on your block list.")
|