147 lines
6.5 KiB
Python
147 lines
6.5 KiB
Python
"""Stop only this package's managed user services before Debian removal."""
|
|
from pathlib import Path
|
|
import hashlib
|
|
import os
|
|
import pwd
|
|
import re
|
|
import shlex
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
|
|
MODULE_ROOT = "/usr/lib/framebridge-expanded/expanded"
|
|
UNIT_NAME = re.compile(r"framebridge-autostart-[0-9a-f]{16}\.service\Z")
|
|
|
|
|
|
def managed_unit(path, uid):
|
|
"""Inspect a bounded owned regular file; never execute user configuration."""
|
|
if not UNIT_NAME.fullmatch(path.name):
|
|
return False
|
|
try:
|
|
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
try:
|
|
info = os.fstat(fd)
|
|
if not stat.S_ISREG(info.st_mode) or info.st_uid != uid or info.st_size > 16384:
|
|
return False
|
|
raw = os.read(fd, 16385)
|
|
finally:
|
|
os.close(fd)
|
|
lines = raw.decode("utf-8").splitlines()
|
|
if not lines or lines[0] != "# FrameBridge managed autostart v1":
|
|
return False
|
|
if lines.count('Environment="PYTHONPATH=' + MODULE_ROOT + '"') != 1:
|
|
return False
|
|
starts = [line[10:] for line in lines if line.startswith("ExecStart=")]
|
|
if len(starts) != 1:
|
|
return False
|
|
args = shlex.split(starts[0])
|
|
if len(args) != 7 or args[0] not in {"/usr/bin/python3", "/usr/bin/python3.13"}:
|
|
return False
|
|
if args[1:6] != ["-B", "-m", "framebridge_expanded.autostart", "_run", "--state"]:
|
|
return False
|
|
state = args[6].replace("%%", "%").replace("$$", "$")
|
|
if not state.startswith("/") or any(ord(c) < 32 for c in state):
|
|
return False
|
|
expected = "framebridge-autostart-" + hashlib.sha256(state.encode()).hexdigest()[:16] + ".service"
|
|
return path.name == expected
|
|
except (OSError, UnicodeError, ValueError):
|
|
return False
|
|
|
|
|
|
def cleanup_user(home=None, *, run=None, bus_available=None):
|
|
run = run or subprocess.run
|
|
home = Path(home) if home is not None else Path.home()
|
|
uid = os.getuid()
|
|
directory = home / ".config/systemd/user"
|
|
if not directory.is_dir():
|
|
return 0
|
|
units = sorted(p.name for p in directory.glob("framebridge-autostart-*.service") if managed_unit(p, uid))
|
|
if not units:
|
|
return 0
|
|
runtime = Path("/run/user") / str(uid)
|
|
if bus_available is None:
|
|
try:
|
|
bus_available = stat.S_ISSOCK((runtime / "bus").stat().st_mode)
|
|
except OSError:
|
|
bus_available = False
|
|
env = {"HOME": str(home), "PATH": "/usr/bin:/bin", "LANG": "C.UTF-8", "XDG_CONFIG_HOME": str(home / ".config"), "XDG_RUNTIME_DIR": str(runtime), "DBUS_SESSION_BUS_ADDRESS": "unix:path=" + str(runtime / "bus")}
|
|
failure = False
|
|
if bus_available:
|
|
try:
|
|
result = run(["/usr/bin/systemctl", "--user", "stop", *units], env=env, capture_output=True, timeout=35)
|
|
if result.returncode:
|
|
current = run(["/usr/bin/systemctl", "--user", "show", *units, "--property=ActiveState", "--value"], env=env, capture_output=True, timeout=5)
|
|
values = current.stdout.decode().split() if isinstance(current.stdout, bytes) else current.stdout.split()
|
|
failure = current.returncode != 0 or len(values) != len(units) or any(value not in {"inactive", "failed"} for value in values)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
failure = True
|
|
try:
|
|
result = run(["/usr/bin/systemctl", "--user", "--no-reload", "disable", *units], env=env, capture_output=True, timeout=10)
|
|
failure = result.returncode != 0 or failure
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
failure = True
|
|
# Remove only enablement symlinks targeting these exact managed files.
|
|
# This also works offline: systemctl can delegate file operations to a live
|
|
# user manager with a different environment, and no bus exists at all for
|
|
# an offline owner. Never delete profile data, ciphertext, or unit files.
|
|
for group in directory.iterdir():
|
|
if not group.name.endswith((".wants", ".requires")) or group.is_symlink() or not group.is_dir():
|
|
continue
|
|
for name in units:
|
|
link = group / name
|
|
if link.is_symlink():
|
|
try:
|
|
if link.resolve() == (directory / name).resolve():
|
|
link.unlink()
|
|
except OSError:
|
|
failure = True
|
|
if bus_available:
|
|
try:
|
|
run(["/usr/bin/systemctl", "--user", "daemon-reload"], env=env, capture_output=True, timeout=3)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
pass # Units are stopped/disabled; an owner may have just logged out.
|
|
if failure:
|
|
raise RuntimeError("A managed FrameBridge service could not be stopped or disabled.")
|
|
return len(units)
|
|
|
|
|
|
def cleanup_all_users(*, run=None, accounts=None):
|
|
if os.getuid() != 0:
|
|
raise RuntimeError("Package service cleanup requires the normal package manager's root context.")
|
|
run = run or subprocess.run
|
|
accounts = pwd.getpwall() if accounts is None else accounts
|
|
seen = set()
|
|
failed = False
|
|
for account in accounts:
|
|
identity = (account.pw_uid, account.pw_dir)
|
|
if identity in seen or account.pw_uid == 65534 or not account.pw_dir.startswith("/") or account.pw_dir in {"/", "/nonexistent", "/dev/null"}:
|
|
continue
|
|
seen.add(identity)
|
|
command = ["/usr/sbin/runuser", "-u", account.pw_name, "--", "/usr/bin/env", "-i", "HOME=" + account.pw_dir, "USER=" + account.pw_name, "LOGNAME=" + account.pw_name, "PATH=/usr/bin:/bin", "LANG=C.UTF-8", "/usr/bin/python3", "-I", "-S", str(Path(__file__).resolve()), "--user"]
|
|
try:
|
|
result = run(command, capture_output=True, timeout=55)
|
|
failed = result.returncode != 0 or failed
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
failed = True
|
|
if failed:
|
|
raise RuntimeError("Some FrameBridge services could not be stopped; package removal was paused.")
|
|
|
|
|
|
def main(argv=None):
|
|
args = list(sys.argv[1:] if argv is None else argv)
|
|
try:
|
|
if args == ["--all-users"]:
|
|
cleanup_all_users()
|
|
elif args == ["--user"]:
|
|
cleanup_user()
|
|
else:
|
|
return 2
|
|
return 0
|
|
except (OSError, RuntimeError, subprocess.TimeoutExpired):
|
|
print("FrameBridge service cleanup did not complete. Package data was retained.", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|