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.
330 lines
11 KiB
Python
Executable File
330 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Kadence's privileged helper: the only part that runs as root.
|
|
|
|
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
|
|
into the file;
|
|
* it only ever rewrites the region between its own markers, so hand-written
|
|
entries above and below are preserved byte for byte;
|
|
* the input list is opened with `O_NOFOLLOW` and must be a regular file, which
|
|
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 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.
|
|
|
|
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
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ipaddress
|
|
import os
|
|
import re
|
|
import shutil
|
|
import stat as stat_module
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
HOSTS_PATH = "/etc/hosts"
|
|
BACKUP_PATH = "/etc/hosts.kadence-backup"
|
|
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.
|
|
FORBIDDEN = {"localhost", "localhost.localdomain"}
|
|
|
|
|
|
def fail(message: str) -> None:
|
|
print(f"kadence-helper: {message}", file=sys.stderr)
|
|
raise SystemExit(2)
|
|
|
|
|
|
def read_hosts() -> list[str]:
|
|
try:
|
|
with open(HOSTS_PATH, "r", encoding="utf-8") as handle:
|
|
return handle.read().splitlines()
|
|
except OSError as exc:
|
|
fail(f"cannot read {HOSTS_PATH}: {exc}")
|
|
return []
|
|
|
|
|
|
def strip_region(lines: list[str]) -> list[str]:
|
|
"""Everything except our managed region (and a stale, unterminated one)."""
|
|
output: list[str] = []
|
|
inside = False
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped == BEGIN_MARKER:
|
|
inside = True
|
|
continue
|
|
if stripped == END_MARKER:
|
|
inside = False
|
|
continue
|
|
if not inside:
|
|
output.append(line)
|
|
while output and not output[-1].strip():
|
|
output.pop()
|
|
return output
|
|
|
|
|
|
def write_hosts(lines: list[str]) -> None:
|
|
if not os.path.exists(BACKUP_PATH):
|
|
try:
|
|
shutil.copy2(HOSTS_PATH, BACKUP_PATH)
|
|
except OSError:
|
|
pass # a missing backup must not stop us unblocking later
|
|
|
|
directory = os.path.dirname(HOSTS_PATH) or "/"
|
|
descriptor, temp_path = tempfile.mkstemp(dir=directory, prefix=".hosts.kadence.")
|
|
try:
|
|
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
handle.write("\n".join(lines) + "\n")
|
|
os.chmod(temp_path, 0o644)
|
|
os.chown(temp_path, 0, 0)
|
|
os.replace(temp_path, HOSTS_PATH)
|
|
except OSError as exc:
|
|
os.unlink(temp_path)
|
|
fail(f"cannot write {HOSTS_PATH}: {exc}")
|
|
|
|
|
|
def flush_dns() -> None:
|
|
"""Best effort: a resolver cache would otherwise keep serving the old answer."""
|
|
for command in (
|
|
["resolvectl", "flush-caches"],
|
|
["systemd-resolve", "--flush-caches"],
|
|
["nscd", "-i", "hosts"],
|
|
):
|
|
if shutil.which(command[0]):
|
|
subprocess.run(command, check=False, capture_output=True, timeout=10)
|
|
|
|
|
|
def load_domains(path: str) -> list[str]:
|
|
"""Read the list the client wrote, refusing anything surprising."""
|
|
try:
|
|
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
except OSError as exc:
|
|
fail(f"cannot open the domain list: {exc}")
|
|
return []
|
|
with os.fdopen(descriptor, "r", encoding="utf-8", errors="replace") as handle:
|
|
info = os.fstat(handle.fileno())
|
|
if not stat_module.S_ISREG(info.st_mode):
|
|
fail("the domain list must be a regular file")
|
|
if info.st_size > 1_000_000:
|
|
fail("the domain list is implausibly large")
|
|
raw = handle.read()
|
|
|
|
domains: list[str] = []
|
|
for line in raw.splitlines():
|
|
name = line.strip().lower()
|
|
if not name or name.startswith("#"):
|
|
continue
|
|
if name in FORBIDDEN:
|
|
continue
|
|
if not HOSTNAME_RE.match(name):
|
|
fail(f"refusing a hostname that does not look like one: {name[:80]!r}")
|
|
if name not in domains:
|
|
domains.append(name)
|
|
if len(domains) > MAX_DOMAINS:
|
|
fail(f"more than {MAX_DOMAINS} domains")
|
|
return domains
|
|
|
|
|
|
def command_apply(ip: str, path: str) -> None:
|
|
try:
|
|
address = ipaddress.ip_address(ip)
|
|
except ValueError:
|
|
fail(f"not an IP address: {ip!r}")
|
|
return
|
|
domains = load_domains(path)
|
|
lines = strip_region(read_hosts())
|
|
if domains:
|
|
lines.append("")
|
|
lines.append(BEGIN_MARKER)
|
|
lines.append("# Managed by Kadence. Edit the list in the app, not here.")
|
|
for domain in domains:
|
|
lines.append(f"{address}\t{domain}")
|
|
lines.append(END_MARKER)
|
|
write_hosts(lines)
|
|
flush_dns()
|
|
print(f"blocking {len(domains)} domain(s) -> {address}")
|
|
|
|
|
|
def command_clear() -> None:
|
|
write_hosts(strip_region(read_hosts()))
|
|
flush_dns()
|
|
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
|
|
for line in read_hosts():
|
|
stripped = line.strip()
|
|
if stripped == BEGIN_MARKER:
|
|
inside = True
|
|
continue
|
|
if stripped == END_MARKER:
|
|
inside = False
|
|
continue
|
|
if inside and stripped and not stripped.startswith("#"):
|
|
count += 1
|
|
print(f"{count} domain(s) blocked")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(prog="kadence-helper", description=__doc__)
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
apply_parser = sub.add_parser("apply", help="replace the managed block")
|
|
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()
|
|
if os.geteuid() != 0:
|
|
fail("must run as root (it is meant to be called through pkexec)")
|
|
|
|
if args.command == "apply":
|
|
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
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|