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

+3 -1
View File
@@ -36,8 +36,10 @@ if [[ $EUID -ne 0 ]]; then
fi
if [[ $UNINSTALL -eq 1 ]]; then
# Unblock first — leaving a block behind after uninstalling would be rude.
# Unblock first — leaving a block behind after uninstalling would be rude,
# and so would leaving the machine's DNS pointed at a stub that is gone.
[[ -x "$HELPER_DEST" ]] && "$HELPER_DEST" clear || true
[[ -x "$HELPER_DEST" ]] && "$HELPER_DEST" resolved-off || true
rm -f "$HELPER_DEST" "$POLICY_DEST" "$RULE_DEST"
echo "Removed the helper, its polkit action and any passwordless rule."
exit 0
+121 -3
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env python3
"""Kadence's privileged helper: the only part that runs as root.
It does exactly one thing — maintain a marked region inside `/etc/hosts` — and
it is written to be boring on purpose:
It does two things, both of them small: maintain a marked region inside
`/etc/hosts`, and keep one file in `/etc/systemd/resolved.conf.d/` that points
the zones Kadence routes at the app's local DNS stub. It is written to be boring
on purpose:
* it never trusts its input: every hostname is matched against a strict pattern
and the redirect address must parse as an IP, so nothing else can be smuggled
@@ -13,7 +15,10 @@ it is written to be boring on purpose:
closes the "point the helper at a symlink" trick;
* the write is atomic (temp file in `/etc` + `rename`), so an interrupted run
cannot leave the machine with half a hosts file;
* the first run keeps a backup at `/etc/hosts.kadence-backup`.
* the first run keeps a backup at `/etc/hosts.kadence-backup`;
* the DNS route is a file it owns outright, whose every line it composes: the
zones are validated as hostnames and the port as a port, so no other setting
can be smuggled in, and removing the file restores plain DNS.
Installed by `install.sh` together with a polkit action, so the desktop app can
call it through `pkexec` without ever running as root itself.
@@ -21,6 +26,8 @@ call it through `pkexec` without ever running as root itself.
Usage:
kadence-helper apply --ip 127.0.0.1 --file /tmp/list
kadence-helper clear
kadence-helper resolved-on --port 5353 --domains youtube.com,t.me
kadence-helper resolved-off
kadence-helper status
"""
@@ -42,6 +49,11 @@ BEGIN_MARKER = "# >>> kadence block >>>"
END_MARKER = "# <<< kadence block <<<"
MAX_DOMAINS = 5000
DROPIN_DIR = "/etc/systemd/resolved.conf.d"
DROPIN_PATH = os.path.join(DROPIN_DIR, "kadence.conf")
STUB_ADDRESS = "127.0.0.1"
MIN_STUB_PORT = 1024
HOSTNAME_RE = re.compile(r"^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$")
# Blocking these would break the machine, not your concentration.
@@ -169,6 +181,104 @@ def command_clear() -> None:
print("cleared")
def load_zones(raw: str) -> list[str]:
"""Validate the zone list the client sent. One bad name refuses the lot.
These names go into a resolved configuration file, so they must be plain
hostnames and nothing else — that is what makes it safe to compose the
`Domains=` line around them.
"""
zones: list[str] = []
for part in raw.split(","):
zone = part.strip().lower().strip(".")
if not zone:
continue
if zone in FORBIDDEN:
fail(f"refusing to route {zone!r}: it is a name this machine needs")
if not HOSTNAME_RE.match(zone):
fail(f"refusing a zone that does not look like a hostname: {zone[:80]!r}")
if len(zone.split(".")) < 2:
fail(f"refusing to route a whole top-level domain: {zone!r}")
if zone not in zones:
zones.append(zone)
if len(zones) > MAX_DOMAINS:
fail(f"more than {MAX_DOMAINS} zones")
if not zones:
fail("no zones given; use resolved-off to stop routing")
return zones
def resolved_dropin_text(port: int, zones: list[str]) -> str:
"""The exact contents of ours, assembled from validated pieces."""
return "\n".join(
[
"# Managed by Kadence. Edit the block lists in the app, not here.",
"#",
"# These zones are answered by the app's own DNS stub, which redirects",
"# blocked names and forwards everything else to the real upstreams.",
"# Deleting this file and restarting systemd-resolved restores plain",
"# DNS; `kadence-helper resolved-off` does both.",
"[Resolve]",
f"DNS={STUB_ADDRESS}:{port}",
"Domains=" + " ".join(f"~{zone}" for zone in zones),
"",
]
)
def write_dropin(text: str) -> None:
try:
os.makedirs(DROPIN_DIR, mode=0o755, exist_ok=True)
except OSError as exc:
fail(f"cannot create {DROPIN_DIR}: {exc}")
descriptor, temp_path = tempfile.mkstemp(dir=DROPIN_DIR, prefix=".kadence.")
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
handle.write(text)
os.chmod(temp_path, 0o644)
os.chown(temp_path, 0, 0)
os.replace(temp_path, DROPIN_PATH)
except OSError as exc:
os.unlink(temp_path)
fail(f"cannot write {DROPIN_PATH}: {exc}")
def restart_resolved() -> None:
"""Make resolved pick the file up. Its cache goes with it, which is wanted."""
if not os.path.isdir("/run/systemd/resolve"):
fail("this machine does not run systemd-resolved, so Kadence cannot route zones")
if not shutil.which("systemctl"):
fail("systemctl is not available to reload systemd-resolved")
result = subprocess.run(
["systemctl", "restart", "systemd-resolved"], capture_output=True, text=True, timeout=60, check=False
)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()[:300]
fail(f"could not restart systemd-resolved: {detail or result.returncode}")
flush_dns()
def command_resolved_on(port: int, domains: str) -> None:
if not MIN_STUB_PORT <= port <= 65535:
fail(f"refusing port {port}: the stub has to bind an unprivileged port")
zones = load_zones(domains)
write_dropin(resolved_dropin_text(port, zones))
restart_resolved()
print(f"routing {len(zones)} zone(s) to {STUB_ADDRESS}:{port}")
def command_resolved_off() -> None:
if not os.path.exists(DROPIN_PATH):
print("no zones were routed")
return
try:
os.unlink(DROPIN_PATH)
except OSError as exc:
fail(f"cannot remove {DROPIN_PATH}: {exc}")
restart_resolved()
print("stopped routing zones")
def command_status() -> None:
inside = False
count = 0
@@ -192,6 +302,10 @@ def main() -> int:
apply_parser.add_argument("--ip", default="127.0.0.1")
apply_parser.add_argument("--file", required=True, help="newline-separated hostnames")
sub.add_parser("clear", help="remove the managed block")
resolved_on = sub.add_parser("resolved-on", help="route DNS zones through the local stub")
resolved_on.add_argument("--port", type=int, required=True, help="the stub's port")
resolved_on.add_argument("--domains", required=True, help="comma-separated zone names")
sub.add_parser("resolved-off", help="stop routing DNS zones through the stub")
sub.add_parser("status", help="how many domains are blocked")
args = parser.parse_args()
@@ -202,6 +316,10 @@ def main() -> int:
command_apply(args.ip, args.file)
elif args.command == "clear":
command_clear()
elif args.command == "resolved-on":
command_resolved_on(args.port, args.domains)
elif args.command == "resolved-off":
command_resolved_off()
else:
command_status()
return 0
+2 -2
View File
@@ -15,8 +15,8 @@
<vendor_url>https://github.com/</vendor_url>
<action id="org.kadence.helper.manage-hosts">
<description>Manage the Kadence website block list</description>
<description xml:lang="ru">Управление списком заблокированных сайтов Kadence</description>
<description>Manage the Kadence website block list and its DNS routing</description>
<description xml:lang="ru">Управление списком заблокированных сайтов Kadence и их DNS-маршрутизацией</description>
<message>Authentication is required to change which websites are blocked.</message>
<message xml:lang="ru">Для изменения списка заблокированных сайтов требуется аутентификация.</message>
<icon_name>kadence</icon_name>