Add Debian, Ubuntu and Mint candidate profiles with scoped systemd 255 credentials

This commit is contained in:
FrameBridge compatibility build committed 2026-10-27 10:49:56 -04:00
1 parent b23819fff1
commit a48a20c936
28 files changed
+2527 -274

No files matched your search

@@ -1 +1 @@
__version__ = "0.3.1"
__version__ = "0.3.2~compat.20261027.1"
+291 -146
View File
@@ -1,6 +1,7 @@
"""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
@@ -20,6 +21,112 @@ 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):
if backend == HOST_BACKEND:
_helper_ready()
args = ["sudo", "-n", "--", str(HELPER), "protect", _profile_id(state)]
elif backend == USER_BACKEND:
args = ["sudo", "-n", "--", "systemd-creds", "encrypt", "--uid=" + str(os.getuid()), "--with-key=host", "--name=" + CREDENTIAL, "-", "-"]
else:
raise RuntimeError("Unsupported protected-credential backend.")
return _command(args, data=key.encode()).stdout
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 "-"])
@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
@@ -148,6 +255,8 @@ def status(state):
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")
@@ -336,11 +445,13 @@ def _configure_connection(state, settings, runtime_key):
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)
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.")
backend = _credential_backend()
_command(["sudo", "-v"], timeout=180)
user = pwd.getpwuid(os.getuid()).pw_name
linger_before = _linger(user)
@@ -349,105 +460,116 @@ def _configure_connection(state, settings, runtime_key):
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 = _command(["sudo", "-n", "--", "systemd-creds", "encrypt", "--uid=" + str(os.getuid()), "--with-key=host", "--name=" + CREDENTIAL, "-", "-"], data=runtime_key.encode()).stdout
encrypted = _encrypt_credential(state, runtime_key, backend)
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
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 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)
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):
@@ -475,11 +597,8 @@ def _enable(state, *, start_now=True, confirm=True):
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"
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.")
@@ -506,64 +625,79 @@ def _enable(state, *, start_now=True, confirm=True):
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
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.")
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)
elif _helper_present():
_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)
_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:
return {"configured": False, "state": "already_disabled"}
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")
@@ -576,7 +710,14 @@ def _disable(state):
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()
@@ -609,7 +750,11 @@ def _credential(state):
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)
if _credential_backend(record) == HOST_BACKEND:
_helper_ready()
result = _command(["sudo", "-n", "--", str(HELPER), "decrypt", _profile_id(state), record["credential_sha256"]], timeout=15)
else:
result = _command(["systemd-creds", "decrypt", "--user", "--name=" + CREDENTIAL, "-", "-"], data=cipher, timeout=15)
try:
key = result.stdout.decode("ascii")
except UnicodeDecodeError:
+3 -3
View File
@@ -13,7 +13,7 @@ import sys
import shlex
from .config import default_config, load_config, save_config
from . import tunnel
from . import tunnel, __version__
_EMAIL = re.compile(r"[^\s@]+@[^\s@]+\.[^\s@]+\Z")
@@ -317,7 +317,7 @@ _OUTPUT = None
def _structured_result(value):
"""Versioned envelope used only when --json was explicitly requested."""
return {"schema_version": 1, "product": "FrameBridge", "version": "0.3.1",
return {"schema_version": 1, "product": "FrameBridge", "version": __version__,
"command": _OUTPUT.get("command") if _OUTPUT else None,
"ok": not isinstance(value, dict) or value.get("ok") is not False,
"result": value}
@@ -394,7 +394,7 @@ def _human_status(value, scope=None):
connection = connection if isinstance(connection, dict) else {}
boot = value.get("boot", {})
boot = boot if isinstance(boot, dict) else {}
print("FrameBridge 0.3.1")
print("FrameBridge " + __version__)
print("Role: " + str(runtime.get("role", "unavailable")))
print("Local tools: " + _runtime_label(runtime))
if runtime.get("role") == "node":
@@ -0,0 +1,145 @@
"""Explicit candidate Linux profiles and installed-artifact ABI checks.
Eligibility is not evidence of a successful installation on a target system.
Never import vendored native code merely to diagnose an incompatible artifact.
"""
from __future__ import annotations
import json
import os
import platform
import re
import sys
from pathlib import Path
PROFILES = {
"debian13-amd64-py313": ("debian", "13", "3.13", "cp313"),
"ubuntu24.04-amd64-py312": ("ubuntu", "24.04", "3.12", "cp312"),
"ubuntu26.04-amd64-py314": ("ubuntu", "26.04", "3.14", "cp314"),
"mint22-amd64-py312": ("linuxmint", "22", "3.12", "cp312"),
"lmde7-amd64-py313": ("linuxmint", "7", "3.13", "cp313"),
}
SYSTEM_PYTHONS = frozenset({"/usr/bin/python3", "/usr/bin/python3.12",
"/usr/bin/python3.13", "/usr/bin/python3.14"})
INSTALLED_PREFIX = Path("/usr/lib/framebridge-expanded")
VENDOR_RELATIVE = Path("original/geranas/FrameBridge-Fleet-Hub/vendor")
def profile_for(os_release):
"""Match named releases only; ID_LIKE never opts other derivatives in."""
distro = os_release.get("ID", "")
version = os_release.get("VERSION_ID", "")
for name, (expected, release, _minor, _abi) in PROFILES.items():
matches = version == release
if name == "mint22-amd64-py312":
matches = bool(re.fullmatch(r"22(?:\.\d+)?", version))
if distro == expected and matches:
return name
return None
def _libc():
try:
value = os.confstr("CS_GNU_LIBC_VERSION") or ""
if value.startswith("glibc "):
return "glibc", value.split()[-1]
except (OSError, ValueError):
pass
return platform.libc_ver()
def _vendor_issues(prefix, abi):
vendor = prefix / VENDOR_RELATIVE
issues = []
expected = abi.removeprefix("cp")
if not vendor.is_dir():
return ["Bundled vendor directory is missing; use a complete artifact for this profile."]
required = {
"_cffi_backend": list(vendor.glob("_cffi_backend.cpython-*.so")),
"pydantic_core": list((vendor / "pydantic_core").glob("_pydantic_core.cpython-*.so")),
"rpds": list((vendor / "rpds").glob("rpds.cpython-*.so")),
}
for module, files in required.items():
if len(files) != 1:
issues.append("Expected exactly one bundled native extension for " + module + ".")
for path in vendor.rglob("*.so"):
match = re.search(r"\.cpython-([^-]+)-", path.name)
if match and (match.group(1) != expected or "x86_64-linux-gnu" not in path.name):
issues.append("Bundled native extension does not match " + abi + " amd64: " + str(path.relative_to(vendor)))
return issues
def assess(prefix, *, os_release=None, machine=None, python_version=None, libc=None, implementation=None, abi_flags=None):
"""Check profile and artifact identity without importing native modules."""
prefix = Path(prefix)
issues = []
warnings = []
if os_release is None:
try:
os_release = platform.freedesktop_os_release()
except OSError:
os_release = {}
machine = platform.machine() if machine is None else machine
version = sys.version_info[:2] if python_version is None else python_version[:2]
minor = ".".join(str(x) for x in version)
abi = "cp" + "".join(str(x) for x in version)
implementation = platform.python_implementation() if implementation is None else implementation
abi_flags = getattr(sys, "abiflags", "") if abi_flags is None else abi_flags
if implementation != "CPython" or abi_flags:
issues.append("This artifact requires standard CPython; alternate, debug or free-threaded interpreter ABIs need separate native artifacts.")
selected = profile_for(os_release)
if selected is None:
issues.append("No candidate profile for this release. Choose Debian 13, Ubuntu 24.04/26.04, Linux Mint 22.x or LMDE 7.")
if machine not in {"x86_64", "amd64"}:
issues.append("This artifact supports amd64 only; ARM and other architectures need separate artifacts.")
if selected and minor != PROFILES[selected][2]:
issues.append("Profile " + selected + " requires Python " + PROFILES[selected][2] + "; running " + minor + ". Install its matching artifact without replacing the system Python.")
libc_name, libc_version = _libc() if libc is None else libc
match = re.match(r"(\d+)\.(\d+)", libc_version)
if libc_name != "glibc" or not match or tuple(map(int, match.groups())) < (2, 34):
issues.append("Bundled native libraries require glibc >= 2.34; this libc is not eligible.")
manifest = None
manifest_path = prefix / "release-profile.json"
if manifest_path.exists():
try:
if manifest_path.stat().st_size > 16384:
raise ValueError("manifest too large")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if not isinstance(manifest, dict) or type(manifest.get("schema")) is not int or manifest["schema"] != 1:
raise ValueError("unsupported schema")
if manifest.get("profile_id") not in PROFILES:
raise ValueError("unknown profile")
expected = PROFILES[manifest["profile_id"]]
if (manifest.get("architecture"), manifest.get("python_minor"), manifest.get("python_abi")) != ("amd64", expected[2], expected[3]):
raise ValueError("inconsistent profile ABI")
if manifest.get("validation_status") not in {"candidate", "validated"}:
raise ValueError("missing validation status")
if manifest["profile_id"] != selected or manifest["python_minor"] != minor or manifest["python_abi"] != abi:
issues.append("Installed artifact profile/ABI does not match this operating system and interpreter.")
except (OSError, UnicodeError, ValueError, TypeError):
issues.append("Invalid release-profile.json; install a complete matching artifact.")
manifest = None
else:
# Source-tree fallback is intentionally the reviewed release's ABI only.
if prefix.resolve() == INSTALLED_PREFIX or selected != "debian13-amd64-py313" or minor != "3.13":
issues.append("Missing release-profile.json; this target requires a profile-specific artifact.")
else:
warnings.append("Source checkout has no artifact manifest; only the legacy Debian 13/cp313 profile is eligible.")
issues.extend(_vendor_issues(prefix, abi))
validation = manifest.get("validation_status") if manifest else "source-unvalidated"
if validation != "validated":
warnings.append("Candidate compatibility checks do not establish a tested installation on this target.")
return {"ok": not issues, "profile_id": selected, "artifact_profile_id": manifest.get("profile_id") if manifest else None,
"architecture": machine, "python_minor": minor, "python_abi": abi, "python_implementation": implementation, "python_abi_flags": abi_flags,
"libc": {"name": libc_name, "version": libc_version}, "validation_status": validation,
"issues": issues, "warnings": warnings}
def display_support(env, context):
wayland = env.get("XDG_SESSION_TYPE", "").lower() == "wayland" or bool(env.get("WAYLAND_DISPLAY"))
notes = []
if wayland:
notes.append("Native Wayland desktop capture/control is not implemented. XWayland does not establish access to the full Wayland desktop; text tools remain available, or explicitly select a private virtual X11 display.")
return {"session_type": "wayland" if wayland else env.get("XDG_SESSION_TYPE", "unknown"),
"physical_x11_verified": context.get("kind") == "physical-x11",
"private_virtual_x11": context.get("kind") == "virtual-x11",
"native_wayland_cua": False, "notes": notes}
@@ -0,0 +1,421 @@
"""Narrow privileged host-credential backend for systemd 255.
Run only through the root-owned installed launcher. Administrative protect and
revoke operations use ordinary sudo authorization. Each protected credential
gets one literal, owner-specific sudoers decrypt command; there is no general
sudo permission for systemd-creds. No owner-controlled paths are opened here.
"""
from __future__ import annotations
import base64
from contextlib import contextmanager
import fcntl
import hashlib
import json
import os
from pathlib import Path
import pwd
import re
import resource
import secrets
import stat
import subprocess
import sys
ROOT = Path("/var/lib/framebridge-expanded/credentials")
SUDOERS = Path("/etc/sudoers.d")
LAUNCHER = Path("/usr/lib/framebridge-expanded/bin/framebridge-credential-helper")
CODE = Path("/usr/lib/framebridge-expanded/expanded/framebridge_expanded/credential_helper.py")
CREDS = "/usr/bin/systemd-creds"
VISUDO = "/usr/sbin/visudo"
HEX = re.compile(r"[0-9a-f]{64}\Z")
USER = re.compile(r"[A-Za-z_][A-Za-z0-9_-]{0,63}\$?\Z")
ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C", "LC_ALL": "C"}
MAX_KEY = 16384
MAX_CIPHER = 131072
def _fail():
raise RuntimeError("The protected credential operation could not be verified.")
def _root_path(path, *, directory=False, private=False):
"""Check every path component; root-owned parents cannot be swapped by users."""
path = Path(path)
if not path.is_absolute():
_fail()
for item in reversed((path, *path.parents)):
info = item.lstat()
last = item == path
if info.st_uid != 0 or info.st_mode & 0o022 or stat.S_ISLNK(info.st_mode):
_fail()
if not last or directory:
if not stat.S_ISDIR(info.st_mode):
_fail()
elif not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
_fail()
if last and private and info.st_mode & 0o077:
_fail()
return path
def _installation():
if os.geteuid() != 0 or not sys.flags.isolated or not sys.flags.no_site:
_fail()
if Path(__file__).absolute() != CODE:
_fail()
_root_path(CODE)
_root_path(LAUNCHER)
_root_path(Path(CREDS))
_root_path(Path(VISUDO))
def _caller():
raw = os.environ.get("SUDO_UID", "")
if not raw.isascii() or not raw.isdecimal() or str(int(raw)) != raw or int(raw) <= 0:
_fail()
owner = pwd.getpwuid(int(raw))
if not USER.fullmatch(owner.pw_name) or os.environ.get("SUDO_USER") != owner.pw_name:
_fail()
# sudo sets these identity variables, and generated rules explicitly deny
# SETENV. There is deliberately no caller-selected UID command argument.
if os.environ.get("SUDO_GID") != str(owner.pw_gid):
_fail()
return owner
def _run(args, data=None):
result = subprocess.run(args, input=data, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env=ENV, cwd="/",
close_fds=True, timeout=30)
if result.returncode:
_fail()
return result.stdout
def _sync(directory):
fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
os.fsync(fd)
finally:
os.close(fd)
def _prepare_root():
# Only this fixed root-owned tree is created. No profile or HOME is opened.
for directory in (ROOT.parent, ROOT):
_root_path(directory.parent, directory=True)
try:
directory.mkdir(mode=0o700)
_sync(directory.parent)
except FileExistsError:
pass
_root_path(directory, directory=True, private=directory == ROOT)
_root_path(SUDOERS, directory=True)
def _read(path, maximum=200000, *, policy=False):
_root_path(path, private=not policy)
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 != 0 or info.st_nlink != 1
or info.st_mode & (0o027 if policy else 0o077)):
_fail()
data = os.read(fd, maximum + 1)
if len(data) > maximum:
_fail()
return data
finally:
os.close(fd)
def _install(path, data, *, policy=False):
_root_path(path.parent, directory=True)
temporary = path.with_name("." + path.name + "." + secrets.token_hex(12))
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 policy:
temporary.chmod(0o440)
_run([VISUDO, "-c", "-f", str(temporary)])
# link is atomic and never overwrites a pre-existing/symlink target.
os.link(temporary, path, follow_symlinks=False)
_sync(path.parent)
finally:
temporary.unlink(missing_ok=True)
_sync(path.parent)
def _cleanup_temporaries():
# Recover only this installer's root-owned, fixed-format staging files.
patterns = ((ROOT, r"\.[1-9][0-9]*-[0-9a-f]{64}-[0-9a-f]{64}\.json\.[0-9a-f]{24}", False),
(SUDOERS, r"\.framebridge-credential-[1-9][0-9]*-[0-9a-f]{64}-[0-9a-f]{64}\.[0-9a-f]{24}", True))
for directory, pattern, policy in patterns:
removed = False
for path in directory.iterdir():
if re.fullmatch(pattern, path.name):
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 != 0
or info.st_mode & (0o027 if policy else 0o077)
or info.st_nlink not in {1, 2}
or info.st_size > (4096 if policy else 200000)):
_fail()
if info.st_nlink == 2:
# A crash after publish/link but before staging unlink.
published = directory / path.name[1:].rsplit(".", 1)[0]
other = published.lstat()
if (not stat.S_ISREG(other.st_mode) or other.st_uid != 0
or (other.st_dev, other.st_ino) != (info.st_dev, info.st_ino)):
_fail()
finally:
os.close(fd)
path.unlink(); removed = True
if removed:
_sync(directory)
def _ids(profile, digest=None):
if not isinstance(profile, str) or not HEX.fullmatch(profile):
_fail()
if digest is not None and (not isinstance(digest, str) or not HEX.fullmatch(digest)):
_fail()
def _paths(uid, profile, digest):
stem = str(uid) + "-" + profile + "-" + digest
return ROOT / (stem + ".json"), SUDOERS / ("framebridge-credential-" + stem)
def _policy(owner, profile, digest):
# All fields are allowlisted tokens; no glob, free path, flags or shell.
return (owner.pw_name + " ALL=(root) NOSETENV: NOPASSWD: " + str(LAUNCHER)
+ " decrypt " + profile + " " + digest + "\n").encode("ascii")
def _record(owner, profile, digest, *, require_policy=True):
record_path, policy_path = _paths(owner.pw_uid, profile, digest)
value = json.loads(_read(record_path))
if (type(value) is not dict or value.get("schema") != 1
or value.get("uid") != owner.pw_uid or value.get("username") != owner.pw_name
or value.get("home") != owner.pw_dir or value.get("profile") != profile
or value.get("sha256") != digest):
_fail()
name = value.get("name", "")
if not re.fullmatch(r"framebridge-" + str(owner.pw_uid) + "-" + profile + r"-[0-9a-f]{32}", name):
_fail()
try:
cipher = base64.b64decode(value["cipher"], validate=True)
except (KeyError, TypeError, ValueError):
_fail()
if not 80 <= len(cipher) <= MAX_CIPHER or hashlib.sha256(cipher).hexdigest() != digest:
_fail()
if require_policy and _read(policy_path, 4096, policy=True) != _policy(owner, profile, digest):
_fail()
return value, cipher, record_path, policy_path
def protect(owner, profile, key):
_ids(profile)
if (not key or len(key) > MAX_KEY or any(c < 33 or c > 126 for c in key)):
_fail()
name = "framebridge-" + str(owner.pw_uid) + "-" + profile + "-" + secrets.token_hex(16)
try:
cipher = _run([CREDS, "encrypt", "--with-key=host", "--name=" + name, "-", "-"], key)
finally:
key = b""
if not 80 <= len(cipher) <= MAX_CIPHER:
_fail()
digest = hashlib.sha256(cipher).hexdigest()
record_path, policy_path = _paths(owner.pw_uid, profile, digest)
value = {"schema": 1, "uid": owner.pw_uid, "username": owner.pw_name,
"home": owner.pw_dir, "profile": profile, "name": name,
"sha256": digest, "cipher": base64.b64encode(cipher).decode("ascii")}
data = (json.dumps(value, sort_keys=True) + "\n").encode("ascii")
_install(record_path, data)
try:
_install(policy_path, _policy(owner, profile, digest), policy=True)
except BaseException:
if policy_path.exists() or policy_path.is_symlink():
if _read(policy_path, 4096, policy=True) != _policy(owner, profile, digest):
_fail()
policy_path.unlink()
_sync(SUDOERS)
record_path.unlink()
_sync(ROOT)
raise
return cipher
def decrypt(owner, profile, digest):
_ids(profile, digest)
value, cipher, _, _ = _record(owner, profile, digest)
key = _run([CREDS, "decrypt", "--name=" + value["name"], "-", "-"], cipher)
if not key or len(key) > MAX_KEY or any(c < 33 or c > 126 for c in key):
_fail()
return key
def revoke(owner, profile, digest):
_ids(profile, digest)
record_path, policy_path = _paths(owner.pw_uid, profile, digest)
if record_path.exists() or record_path.is_symlink():
_record(owner, profile, digest, require_policy=False)
if policy_path.exists() or policy_path.is_symlink():
if _read(policy_path, 4096, policy=True) != _policy(owner, profile, digest):
_fail()
# Remove authorization first. A failure cannot leave a decryption grant to
# a missing/replaced record. The lock excludes concurrent decrypt/rotation.
policy_path.unlink(missing_ok=True)
_sync(SUDOERS)
record_path.unlink(missing_ok=True)
_sync(ROOT)
def _keep_digests(value):
values = [] if value == "-" else value.split(",")
if len(values) > 128 or any(not HEX.fullmatch(v) for v in values):
_fail()
return set(values)
def prune(owner, profile, keep):
"""Administrative crash recovery: remove only this owner's profile grants."""
_ids(profile)
digests = set()
prefix = str(owner.pw_uid) + "-" + profile + "-"
for path in ROOT.glob(prefix + "*"):
match = re.fullmatch(re.escape(prefix) + r"([0-9a-f]{64})\.json", path.name)
if not match:
_fail()
digest = match.group(1)
_record(owner, profile, digest, require_policy=False)
digests.add(digest)
for path in SUDOERS.glob("framebridge-credential-" + prefix + "*"):
digest = path.name.removeprefix("framebridge-credential-" + prefix)
_ids(profile, digest)
if _read(path, 4096, policy=True) != _policy(owner, profile, digest):
_fail()
digests.add(digest)
# Prevalidation above completes before any grant or encrypted record moves.
for digest in sorted(digests - keep):
revoke(owner, profile, digest)
def purge_all():
"""Administrative uninstall cleanup, not permitted by decrypt sudo rules."""
_cleanup_temporaries()
entries = []
for path in sorted(ROOT.iterdir()):
if path.name == ".lock":
continue
match = re.fullmatch(r"([1-9][0-9]*)-([0-9a-f]{64})-([0-9a-f]{64})\.json", path.name)
if not match:
_fail()
uid, profile, digest = match.groups()
# A removed/reused account must not prevent revocation. Validate the
# original principal from the root-owned record without trusting passwd.
value = json.loads(_read(path))
if type(value) is not dict or not USER.fullmatch(value.get("username", "")):
_fail()
owner = type("Owner", (), {"pw_uid": int(uid), "pw_name": value["username"],
"pw_dir": value.get("home")})()
_, _, record_path, policy_path = _record(owner, profile, digest, require_policy=False)
if policy_path.exists() or policy_path.is_symlink():
if _read(policy_path, 4096, policy=True) != _policy(owner, profile, digest):
_fail()
entries.append((record_path, policy_path))
policies = []
for policy_path in sorted(SUDOERS.glob("framebridge-credential-*")):
match = re.fullmatch(r"framebridge-credential-([1-9][0-9]*)-([0-9a-f]{64})-([0-9a-f]{64})", policy_path.name)
if not match:
_fail()
uid, profile, digest = match.groups()
data = _read(policy_path, 4096, policy=True)
username = data.split(b" ", 1)[0].decode("ascii")
if not USER.fullmatch(username):
_fail()
owner = type("Owner", (), {"pw_name": username})()
if data != _policy(owner, profile, digest):
_fail()
policies.append(policy_path)
# Prevalidate every record and policy before removing any of them.
for policy_path in policies:
policy_path.unlink()
_sync(SUDOERS)
for record_path, _ in entries:
record_path.unlink()
_sync(ROOT)
@contextmanager
def _registry_lock():
fd = os.open(ROOT / ".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 != 0 or info.st_nlink != 1 or info.st_mode & 0o077:
_fail()
fcntl.flock(fd, fcntl.LOCK_EX)
yield
finally:
os.close(fd)
def main(argv=None):
os.umask(0o077)
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
args = list(sys.argv[1:] if argv is None else argv)
try:
_installation()
if not args or args[0] not in {"protect", "decrypt", "revoke", "prune", "purge-all"}:
_fail()
operation = args[0]
if len(args) != {"protect": 2, "decrypt": 3, "revoke": 3, "prune": 3, "purge-all": 1}[operation]:
_fail()
owner = _caller() if operation != "purge-all" else None
if operation == "prune":
_ids(args[1]); keep = _keep_digests(args[2])
elif operation != "purge-all":
_ids(*args[1:])
_prepare_root()
result = b""
with _registry_lock():
_cleanup_temporaries()
if operation == "protect":
key = sys.stdin.buffer.read(MAX_KEY + 1)
result = protect(owner, args[1], key)
key = b""
elif operation == "decrypt":
result = decrypt(owner, args[1], args[2])
elif operation == "revoke":
revoke(owner, args[1], args[2])
elif operation == "prune":
prune(owner, args[1], keep)
else:
purge_all()
# stdout belongs to an unprivileged caller and can block indefinitely.
# Never hold the shared registry lock while writing or flushing it.
try:
if result:
sys.stdout.buffer.write(result)
sys.stdout.buffer.flush()
except BaseException:
if operation == "protect" and result:
# A broken receipt pipe must not orphan an authorization. The
# lock is reacquired only for this bounded administrative undo.
with _registry_lock():
revoke(owner, args[1], hashlib.sha256(result).hexdigest())
raise
return 0
except (Exception, KeyboardInterrupt):
# Never export subprocess output, arguments, record contents or traces.
sys.stderr.write("FrameBridge protected credential operation failed.\n")
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,5 +1,7 @@
"""Shared English guide content. Regenerate command docs after editing."""
from . import __version__
GUIDE = {'start': {'title': 'Start with FrameBridge',
'sections': [{'heading': 'One interface for people and agents',
'body': 'FrameBridge provides tools and a shared command-line interface '
@@ -14,20 +16,10 @@ GUIDE = {'start': {'title': 'Start with FrameBridge',
'installed program.',
'commands': ['fb help', 'fb status', 'fb --json status']},
{'heading': 'Install and connect',
'body': 'On a supported Debian system, install the downloaded package '
'with the package manager. Normal first connection does not '
'require a separate setup, doctor or start sequence. Run connect '
'in a normal interactive terminal. Advanced setup is optional for '
'selecting central/node roles and public settings.',
'commands': ['sudo apt install ./FrameBridge-0.3.1.deb', 'fb connect']},
'body': 'Install the .deb built for your exact Debian 13, Ubuntu 24.04/26.04, Linux Mint 22.x or LMDE 7 amd64 profile. These compatibility candidates still require installation and lifecycle validation on each target. Select the artifact from the matching profile folder and keep the distribution system Python. Normal first connection does not require a separate setup, doctor or start sequence. Run connect in a normal interactive terminal; advanced setup is optional for central/node roles and public settings.',
'commands': ["sudo apt install ./framebridge-expanded_" + __version__ + "_amd64.deb", 'fb connect']},
{'heading': 'Updating and removal',
'body': 'Install an official update through the normal package manager. '
'Normal package removal stops package-owned managed processes and '
'retains the profile and saved encrypted connection material for '
'deliberate reuse; it is not a credential-forgetting operation. '
'Use the separately documented explicit forget action first if '
'that is your intended outcome. Do not assume purge erases '
'user-profile data.',
'body': 'Install an official update through the normal package manager when that update has been published. Normal removal stops package-owned managed processes and can retain the user profile and encrypted connection material; it is not a credential-forgetting operation. On systemd 255, package removal also revokes the administrative boot-credential grant. After reinstalling, run fb connect interactively to provision that grant again; retained files alone do not restore startup. Use the explicit credentials forget action first when that is your intended outcome. Do not assume purge erases user-profile data.',
'commands': ['sudo dpkg --remove framebridge-original-expanded']},
{'heading': 'Human authentication',
'body': 'The normal connection asks for an optional account label, a '
@@ -310,17 +302,7 @@ GUIDE = {'start': {'title': 'Start with FrameBridge',
'partial invented view as a real screenshot.',
'commands': []},
{'heading': 'Automatic graphical discovery',
'body': 'The installer and control console stay text interfaces in both '
'TTY and a desktop terminal. Compatible graphical CUA is '
'discovered automatically; availability depends on the actual '
'display/session and permissions. A loaded module is not proof '
'that real pixel capture or input succeeded. New profiles use '
'adaptive auto mode: without a verified active X11 session the '
'runtime provides FBEXEC, FBTERM and CUA-TTY in text mode, '
'without requiring a graphical login or automatically creating '
'a private desktop. Explicit virtual-desktop settings remain '
'advanced options and are labelled separately from '
'physical-screen evidence.',
'body': 'The installer and control console stay text interfaces in both TTY and a desktop terminal. Compatible graphical CUA is discovered automatically; availability depends on the actual display/session and permissions. A loaded module is not proof that real pixel capture or input succeeded. New profiles use adaptive auto mode: without a verified active X11 session the runtime provides FBEXEC, FBTERM and CUA-TTY in text mode, without requiring a graphical login or automatically creating a private desktop. Explicit virtual-desktop settings remain advanced options and are labelled separately from physical-screen evidence. Physical-screen capture and input currently require the supported active X11 session. Native Wayland control is not validated; showing an XWayland window does not establish full-desktop capture or input.',
'commands': ['fb status', 'fb doctor']},
{'heading': 'Frame-buffer read access',
'body': 'screen-access is a narrow, explicit administrative permission '
@@ -351,11 +333,7 @@ GUIDE = {'start': {'title': 'Start with FrameBridge',
'fb status runtime',
'fb status boot']},
{'heading': 'Doctor',
'body': 'doctor diagnoses prerequisites and reports findings with '
'suggested next steps. It is not an implicit repair, and it '
'is not a substitute for checking actual live connection '
'state. Inspect the diagnosis and relevant private '
'application logs before considering configuration changes.',
'body': 'doctor diagnoses prerequisites and reports findings with suggested next steps. It is not an implicit repair, and it is not a substitute for checking actual live connection state. Inspect the diagnosis and relevant private application logs before considering configuration changes. Candidate eligibility checks the exact OS release, amd64 architecture, package profile and CPython ABI before starting native components. Debian 13/LMDE 7 use cp313, Ubuntu 24.04/Mint 22.x use cp312, and Ubuntu 26.04 uses cp314. A matching profile does not establish a tested installation; never repair an ABI mismatch by replacing system Python or weakening package dependencies.',
'commands': ['fb doctor']},
{'heading': 'Logs and progress',
'body': 'Normal connection presents concise progress and a final '
@@ -159,10 +159,10 @@ def doctor(state):
missing.extend(graphical_missing)
elif config.get("headless") in ("yes", True):
missing.extend(graphical_missing + virtual_missing)
os_release = platform.freedesktop_os_release()
compatible = (sys.version_info[:2] == (3, 13) and platform.machine() == "x86_64"
and os_release.get("ID") == "debian" and os_release.get("VERSION_ID") == "13")
issues = []
from .compatibility import assess, display_support
compatibility = assess(PREFIX)
compatible = compatibility["ok"]
issues = list(compatibility["issues"])
if config["role"] == "central" and config.get("broker_listen_host", "127.0.0.1") not in ("127.0.0.1", "::1", "localhost"):
if not config.get("tls_cert") or not config.get("tls_key"):
issues.append("A central listener outside loopback requires a TLS certificate and private-key reference.")
@@ -173,7 +173,10 @@ def doctor(state):
display_context = describe(env)
if config.get("headless") in ("no", False) and display_context.get("kind") != "physical-x11":
issues.append("No verified active physical X11 session. Log in graphically or explicitly choose a virtual headless display.")
return {"ok": compatible and not missing and not issues, "compatible_debian_13_amd64": compatible,
return {"ok": compatible and not missing and not issues,
"compatible_debian_13_amd64": compatible and compatibility["profile_id"] == "debian13-amd64-py313",
"platform_compatibility": compatibility,
"display_support": display_support(env, display_context),
"python": platform.python_version(), "missing_commands": missing,
"display": {"physical-x11": "existing physical X11", "virtual-x11": "explicit private virtual display", "text": "text tools"}.get(display_context.get("kind"), "unavailable"),
"display_context": display_context,
@@ -9,6 +9,9 @@ import stat
import subprocess
import sys
# This module also runs as an isolated standalone package-removal script.
SYSTEM_PYTHONS = frozenset({"/usr/bin/python3", "/usr/bin/python3.12",
"/usr/bin/python3.13", "/usr/bin/python3.14"})
MODULE_ROOT = "/usr/lib/framebridge-expanded/expanded"
UNIT_NAME = re.compile(r"framebridge-autostart-[0-9a-f]{16}\.service\Z")
@@ -35,7 +38,7 @@ def managed_unit(path, uid):
if len(starts) != 1:
return False
args = shlex.split(starts[0])
if len(args) != 7 or args[0] not in {"/usr/bin/python3", "/usr/bin/python3.13"}:
if len(args) != 7 or args[0] not in SYSTEM_PYTHONS:
return False
if args[1:6] != ["-B", "-m", "framebridge_expanded.autostart", "_run", "--state"]:
return False
@@ -105,6 +108,45 @@ def cleanup_user(home=None, *, run=None, bus_available=None):
return len(units)
CREDENTIAL_HELPER = Path("/usr/lib/framebridge-expanded/bin/framebridge-credential-helper")
CREDENTIAL_HELPER_MODULE = Path(MODULE_ROOT) / "framebridge_expanded/credential_helper.py"
def _trusted_root_file(path):
"""Trust only fixed package paths with root-owned, non-writable ancestors."""
for current in (path, *path.parents):
info = current.lstat()
if info.st_uid != 0 or info.st_mode & 0o022:
return False
if current == path:
if not stat.S_ISREG(info.st_mode):
return False
elif not stat.S_ISDIR(info.st_mode):
return False
return True
def purge_credential_helpers(*, run=None):
"""Revoke only the installed helper's managed metadata during root removal."""
if os.getuid() != 0:
raise RuntimeError("Credential-helper cleanup requires package-manager root context.")
# Old packages did not ship this helper. Never select code from user state.
if not os.path.lexists(CREDENTIAL_HELPER):
if os.path.lexists(CREDENTIAL_HELPER_MODULE):
raise RuntimeError("Installed credential helper is incomplete; package removal was paused.")
return
try:
if not _trusted_root_file(CREDENTIAL_HELPER) or not _trusted_root_file(CREDENTIAL_HELPER_MODULE):
raise RuntimeError("Installed credential helper has unsafe ownership or permissions.")
result = (run or subprocess.run)([str(CREDENTIAL_HELPER), "purge-all"],
env={"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8"},
capture_output=True, timeout=30)
if result.returncode:
raise RuntimeError("Credential-helper cleanup failed; package removal was paused.")
except (OSError, subprocess.TimeoutExpired) as exc:
raise RuntimeError("Credential-helper cleanup failed; package removal was paused.") from exc
def cleanup_all_users(*, run=None, accounts=None):
if os.getuid() != 0:
raise RuntimeError("Package service cleanup requires the normal package manager's root context.")
@@ -125,6 +167,7 @@ def cleanup_all_users(*, run=None, accounts=None):
failed = True
if failed:
raise RuntimeError("Some FrameBridge services could not be stopped; package removal was paused.")
purge_credential_helpers(run=run)
def main(argv=None):