`[tool.ruff]` set a line length and a target version and nothing else, so which rules ran depended on the installed ruff's defaults — fine while nobody ran it, not fine for a CI job that can turn a push red because a tool changed its mind. The set is named explicitly (E, F, I, UP, SIM, B, RUF) with three ignores that are each a real decision rather than a shrug: B008, because FastAPI's whole dependency idiom is a call in a default argument; E501, because wrapping prose is a formatter's business; and RUF001, because the reports and the bot speak Russian and "ambiguous character" is the wrong complaint about a string the user is meant to read. The 73 findings that remained are fixed — mostly `datetime.timezone.utc` → `datetime.UTC`, `typing.Callable` → `collections.abc.Callable`, import order, and unused `noqa` markers — plus seven by hand: `contextlib.suppress` for two try/except/pass pairs, unpacking instead of list concatenation, one negated return. `ruff format` is deliberately *not* adopted: it would reformat 51 files, and some of the layout here is hand-set (the comment tables, the DNS wire-format structs) in ways a formatter flattens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
293 lines
12 KiB
Python
293 lines
12 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
|
|
import time
|
|
from collections.abc import Callable
|
|
from types import ModuleType
|
|
|
|
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,
|
|
procs: ModuleType | None = None,
|
|
report_cooldown: float = 20.0,
|
|
kill_grace: float = 5.0,
|
|
) -> 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)
|
|
# Injectable too, so the app-blocking tests do not have to own a victim
|
|
# process — and because `/proc` is not something a test should need.
|
|
self._procs = procs if procs is not None else processes
|
|
self._report = report
|
|
self._notify = notify
|
|
self._active = False
|
|
self._applied: list[str] = []
|
|
#: The app list and kill/warn flag from the last policy, so the scan
|
|
#: timer has something to work from between polls.
|
|
self._apps: list[str] = []
|
|
self._kill_apps = True
|
|
#: What we have already told you about, keyed by (pid, start time) and
|
|
#: stamped with when. It throttles the *report*, never the block — see
|
|
#: [_enforce_apps].
|
|
self._reported: dict[tuple[int, int], float] = {}
|
|
#: When each process was asked politely to quit, so the next pass can
|
|
#: insist. Keyed the same way.
|
|
self._asked: dict[tuple[int, int], float] = {}
|
|
self.report_cooldown = report_cooldown
|
|
self.kill_grace = kill_grace
|
|
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
|
|
self._apps = apps
|
|
self._kill_apps = bool(policy.get("kill_apps", True))
|
|
self._active = True
|
|
self.enforce_apps_now()
|
|
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._reported.clear()
|
|
self._asked.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_now(self) -> None:
|
|
"""One app-blocking pass, from the timer that owns the cadence.
|
|
|
|
Separate from [sync] because the two halves want different clocks: the
|
|
hosts file only changes when the policy does, while a process can be
|
|
launched at any moment and the server says how often to look
|
|
(`blocker.app_poll_seconds`, arriving as `poll_seconds`).
|
|
"""
|
|
if not (self._active and self.enforce_apps and self._apps):
|
|
return
|
|
self._enforce_apps(self._apps, kill=self._kill_apps)
|
|
|
|
def _enforce_apps(self, patterns: list[str], *, kill: bool) -> None:
|
|
"""Close (or report) every blocked process running right now.
|
|
|
|
The important word is *every*, on *every* pass. An earlier version kept a
|
|
set of pids it had dealt with and skipped them forever, which conflated
|
|
two different jobs: not spamming the daily report with one row per poll,
|
|
and actually blocking the app. The second launch of a blocked app — or
|
|
the first one whose SIGTERM was refused — then went unchallenged for the
|
|
rest of the interval, which is the whole bug this docstring exists for.
|
|
|
|
So the enforcement runs again each pass and only the *report* is
|
|
throttled, per process per [report_cooldown]. "Per process" is
|
|
(pid, start time), not pid: a pid alone is reused, and a relaunch
|
|
landing on a recycled number must not inherit the old entry.
|
|
|
|
The kill escalates across passes rather than inside one: SIGTERM now,
|
|
SIGKILL once [kill_grace] has gone by and the process is still there.
|
|
That is gentler than the old two-second busy-wait and it never blocks
|
|
the thread drawing the countdown.
|
|
"""
|
|
self._forget_dead()
|
|
now = time.monotonic()
|
|
for app in self._procs.scan(patterns):
|
|
identity = app.identity
|
|
if not kill:
|
|
self._announce(app, "warned")
|
|
continue
|
|
|
|
asked_at = self._asked.get(identity)
|
|
if asked_at is None:
|
|
delivered = self._procs.terminate(app)
|
|
self._asked[identity] = now
|
|
if delivered:
|
|
self.killed_today += 1
|
|
self._announce(app, "killed" if delivered else "warned")
|
|
elif now - asked_at >= self.kill_grace:
|
|
# It sat through the polite request. Insist, and keep insisting
|
|
# one grace period at a time if something refuses the signal.
|
|
self._procs.terminate(app, force=True)
|
|
self._asked[identity] = now
|
|
self._announce(app, "killed")
|
|
|
|
def _announce(self, app: processes.RunningApp, action: str) -> None:
|
|
"""Report and notify, at most once per process per cooldown."""
|
|
if not self._claim_report(app.identity):
|
|
return
|
|
details = f"{app.name} (pid {app.pid})"
|
|
self._report("app", app.matched, action, details)
|
|
if not self._notify:
|
|
return
|
|
if action == "killed":
|
|
self._notify("Closed a distraction", f"{app.name} is blocked during focus.")
|
|
else:
|
|
self._notify("That app is blocked", f"{app.name} is on your block list.")
|
|
|
|
def _claim_report(self, identity: tuple[int, int]) -> bool:
|
|
"""Whether this process is due a report, and stamp it if so."""
|
|
now = time.monotonic()
|
|
last = self._reported.get(identity)
|
|
if last is not None and now - last < self.report_cooldown:
|
|
return False
|
|
self._reported[identity] = now
|
|
return True
|
|
|
|
def _forget_dead(self) -> None:
|
|
"""Drop processes that are gone, so an all-day interval cannot leak."""
|
|
for book in (self._reported, self._asked):
|
|
for pid, started in list(book):
|
|
if not self._procs.alive(pid, started):
|
|
del book[(pid, started)]
|