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

270 lines
11 KiB
Python

"""Local authenticated Bash/FBTERM console, shared across desktop and TTY clients."""
from __future__ import annotations
import argparse
import hashlib
import os
from pathlib import Path
import pwd
import re
import shutil
import socket
import stat
import subprocess
import sys
from .config import load_config, private_dir, state_path
_NODE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,79}\Z")
def _node(state, value):
value = value or "LOCAL"
if type(value) is not str or _NODE.fullmatch(value) is None:
raise ValueError("CONSOLE_NODE_INVALID")
host = socket.gethostname()
names = {"local", "localhost", host.lower(), host.split(".")[0].lower()}
try:
config = load_config(state)
except (ValueError, OSError):
config = {}
name = config.get("name")
if isinstance(name, str):
names.add(name.lower())
if value.lower() not in names:
raise RuntimeError(
"REMOTE_CONSOLE_SSH_CONFIGURATION_REQUIRED: run fb console on the target node, "
"or use your explicitly configured SSH destination. Remote EXEC, CUA and TTY "
"operations remain available through the enrolled broker.")
return re.sub(r"[^A-Za-z0-9_-]", "_", host)[:64] or "NODE"
def _private_runtime(path):
path = Path(path)
info = path.lstat()
if (not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode)
or info.st_uid != os.getuid() or info.st_mode & 0o077):
raise RuntimeError("CONSOLE_RUNTIME_DIRECTORY_UNTRUSTED")
return path
def context(state, node="LOCAL", *, create=False):
state = state_path(state)
label = _node(state, node)
key = hashlib.sha256(str(state).encode()).hexdigest()[:20]
runtime = _private_runtime(Path("/run/user") / str(os.getuid()))
from .console_desktop import legacy_context
legacy = legacy_context(state, label, runtime)
root = legacy["socket"].parent if legacy else runtime / "framebridge-expanded-console"
profile = root if legacy else root / key
if create:
private_dir(state)
for path in (root, profile):
path.mkdir(mode=0o700, exist_ok=True)
_private_runtime(path)
else:
for path in (root, profile):
if path.exists() or path.is_symlink():
_private_runtime(path)
return {
"state": state, "node": label, "runtime": runtime,
"socket": profile / "console.sock",
"session": legacy["session"] if legacy else "FB-" + label + "-" + key[:10],
"jobs_root": state / "home/.local/share/framebridge/fbterm",
}
def _properties(text):
return dict(line.split("=", 1) for line in text.splitlines() if "=" in line)
def _active_graphical_display(env=None):
"""Auto GUI requires the actual active local X11 session, never a guessed Xvfb."""
env = os.environ if env is None else env
display = env.get("DISPLAY", "")
sid = env.get("XDG_SESSION_ID", "")
if re.fullmatch(r":[0-9]+(?:\.[0-9]+)?", display) is None:
return False
try:
# Desktop launchers started through the user bus may omit XDG_SESSION_ID.
# Ask logind for this user's display session and validate every field below.
if not sid:
session = subprocess.run([
"loginctl", "show-user", str(os.getuid()), "--property=Display", "--value",
], capture_output=True, text=True, timeout=5)
if session.returncode:
return False
sid = session.stdout.strip()
if re.fullmatch(r"[A-Za-z0-9_-]{1,80}", sid) is None:
return False
result = subprocess.run([
"loginctl", "show-session", sid, "--no-pager",
"-p", "User", "-p", "Type", "-p", "Active", "-p", "Remote",
"-p", "Display", "-p", "VTNr",
], capture_output=True, text=True, timeout=5)
if result.returncode:
return False
fields = _properties(result.stdout)
vt = fields.get("VTNr", "")
if not vt.isdigit() or int(vt) < 1:
return False
active = Path("/sys/class/tty/tty0/active").read_text().strip()
return (fields.get("User") == str(os.getuid())
and fields.get("Type") == "x11"
and fields.get("Active") == "yes"
and fields.get("Remote") == "no"
and fields.get("Display", "").split(".")[0] == display.split(".")[0]
and active == "tty" + str(int(vt)))
except (OSError, subprocess.SubprocessError):
return False
def _environment(ctx):
env = os.environ.copy()
real_home = pwd.getpwuid(os.getuid()).pw_dir
env.update({
"HOME": real_home,
"XDG_RUNTIME_DIR": str(ctx["runtime"]),
"FRAMEBRIDGE_EXPANDED_STATE": str(ctx["state"]),
"FRAMEBRIDGE_FBTERM_ROOT": str(ctx["jobs_root"]),
"FRAMEBRIDGE_FBTERM_ROOT_OVERRIDE": str(ctx["jobs_root"]),
"FBTERM_ROOT": str(ctx["jobs_root"]),
"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8",
})
# The installed command adjusts sys.path only in its own process. Make the
# new graphical-terminal process resolve this same installed module.
package_root = str(Path(__file__).resolve().parent.parent)
env["PYTHONPATH"] = os.pathsep.join(
[package_root] + ([env["PYTHONPATH"]] if env.get("PYTHONPATH") else []))
env.pop("TMUX", None)
env.pop("TMUX_PANE", None)
return env
def _socket_exists(sock):
try:
info = sock.lstat()
except FileNotFoundError:
return False
if (not stat.S_ISSOCK(info.st_mode) or stat.S_ISLNK(info.st_mode)
or info.st_uid != os.getuid() or info.st_mode & 0o077):
raise RuntimeError("CONSOLE_SOCKET_UNTRUSTED")
return True
def status(state, node="LOCAL"):
ctx = context(state, node)
sock = ctx["socket"]
value = {"ok": True, "node": ctx["node"], "socket": str(sock),
"session": ctx["session"], "jobs_root": str(ctx["jobs_root"]),
"exists": False, "panes": []}
if not _socket_exists(sock):
return value
try:
result = subprocess.run([
"/usr/bin/tmux", "-S", str(sock), "list-panes",
"-t", "=" + ctx["session"] + ":Console",
"-F", "#{pane_id}|#{pane_pid}|#{pane_current_command}|#{pane_width}|#{pane_height}|#{@framebridge_role}",
], capture_output=True, text=True, timeout=5)
except (OSError, subprocess.SubprocessError):
value.update(ok=False, error="CONSOLE_TMUX_STATUS_FAILED")
return value
if result.returncode:
value.update(ok=False, error="CONSOLE_TMUX_STATUS_FAILED")
return value
value["exists"] = True
for line in result.stdout.splitlines():
fields = line.split("|")
if len(fields) != 6:
raise RuntimeError("CONSOLE_TMUX_STATUS_INVALID")
pane, pid, command, width, height, role = fields
if not re.fullmatch(r"%[0-9]+", pane) or not all(x.isdigit() for x in [pid, width, height]):
raise RuntimeError("CONSOLE_TMUX_STATUS_INVALID")
value["panes"].append({"pane": pane, "pid": int(pid), "command": command,
"cols": int(width), "rows": int(height), "role": role})
return value
def _prevent_recursive_attach(ctx, env):
"""Reject attaching a console from one of its own panes before reflow."""
marker = os.environ.get("TMUX", "")
if not marker:
return
parts = marker.rsplit(",", 2)
try:
same_socket = Path(parts[0]).resolve() == ctx["socket"].resolve()
except (OSError, RuntimeError):
raise RuntimeError("CONSOLE_TMUX_CONTEXT_UNVERIFIED") from None
if not same_socket:
return
pane = os.environ.get("TMUX_PANE", "")
if (len(parts) != 3 or not parts[1].isdigit() or not parts[2].isdigit()
or re.fullmatch(r"%[0-9]+", pane) is None):
raise RuntimeError("CONSOLE_TMUX_CONTEXT_UNVERIFIED")
try:
result = subprocess.run([
"/usr/bin/tmux", "-S", str(ctx["socket"]), "list-panes", "-s",
"-t", "=" + ctx["session"], "-F", "#{pane_id}",
], env=env, capture_output=True, text=True, timeout=5)
except (OSError, subprocess.SubprocessError):
raise RuntimeError("CONSOLE_TMUX_CONTEXT_UNVERIFIED") from None
panes = result.stdout.splitlines() if isinstance(result.stdout, str) else []
if (result.returncode or not panes
or any(re.fullmatch(r"%[0-9]+", value) is None for value in panes)):
raise RuntimeError("CONSOLE_TMUX_CONTEXT_UNVERIFIED")
if pane in panes:
raise RuntimeError(
"CONSOLE_ALREADY_ATTACHED: This terminal is already inside this "
"FrameBridge console. Use its existing shell or open a separate terminal.")
def run(state, node="LOCAL", *, inside=False, mode="auto"):
if mode not in {"auto", "gui", "tty"}:
raise ValueError("CONSOLE_MODE_INVALID")
ctx = context(state, node, create=True)
env = _environment(ctx)
_socket_exists(ctx["socket"])
ui = Path(__file__).resolve().parent / "console_ui/framebridge_console.py"
if not ui.is_file():
raise RuntimeError("CONSOLE_COMPONENT_NOT_INSTALLED")
graphical = _active_graphical_display(env)
if not inside and mode != "tty" and (mode == "gui" or graphical):
if not graphical:
raise RuntimeError("CONSOLE_ACTIVE_X11_DESKTOP_REQUIRED")
terminal = shutil.which("gnome-terminal")
if not terminal:
raise RuntimeError("CONSOLE_GRAPHICAL_TERMINAL_UNAVAILABLE")
try:
result = subprocess.run([
terminal, "--window", "--maximize",
"--title=FrameBridge Console | " + ctx["node"], "--",
sys.executable, "-m", "framebridge_expanded.console",
"--state-dir", str(ctx["state"]), "--node", node, "--inside", "--mode", "gui",
], env=env, capture_output=True, text=True, timeout=15)
except (OSError, subprocess.SubprocessError):
raise RuntimeError("CONSOLE_GRAPHICAL_TERMINAL_FAILED") from None
if result.returncode:
raise RuntimeError("CONSOLE_GRAPHICAL_TERMINAL_FAILED")
return 0
if not os.isatty(0):
raise RuntimeError("CONSOLE_INTERACTIVE_TERMINAL_REQUIRED")
_prevent_recursive_attach(ctx, env)
try:
result = subprocess.run([
sys.executable, str(ui), "--node", ctx["node"],
"--socket", str(ctx["socket"]), "--session", ctx["session"],
"--jobs-root", str(ctx["jobs_root"]),
"--layout", "horizontal" if inside else "vertical", "--apply",
], env=env, capture_output=True, text=True, timeout=20)
except (OSError, subprocess.SubprocessError):
raise RuntimeError("CONSOLE_LAYOUT_FAILED") from None
if result.returncode:
raise RuntimeError("CONSOLE_LAYOUT_FAILED")
os.execve("/usr/bin/tmux", [
"tmux", "-S", str(ctx["socket"]), "attach-session", "-f", "ignore-size",
"-t", "=" + ctx["session"],
], env)
return 0
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--state-dir")
parser.add_argument("--node", default="LOCAL")
parser.add_argument("--inside", action="store_true")
parser.add_argument("--mode", choices=["auto", "gui", "tty"], default="auto")
args = parser.parse_args(argv)
return run(state_path(args.state_dir), args.node, inside=args.inside, mode=args.mode)
if __name__ == "__main__":
raise SystemExit(main())