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,884 @@
|
||||
"""Explicit user-service startup with host-bound encrypted systemd credentials."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import fcntl
|
||||
import getpass
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pwd
|
||||
import re
|
||||
import resource
|
||||
import secrets
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from .config import load_config, save_config, private_dir, state_path, default_config, validate
|
||||
|
||||
CREDENTIAL = "chatgpt-runtime-key"
|
||||
_STOP = False
|
||||
|
||||
|
||||
def _paths(state):
|
||||
state = state_path(state)
|
||||
directory = private_dir(private_dir(state) / "autostart")
|
||||
name = "framebridge-autostart-" + hashlib.sha256(str(state).encode()).hexdigest()[:16] + ".service"
|
||||
return state, directory, Path.home() / ".config/systemd/user" / name
|
||||
|
||||
|
||||
def _read(path, maximum=131072):
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1 or info.st_mode & 0o077:
|
||||
raise RuntimeError("Unsafe autostart file ownership or permissions.")
|
||||
data = os.read(fd, maximum + 1)
|
||||
if len(data) > maximum:
|
||||
raise RuntimeError("Autostart file exceeds the size limit.")
|
||||
return data
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _write(path, data, replace=False):
|
||||
path = Path(path)
|
||||
if path.exists() or path.is_symlink():
|
||||
if not replace:
|
||||
raise RuntimeError("Refusing to replace an existing autostart file.")
|
||||
_read(path)
|
||||
temporary = path.with_name("." + path.name + "." + secrets.token_hex(8))
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
if replace:
|
||||
os.replace(temporary, path)
|
||||
else:
|
||||
os.link(temporary, path, follow_symlinks=False)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _json(path, value, replace=False):
|
||||
_write(path, (json.dumps(value, sort_keys=True, indent=2) + "\n").encode(), replace)
|
||||
|
||||
|
||||
def _command(args, *, data=None, timeout=35, check=True):
|
||||
result = subprocess.run(args, input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
|
||||
if check and result.returncode:
|
||||
raise RuntimeError("Autostart command failed: " + Path(args[0]).name + ". No command output was exported.")
|
||||
return result
|
||||
|
||||
|
||||
def _systemctl(*args, check=True):
|
||||
return _command(["systemctl", "--user", *args], check=check)
|
||||
|
||||
|
||||
def _quote(value, *, command=False):
|
||||
value = str(value)
|
||||
if any(ord(ch) < 32 or ord(ch) == 127 for ch in value):
|
||||
raise ValueError("Control characters are not allowed in service paths.")
|
||||
if command:
|
||||
value = value.replace("$", "$$")
|
||||
return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%") + '"'
|
||||
|
||||
|
||||
def unit_text(state, encrypted, *, node=False):
|
||||
module_path = Path(__file__).resolve().parents[1]
|
||||
executable = [sys.executable, "-B", "-m", "framebridge_expanded.autostart", "_run", "--state", str(state)]
|
||||
lines = ["# FrameBridge managed autostart v1", "[Unit]", "Description=FrameBridge profile and ChatGPT connection", "StartLimitIntervalSec=0", "ConditionPathExists=" + _quote(module_path / "framebridge_expanded/autostart.py"), "", "[Service]", "Type=simple", "UMask=0077", "LimitCORE=0", "Environment=" + _quote("PYTHONPATH=" + str(module_path)), "Environment=LANG=C.UTF-8", "Environment=LC_ALL=C.UTF-8"]
|
||||
lines += ["ExecStart=" + " ".join(_quote(x, command=True) for x in executable), "Restart=on-failure", "RestartSec=15", "TimeoutStopSec=25", "KillMode=control-group", "StandardInput=null", "StandardOutput=null", "StandardError=null", "", "[Install]", "WantedBy=default.target", ""]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _linger(user):
|
||||
directory = Path("/var/lib/systemd/linger")
|
||||
try:
|
||||
info = directory.stat()
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or directory.is_symlink():
|
||||
raise RuntimeError("The systemd linger directory is unavailable or unsafe.")
|
||||
try:
|
||||
marker = (directory / user).lstat()
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
if not stat.S_ISREG(marker.st_mode) or marker.st_uid != 0:
|
||||
raise RuntimeError("The systemd linger record is unsafe.")
|
||||
return True
|
||||
|
||||
|
||||
def _enabled_units():
|
||||
result = _systemctl("list-unit-files", "--state=enabled", "--no-legend", "--no-pager")
|
||||
return sorted(line.split()[0] for line in result.stdout.decode(errors="replace").splitlines() if line.split())
|
||||
|
||||
|
||||
def _check_unit_directory(path):
|
||||
for directory in [Path.home() / ".config", Path.home() / ".config/systemd", path.parent]:
|
||||
if directory.is_symlink():
|
||||
raise RuntimeError("User service directories must not be symbolic links.")
|
||||
directory.mkdir(mode=0o700, exist_ok=True)
|
||||
info = directory.stat()
|
||||
if info.st_uid != os.getuid() or info.st_mode & 0o022:
|
||||
raise RuntimeError("User service directories must be owned by this user and not writable by others.")
|
||||
|
||||
|
||||
def _manifest(directory):
|
||||
path = directory / "installation.json"
|
||||
try:
|
||||
value = json.loads(_read(path))
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if not isinstance(value, dict) or value.get("schema") != 1:
|
||||
raise RuntimeError("Unsupported autostart installation record.")
|
||||
return value
|
||||
|
||||
|
||||
def status(state):
|
||||
state, directory, unit = _paths(state)
|
||||
manifest = _manifest(directory)
|
||||
properties = _systemctl("show", unit.name, "--property=ActiveState,SubState,UnitFileState", check=False)
|
||||
values = dict(line.split("=", 1) for line in properties.stdout.decode(errors="replace").splitlines() if "=" in line)
|
||||
from . import runtime, connection_background
|
||||
result = {"configured": bool(manifest), "unit": unit.name, "service_active": values.get("ActiveState") == "active", "service_state": values.get("ActiveState", "unavailable"), "enabled": values.get("UnitFileState") == "enabled", "linger": _linger(pwd.getpwuid(os.getuid()).pw_name), "encrypted_credential_present": False, "runtime": _safe_runtime_status(runtime, state), "connection": connection_background.status(state)}
|
||||
if manifest and not manifest.get("node"):
|
||||
try:
|
||||
cipher = _read(directory / "credential.cred")
|
||||
result["encrypted_credential_present"] = hashlib.sha256(cipher).hexdigest() == manifest.get("credential_sha256")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
try:
|
||||
result["worker"] = json.loads(_read(directory / "worker-status.json"))
|
||||
except FileNotFoundError:
|
||||
result["worker"] = {"state": "not_started"}
|
||||
result["boot_desired_enabled"] = boot_requested(state)
|
||||
result["desired_connection"] = "not_configured" if not manifest else ("not_applicable" if manifest.get("node") else ("connected" if connection_requested(state) else "disconnected"))
|
||||
return result
|
||||
|
||||
|
||||
def _safe_runtime_status(runtime, state):
|
||||
try:
|
||||
return runtime.status(state)
|
||||
except Exception:
|
||||
return {"running": False, "ready": False, "status_unavailable": True}
|
||||
|
||||
|
||||
def _configuration_action(state, action):
|
||||
_, directory, _ = _paths(state)
|
||||
fd = os.open(directory / "configuration.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1 or info.st_mode & 0o077:
|
||||
raise RuntimeError("Unsafe autostart configuration lock.")
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
raise RuntimeError("Another autostart configuration is already in progress.") from None
|
||||
return action(state)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def enable(state):
|
||||
try:
|
||||
return _configuration_action(state, _enable)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print(flush=True)
|
||||
return {"configured": False, "state": "cancelled"}
|
||||
|
||||
|
||||
def disable(state):
|
||||
return _configuration_action(state, _disable)
|
||||
|
||||
|
||||
def _committed_connection_status(state):
|
||||
try:
|
||||
return status(state)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
# Enabling the service already committed. Cancelling only this status
|
||||
# read must not suggest that the installed connection was cancelled.
|
||||
return {"configured": True, "enabled": True, "interrupted_after_commit": True}
|
||||
|
||||
|
||||
def configure_connection(state, public_settings, runtime_key, *, output=None):
|
||||
"""Install this connection once, using only the supplied hidden in-memory key."""
|
||||
if not isinstance(runtime_key, str) or not runtime_key or not runtime_key.isascii() or len(runtime_key) > 16384 or any(ord(ch) < 33 or ord(ch) > 126 for ch in runtime_key):
|
||||
raise ValueError("Enter one nonempty MCP server access key.")
|
||||
allowed = {"account_label", "tunnel_id", "control_plane_organization_id", "control_plane_url_path"}
|
||||
if not isinstance(public_settings, dict) or set(public_settings) - allowed:
|
||||
raise ValueError("Unsupported public connection settings.")
|
||||
from .tunnel import TunnelSpec
|
||||
settings = dict(public_settings)
|
||||
spec = TunnelSpec(settings.get("tunnel_id", ""), settings.get("control_plane_organization_id", ""), settings.get("control_plane_url_path", ""))
|
||||
label = settings.get("account_label", "")
|
||||
if not isinstance(label, str) or len(label) > 320 or any(ord(ch) < 32 or ord(ch) == 127 for ch in label):
|
||||
raise ValueError("Enter a valid account label.")
|
||||
settings.update(spec.public_settings())
|
||||
settings["account_label"] = label
|
||||
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
|
||||
try:
|
||||
return _configuration_action(state, lambda selected: _configure_connection(selected, settings, runtime_key))
|
||||
finally:
|
||||
runtime_key = ""
|
||||
|
||||
|
||||
def _connection_public_digest(config):
|
||||
fields = {name: config.get(name, "") for name in ("account_label", "tunnel_id", "control_plane_organization_id", "control_plane_url_path", "role", "node_id")}
|
||||
fields["mcp_port"] = config["ports"]["mcp"]
|
||||
return hashlib.sha256(json.dumps(fields, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def resume_connection(state):
|
||||
"""Resume the verified saved connection without asking for its key again."""
|
||||
return _configuration_action(state, _resume_connection)
|
||||
|
||||
|
||||
def _resume_connection(state):
|
||||
if os.geteuid() == 0:
|
||||
raise RuntimeError("Run connect as the profile owner, not as root.")
|
||||
state, directory, unit = _paths(state)
|
||||
config = load_config(state)
|
||||
record = _manifest(directory)
|
||||
error = "The saved connection cannot be verified. Run fb connect chatgpt --replace."
|
||||
if not record or record.get("node") or config["role"] == "node" or record.get("unit") != unit.name:
|
||||
raise RuntimeError(error)
|
||||
try:
|
||||
valid_unit = hashlib.sha256(_read(unit)).hexdigest() == record.get("unit_sha256")
|
||||
valid_cipher = hashlib.sha256(_read(directory / "credential.cred")).hexdigest() == record.get("credential_sha256")
|
||||
bound = record.get("connection_settings_sha256")
|
||||
if bound:
|
||||
valid_settings = _connection_public_digest(config) == bound
|
||||
else:
|
||||
# Older releases only bound the complete profile when converting its desktop setting.
|
||||
old_bound = record.get("changed_config_sha256")
|
||||
valid_settings = bool(old_bound) and hashlib.sha256(_read(state / "config.json")).hexdigest() == old_bound
|
||||
if record.get("headless_changed"):
|
||||
valid_backup = hashlib.sha256(_read(directory / "config-before.json")).hexdigest() == record.get("config_before_sha256")
|
||||
else:
|
||||
valid_backup = True
|
||||
except (OSError, ValueError):
|
||||
raise RuntimeError(error) from None
|
||||
if not (valid_unit and valid_cipher and valid_settings and valid_backup):
|
||||
raise RuntimeError(error)
|
||||
from .tunnel import TunnelSpec
|
||||
TunnelSpec(config["tunnel_id"], config.get("control_plane_organization_id", ""), config.get("control_plane_url_path", ""))
|
||||
user = pwd.getpwuid(os.getuid()).pw_name
|
||||
if not _linger(user):
|
||||
_command(["sudo", "-v"], timeout=180)
|
||||
_command(["sudo", "-n", "--", "loginctl", "enable-linger", user])
|
||||
was_manually_stopped = manual_stop_requested(state)
|
||||
_connection_action(state, lambda _: _set_connection_requested(state, True))
|
||||
_clear_manual_stop(state)
|
||||
current = status(state)
|
||||
local = current.get("runtime", {})
|
||||
# A live managed worker owns local tools independently of its transport.
|
||||
# Change its requested connection state instead of restarting those tools.
|
||||
if current.get("service_active") and local.get("running") and local.get("ready") and current.get("worker", {}).get("state") not in {"failed", "stopped"} and not was_manually_stopped:
|
||||
if not current.get("enabled") and boot_requested(state) is not False:
|
||||
_systemctl("enable", unit.name)
|
||||
return _committed_connection_status(state)
|
||||
_systemctl("stop", unit.name)
|
||||
from . import connection_background
|
||||
stopped = connection_background.disconnect(state)
|
||||
if stopped.get("ok") is False or stopped.get("alive") or connection_background.status(state).get("alive"):
|
||||
raise RuntimeError("The previous connection is still stopping. Try connecting again shortly.")
|
||||
_systemctl("daemon-reload")
|
||||
if boot_requested(state) is False:
|
||||
_systemctl("start", unit.name)
|
||||
else:
|
||||
_systemctl("enable", "--now", unit.name)
|
||||
return _committed_connection_status(state)
|
||||
|
||||
|
||||
def _optional_read(path):
|
||||
try:
|
||||
return _read(path)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def _configure_connection(state, settings, runtime_key):
|
||||
if os.geteuid() == 0:
|
||||
raise RuntimeError("Run connect as the profile owner, not as root.")
|
||||
state, directory, unit = _paths(state)
|
||||
config_path = state / "config.json"
|
||||
original_config = _optional_read(config_path)
|
||||
config = load_config(state) if original_config is not None else default_config()
|
||||
if config["role"] == "node":
|
||||
raise RuntimeError("Connect ChatGPT from the central profile.")
|
||||
config.update(settings)
|
||||
headless_before = config.get("headless", "auto")
|
||||
changed_headless = headless_before in ("no", False)
|
||||
if changed_headless:
|
||||
config["headless"] = "auto"
|
||||
validate(config)
|
||||
config_bytes = (json.dumps(config, indent=2, sort_keys=True) + "\n").encode()
|
||||
record_path = directory / "installation.json"
|
||||
blob = directory / "credential.cred"
|
||||
backup = directory / "config-before.json"
|
||||
manual_stop = directory / "manual-stop.json"
|
||||
intent = directory / "connection-intent.json"
|
||||
targets = (config_path, unit, blob, record_path, backup, manual_stop, intent)
|
||||
originals = {path: _optional_read(path) for path in targets}
|
||||
previous = _manifest(directory)
|
||||
if previous:
|
||||
if previous.get("node") or previous.get("unit") != unit.name or originals[unit] is None or hashlib.sha256(originals[unit]).hexdigest() != previous.get("unit_sha256"):
|
||||
raise RuntimeError("The existing service changed outside FrameBridge; nothing was replaced.")
|
||||
if originals[blob] is None or hashlib.sha256(originals[blob]).hexdigest() != previous.get("credential_sha256"):
|
||||
raise RuntimeError("The encrypted connection changed outside FrameBridge; nothing was replaced.")
|
||||
if previous.get("headless_changed") and (originals[backup] is None or hashlib.sha256(originals[backup]).hexdigest() != previous.get("config_before_sha256")):
|
||||
raise RuntimeError("The profile backup changed outside FrameBridge; nothing was replaced.")
|
||||
elif any(originals[path] is not None for path in (unit, blob, backup)):
|
||||
raise RuntimeError("Existing unowned connection files; nothing was replaced.")
|
||||
_check_unit_directory(unit)
|
||||
version = _command(["systemd-creds", "--version"]).stdout.decode(errors="replace")
|
||||
match = re.match(r"systemd (\d+)", version)
|
||||
if not match or int(match.group(1)) < 256:
|
||||
raise RuntimeError("Protected connections require systemd 256 or later.")
|
||||
_command(["sudo", "-v"], timeout=180)
|
||||
user = pwd.getpwuid(os.getuid()).pw_name
|
||||
linger_before = _linger(user)
|
||||
units_before = _enabled_units()
|
||||
properties = _systemctl("show", unit.name, "--property=ActiveState,UnitFileState", check=False)
|
||||
values = dict(line.split("=", 1) for line in properties.stdout.decode(errors="replace").splitlines() if "=" in line)
|
||||
was_enabled = values.get("UnitFileState") == "enabled"
|
||||
was_active = values.get("ActiveState") in {"active", "activating", "reloading"}
|
||||
try:
|
||||
encrypted = _command(["sudo", "-n", "--", "systemd-creds", "encrypt", "--uid=" + str(os.getuid()), "--with-key=host", "--name=" + CREDENTIAL, "-", "-"], data=runtime_key.encode()).stdout
|
||||
finally:
|
||||
runtime_key = ""
|
||||
if len(encrypted) < 80 or len(encrypted) > 131072:
|
||||
raise RuntimeError("Connection protection did not complete.")
|
||||
# Recheck the exact originals after authentication/encryption and before any stop.
|
||||
if any(_optional_read(path) != data for path, data in originals.items()):
|
||||
raise RuntimeError("The profile changed during connection setup; nothing was replaced.")
|
||||
text = unit_text(state, blob).encode()
|
||||
record = dict(previous) if previous else {"schema": 1, "unit": unit.name, "node": False, "linger_before": linger_before, "enabled_units_before": units_before, "headless_before": headless_before, "headless_changed": changed_headless, "config_before_sha256": hashlib.sha256(original_config).hexdigest() if original_config is not None else ""}
|
||||
backup_bytes = originals[backup]
|
||||
if changed_headless and not record.get("headless_changed"):
|
||||
record.update(headless_before=headless_before, headless_changed=True, config_before_sha256=hashlib.sha256(original_config).hexdigest())
|
||||
backup_bytes = original_config
|
||||
elif not previous and changed_headless:
|
||||
backup_bytes = original_config
|
||||
if record.get("headless_changed"):
|
||||
# Disabling changes only the desktop preference, preserving the newly selected connection.
|
||||
restored_profile = dict(config)
|
||||
restored_profile["headless"] = record["headless_before"]
|
||||
backup_bytes = (json.dumps(restored_profile, indent=2, sort_keys=True) + "\n").encode()
|
||||
record["config_before_sha256"] = hashlib.sha256(backup_bytes).hexdigest()
|
||||
record["changed_config_sha256"] = hashlib.sha256(config_bytes).hexdigest()
|
||||
record.update(unit_sha256=hashlib.sha256(text).hexdigest(), credential_sha256=hashlib.sha256(encrypted).hexdigest(), connection_settings_sha256=_connection_public_digest(config))
|
||||
from . import connection_background
|
||||
background_before = connection_background.status(state)
|
||||
changed = {}
|
||||
service_touched = False
|
||||
manual_connection_stopped = False
|
||||
def write(path, data):
|
||||
changed[path] = data
|
||||
_write(path, data, replace=originals.get(path) is not None)
|
||||
try:
|
||||
if previous:
|
||||
service_touched = True
|
||||
_systemctl("stop", unit.name)
|
||||
disconnected = connection_background.disconnect(state)
|
||||
if disconnected.get("ok") is False or disconnected.get("alive"):
|
||||
raise RuntimeError("The previous connection could not be stopped safely; nothing was replaced.")
|
||||
manual_connection_stopped = not previous and background_before.get("alive") is True
|
||||
if connection_background.status(state).get("alive"):
|
||||
raise RuntimeError("The previous connection is still stopping; nothing was replaced.")
|
||||
write(blob, encrypted)
|
||||
write(unit, text)
|
||||
write(config_path, config_bytes)
|
||||
if backup_bytes is not None and backup_bytes != originals[backup]:
|
||||
write(backup, backup_bytes)
|
||||
write(record_path, (json.dumps(record, sort_keys=True, indent=2) + "\n").encode())
|
||||
write(intent, (json.dumps({"schema": 1, "connect": True, "revision": connection_request_revision(state) + 1}, sort_keys=True) + "\n").encode())
|
||||
_command(["sudo", "-n", "--", "loginctl", "enable-linger", user])
|
||||
_systemctl("daemon-reload")
|
||||
service_touched = True
|
||||
if boot_requested(state) is False:
|
||||
_systemctl("start", unit.name)
|
||||
else:
|
||||
_systemctl("enable", "--now", unit.name)
|
||||
except BaseException as error:
|
||||
rollback_failed = False
|
||||
if service_touched or unit in changed:
|
||||
try:
|
||||
rollback_failed = bool(_systemctl("disable", "--now", unit.name, check=False).returncode)
|
||||
except Exception:
|
||||
rollback_failed = True
|
||||
for path, installed in reversed(list(changed.items())):
|
||||
try:
|
||||
current = _optional_read(path)
|
||||
if current == originals[path]:
|
||||
continue
|
||||
if current != installed:
|
||||
rollback_failed = True
|
||||
continue
|
||||
if originals[path] is None:
|
||||
path.unlink()
|
||||
else:
|
||||
_write(path, originals[path], replace=True)
|
||||
except Exception:
|
||||
rollback_failed = True
|
||||
try:
|
||||
if originals[manual_stop] is not None and _optional_read(manual_stop) is None:
|
||||
_write(manual_stop, originals[manual_stop])
|
||||
_systemctl("daemon-reload")
|
||||
if previous:
|
||||
result = _systemctl("enable" if was_enabled else "disable", unit.name, check=False)
|
||||
rollback_failed = rollback_failed or bool(result.returncode)
|
||||
if was_active:
|
||||
restarted = _systemctl("start", unit.name, check=False)
|
||||
rollback_failed = rollback_failed or bool(restarted.returncode)
|
||||
if not linger_before and _enabled_units() == units_before:
|
||||
restored_linger = _command(["sudo", "-n", "--", "loginctl", "disable-linger", user], check=False)
|
||||
rollback_failed = rollback_failed or bool(restored_linger.returncode)
|
||||
except Exception:
|
||||
rollback_failed = True
|
||||
if rollback_failed:
|
||||
raise RuntimeError("Connection setup did not complete, and restoration could not be fully verified. Inspect fb autostart status before retrying.") from None
|
||||
if manual_connection_stopped:
|
||||
raise RuntimeError("Connection setup did not complete. The previous manually started connection was stopped; reconnect it using its original key.") from None
|
||||
raise
|
||||
return _committed_connection_status(state)
|
||||
|
||||
|
||||
def _enable(state, *, start_now=True, confirm=True):
|
||||
if os.geteuid() == 0:
|
||||
raise RuntimeError("Run autostart enable as the profile owner, not as root.")
|
||||
state, directory, unit = _paths(state)
|
||||
config = load_config(state)
|
||||
if not confirm and config["role"] != "node":
|
||||
raise RuntimeError("Only node startup can be initialized without the connection wizard.")
|
||||
original_config = _read(state / "config.json")
|
||||
existing = _manifest(directory)
|
||||
if existing:
|
||||
if existing.get("unit") != unit.name or hashlib.sha256(_read(unit)).hexdigest() != existing.get("unit_sha256"):
|
||||
raise RuntimeError("The service changed outside FrameBridge; refusing to enable it.")
|
||||
if not existing.get("node") and hashlib.sha256(_read(directory / "credential.cred")).hexdigest() != existing.get("credential_sha256"):
|
||||
raise RuntimeError("The encrypted credential changed outside FrameBridge; refusing to enable it.")
|
||||
user = pwd.getpwuid(os.getuid()).pw_name
|
||||
if not _linger(user):
|
||||
_command(["sudo", "-v"], timeout=180)
|
||||
_command(["sudo", "-n", "--", "loginctl", "enable-linger", user])
|
||||
_set_boot_requested(state, True)
|
||||
_systemctl("daemon-reload")
|
||||
if start_now:
|
||||
_systemctl("enable", "--now", unit.name)
|
||||
else:
|
||||
_systemctl("enable", unit.name)
|
||||
return status(state)
|
||||
version = _command(["systemd-creds", "--version"]).stdout.decode(errors="replace")
|
||||
match = re.match(r"systemd (\d+)", version)
|
||||
if not match or int(match.group(1)) < 256:
|
||||
raise RuntimeError("Encrypted user credentials require systemd 256 or later.")
|
||||
node = config["role"] == "node"
|
||||
if not node and not config.get("tunnel_id"):
|
||||
raise RuntimeError("Save a tunnel with fb connect chatgpt before enabling automatic startup.")
|
||||
print("FrameBridge will start automatically when this computer starts.")
|
||||
print("Available tools are detected automatically.")
|
||||
if not node:
|
||||
print("Your connection key will be encrypted on this computer.")
|
||||
original_headless = config.get("headless", "auto")
|
||||
changed_headless = original_headless in ("no", False)
|
||||
if confirm and input("Enable automatic startup? [y/N]: ").strip().lower() not in {"y", "yes"}:
|
||||
return {"configured": False, "state": "cancelled"}
|
||||
print("[1/3] Preparing automatic startup...", flush=True)
|
||||
_check_unit_directory(unit)
|
||||
if unit.exists() or unit.is_symlink() or (directory / "credential.cred").exists() or (directory / "credential.cred").is_symlink():
|
||||
raise RuntimeError("Existing unowned service or encrypted credential; nothing was replaced.")
|
||||
_command(["sudo", "-v"], timeout=180)
|
||||
user = pwd.getpwuid(os.getuid()).pw_name
|
||||
linger_before = _linger(user)
|
||||
units_before = _enabled_units()
|
||||
encrypted = b""
|
||||
print("[2/3] Preparing this node..." if node else "[2/3] Protecting the connection key...", flush=True)
|
||||
if not node:
|
||||
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
|
||||
runtime_key = getpass.getpass("Connection key (hidden): ")
|
||||
if not runtime_key or not runtime_key.isascii() or len(runtime_key) > 16384 or any(ord(ch) < 33 or ord(ch) > 126 for ch in runtime_key):
|
||||
raise ValueError("The runtime key must be nonempty and contain no whitespace or control characters.")
|
||||
try:
|
||||
encrypted = _command(["sudo", "-n", "--", "systemd-creds", "encrypt", "--uid=" + str(os.getuid()), "--with-key=host", "--name=" + CREDENTIAL, "-", "-"], data=runtime_key.encode()).stdout
|
||||
finally:
|
||||
runtime_key = ""
|
||||
if len(encrypted) < 80 or len(encrypted) > 131072:
|
||||
raise RuntimeError("Credential encryption returned an invalid result.")
|
||||
blob = directory / "credential.cred"
|
||||
text = unit_text(state, blob, node=node).encode()
|
||||
record = {"schema": 1, "unit": unit.name, "unit_sha256": hashlib.sha256(text).hexdigest(), "credential_sha256": hashlib.sha256(encrypted).hexdigest() if encrypted else "", "node": node, "linger_before": linger_before, "enabled_units_before": units_before, "headless_before": original_headless, "headless_changed": changed_headless, "config_before_sha256": hashlib.sha256(original_config).hexdigest()}
|
||||
created = []
|
||||
try:
|
||||
if encrypted:
|
||||
_write(blob, encrypted); created.append(blob)
|
||||
_write(unit, text); created.append(unit)
|
||||
if changed_headless:
|
||||
if _read(state / "config.json") != original_config:
|
||||
raise RuntimeError("The profile changed during configuration; no profile settings were overwritten.")
|
||||
_write(directory / "config-before.json", original_config)
|
||||
created.append(directory / "config-before.json")
|
||||
config["headless"] = "auto"
|
||||
save_config(state, config)
|
||||
record["changed_config_sha256"] = hashlib.sha256(_read(state / "config.json")).hexdigest()
|
||||
_json(directory / "installation.json", record); created.append(directory / "installation.json")
|
||||
print("[3/3] Enabling automatic startup...", flush=True)
|
||||
_command(["sudo", "-n", "--", "loginctl", "enable-linger", user])
|
||||
_systemctl("daemon-reload")
|
||||
if start_now:
|
||||
_systemctl("enable", "--now", unit.name)
|
||||
else:
|
||||
_systemctl("enable", unit.name)
|
||||
except BaseException:
|
||||
if unit in created:
|
||||
_systemctl("disable", "--now", unit.name, check=False)
|
||||
for path in reversed(created):
|
||||
try: path.unlink()
|
||||
except FileNotFoundError: pass
|
||||
if changed_headless and record.get("changed_config_sha256") == hashlib.sha256(_read(state / "config.json")).hexdigest():
|
||||
_write(state / "config.json", original_config, replace=True)
|
||||
_systemctl("daemon-reload", check=False)
|
||||
if not linger_before:
|
||||
try:
|
||||
if _enabled_units() == units_before:
|
||||
_command(["sudo", "-n", "--", "loginctl", "disable-linger", user], check=False)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
return status(state)
|
||||
|
||||
|
||||
def _disable(state):
|
||||
state, directory, unit = _paths(state)
|
||||
record = _manifest(directory)
|
||||
if not record:
|
||||
return {"configured": False, "state": "already_disabled"}
|
||||
if record.get("unit") != unit.name or hashlib.sha256(_read(unit)).hexdigest() != record.get("unit_sha256"):
|
||||
raise RuntimeError("The service changed outside FrameBridge; refusing to remove it.")
|
||||
blob = directory / "credential.cred"
|
||||
if not record.get("node") and hashlib.sha256(_read(blob)).hexdigest() != record.get("credential_sha256"):
|
||||
raise RuntimeError("The encrypted credential changed outside FrameBridge; refusing to remove it.")
|
||||
backup = None
|
||||
if record.get("headless_changed"):
|
||||
backup = _read(directory / "config-before.json")
|
||||
if hashlib.sha256(backup).hexdigest() != record.get("config_before_sha256"):
|
||||
raise RuntimeError("The profile backup changed outside FrameBridge; refusing to remove it.")
|
||||
owned_policy = [directory / "boot-policy.json", directory / "connection-intent.json"]
|
||||
for policy in owned_policy:
|
||||
if policy.exists() or policy.is_symlink():
|
||||
_read(policy)
|
||||
restore_linger = not record.get("linger_before") and [name for name in _enabled_units() if name != unit.name] == record.get("enabled_units_before")
|
||||
if restore_linger:
|
||||
_command(["sudo", "-v"], timeout=180)
|
||||
_systemctl("disable", "--now", unit.name)
|
||||
unit.unlink()
|
||||
if not record.get("node"):
|
||||
blob.unlink()
|
||||
config_restored = False
|
||||
if record.get("headless_changed") and hashlib.sha256(_read(state / "config.json")).hexdigest() == record.get("changed_config_sha256"):
|
||||
_write(state / "config.json", backup, replace=True)
|
||||
config_restored = True
|
||||
_systemctl("daemon-reload")
|
||||
linger_restored = False
|
||||
if restore_linger and _enabled_units() == record.get("enabled_units_before"):
|
||||
user = pwd.getpwuid(os.getuid()).pw_name
|
||||
_command(["sudo", "-n", "--", "loginctl", "disable-linger", user])
|
||||
linger_restored = True
|
||||
if backup is not None:
|
||||
(directory / "config-before.json").unlink()
|
||||
for policy in owned_policy:
|
||||
policy.unlink(missing_ok=True)
|
||||
(directory / "installation.json").unlink()
|
||||
return {"configured": False, "state": "disabled", "encrypted_credential_removed": True, "desktop_setting_restored": config_restored, "linger_restored": linger_restored}
|
||||
|
||||
|
||||
def _credential(state):
|
||||
# Debian 13 systemd 257 cannot load user encrypted credentials directly.
|
||||
# Its systemd-creds CLI delegates owner-scoped decryption to the OS broker.
|
||||
_, directory, _ = _paths(state)
|
||||
record = _manifest(directory)
|
||||
if not record or record.get("node"):
|
||||
raise RuntimeError("The encrypted runtime credential is not configured.")
|
||||
cipher = _read(directory / "credential.cred")
|
||||
if hashlib.sha256(cipher).hexdigest() != record.get("credential_sha256"):
|
||||
raise RuntimeError("The encrypted runtime credential changed outside FrameBridge.")
|
||||
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
|
||||
result = _command(["systemd-creds", "decrypt", "--user", "--name=" + CREDENTIAL, "-", "-"], data=cipher, timeout=15)
|
||||
try:
|
||||
key = result.stdout.decode("ascii")
|
||||
except UnicodeDecodeError:
|
||||
raise RuntimeError("The decrypted runtime credential is invalid.") from None
|
||||
if not key or len(key) > 16384 or any(ord(ch) < 33 or ord(ch) == 127 for ch in key):
|
||||
raise RuntimeError("The decrypted runtime credential is invalid.")
|
||||
return key
|
||||
|
||||
|
||||
def stop_for_current_boot(state):
|
||||
# systemctl stop also cancels an already queued Restart=on-failure job.
|
||||
# Keep the unit enabled and the encrypted credential for the next boot.
|
||||
directory = _intent_directory(state)
|
||||
if directory is None:
|
||||
return {"configured": False, "state": "not_configured"}
|
||||
_, _, unit = _paths(state)
|
||||
record = _manifest(directory)
|
||||
if record.get("unit") != unit.name or hashlib.sha256(_read(unit)).hexdigest() != record.get("unit_sha256"):
|
||||
raise RuntimeError("The service changed outside FrameBridge; refusing to stop it.")
|
||||
_systemctl("stop", unit.name)
|
||||
return {"configured": True, "state": "stopped_for_current_boot", "autostart_configuration_preserved": True}
|
||||
|
||||
|
||||
def _intent_directory(state):
|
||||
directory = state_path(state) / "autostart"
|
||||
if not directory.exists() and not directory.is_symlink():
|
||||
return None
|
||||
private_dir(directory)
|
||||
return directory if _manifest(directory) else None
|
||||
|
||||
|
||||
def mark_manual_stop(state):
|
||||
directory = _intent_directory(state)
|
||||
if directory is None:
|
||||
return False
|
||||
_json(directory / "manual-stop.json", {"requested": True, "boot_id": Path("/proc/sys/kernel/random/boot_id").read_text().strip()}, replace=True)
|
||||
return True
|
||||
|
||||
|
||||
def manual_stop_requested(state):
|
||||
directory = _intent_directory(state)
|
||||
if directory is None:
|
||||
return False
|
||||
try:
|
||||
record = json.loads(_read(directory / "manual-stop.json"))
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
return isinstance(record, dict) and record.get("requested") is True and record.get("boot_id") == Path("/proc/sys/kernel/random/boot_id").read_text().strip()
|
||||
|
||||
|
||||
def _clear_manual_stop(state):
|
||||
directory = _intent_directory(state)
|
||||
if directory is None:
|
||||
return
|
||||
path = directory / "manual-stop.json"
|
||||
try:
|
||||
_read(path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
path.unlink()
|
||||
|
||||
|
||||
def boot_requested(state):
|
||||
"""Explicit boot policy; None retains the normal first-connect default."""
|
||||
directory = _intent_directory(state)
|
||||
if directory is None:
|
||||
return None
|
||||
try:
|
||||
value = json.loads(_read(directory / "boot-policy.json"))
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if type(value) is not dict or value.get("schema") != 1 or type(value.get("enabled")) is not bool:
|
||||
raise RuntimeError("The saved boot policy cannot be verified.")
|
||||
return value["enabled"]
|
||||
|
||||
|
||||
def _set_boot_requested(state, enabled):
|
||||
if type(enabled) is not bool:
|
||||
raise ValueError("Boot policy must be true or false.")
|
||||
directory = _intent_directory(state)
|
||||
if directory is None:
|
||||
return False
|
||||
_json(directory / "boot-policy.json", {"schema": 1, "enabled": enabled}, replace=True)
|
||||
return True
|
||||
|
||||
|
||||
def connection_requested(state):
|
||||
"""Desired transport state survives worker recovery and reboot until connect."""
|
||||
directory = _intent_directory(state)
|
||||
if directory is None:
|
||||
return True
|
||||
try:
|
||||
value = json.loads(_read(directory / "connection-intent.json"))
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
if type(value) is not dict or value.get("schema") != 1 or type(value.get("connect")) is not bool:
|
||||
raise RuntimeError("The saved connection intent cannot be verified.")
|
||||
return value["connect"]
|
||||
|
||||
|
||||
def connection_request_revision(state):
|
||||
directory = _intent_directory(state)
|
||||
if directory is None:
|
||||
return 0
|
||||
try:
|
||||
value = json.loads(_read(directory / "connection-intent.json"))
|
||||
except FileNotFoundError:
|
||||
return 0
|
||||
revision = value.get("revision", 0) if type(value) is dict else None
|
||||
if type(revision) is not int or revision < 0:
|
||||
raise RuntimeError("The saved connection intent cannot be verified.")
|
||||
return revision
|
||||
|
||||
|
||||
def _set_connection_requested(state, requested):
|
||||
if type(requested) is not bool:
|
||||
raise ValueError("Connection intent must be true or false.")
|
||||
directory = _intent_directory(state)
|
||||
if directory is None:
|
||||
return False
|
||||
_json(directory / "connection-intent.json", {"schema": 1, "connect": requested, "revision": connection_request_revision(state) + 1}, replace=True)
|
||||
return True
|
||||
|
||||
|
||||
def _connection_action(state, action):
|
||||
"""Serialize the worker's transport startup with an explicit pause request."""
|
||||
directory = _intent_directory(state)
|
||||
if directory is None:
|
||||
return action(state)
|
||||
fd = os.open(directory / "connection-action.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1 or info.st_mode & 0o077:
|
||||
raise RuntimeError("Unsafe connection action lock.")
|
||||
deadline = time.monotonic() + 20
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except BlockingIOError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError("Another connection operation is still finishing. Try again shortly.")
|
||||
time.sleep(0.05)
|
||||
return action(state)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _worker_status(directory, name):
|
||||
_json(directory / "worker-status.json", {"state": name, "updated_at": time.time()}, replace=True)
|
||||
|
||||
|
||||
def worker(state):
|
||||
global _STOP
|
||||
_STOP = False
|
||||
state, directory, unit = _paths(state)
|
||||
config = load_config(state)
|
||||
from . import runtime, connection_background, tunnel
|
||||
from .display_context import DisplayContextError
|
||||
def stop(signum, frame):
|
||||
global _STOP
|
||||
_STOP = True
|
||||
old = {s: signal.signal(s, stop) for s in (signal.SIGTERM, signal.SIGINT)}
|
||||
lockfd = os.open(directory / "worker.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
|
||||
info = os.fstat(lockfd)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1 or info.st_mode & 0o077:
|
||||
os.close(lockfd)
|
||||
raise RuntimeError("Unsafe autostart worker lock.")
|
||||
fcntl.flock(lockfd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
connected = False
|
||||
attempted_connection = False
|
||||
last_revision = None
|
||||
next_connection_attempt = 0.0
|
||||
try:
|
||||
_clear_manual_stop(state)
|
||||
while not _STOP:
|
||||
if manual_stop_requested(state):
|
||||
return 0
|
||||
try:
|
||||
current = runtime.start(state)
|
||||
if manual_stop_requested(state):
|
||||
return 0
|
||||
except DisplayContextError:
|
||||
if config.get("headless") in ("no", False):
|
||||
_worker_status(directory, "waiting_for_graphical_login")
|
||||
time.sleep(3)
|
||||
continue
|
||||
raise
|
||||
if current.get("running") and current.get("ready"):
|
||||
break
|
||||
_worker_status(directory, "waiting_for_graphical_login" if config.get("headless") in ("no", False) else "waiting_for_local_services")
|
||||
time.sleep(3)
|
||||
if _STOP:
|
||||
return 0
|
||||
_worker_status(directory, "local_services_ready")
|
||||
if config["role"] == "node":
|
||||
while not _STOP:
|
||||
if manual_stop_requested(state):
|
||||
return 0
|
||||
if not runtime.status(state).get("running"):
|
||||
return 0 if manual_stop_requested(state) else 1
|
||||
time.sleep(2)
|
||||
return 0
|
||||
while not _STOP:
|
||||
if manual_stop_requested(state):
|
||||
return 0
|
||||
def synchronize_connection(_state):
|
||||
nonlocal connected, attempted_connection, last_revision, next_connection_attempt
|
||||
revision = connection_request_revision(state)
|
||||
if revision != last_revision:
|
||||
attempted_connection = False
|
||||
next_connection_attempt = 0.0
|
||||
last_revision = revision
|
||||
current = connection_background.status(state)
|
||||
connected = connected or current.get("alive") is True
|
||||
if not connection_requested(state):
|
||||
if current.get("alive"):
|
||||
stopped = connection_background.disconnect(state)
|
||||
if stopped.get("ok") is False or stopped.get("alive"):
|
||||
raise RuntimeError("The paused connection is still stopping.")
|
||||
_worker_status(directory, "disconnected")
|
||||
return
|
||||
if not current.get("alive") and (not attempted_connection or current.get("state") not in ("stopped", "closed")):
|
||||
if time.monotonic() < next_connection_attempt:
|
||||
_worker_status(directory, "reconnecting")
|
||||
return
|
||||
attempted_connection = True
|
||||
next_connection_attempt = time.monotonic() + 15
|
||||
key = _credential(state)
|
||||
try:
|
||||
spec = tunnel.TunnelSpec(config["tunnel_id"], config.get("control_plane_organization_id", ""), config.get("control_plane_url_path", ""))
|
||||
binary = runtime.PREFIX / "original/geranas/FrameBridge-MCP-Private/bin/tunnel-client"
|
||||
mcp = "http://127.0.0.1:" + str(config["ports"]["mcp"]) + "/mcp"
|
||||
command = tunnel.build_command(binary, state, mcp, spec)
|
||||
_worker_status(directory, "connecting")
|
||||
connected = True
|
||||
connection_background.start(command, key, state, spec.tunnel_id, mcp, output=io.StringIO(), wait_timeout=1)
|
||||
finally:
|
||||
key = ""
|
||||
current = connection_background.status(state)
|
||||
if not current.get("alive"):
|
||||
_worker_status(directory, "disconnected" if current.get("state") in ("stopped", "closed") else "reconnecting")
|
||||
else:
|
||||
_worker_status(directory, "connected" if current.get("verified") else "reconnecting")
|
||||
_connection_action(state, synchronize_connection)
|
||||
if not runtime.status(state).get("running"):
|
||||
return 0 if manual_stop_requested(state) else 1
|
||||
time.sleep(2)
|
||||
return 0
|
||||
except Exception:
|
||||
_worker_status(directory, "failed")
|
||||
return 1
|
||||
finally:
|
||||
if connected:
|
||||
connection_background.disconnect(state)
|
||||
runtime.stop(state)
|
||||
_worker_status(directory, "stopped")
|
||||
os.close(lockfd)
|
||||
for sig, handler in old.items():
|
||||
signal.signal(sig, handler)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("action", choices=["_run"])
|
||||
parser.add_argument("--state", required=True)
|
||||
args = parser.parse_args()
|
||||
return worker(args.state)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user