Snapshot assembled only from the signed corresponding-source archive. Source archive SHA-256: db91a270cdefc4be79056ed38a0b4a16747e1ab130592645eac40fa3809809df
1129 lines
55 KiB
Python
1129 lines
55 KiB
Python
"""Explicit user-service startup with host-bound encrypted systemd credentials."""
|
|
from __future__ import annotations
|
|
import argparse
|
|
from contextlib import contextmanager
|
|
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"
|
|
USER_BACKEND = "systemd-user-v1"
|
|
HOST_BACKEND = "systemd-host-helper-v1"
|
|
HELPER = Path("/usr/lib/framebridge-expanded/bin/framebridge-credential-helper")
|
|
|
|
|
|
def _helper_present():
|
|
return HELPER.exists() or HELPER.is_symlink()
|
|
|
|
|
|
def _helper_ready():
|
|
for target in (HELPER, HELPER.parent.parent / "expanded/framebridge_expanded/credential_helper.py"):
|
|
for path in reversed((target, *target.parents)):
|
|
try:
|
|
info = path.lstat()
|
|
except FileNotFoundError:
|
|
raise RuntimeError("This system needs the installed FrameBridge protected-credential helper. Install the matching package before connecting.") from None
|
|
if info.st_uid != 0 or info.st_mode & 0o022 or stat.S_ISLNK(info.st_mode):
|
|
raise RuntimeError("The installed protected-credential helper is unsafe.")
|
|
if path == target:
|
|
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
|
raise RuntimeError("The installed protected-credential helper is unsafe.")
|
|
elif not stat.S_ISDIR(info.st_mode):
|
|
raise RuntimeError("The installed protected-credential helper is unsafe.")
|
|
|
|
|
|
def _credential_backend(record=None):
|
|
if record is not None:
|
|
backend = record.get("credential_backend", USER_BACKEND)
|
|
if backend not in {USER_BACKEND, HOST_BACKEND}:
|
|
raise RuntimeError("Unsupported protected-credential backend.")
|
|
return backend
|
|
version = _command(["systemd-creds", "--version"]).stdout.decode(errors="replace")
|
|
match = re.match(r"systemd (\d+)", version)
|
|
if not match or int(match.group(1)) < 255:
|
|
raise RuntimeError("Protected connections require systemd 255 or later.")
|
|
if int(match.group(1)) >= 256:
|
|
return USER_BACKEND
|
|
_helper_ready()
|
|
return HOST_BACKEND
|
|
|
|
|
|
def _profile_id(state):
|
|
return hashlib.sha256(str(state_path(state)).encode()).hexdigest()
|
|
|
|
|
|
def _encrypt_credential(state, key, backend):
|
|
_helper_ready()
|
|
if backend == HOST_BACKEND:
|
|
kind = "host"
|
|
command = [str(HELPER), "protect", _profile_id(state)]
|
|
elif backend == USER_BACKEND:
|
|
kind = "user"
|
|
command = ["/usr/bin/systemd-creds", "encrypt", "--uid=" + str(os.getuid()), "--with-key=host", "--name=" + CREDENTIAL, "-", "-"]
|
|
else:
|
|
raise RuntimeError("Unsupported protected-credential backend.")
|
|
# Bootstrap is administratively authorized and carries no runtime key.
|
|
# A command-specific effective-policy check precedes ALL secret input.
|
|
_command(["sudo", "-n", "--", str(HELPER), "authorize", _profile_id(state), kind])
|
|
try:
|
|
_require_no_sudo_io_logging(command)
|
|
return _command(["sudo", "-n", "--", *command], data=key.encode()).stdout
|
|
finally:
|
|
# Host protect consumes its grant before reading input. A missing
|
|
# cancel grant after that successful consume is harmless. Native user
|
|
# encryption cannot consume the grant itself, so cancellation is strict.
|
|
_command(["sudo", "-n", "--", str(HELPER), "unauthorize", _profile_id(state), kind], check=kind == "user")
|
|
|
|
|
|
def _require_no_sudo_io_logging(command):
|
|
environment = dict(os.environ, LANG="C", LC_ALL="C")
|
|
result = _command(["sudo", "-n", "-ll", "--", *command], env=environment)
|
|
error = "The effective sudo policy must explicitly disable input/output logging for this exact credential command. No runtime key was sent."
|
|
if result.returncode or len(result.stdout) > 131072:
|
|
raise RuntimeError(error)
|
|
try:
|
|
text = result.stdout.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
raise RuntimeError(error) from None
|
|
# sudo 1.9's command-specific verbose list reports the effective matching
|
|
# rule. Unknown/ambiguous formats (including other policy plugins) fail
|
|
# closed; absence of log_* in generic Defaults is never accepted as proof.
|
|
if text.count("Sudoers entry:") != 1 or len(re.findall(r"^\s*Matched:", text, re.M)) != 1:
|
|
raise RuntimeError(error)
|
|
options = re.search(r"^\s*Options:\s*(.*?)^\s*Commands:\s*$", text, re.M | re.S)
|
|
matched = re.search(r"^\s*Matched:\s*(.*?)\s*\Z", text, re.M | re.S)
|
|
run_as = re.findall(r"^\s*RunAsUsers:\s*(.*?)\s*$", text, re.M)
|
|
if not options or not matched or run_as != ["root"]:
|
|
raise RuntimeError(error)
|
|
flags = {value.strip() for value in options[1].replace("\n", " ").split(",")}
|
|
logs = {"log_input", "log_output", "log_stdin", "log_stdout", "log_stderr", "log_ttyin", "log_ttyout"}
|
|
if not {"!log_input", "!log_output", "!setenv"}.issubset(flags) or logs.intersection(flags):
|
|
raise RuntimeError(error)
|
|
if " ".join(matched[1].split()) != " ".join(command):
|
|
raise RuntimeError(error)
|
|
|
|
|
|
def _revoke_helper(state, digest):
|
|
if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest):
|
|
raise RuntimeError("Invalid protected-credential cleanup record.")
|
|
_helper_ready()
|
|
_command(["sudo", "-n", "--", str(HELPER), "revoke", _profile_id(state), digest])
|
|
|
|
|
|
def _prune_helpers(state, keep=()):
|
|
values = list(dict.fromkeys(keep))
|
|
if len(values) > 128 or any(type(v) is not str or not re.fullmatch(r"[0-9a-f]{64}", v) for v in values):
|
|
raise RuntimeError("Invalid protected-credential cleanup record.")
|
|
_helper_ready()
|
|
_command(["sudo", "-n", "--", str(HELPER), "prune", _profile_id(state), ",".join(values) or "-"])
|
|
|
|
|
|
def _node_helper_cleanup_required(state):
|
|
"""Only omit privileged cleanup when no managed grant can authorize it.
|
|
|
|
The encrypted registry is deliberately private to root. Its orphaned
|
|
ciphertext does not grant access without a matching sudo policy. Inspect
|
|
only policy names in the secured, root-owned policy directory; never read
|
|
rule contents or treat a failed/incomplete inspection as an empty registry.
|
|
"""
|
|
from .credential_helper import SUDOERS, _root_path
|
|
_helper_ready()
|
|
uid, profile = str(os.getuid()), _profile_id(state)
|
|
prefixes = ("framebridge-credential-" + uid + "-" + profile + "-",
|
|
"framebridge-credential-authorize-" + uid + "-" + profile + "-")
|
|
patterns = (re.escape(prefixes[0]) + r"[0-9a-f]{64}",
|
|
re.escape(prefixes[1]) + r"(?:host|user)")
|
|
try:
|
|
directory = _root_path(SUDOERS, directory=True)
|
|
names = [path.name for path in directory.iterdir()]
|
|
except (OSError, RuntimeError):
|
|
return True
|
|
cleanup_required = False
|
|
for name in names:
|
|
if any(re.fullmatch(pattern, name) for pattern in patterns):
|
|
cleanup_required = True
|
|
continue
|
|
# A malformed entry in this exact owner's/profile's managed namespace
|
|
# is ambiguous. The helper's normal cleanup does not recognize every
|
|
# malformed authorization name, so require explicit review instead.
|
|
if name.startswith(prefixes):
|
|
raise RuntimeError("An ambiguous managed credential policy needs administrator review before node startup can be configured.")
|
|
return cleanup_required
|
|
|
|
|
|
@contextmanager
|
|
def _credential_transaction(state, encrypted, backend):
|
|
transaction = {"committed": False}
|
|
try:
|
|
yield transaction
|
|
finally:
|
|
if not transaction["committed"] and backend == HOST_BACKEND:
|
|
try:
|
|
_revoke_helper(state, hashlib.sha256(encrypted).hexdigest())
|
|
except Exception:
|
|
raise RuntimeError("Connection setup did not complete and protected-credential cleanup could not be verified. Ask the administrator to inspect the credential registry before retrying.") from None
|
|
|
|
|
|
def _retired_helpers(record):
|
|
values = record.get("retired_helper_credentials", [])
|
|
if type(values) is not list or len(values) > 128 or any(type(v) is not str or not re.fullmatch(r"[0-9a-f]{64}", v) for v in values):
|
|
raise RuntimeError("Invalid protected-credential cleanup record.")
|
|
return list(dict.fromkeys(values))
|
|
|
|
|
|
def _cleanup_retired_helpers(state, directory, record):
|
|
pending = _retired_helpers(record)
|
|
if not pending:
|
|
return
|
|
for digest in list(pending):
|
|
try:
|
|
_revoke_helper(state, digest)
|
|
except Exception:
|
|
# The committed current credential remains valid; keep an explicit
|
|
# cleanup list so disable/uninstall can revoke all older grants.
|
|
break
|
|
pending.remove(digest)
|
|
record["retired_helper_credentials"] = pending
|
|
_json(directory / "installation.json", record, replace=True)
|
|
|
|
_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, env=None):
|
|
result = subprocess.run(args, input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, env=env)
|
|
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.")
|
|
try:
|
|
directory.mkdir(mode=0o700, exist_ok=True)
|
|
except PermissionError:
|
|
raise RuntimeError("This account cannot create its user service directory; existing ownership and permissions were left unchanged.") from None
|
|
info = directory.lstat()
|
|
# Service accounts can have administrator-owned .config ancestors.
|
|
# Those ancestors are safe when immutable to other accounts; the
|
|
# directory containing the unit must still belong to this owner.
|
|
allowed_owner = info.st_uid == os.getuid() or (directory != path.parent and info.st_uid == 0)
|
|
if not stat.S_ISDIR(info.st_mode) or not allowed_owner or info.st_mode & 0o022:
|
|
raise RuntimeError("User service directories must have a safe owner and not be writable by others; the final directory must belong to this user.")
|
|
|
|
|
|
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"):
|
|
result["credential_backend"] = _credential_backend(manifest)
|
|
result["credential_cleanup_pending"] = bool(_retired_helpers(manifest))
|
|
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.")
|
|
pending = _retired_helpers(previous or {})
|
|
if previous and _credential_backend(previous) == HOST_BACKEND:
|
|
pending = list(dict.fromkeys([*pending, previous["credential_sha256"]]))
|
|
if len(pending) > 128:
|
|
raise RuntimeError("Retired protected credentials must be cleaned before replacing this connection. Disable automatic startup and retry connection setup.")
|
|
_check_unit_directory(unit)
|
|
backend = _credential_backend()
|
|
_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"}
|
|
if backend == HOST_BACKEND or _helper_present():
|
|
keep = _retired_helpers(previous or {})
|
|
if previous and _credential_backend(previous) == HOST_BACKEND:
|
|
keep.append(previous["credential_sha256"])
|
|
_prune_helpers(state, keep)
|
|
try:
|
|
encrypted = _encrypt_credential(state, runtime_key, backend)
|
|
finally:
|
|
runtime_key = ""
|
|
with _credential_transaction(state, encrypted, backend) as transaction:
|
|
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()
|
|
retired = _retired_helpers(previous or {})
|
|
if previous and _credential_backend(previous) == HOST_BACKEND:
|
|
retired.append(previous["credential_sha256"])
|
|
record.update(unit_sha256=hashlib.sha256(text).hexdigest(), credential_sha256=hashlib.sha256(encrypted).hexdigest(), credential_backend=backend, retired_helper_credentials=list(dict.fromkeys(retired)), 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
|
|
transaction["committed"] = True
|
|
_cleanup_retired_helpers(state, directory, record)
|
|
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)
|
|
node = config["role"] == "node"
|
|
backend = None if node else _credential_backend()
|
|
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.")
|
|
user = pwd.getpwuid(os.getuid()).pw_name
|
|
linger_before = _linger(user)
|
|
helper_cleanup_required = (node and _helper_present() and
|
|
(not linger_before or _node_helper_cleanup_required(state)))
|
|
# An administrator may already have enabled this node owner's user
|
|
# services at boot. Installing its own unit then needs no new privilege.
|
|
# Central profiles still authenticate before protecting connection input.
|
|
if not node or not linger_before or helper_cleanup_required:
|
|
_command(["sudo", "-v"], timeout=180)
|
|
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:
|
|
if backend == HOST_BACKEND or _helper_present():
|
|
_prune_helpers(state)
|
|
encrypted = _encrypt_credential(state, runtime_key, backend)
|
|
finally:
|
|
runtime_key = ""
|
|
if len(encrypted) < 80 or len(encrypted) > 131072:
|
|
raise RuntimeError("Credential encryption returned an invalid result.")
|
|
elif helper_cleanup_required:
|
|
_prune_helpers(state)
|
|
with _credential_transaction(state, encrypted, backend) as transaction:
|
|
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 "", "credential_backend": backend or USER_BACKEND, "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)
|
|
if not node or not linger_before:
|
|
_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
|
|
transaction["committed"] = True
|
|
return status(state)
|
|
|
|
|
|
def _disable(state):
|
|
state, directory, unit = _paths(state)
|
|
record = _manifest(directory)
|
|
if not record:
|
|
if _helper_present():
|
|
# A killed first provisioning process can publish a root grant
|
|
# before writing any owner manifest. Explicit disable still
|
|
# reconciles that profile under ordinary administrative approval.
|
|
_helper_ready()
|
|
_command(["sudo", "-v"], timeout=180)
|
|
_prune_helpers(state)
|
|
return {"configured": False, "state": "already_disabled", "orphan_credential_cleanup_verified": True}
|
|
return {"configured": False, "state": "already_disabled", "orphan_credential_cleanup_verified": False}
|
|
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.")
|
|
retired = _retired_helpers(record)
|
|
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)
|
|
helper_credentials = _helper_present() or (not record.get("node") and (_credential_backend(record) == HOST_BACKEND or retired))
|
|
if helper_credentials:
|
|
_command(["sudo", "-v"], timeout=180)
|
|
_systemctl("disable", "--now", unit.name)
|
|
if helper_credentials:
|
|
# Include grants whose receipt was lost to SIGKILL/power loss during a
|
|
# previous provisioning operation, not only digests in the manifest.
|
|
_prune_helpers(state)
|
|
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))
|
|
if _credential_backend(record) == HOST_BACKEND:
|
|
_helper_ready()
|
|
command = [str(HELPER), "decrypt", _profile_id(state), record["credential_sha256"]]
|
|
_require_no_sudo_io_logging(command)
|
|
result = _command(["sudo", "-n", "--", *command], timeout=15)
|
|
else:
|
|
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):
|
|
path = directory / "worker-status.json"
|
|
try:
|
|
previous = json.loads(_read(path))
|
|
except (FileNotFoundError, ValueError):
|
|
previous = None
|
|
# This is a transition timestamp, not a liveness heartbeat. systemd and the
|
|
# owned process identity report liveness separately. Avoid a durable fsync
|
|
# every two seconds while the worker remains in the same state.
|
|
if isinstance(previous, dict) and previous.get("state") == name:
|
|
return
|
|
_json(path, {"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())
|