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
@@ -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
|
||||
Reference in new issue
Block a user