init
This commit is contained in:
121 files changed
+17641
No files matched your search
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installs Kadence's privileged helper and its polkit action.
|
||||
#
|
||||
# The desktop app itself never runs as root; this one small script is the entire
|
||||
# privileged surface, and you can read all of it in a minute.
|
||||
#
|
||||
# sudo ./install.sh # prompt once per session (recommended)
|
||||
# sudo ./install.sh --passwordless # no prompt for the invoking user
|
||||
# sudo ./install.sh --uninstall
|
||||
set -euo pipefail
|
||||
|
||||
HELPER_SRC="$(dirname "$(readlink -f "$0")")/kadence-helper"
|
||||
POLICY_SRC="$(dirname "$(readlink -f "$0")")/org.kadence.helper.policy"
|
||||
HELPER_DEST="/usr/local/bin/kadence-helper"
|
||||
POLICY_DEST="/usr/share/polkit-1/actions/org.kadence.helper.policy"
|
||||
RULE_DEST="/etc/polkit-1/rules.d/49-kadence.rules"
|
||||
|
||||
TARGET_USER="${SUDO_USER:-${USER:-root}}"
|
||||
PASSWORDLESS=0
|
||||
UNINSTALL=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--passwordless) PASSWORDLESS=1 ;;
|
||||
--uninstall) UNINSTALL=1 ;;
|
||||
--user) TARGET_USER="$2"; shift ;;
|
||||
-h|--help) sed -n '2,12p' "$0"; exit 0 ;;
|
||||
*) echo "unknown option: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "Run me with sudo." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $UNINSTALL -eq 1 ]]; then
|
||||
# Unblock first — leaving a block behind after uninstalling would be rude.
|
||||
[[ -x "$HELPER_DEST" ]] && "$HELPER_DEST" clear || true
|
||||
rm -f "$HELPER_DEST" "$POLICY_DEST" "$RULE_DEST"
|
||||
echo "Removed the helper, its polkit action and any passwordless rule."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
install -m 0755 -o root -g root "$HELPER_SRC" "$HELPER_DEST"
|
||||
install -m 0644 -o root -g root "$POLICY_SRC" "$POLICY_DEST"
|
||||
echo "Installed $HELPER_DEST and its polkit action."
|
||||
|
||||
if [[ $PASSWORDLESS -eq 1 ]]; then
|
||||
mkdir -p "$(dirname "$RULE_DEST")"
|
||||
cat > "$RULE_DEST" <<RULE
|
||||
// Lets $TARGET_USER manage the Kadence block list without a password prompt.
|
||||
// Scope: this one action, this one user, this one helper binary.
|
||||
polkit.addRule(function (action, subject) {
|
||||
if (action.id == "org.kadence.helper.manage-hosts" &&
|
||||
subject.user == "$TARGET_USER" && subject.local && subject.active) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
RULE
|
||||
chmod 0644 "$RULE_DEST"
|
||||
echo "Passwordless blocking enabled for '$TARGET_USER' ($RULE_DEST)."
|
||||
echo "Remove that file to go back to prompting."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Check it works: $HELPER_DEST status"
|
||||
Executable
+211
@@ -0,0 +1,211 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC
|
||||
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||
<!--
|
||||
Lets a logged-in user run Kadence's helper through pkexec.
|
||||
|
||||
The default is `auth_admin_keep`: you authenticate once and the grant is kept
|
||||
for the rest of the session, so a day of pomodoros is one password prompt, not
|
||||
twenty. install.sh can optionally drop in a rule that skips the prompt
|
||||
entirely for one user.
|
||||
-->
|
||||
<policyconfig>
|
||||
<vendor>Kadence</vendor>
|
||||
<vendor_url>https://github.com/</vendor_url>
|
||||
|
||||
<action id="org.kadence.helper.manage-hosts">
|
||||
<description>Manage the Kadence website block list</description>
|
||||
<description xml:lang="ru">Управление списком заблокированных сайтов Kadence</description>
|
||||
<message>Authentication is required to change which websites are blocked.</message>
|
||||
<message xml:lang="ru">Для изменения списка заблокированных сайтов требуется аутентификация.</message>
|
||||
<icon_name>kadence</icon_name>
|
||||
<defaults>
|
||||
<allow_any>auth_admin</allow_any>
|
||||
<allow_inactive>auth_admin</allow_inactive>
|
||||
<allow_active>auth_admin_keep</allow_active>
|
||||
</defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/local/bin/kadence-helper</annotate>
|
||||
<annotate key="org.freedesktop.policykit.exec.allow_gui">true</annotate>
|
||||
</action>
|
||||
</policyconfig>
|
||||
Reference in new issue
Block a user