144 lines
6.3 KiB
Python
144 lines
6.3 KiB
Python
"""Private evidence for one TTY daemon's cleanup; never authorizes process kills."""
|
|
from __future__ import annotations
|
|
import json
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
import stat
|
|
import tempfile
|
|
|
|
NAME = "tty-cleanup.json"
|
|
|
|
|
|
class CleanupUnverified(RuntimeError):
|
|
pass
|
|
|
|
|
|
def current_boot():
|
|
return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
|
|
|
|
|
|
def daemon_identity(record):
|
|
return {"pid": record["pid"], "birth": record["birth"], "uid": record["uid"],
|
|
"boot": record.get("boot", current_boot())}
|
|
|
|
|
|
def _session_valid(record):
|
|
keys = {"session_id", "pid", "birth", "server_pid", "server_birth", "unit_name",
|
|
"control_group", "pane_control_group"}
|
|
if type(record) is not dict or set(record) != keys:
|
|
return False
|
|
if (type(record["session_id"]) is not str or not re.fullmatch(r"[a-f0-9]{32}", record["session_id"])
|
|
or any(type(record[key]) is not int or record[key] <= 0 for key in ("pid", "server_pid"))
|
|
or any(type(record[key]) is not str or not record[key].isdigit() for key in ("birth", "server_birth"))
|
|
or type(record["unit_name"]) is not str
|
|
or not re.fullmatch(r"framebridge-tty-[a-f0-9]{12}-" + record["session_id"] + r"-[a-f0-9]{8}\.service", record["unit_name"])):
|
|
return False
|
|
for key in ("control_group", "pane_control_group"):
|
|
group = record[key]
|
|
if type(group) is not str or not group.startswith("/") or ".." in Path(group).parts or "\0" in group:
|
|
return False
|
|
server = Path(record["control_group"])
|
|
pane = Path(record["pane_control_group"])
|
|
return (server.name == record["unit_name"] and (pane == server or (
|
|
pane.parent == server.parent and re.fullmatch(r"tmux-spawn-[a-f0-9-]{36}\.scope", pane.name))))
|
|
|
|
|
|
def read_private(path):
|
|
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
with os.fdopen(fd, "r") as stream:
|
|
info = os.fstat(stream.fileno())
|
|
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid()
|
|
or stat.S_IMODE(info.st_mode) != 0o600 or info.st_size > 1048576):
|
|
raise CleanupUnverified()
|
|
return json.load(stream)
|
|
|
|
|
|
def read(state):
|
|
try:
|
|
value = read_private(Path(state) / NAME)
|
|
daemon = value["daemon"]
|
|
if (type(value) is not dict or value.get("schema") != 1
|
|
or value.get("state_dir") != str(Path(state).absolute())
|
|
or type(value.get("cleanup_verified")) is not bool
|
|
or type(value.get("sessions")) is not list
|
|
or type(value.get("errors")) is not list
|
|
or type(value.get("operation_pending")) is not bool
|
|
or set(daemon) != {"pid", "birth", "uid", "boot"}
|
|
or type(daemon["pid"]) is not int or daemon["pid"] <= 0
|
|
or type(daemon["birth"]) is not str or not daemon["birth"].isdigit()
|
|
or daemon["uid"] != os.geteuid()
|
|
or type(daemon["boot"]) is not str
|
|
or not re.fullmatch(r"[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}", daemon["boot"])
|
|
or any(not _session_valid(record) for record in value["sessions"])
|
|
or (value["cleanup_verified"] and (value["sessions"] or value["errors"]
|
|
or value["operation_pending"]))):
|
|
raise CleanupUnverified()
|
|
return value
|
|
except (OSError, ValueError, KeyError, TypeError):
|
|
raise CleanupUnverified() from None
|
|
|
|
|
|
def write(state, daemon, sessions, *, verified=False, errors=(), operation_pending=False):
|
|
state = Path(state)
|
|
value = {"schema": 1, "state_dir": str(state.absolute()), "daemon": daemon,
|
|
"cleanup_verified": verified, "sessions": sessions,
|
|
"errors": list(errors), "operation_pending": operation_pending}
|
|
fd, name = tempfile.mkstemp(prefix=".tty-cleanup-", dir=state)
|
|
try:
|
|
with os.fdopen(fd, "w") as stream:
|
|
json.dump(value, stream, ensure_ascii=True)
|
|
stream.write("\n")
|
|
os.replace(name, state / NAME)
|
|
finally:
|
|
Path(name).unlink(missing_ok=True)
|
|
|
|
|
|
def _gone(pid, birth, uid):
|
|
if type(pid) is not int or pid <= 0 or type(birth) is not str or not birth.isdigit():
|
|
return False
|
|
try:
|
|
path = Path("/proc") / str(pid)
|
|
fields = (path / "stat").read_text().rsplit(")", 1)[1].split()
|
|
return fields[19] != birth or (path.stat().st_uid == uid and fields[0] in {"Z", "X"})
|
|
except FileNotFoundError:
|
|
return True
|
|
except (OSError, IndexError):
|
|
return False
|
|
|
|
|
|
def require_prior_cleanup(state):
|
|
"""Permit restart only after prior evidence is clean or identities are gone.
|
|
|
|
Persisted identities are read-only evidence, never targets for a later kill.
|
|
"""
|
|
state = Path(state)
|
|
if not (state / NAME).exists() and not (state / NAME).is_symlink():
|
|
if (state / "tty-process.json").exists() or (state / "tty-process.json").is_symlink():
|
|
raise CleanupUnverified()
|
|
return
|
|
value = read(state)
|
|
# A different kernel boot cannot retain any process or user cgroup from
|
|
# this daemon. Do not compare old PID numbers with this boot's processes.
|
|
if value["daemon"]["boot"] != current_boot() or value["cleanup_verified"]:
|
|
return
|
|
daemon = value["daemon"]
|
|
if value["operation_pending"] or not _gone(daemon["pid"], daemon["birth"], daemon["uid"]):
|
|
raise CleanupUnverified()
|
|
for record in value["sessions"]:
|
|
try:
|
|
if not _gone(record["pid"], record["birth"], daemon["uid"]):
|
|
raise CleanupUnverified()
|
|
if not _gone(record["server_pid"], record["server_birth"], daemon["uid"]):
|
|
raise CleanupUnverified()
|
|
for group in {record["control_group"], record["pane_control_group"]}:
|
|
if not group.startswith("/") or ".." in Path(group).parts:
|
|
raise CleanupUnverified()
|
|
root = Path("/sys/fs/cgroup") / group.lstrip("/")
|
|
if root.exists():
|
|
files = [root / "cgroup.procs", *root.glob("**/cgroup.procs")]
|
|
if any(path.read_text().strip() for path in files):
|
|
raise CleanupUnverified()
|
|
except (OSError, KeyError, TypeError, AttributeError):
|
|
raise CleanupUnverified() from None
|