147 lines
5.2 KiB
Python
147 lines
5.2 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 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, "r", 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)})
|
|
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:
|
|
try:
|
|
os.unlink(list_path)
|
|
except OSError:
|
|
pass
|
|
|
|
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
|