This commit is contained in:
timka123 committed 2026-10-18 18:02:05 +03:00
commit 17a0a3c4c9
121 files changed
+17641

No files matched your search

@@ -0,0 +1,6 @@
"""Enforcement: the part that actually stands between you and the distraction.
The *rules* live on the server (so the web UI can edit them and a second machine
obeys the same list); this package is what turns a rule into a redirected
hostname and a terminated process on this particular computer.
"""
+146
View File
@@ -0,0 +1,146 @@
"""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
+109
View File
@@ -0,0 +1,109 @@
"""Turning the server's policy into what this machine actually does.
Called on every poll with the policy the backend computed. It keeps the hosts
file and the process list in step with it, reports every turned-away attempt
back to the API (that is what makes the daily report fun), and — importantly —
puts everything back the moment the policy says blocking is off, including when
the app exits or crashes mid-interval.
"""
from __future__ import annotations
import logging
from typing import Callable
from kadence_desktop.blocking import processes
from kadence_desktop.blocking.hosts import HostsBlocker
log = logging.getLogger(__name__)
class BlockerManager:
def __init__(
self,
*,
helper_path: str,
report: Callable[[str, str, str, str | None], None],
notify: Callable[[str, str], None] | None = None,
) -> None:
self._hosts = HostsBlocker(helper_path)
self._report = report
self._notify = notify
self._active = False
self._applied: list[str] = []
self._announced: set[int] = set()
self.enforce_hosts = True
self.enforce_apps = True
self.last_error: str | None = None
self.killed_today = 0
# ---- state -------------------------------------------------------------
@property
def active(self) -> bool:
return self._active
@property
def helper_installed(self) -> bool:
return self._hosts.helper_installed
@property
def blocked_domains(self) -> list[str]:
return list(self._applied)
# ---- the one entry point ----------------------------------------------
def sync(self, policy: dict) -> None:
"""Bring the machine in line with `policy` (from /api/blocker/policy)."""
should_block = bool(policy.get("active")) and bool(policy.get("enabled"))
domains = [str(item) for item in policy.get("domains", [])]
apps = [str(item) for item in policy.get("apps", [])]
redirect_ip = str(policy.get("redirect_ip") or "127.0.0.1")
if should_block:
if self.enforce_hosts and domains != self._applied:
if self._hosts.apply(domains, redirect_ip):
self._applied = domains
self.last_error = None
log.info("blocking %s domain(s)", len(domains))
else:
self.last_error = self._hosts.last_error
if self.enforce_apps and apps:
self._enforce_apps(apps, kill=bool(policy.get("kill_apps", True)))
self._active = True
else:
self.release()
def release(self) -> None:
"""Unblock everything. Called when the interval ends, and on shutdown."""
if self._applied or self._hosts_dirty():
if self._hosts.clear():
self._applied = []
self.last_error = None
else:
self.last_error = self._hosts.last_error
self._active = False
self._announced.clear()
def _hosts_dirty(self) -> bool:
from kadence_desktop.blocking.hosts import current_block
return bool(current_block())
# ---- apps --------------------------------------------------------------
def _enforce_apps(self, patterns: list[str], *, kill: bool) -> None:
for app in processes.scan(patterns):
if app.pid in self._announced:
continue
self._announced.add(app.pid)
if kill and processes.terminate(app):
self.killed_today += 1
self._report("app", app.matched, "killed", f"{app.name} (pid {app.pid})")
if self._notify:
self._notify("Closed a distraction", f"{app.name} is blocked during focus.")
else:
self._report("app", app.matched, "warned", f"{app.name} (pid {app.pid})")
if self._notify:
self._notify("That app is blocked", f"{app.name} is on your block list.")
@@ -0,0 +1,102 @@
"""Application blocking by watching `/proc`.
No root needed: these are your own processes. The scan reads `/proc/*/comm` and
the cmdline, matches against the configured names, and either terminates the
process (SIGTERM, then SIGKILL if it ignores us) or just reports it, depending
on the setting.
Matching is deliberately forgiving — `steam` should catch `steamwebhelper`, and
`telegram` should catch `Telegram` — but it never matches ourselves, PID 1, or
anything outside the current user's processes.
"""
from __future__ import annotations
import logging
import os
import signal
import time
from dataclasses import dataclass
from pathlib import Path
log = logging.getLogger(__name__)
_SELF_MARKERS = ("kadence", "python3 -m kadence_desktop")
@dataclass(slots=True)
class RunningApp:
pid: int
name: str
cmdline: str
matched: str
def _read(path: Path) -> str:
try:
return path.read_text("utf-8", errors="replace")
except OSError:
return ""
def scan(patterns: list[str]) -> list[RunningApp]:
"""Every live process of this user matching one of `patterns`."""
wanted = [pattern.strip().lower() for pattern in patterns if pattern.strip()]
if not wanted:
return []
uid = os.getuid()
self_pid = os.getpid()
found: list[RunningApp] = []
for entry in Path("/proc").iterdir():
if not entry.name.isdigit():
continue
pid = int(entry.name)
if pid in (1, self_pid):
continue
try:
if entry.stat().st_uid != uid:
continue
except OSError:
continue
comm = _read(entry / "comm").strip()
if not comm:
continue
cmdline = _read(entry / "cmdline").replace("\x00", " ").strip()
haystack = f"{comm} {cmdline}".lower()
if any(marker in haystack for marker in _SELF_MARKERS):
continue
match = next((pattern for pattern in wanted if pattern in haystack), None)
if match:
found.append(RunningApp(pid=pid, name=comm, cmdline=cmdline[:200], matched=match))
return found
def terminate(app: RunningApp, *, grace_seconds: float = 2.0) -> bool:
"""Ask the process to quit, then insist. True when it is gone."""
try:
os.kill(app.pid, signal.SIGTERM)
except ProcessLookupError:
return True
except PermissionError:
log.warning("not allowed to stop %s (pid %s)", app.name, app.pid)
return False
deadline = time.monotonic() + grace_seconds
while time.monotonic() < deadline:
try:
os.kill(app.pid, 0)
except ProcessLookupError:
return True
time.sleep(0.1)
try:
os.kill(app.pid, signal.SIGKILL)
except ProcessLookupError:
return True
except PermissionError:
return False
return True