FrameBridge 0.3.1 community source release
This commit is contained in:
commit
b23819fff1
1354 files changed
+343716
No files matched your search
@@ -0,0 +1,256 @@
|
||||
"""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()))
|
||||
root = runtime / "framebridge-expanded-console"
|
||||
profile = 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": "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
|
||||
or re.fullmatch(r"[A-Za-z0-9_-]{1,80}", sid) is None):
|
||||
return False
|
||||
try:
|
||||
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"],
|
||||
"-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"] ,
|
||||
"--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())
|
||||
Reference in new issue
Block a user