520 lines
26 KiB
Python
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"]}
|