Files
framebridge/source/expanded/framebridge_expanded/tty_sudo_control.py
T

520 lines
26 KiB
Python

"""Per-console ordinary-sudo capability; no privilege grant or custom authentication.
Only transient user services created by this controller are stopped. Each new
console has its own tmux server and cgroup. NoNewPrivileges is checked on both
the actual server and Bash process. Callers must hold their terminal-manager
lock while changing mode so keyboard input cannot race the transition.
"""
from __future__ import annotations
import hashlib
import os
from pathlib import Path
import pwd
import re
import shutil
import shlex
import stat
import subprocess
import threading
import time
import uuid
class SudoControlError(RuntimeError):
def __init__(self, code):
self.code = code
super().__init__(code)
def _fail(code):
raise SudoControlError(code)
def _proc(pid):
try:
root = Path("/proc") / str(int(pid))
raw = (root / "stat").read_text().rsplit(") ", 1)[1].split()
fields = {}
for line in (root / "status").read_text().splitlines():
key, _, value = line.partition(":")
if key in {"Uid", "NoNewPrivs"}:
fields[key] = value.split()
try:
executable = os.readlink(root / "exe")
except OSError:
executable = ""
return {"pid": int(pid), "state": raw[0], "ppid": int(raw[1]),
"pgrp": int(raw[2]), "sid": int(raw[3]), "tty": int(raw[4]),
"tpgid": int(raw[5]), "birth": raw[19], "uids": tuple(map(int, fields["Uid"])),
"nnp": int(fields["NoNewPrivs"][0]), "exe": executable}
except (OSError, ValueError, KeyError, IndexError):
return None
def _processes():
result = {}
for path in Path("/proc").iterdir():
if path.name.isdigit():
item = _proc(int(path.name))
if item and item["state"] not in {"Z", "X"}:
result[item["pid"]] = item
return result
def _descendants(processes, pid):
found = set()
while True:
added = {p for p, info in processes.items()
if info["ppid"] == pid or info["ppid"] in found} - found
if not added:
return found
found.update(added)
SESSION_ENVIRONMENT_KEYS = (
"DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR",
"LD_LIBRARY_PATH", "LIBGL_DRIVERS_PATH", "GBM_BACKENDS_PATH",
"__EGL_VENDOR_LIBRARY_FILENAMES", "__GLX_VENDOR_LIBRARY_NAME",
"GALLIUM_DRIVER", "MESA_LOADER_DRIVER_OVERRIDE", "VK_DRIVER_FILES",
"VK_ICD_FILENAMES", "DRI_PRIME",
)
class SudoConsoleController:
def __init__(self, runtime_dir, home=None, timeout=6.0, *, min_rows=2, max_rows=500, min_cols=10, max_cols=1000):
if os.geteuid() == 0:
_fail("TTY_SUDO_USER_SESSION_REQUIRED")
self.uid = os.getuid()
dimensions = (min_rows, max_rows, min_cols, max_cols)
if (not all(type(value) is int for value in dimensions)
or not 1 <= min_rows <= max_rows or not 1 <= min_cols <= max_cols):
_fail("TTY_SUDO_SIZE_LIMITS_INVALID")
self.min_rows, self.max_rows = min_rows, max_rows
self.min_cols, self.max_cols = min_cols, max_cols
# The package may run inside a profile-scoped private desktop bus.
# sudo-mode units belong to the actual logged-in user's systemd manager.
self.user_runtime = Path("/run/user") / str(self.uid)
try:
runtime_info = self.user_runtime.lstat()
bus_info = (self.user_runtime / "bus").lstat()
except OSError:
_fail("TTY_SUDO_USER_MANAGER_UNAVAILABLE")
if (not stat.S_ISDIR(runtime_info.st_mode) or runtime_info.st_uid != self.uid
or stat.S_IMODE(runtime_info.st_mode) != 0o700
or not stat.S_ISSOCK(bus_info.st_mode) or bus_info.st_uid != self.uid):
_fail("TTY_SUDO_USER_MANAGER_UNAVAILABLE")
self.user_bus_address = "unix:path=" + str(self.user_runtime / "bus")
# Preserve only this profile's explicit graphical context in the shell
# unit. These are paths and driver selections, never authority cookies.
self.session_environment = {
key: os.environ[key] for key in SESSION_ENVIRONMENT_KEYS if key in os.environ
}
self.home = Path(home or pwd.getpwuid(self.uid).pw_dir).resolve()
self.base = Path(runtime_dir).absolute() / "sudo"
self.base.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
self.base.mkdir(mode=0o700, exist_ok=True)
for path in (self.base.parent, self.base):
info = path.lstat()
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != self.uid
or stat.S_IMODE(info.st_mode) != 0o700):
_fail("TTY_SUDO_RUNTIME_NOT_PRIVATE")
if len(os.fsencode(self.base / ("s-" + "a" * 26 + ".sock"))) >= 104:
_fail("TTY_SUDO_SOCKET_PATH_TOO_LONG")
self.tmux = shutil.which("tmux")
self.systemd_run = shutil.which("systemd-run")
self.systemctl = shutil.which("systemctl")
if not all((self.tmux, self.systemd_run, self.systemctl)):
_fail("TTY_SUDO_BACKEND_NOT_AVAILABLE")
self.timeout = float(timeout)
self.prefix = "framebridge-tty-" + hashlib.sha256(str(self.base).encode()).hexdigest()[:12] + "-"
self._records = {}
self._lock = threading.RLock()
def _run(self, command, *, data=None, optional=False):
env = {k: v for k, v in os.environ.items() if k not in {"TMUX", "TMUX_PANE"}}
env["XDG_RUNTIME_DIR"] = str(self.user_runtime)
env["DBUS_SESSION_BUS_ADDRESS"] = self.user_bus_address
try:
result = subprocess.run(command, input=data, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env=env, timeout=self.timeout)
except subprocess.TimeoutExpired:
_fail("TTY_SUDO_BACKEND_TIMEOUT")
except OSError:
_fail("TTY_SUDO_BACKEND_UNAVAILABLE")
if result.returncode and not optional:
_fail("TTY_SUDO_BACKEND_FAILED")
return result
def _unit(self, name):
if not re.fullmatch(re.escape(self.prefix) + r"[a-f0-9]{32}-[a-f0-9]{8}\.service", name):
_fail("TTY_SUDO_UNIT_NOT_OWNED")
result = self._run([self.systemctl, "--user", "show", name, "--no-pager",
"--property=MainPID,ActiveState,SubState,NoNewPrivileges,ControlGroup"], optional=True)
return dict(line.split("=", 1) for line in result.stdout.decode("utf-8", "replace").splitlines()
if "=" in line)
def _tmux(self, record, *args, optional=False, data=None):
socket = record.get("tmux_socket") or record.get("legacy_socket")
if not isinstance(socket, str) or not socket.startswith("/") or "\0" in socket:
_fail("TTY_SUDO_SOCKET_INVALID")
return self._run([self.tmux, "-S", socket, "-f", "/dev/null", *map(str, args)],
optional=optional, data=data)
def _resolve(self, session):
if isinstance(session, str):
session = {"session_id": session}
elif not isinstance(session, dict):
value = dict(vars(session))
value.update(value.pop("_sudo_record", value.pop("sudo_record", {})) or {})
session = value
sid = session.get("session_id")
if not isinstance(sid, str) or not re.fullmatch(r"[a-f0-9]{32}", sid):
_fail("TTY_SUDO_SESSION_INVALID")
record = dict(self._records.get(sid) or session)
if not record.get("pane") or not (record.get("tmux_socket") or record.get("legacy_socket")):
_fail("TTY_SUDO_SESSION_NOT_FOUND")
return record
def _pane(self, record):
values = {}
for field in ("pane_pid", "pane_width", "pane_height", "pane_dead"):
value = self._tmux(record, "display-message", "-p", "-t", record["pane"],
"#{" + field + "}").stdout.decode().strip()
if not value.isdigit():
_fail("TTY_SUDO_PANE_METADATA_INVALID")
values[field] = int(value)
if values["pane_dead"]:
_fail("TTY_SUDO_CONSOLE_EXITED")
proc = _proc(values["pane_pid"])
if proc is None:
_fail("TTY_SUDO_PROCESS_NOT_FOUND")
if record.get("pid") and (record["pid"] != proc["pid"] or record.get("birth") != proc["birth"]):
_fail("TTY_SUDO_PROCESS_IDENTITY_CHANGED")
try:
cwd = os.readlink(Path("/proc") / str(proc["pid"]) / "cwd")
except OSError:
_fail("TTY_SUDO_CWD_UNAVAILABLE")
return proc, cwd, values["pane_height"], values["pane_width"]
def _scope(self, group):
# cgroup v2 cgroup.procs plus nested cgroups includes reparented descendants.
if not group or not group.startswith("/") or ".." in Path(group).parts:
_fail("TTY_SUDO_CGROUP_UNAVAILABLE")
root = Path("/sys/fs/cgroup") / group.lstrip("/")
try:
files = [root / "cgroup.procs", *root.glob("**/cgroup.procs")]
return {int(line) for file in files for line in file.read_text().splitlines() if line.isdigit()}
except OSError:
_fail("TTY_SUDO_CGROUP_UNAVAILABLE")
def _pane_group(self, pid):
try:
entries = Path("/proc", str(pid), "cgroup").read_text().splitlines()
return next(line[3:] for line in entries if line.startswith("0::"))
except (OSError, StopIteration):
_fail("TTY_SUDO_CGROUP_UNAVAILABLE")
def _pane_scope(self, group, server_group):
if group == server_group:
return None
name = Path(group).name
if not re.fullmatch(r"tmux-spawn-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.scope", name):
_fail("TTY_SUDO_CGROUP_UNEXPECTED")
if Path(group).parent != Path(server_group).parent:
_fail("TTY_SUDO_CGROUP_UNEXPECTED")
return name
def _inspect(self, record):
proc, cwd, rows, cols = self._pane(record)
all_processes = _processes()
shell_pid = proc["pid"]
candidates = _descendants(all_processes, shell_pid)
candidates.update(p for p, info in all_processes.items() if p != shell_pid and (
info["sid"] == proc["sid"] or (proc["tty"] and info["tty"] == proc["tty"])))
unit_name = record.get("unit_name")
server = None
group = None
pane_group = None
pane_scope = None
if unit_name:
unit = self._unit(unit_name)
try:
main_pid = int(unit["MainPID"])
except (KeyError, ValueError):
_fail("TTY_SUDO_UNIT_NOT_RUNNING")
server = _proc(main_pid)
if (unit.get("ActiveState") != "active" or server is None
or Path(server["exe"]).name != Path(self.tmux).name
or (record.get("server_pid") and record["server_pid"] != main_pid)
or (record.get("server_birth") and record["server_birth"] != server["birth"])):
_fail("TTY_SUDO_UNIT_IDENTITY_CHANGED")
group = unit.get("ControlGroup")
pane_group = self._pane_group(shell_pid)
pane_scope = self._pane_scope(pane_group, group)
if record.get("pane_control_group") and record["pane_control_group"] != pane_group:
_fail("TTY_SUDO_CGROUP_IDENTITY_CHANGED")
scoped = self._scope(group) | self._scope(pane_group)
candidates.update(scoped - {main_pid, shell_pid})
expected = "yes" if proc["nnp"] else "no"
if unit.get("NoNewPrivileges") != expected or server["nnp"] != proc["nnp"]:
_fail("TTY_SUDO_PRIVILEGE_STATE_MISMATCH")
elevated = (any(uid != self.uid for uid in proc["uids"])
or any(p in all_processes and any(u != self.uid for u in all_processes[p]["uids"])
for p in candidates))
unknown = candidates - all_processes.keys()
idle_shell = (Path(proc["exe"]).name == "bash" and proc["state"] == "S"
and proc["tpgid"] == proc["pgrp"] == proc["pid"])
marker = record.get("prompt_marker")
try:
prompt_idle = bool(marker and Path(marker).read_text().strip() == record.get("generation", "") + ":idle")
except OSError:
prompt_idle = False
reason = "TTY_SUDO_ELEVATED_PROCESS_PRESENT" if elevated else (
"TTY_SUDO_PROCESS_STATE_UNVERIFIED" if unknown else (
"TTY_SUDO_CONSOLE_BUSY" if candidates or not idle_shell else (
"TTY_SUDO_PROMPT_UNVERIFIED" if not prompt_idle else None)))
return {**record, "ok": True, "verified": True, "pid": proc["pid"], "birth": proc["birth"],
"mode": "off" if proc["nnp"] else "on", "no_new_privs": proc["nnp"],
"server_pid": server["pid"] if server else None,
"server_no_new_privs": server["nnp"] if server else None,
"server_birth": server["birth"] if server else None, "control_group": group,
"pane_control_group": pane_group, "pane_scope_unit": pane_scope,
"cwd": cwd, "rows": rows, "cols": cols, "busy": bool(reason),
"busy_reason": reason, "busy_pids": sorted(candidates),
"managed": bool(unit_name), "sudo_authentication": "system_policy",
"sudo_authorization_granted": False,
"legacy_detached_scope_complete": bool(unit_name), "prompt_verified": prompt_idle}
def _old_cleanup_complete(self, record):
try:
old = _proc(record["pid"])
if old and old["birth"] == record["birth"] and old["state"] not in {"Z", "X"}:
return False
unit = self._unit(record["unit_name"])
if int(unit.get("MainPID", "0")) or unit.get("ActiveState") in {"active", "activating", "deactivating"}:
return False
for group in {record.get("control_group"), record.get("pane_control_group")}:
if not group:
return False
if (Path("/sys/fs/cgroup") / group.lstrip("/")).exists() and self._scope(group):
return False
return True
except (SudoControlError, KeyError, ValueError):
return False
def _refresh_pending(self, record):
old = record.get("pending_old_record")
if old and self._old_cleanup_complete(old):
record = dict(record)
record.pop("pending_old_record", None)
record.update(cleanup_pending=False, transition_complete=True, cleanup_error=None)
self._records[record["session_id"]] = record
return record
def status(self, session):
with self._lock:
record = self._refresh_pending(self._resolve(session))
return self._inspect(record)
def _idle(self, record):
current = self._inspect(record)
if current["busy"]:
_fail(current["busy_reason"])
return current
def _launch(self, session_id, cwd, rows, cols, mode):
if mode not in {"on", "off"}:
_fail("TTY_SUDO_MODE_INVALID")
if not re.fullmatch(r"[a-f0-9]{32}", str(session_id)):
_fail("TTY_SUDO_SESSION_INVALID")
if not (type(rows) is int and self.min_rows <= rows <= self.max_rows and type(cols) is int and self.min_cols <= cols <= self.max_cols):
_fail("TTY_SUDO_SIZE_INVALID")
cwd = str(Path(cwd).resolve())
if not Path(cwd).is_dir():
_fail("TTY_SUDO_CWD_INVALID")
nonce = uuid.uuid4().hex[:8]
socket = self.base / ("s-" + hashlib.sha256((session_id + nonce).encode()).hexdigest()[:26] + ".sock")
unit_name = self.prefix + session_id + "-" + nonce + ".service"
marker = self.base / ("p-" + nonce + ".state")
rcfile = self.base / ("p-" + nonce + ".bashrc")
record = {"session_id": session_id, "name": "fb-real-" + session_id, "pane": "",
"tmux_socket": str(socket), "unit_name": unit_name, "mode": mode,
"prompt_marker": str(marker), "rcfile": str(rcfile), "generation": nonce}
def write_private(path, text):
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, "w") as stream:
stream.write(text)
quoted = shlex.quote(str(marker))
rc = ("__fb_tty_busy() { builtin printf '%s\\n' '" + nonce + ":busy' > " + quoted + "; }\n"
"__fb_tty_prompt() { if [[ $(builtin trap -p DEBUG) == \"trap -- '__fb_tty_busy' DEBUG\" ]]; then "
"builtin printf '%s\\n' '" + nonce + ":idle' > " + quoted + "; else "
"builtin printf '%s\\n' '" + nonce + ":busy' > " + quoted + "; fi; }\n"
"set -o functrace\ntrap '__fb_tty_busy' DEBUG\nPROMPT_COMMAND=__fb_tty_prompt\nPS1='\\s-\\v\\$ '\n")
write_private(marker, nonce + ":busy\n")
write_private(rcfile, rc)
identity = pwd.getpwuid(self.uid).pw_name
command = [self.systemd_run, "--user", "--quiet", "--collect", "--unit=" + unit_name,
"--property=Type=exec", "--property=NoNewPrivileges=" + ("yes" if mode == "off" else "no"),
"--property=KillMode=control-group", "--property=TimeoutStopSec=3",
"--working-directory=" + str(self.home)]
for key, value in {"HOME": str(self.home), "USER": identity, "LOGNAME": identity,
"HISTFILE": "/dev/null", "SHELL": "/usr/bin/bash",
"LANG": "C.UTF-8", "TERM": "xterm-256color"}.items():
command.append("--setenv=" + key + "=" + value)
for key, value in self.session_environment.items():
command.append("--setenv=" + key + "=" + value)
command += [self.tmux, "-D", "-S", str(socket), "-f", "/dev/null"]
launched = False
try:
launched = True
self._run(command)
deadline = time.monotonic() + self.timeout
while time.monotonic() < deadline:
if socket.exists():
break
time.sleep(0.05)
else:
_fail("TTY_SUDO_TMUX_NOT_READY")
pane = self._tmux(record, "new-session", "-d", "-s", record["name"],
"-x", cols, "-y", rows, "-c", cwd, "-P", "-F", "#{pane_id}",
shlex.join(["/usr/bin/bash", "--noprofile", "--rcfile", str(rcfile), "-i"])).stdout.decode().strip()
if not re.fullmatch(r"%[0-9]+", pane):
_fail("TTY_SUDO_PANE_METADATA_INVALID")
record["pane"] = pane
self._tmux(record, "set-option", "-p", "-t", pane, "remain-on-exit", "on")
self._tmux(record, "set-option", "-t", record["name"], "status", "off")
# Verify the real process, not merely the requested unit property.
expected = 1 if mode == "off" else 0
deadline = time.monotonic() + self.timeout
while time.monotonic() < deadline:
current = self._inspect(record)
if current["no_new_privs"] != expected:
_fail("TTY_SUDO_PRIVILEGE_STATE_MISMATCH")
if not current["busy"]:
return current
time.sleep(0.05)
_fail("TTY_SUDO_CONSOLE_NOT_IDLE")
except BaseException:
if launched:
try:
unit = self._unit(unit_name)
proc, _, _, _ = self._pane(record)
scope = self._pane_scope(self._pane_group(proc["pid"]), unit.get("ControlGroup"))
if scope:
self._run([self.systemctl, "--user", "stop", scope], optional=True)
except SudoControlError:
pass
self._run([self.systemctl, "--user", "stop", unit_name], optional=True)
socket.unlink(missing_ok=True)
marker.unlink(missing_ok=True)
rcfile.unlink(missing_ok=True)
raise
def launch(self, session_id, cwd=None, rows=24, cols=80, mode="off"):
with self._lock:
if session_id in self._records:
_fail("TTY_SUDO_SESSION_EXISTS")
record = self._launch(session_id, cwd or self.home, rows, cols, mode)
self._records[session_id] = record
return dict(record)
def _terminated(self, record):
# A dead pane still owns its tmux service and may retain detached jobs.
unit = self._unit(record["unit_name"])
server = _proc(int(unit.get("MainPID", "0")))
if server and (server["pid"] != record.get("server_pid")
or server["birth"] != record.get("server_birth")):
_fail("TTY_SUDO_UNIT_IDENTITY_CHANGED")
groups = {record.get("control_group"), record.get("pane_control_group")}
if None in groups:
_fail("TTY_SUDO_CGROUP_UNAVAILABLE")
processes = _processes()
members = set()
for group in groups:
if (Path("/sys/fs/cgroup") / group.lstrip("/")).exists():
members.update(self._scope(group))
if members - processes.keys():
_fail("TTY_SUDO_PROCESS_STATE_UNVERIFIED")
if any(any(uid != self.uid for uid in processes[pid]["uids"]) for pid in members):
_fail("TTY_SUDO_ELEVATED_PROCESS_PRESENT")
return {**record, "busy_reason": None}
def _close(self, record, require_idle=False):
try:
current = self._idle(record) if require_idle else self._inspect(record)
except SudoControlError as error:
if (require_idle or not record.get("unit_name")
or error.code not in {"TTY_SUDO_CONSOLE_EXITED", "TTY_SUDO_PROCESS_NOT_FOUND"}):
raise
current = self._terminated(record)
if current["busy_reason"] in {"TTY_SUDO_ELEVATED_PROCESS_PRESENT", "TTY_SUDO_PROCESS_STATE_UNVERIFIED"}:
_fail(current["busy_reason"])
if record.get("unit_name"):
if current.get("pane_scope_unit"):
self._run([self.systemctl, "--user", "stop", current["pane_scope_unit"]], optional=True)
self._run([self.systemctl, "--user", "stop", record["unit_name"]], optional=True)
deadline = time.monotonic() + self.timeout
while time.monotonic() < deadline:
remaining = set()
for group in {current["control_group"], current["pane_control_group"]}:
if (Path("/sys/fs/cgroup") / group.lstrip("/")).exists():
remaining.update(self._scope(group))
if not remaining and _proc(current["pid"]) is None:
break
time.sleep(0.05)
else:
_fail("TTY_SUDO_SCOPE_STILL_RUNNING")
Path(record["tmux_socket"]).unlink(missing_ok=True)
else:
self._tmux(record, "kill-session", "-t", record["name"])
deadline = time.monotonic() + self.timeout
while time.monotonic() < deadline:
proc = _proc(current["pid"])
if proc is None or proc["birth"] != current["birth"]:
break
time.sleep(0.05)
else:
_fail("TTY_SUDO_OLD_CONSOLE_STILL_RUNNING")
for key in ("prompt_marker", "rcfile"):
if record.get(key):
path = Path(record[key])
if path.parent == self.base:
path.unlink(missing_ok=True)
return {"ok": True, "session_id": record["session_id"], "closed": True,
"root_process_cleanup_claimed": False}
def close(self, session):
with self._lock:
record = self._refresh_pending(self._resolve(session))
if record.get("cleanup_pending"):
_fail("TTY_SUDO_TRANSITION_CLEANUP_PENDING")
result = self._close(record)
self._records.pop(record["session_id"], None)
return result
def set_mode(self, session, mode):
if mode not in {"on", "off"}:
_fail("TTY_SUDO_MODE_INVALID")
with self._lock:
old = self._refresh_pending(self._resolve(session))
if old.get("cleanup_pending"):
_fail("TTY_SUDO_TRANSITION_CLEANUP_PENDING")
current = self._idle(old)
if old.get("unit_name") and current["mode"] == mode:
return {**current, "recreated": False}
candidate = self._launch(current["session_id"], current["cwd"],
current["rows"], current["cols"], mode)
try:
# The old console remains alive until its replacement is verified.
self._idle(old)
self._close(old, require_idle=True)
except BaseException as error:
previous = _proc(current["pid"])
if previous and previous["birth"] == current["birth"] and previous["state"] not in {"Z", "X"}:
self._close(candidate)
raise
# The former Bash is gone. Keep the verified replacement and
# retain old ownership metadata until all old scopes are empty.
candidate = {**candidate, "cleanup_pending": True, "transition_complete": False,
"cleanup_error": getattr(error, "code", "TTY_SUDO_OLD_CLEANUP_FAILED"),
"pending_old_record": dict(current)}
self._records[current["session_id"]] = candidate
return {**candidate, "recreated": True, "previous_pid": current["pid"]}
candidate.update(cleanup_pending=False, transition_complete=True)
self._records[current["session_id"]] = candidate
return {**candidate, "recreated": True, "previous_pid": current["pid"]}