Files
timka123andClaude Opus 5 ec7eedbc66 style: say which lint rules this repo keeps, then keep them
`[tool.ruff]` set a line length and a target version and nothing else, so which
rules ran depended on the installed ruff's defaults — fine while nobody ran it,
not fine for a CI job that can turn a push red because a tool changed its mind.

The set is named explicitly (E, F, I, UP, SIM, B, RUF) with three ignores that
are each a real decision rather than a shrug: B008, because FastAPI's whole
dependency idiom is a call in a default argument; E501, because wrapping prose
is a formatter's business; and RUF001, because the reports and the bot speak
Russian and "ambiguous character" is the wrong complaint about a string the
user is meant to read.

The 73 findings that remained are fixed — mostly `datetime.timezone.utc` →
`datetime.UTC`, `typing.Callable` → `collections.abc.Callable`, import order,
and unused `noqa` markers — plus seven by hand: `contextlib.suppress` for two
try/except/pass pairs, unpacking instead of list concatenation, one negated
return.

`ruff format` is deliberately *not* adopted: it would reformat 51 files, and
some of the layout here is hand-set (the comment tables, the DNS wire-format
structs) in ways a formatter flattens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-22 17:28:57 +03:00

159 lines
5.9 KiB
Python

"""Website blocking through `/etc/hosts`.
Why the hosts file rather than a proxy or an extension: it needs no browser
support, survives a browser restart, covers every app on the machine, and is
trivially auditable — you can read exactly what is blocked with `cat`.
Writing it needs root, which a desktop app must not have, so all writes go
through the small privileged helper (`kadence-helper`) under polkit. The helper
only ever rewrites the region between its own markers and validates every
hostname, so the worst a compromised client can do is block a site.
"""
from __future__ import annotations
import contextlib
import logging
import os
import re
import shutil
import subprocess
import tempfile
log = logging.getLogger(__name__)
HOSTS_PATH = "/etc/hosts"
BEGIN_MARKER = "# >>> kadence block >>>"
END_MARKER = "# <<< kadence block <<<"
_HOSTNAME_RE = re.compile(r"^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$")
def valid_hostname(name: str) -> bool:
return bool(_HOSTNAME_RE.match(name.strip().lower()))
def current_block() -> list[str]:
"""The hostnames currently inside our managed region of `/etc/hosts`."""
try:
with open(HOSTS_PATH, encoding="utf-8") as handle:
lines = handle.read().splitlines()
except OSError:
return []
inside = False
hosts: list[str] = []
for line in lines:
if line.strip() == BEGIN_MARKER:
inside = True
continue
if line.strip() == END_MARKER:
inside = False
continue
if inside:
parts = line.split()
if len(parts) >= 2 and not line.lstrip().startswith("#"):
hosts.extend(parts[1:])
return hosts
class HostsBlocker:
"""Applies and clears the managed block, as rarely as possible.
Every change costs a polkit prompt in the worst case, so the desired state is
diffed against what is already in the file and a no-op is really a no-op.
"""
def __init__(self, helper_path: str = "/usr/local/bin/kadence-helper") -> None:
self.helper_path = helper_path
self._last_error: str | None = None
# ---- availability ------------------------------------------------------
@property
def helper_installed(self) -> bool:
return os.path.isfile(self.helper_path) and os.access(self.helper_path, os.X_OK)
@property
def last_error(self) -> str | None:
return self._last_error
def _runner(self) -> list[str] | None:
"""How to become root: polkit if we can, passwordless sudo otherwise."""
if not self.helper_installed:
self._last_error = (
f"The privileged helper is not installed at {self.helper_path}. "
"Run desktop/helper/install.sh once."
)
return None
if shutil.which("pkexec") and os.environ.get("DISPLAY", os.environ.get("WAYLAND_DISPLAY")):
return ["pkexec", self.helper_path]
if shutil.which("sudo"):
return ["sudo", "-n", self.helper_path]
self._last_error = "Neither pkexec nor sudo is available to run the helper."
return None
# ---- operations --------------------------------------------------------
def apply(self, domains: list[str], redirect_ip: str = "127.0.0.1") -> bool:
"""Make the managed region exactly `domains`. True when it already was."""
wanted = sorted({d.strip().lower() for d in domains if valid_hostname(d)})
# Never drop a hostname without saying so. The server expands masks for us,
# and a bad `subdomain_expansions` entry (`*` is the one people try) used to
# arrive here as a non-hostname and vanish into this filter — the mask looked
# applied and blocked nothing. The server refuses those labels now; this is
# the backstop, and it is loud.
refused = sorted({d.strip().lower() for d in domains if d.strip() and not valid_hostname(d)})
if refused:
log.warning(
"not a hostname, so not blocked: %s — check subdomain_expansions",
", ".join(refused[:10]),
)
if sorted(set(current_block())) == wanted:
return True
if not wanted:
return self.clear()
runner = self._runner()
if runner is None:
return False
with tempfile.NamedTemporaryFile("w", suffix=".kadence", delete=False, encoding="utf-8") as handle:
handle.write("\n".join(wanted))
list_path = handle.name
os.chmod(list_path, 0o644) # the helper reads it as root from /tmp
try:
return self._run([*runner, "apply", "--ip", redirect_ip, "--file", list_path])
finally:
with contextlib.suppress(OSError):
os.unlink(list_path)
def clear(self) -> bool:
"""Remove the managed region. Safe to call when it is not there."""
if not current_block():
return True
runner = self._runner()
if runner is None:
return False
return self._run([*runner, "clear"])
def _run(self, command: list[str]) -> bool:
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=60, check=False)
except (OSError, subprocess.SubprocessError) as exc:
self._last_error = f"Could not run the helper: {exc}"
log.warning(self._last_error)
return False
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()[:300]
# 126/127 is polkit's "you said no" — worth saying plainly.
if result.returncode in (126, 127):
detail = detail or "the authorisation prompt was dismissed"
self._last_error = f"Helper failed: {detail}"
log.warning(self._last_error)
return False
self._last_error = None
return True