init
This commit is contained in:
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.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user