"""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"]}