270 lines
11 KiB
Python
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())
|