Files
framebridge/source/expanded/framebridge_expanded/tty_cleanup.py

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