Files
kadence/desktop/helper/kadence-helper
T
2026-10-18 18:02:05 +03:00

212 lines
6.7 KiB
Python
Executable File

#!/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 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`.
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 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
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 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")
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()
else:
command_status()
return 0
if __name__ == "__main__":
raise SystemExit(main())