desktop: route wildcard zones to a local DNS stub

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

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

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

No files matched your search

+38 -4
View File
@@ -289,19 +289,52 @@ class Controller(QObject):
@pyqtProperty("QVariantList", notify=blockerChanged)
def blockedDomains(self) -> list[str]:
"""Every hostname the policy wants blocked, server-expanded.
This is the *intent*: what the rules add up to. What is actually in this
machine's hosts file is [appliedDomains] — reporting this one as the
state was the page lying about a machine it had not touched.
"""
return [str(item) for item in self._policy.get("all_domains", [])]
@pyqtProperty("QVariantList", notify=blockerChanged)
def appliedDomains(self) -> list[str]:
"""What is really inside the managed region of `/etc/hosts` right now."""
return self._blocker.blocked_domains
@pyqtProperty("QVariantList", notify=blockerChanged)
def routedZones(self) -> list[str]:
"""DNS zones currently pointed at this machine's local resolver stub."""
return list(self._blocker.routed_zones)
@pyqtProperty("QVariantList", notify=blockerChanged)
def unroutableDomains(self) -> list[str]:
"""Masks no machine can enforce: no DNS zone can be routed for them."""
if not self._policy.get("active"):
return []
return list(self._blocker.unroutable)
@pyqtProperty("QVariantList", notify=blockerChanged)
def blockedApps(self) -> list[str]:
return [str(item) for item in self._policy.get("all_apps", [])]
@pyqtProperty("QVariantList", notify=blockerChanged)
def invalidExpansions(self) -> list[str]:
"""Subdomain expansions the server refused, e.g. `not a label`.
Worth a banner rather than a log line: a mistyped label silently reduces
every `*.site` mask to the bare domain, which looks like "blocking is on
and does nothing".
"""
return [str(item) for item in self._policy.get("invalid_expansions", [])]
@pyqtProperty(bool, notify=blockerChanged)
def helperInstalled(self) -> bool:
return self._blocker.helper_installed
@pyqtProperty(str, notify=blockerChanged)
def blockerError(self) -> str:
return self._blocker.last_error or ""
return self._blocker.error or ""
@pyqtProperty(bool, notify=miniChanged)
def miniVisible(self) -> bool:
@@ -510,7 +543,8 @@ class Controller(QObject):
elif key in ("mini_timer_corner", "mini_timer_margin"):
self.miniPlacementChanged.emit()
elif key == "enforce_blocking" and not value:
self._blocker.release()
# Takes the DNS route down with it, in the safe order.
self._blocker.shutdown()
self.configChanged.emit()
@pyqtSlot()
@@ -781,14 +815,14 @@ class Controller(QObject):
# ---- shutdown ----------------------------------------------------------
def shutdown(self) -> None:
"""Hand everything back: inhibits, DND, and the hosts file."""
"""Hand everything back: inhibits, DND, the hosts file and the DNS route."""
self._tick.stop()
self._idle_check.stop()
self._screensaver.release()
self._dnd.release()
self._launcher.clear()
try:
self._blocker.release()
self._blocker.shutdown()
except Exception: # pragma: no cover
log.exception("could not clear the block on exit")
self._thread.quit()