Add Debian, Ubuntu and Mint candidate profiles with scoped systemd 255 credentials
This commit is contained in:
1 parent
b23819fff1
commit
a48a20c936
28 files changed
+2527
-274
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
# FrameBridge — command guide
|
# FrameBridge — command guide
|
||||||
|
|
||||||
Version: 0.3.1 | Guide schema: 1
|
Version: 0.3.2~compat.20261027.1 | Guide schema: 1
|
||||||
|
|
||||||
This reference is generated from the shared CLI/MCP guide. Use the matching installed version.
|
This reference is generated from the shared CLI/MCP guide. Use the matching installed version.
|
||||||
|
|
||||||
@@ -33,16 +33,16 @@ fb --json status
|
|||||||
|
|
||||||
### Install and connect
|
### Install and connect
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
~~~sh
|
~~~sh
|
||||||
sudo apt install ./FrameBridge-0.3.1.deb
|
sudo apt install ./framebridge-expanded_0.3.2~compat.20261027.1_amd64.deb
|
||||||
fb connect
|
fb connect
|
||||||
~~~
|
~~~
|
||||||
|
|
||||||
### Updating and removal
|
### Updating and removal
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
~~~sh
|
~~~sh
|
||||||
sudo dpkg --remove framebridge-original-expanded
|
sudo dpkg --remove framebridge-original-expanded
|
||||||
@@ -303,7 +303,7 @@ A CLI tty transcript describes a terminal and explicitly does not claim authenti
|
|||||||
|
|
||||||
### Automatic graphical discovery
|
### Automatic graphical discovery
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
~~~sh
|
~~~sh
|
||||||
fb status
|
fb status
|
||||||
@@ -340,7 +340,7 @@ fb status boot
|
|||||||
|
|
||||||
### Doctor
|
### Doctor
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
~~~sh
|
~~~sh
|
||||||
fb doctor
|
fb doctor
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Debian, Ubuntu and Linux Mint compatibility candidate
|
||||||
|
|
||||||
|
This development candidate is based on signed 0.3.1 source commit
|
||||||
|
b23819fff1500cd43bb7c44dc9d866e3fb701672.
|
||||||
|
Candidate version: 0.3.2~compat.20261027.1.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Only Debian, Ubuntu and Linux Mint are in this candidate's support plan.
|
||||||
|
Initial architecture: amd64/x86_64.
|
||||||
|
|
||||||
|
| Profile | Native interpreter ABI |
|
||||||
|
| --- | --- |
|
||||||
|
| Debian 13 | cp313 |
|
||||||
|
| Ubuntu 24.04 | cp312 |
|
||||||
|
| Mint 22.x (Ubuntu Noble base) | cp312 |
|
||||||
|
| Ubuntu 26.04 | cp314 |
|
||||||
|
| LMDE 7 (Debian Trixie base) | cp313 |
|
||||||
|
|
||||||
|
A build profile is not installation evidence. Results must distinguish static
|
||||||
|
and unit checks, interpreter imports, disposable OS installation, and a pilot
|
||||||
|
on a real machine. Mint and LMDE are separate targets.
|
||||||
|
|
||||||
|
## Changes and boundaries
|
||||||
|
|
||||||
|
Platform preflight uses explicit profiles and an installed manifest tied to the
|
||||||
|
Python ABI and native modules. Build checks reject wrong native ABI, CPU,
|
||||||
|
dependency versions and unsupported libc requirements.
|
||||||
|
|
||||||
|
Python 3.12/3.14 vendors preserve the pinned dependency versions. Only three
|
||||||
|
CPython-specific native distributions are replaced with matching official wheels.
|
||||||
|
Record their URLs and SHA-256 digests. The abi3 cryptography build is retained
|
||||||
|
when its requirements are satisfied.
|
||||||
|
|
||||||
|
The system Python stays intact. Standalone interpreters in the isolated build
|
||||||
|
workspace are build/test tools, not payload runtimes.
|
||||||
|
|
||||||
|
Protected user-service credentials retain the systemd >=256 route. Systemd 255
|
||||||
|
support requires a separately installed, narrowly scoped helper and explicit
|
||||||
|
owner provisioning. Missing backend must fail closed. Read its documentation
|
||||||
|
and tests before installing or provisioning it.
|
||||||
|
|
||||||
|
Physical desktop capture/control remains X11 based. A functioning CLI, virtual
|
||||||
|
display or Xwayland window does not establish physical Wayland desktop support.
|
||||||
|
ARM, Alpine and other distribution families are outside this candidate scope.
|
||||||
|
|
||||||
|
## Build and evidence
|
||||||
|
|
||||||
|
From the source root, use an explicit target and ABI-matched vendor:
|
||||||
|
|
||||||
|
python3 source/packaging/build_expanded.py --target mint22-amd64-py312 --vendor-dir /absolute/path/to/vendor-cp312 --version '0.3.2~compat.20261027.1'
|
||||||
|
|
||||||
|
Use the Debian/Ubuntu/LMDE target IDs documented by the builder. Output files and
|
||||||
|
manifests are separated by target. Candidate files need their own checksums and
|
||||||
|
must not reuse the signed 0.3.1 download path or signatures.
|
||||||
|
|
||||||
|
Native wheel provenance and matching source must accompany the candidate.
|
||||||
|
Existing license/source notices refer to the same upstream versions; preserve
|
||||||
|
the new wheel identities with the new build evidence.
|
||||||
|
|
||||||
|
## Pilot on existing machines
|
||||||
|
|
||||||
|
Order: disposable Debian and Ubuntu 24.04 VMs, GERAMEG (Debian 13), then ASUS
|
||||||
|
(Mint 22.3). Exclude GERANAS from this initial pilot: it is the central router
|
||||||
|
and its 160 GB RAID contains a degraded member.
|
||||||
|
|
||||||
|
The original services observed today run outside this published package.
|
||||||
|
Publishing the 0.3.1 website did not install its .deb on these hosts. GERAMEG
|
||||||
|
also has older Alpha packages. Check package ownership, file paths, maintainer
|
||||||
|
scripts, dependency transactions, ports and state before installation.
|
||||||
|
|
||||||
|
A production pilot needs separate owner authorization after concrete results:
|
||||||
|
|
||||||
|
- Review exact candidate checksum and dependency simulation.
|
||||||
|
- Confirm a recoverable backup for every existing path it will change.
|
||||||
|
- Retain an independent management route and the original service.
|
||||||
|
- Install before provisioning a new connection or replacing an existing role.
|
||||||
|
- Explain and authorize permission, udev, sudoers or service changes.
|
||||||
|
- Prove rollback/removal in a disposable machine, including credential cleanup
|
||||||
|
and preservation of unrelated owner files.
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"product": "FrameBridge",
|
"product": "FrameBridge",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2~compat.20261027.1",
|
||||||
"guide_schema": 1,
|
"guide_schema": 1,
|
||||||
"topics": {
|
"topics": {
|
||||||
"start": {
|
"start": {
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"product": "FrameBridge",
|
"product": "FrameBridge",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2~compat.20261027.1",
|
||||||
"guide_schema": 1,
|
"guide_schema": 1,
|
||||||
"topic": "start",
|
"topic": "start",
|
||||||
"title": "Start with FrameBridge",
|
"title": "Start with FrameBridge",
|
||||||
@@ -22,15 +22,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"heading": "Install and connect",
|
"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.",
|
"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": [
|
"commands": [
|
||||||
"sudo apt install ./FrameBridge-0.3.1.deb",
|
"sudo apt install ./framebridge-expanded_0.3.2~compat.20261027.1_amd64.deb",
|
||||||
"fb connect"
|
"fb connect"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"heading": "Updating and removal",
|
"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": [
|
"commands": [
|
||||||
"sudo dpkg --remove framebridge-original-expanded"
|
"sudo dpkg --remove framebridge-original-expanded"
|
||||||
]
|
]
|
||||||
@@ -64,12 +64,12 @@
|
|||||||
],
|
],
|
||||||
"read_only": true,
|
"read_only": true,
|
||||||
"capabilities_verified": false,
|
"capabilities_verified": false,
|
||||||
"guide_sha256": "ac55eed900b5d1687b784299c5dfa238c7502c8472b1135dfa2d7231352f9daa"
|
"guide_sha256": "ce03094ef55168ab22d393ce1f9193aa0a26bc3524eaaecfc8f48bed1c788cf1"
|
||||||
},
|
},
|
||||||
"commands": {
|
"commands": {
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"product": "FrameBridge",
|
"product": "FrameBridge",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2~compat.20261027.1",
|
||||||
"guide_schema": 1,
|
"guide_schema": 1,
|
||||||
"topic": "commands",
|
"topic": "commands",
|
||||||
"title": "Command reference",
|
"title": "Command reference",
|
||||||
@@ -136,12 +136,12 @@
|
|||||||
],
|
],
|
||||||
"read_only": true,
|
"read_only": true,
|
||||||
"capabilities_verified": false,
|
"capabilities_verified": false,
|
||||||
"guide_sha256": "9bc22a59b75eba4a6a0734320f76b5216001bbe527b2575f9f5dcee2a6abfc99"
|
"guide_sha256": "a221dd430ceac8d1a7ddc859e8d38d9d07b07596000ff6a4e70826b0e7e4e127"
|
||||||
},
|
},
|
||||||
"connection": {
|
"connection": {
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"product": "FrameBridge",
|
"product": "FrameBridge",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2~compat.20261027.1",
|
||||||
"guide_schema": 1,
|
"guide_schema": 1,
|
||||||
"topic": "connection",
|
"topic": "connection",
|
||||||
"title": "Connect and disconnect ChatGPT",
|
"title": "Connect and disconnect ChatGPT",
|
||||||
@@ -190,12 +190,12 @@
|
|||||||
],
|
],
|
||||||
"read_only": true,
|
"read_only": true,
|
||||||
"capabilities_verified": false,
|
"capabilities_verified": false,
|
||||||
"guide_sha256": "601d608bf403f0cf3cd531dc42013c1d53cddaf622dd73b8ec2a13aeb9c98fb8"
|
"guide_sha256": "91b8d6d7e531ba84512de58d561b71c92212160ddabc78349c459299cd96751a"
|
||||||
},
|
},
|
||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"product": "FrameBridge",
|
"product": "FrameBridge",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2~compat.20261027.1",
|
||||||
"guide_schema": 1,
|
"guide_schema": 1,
|
||||||
"topic": "lifecycle",
|
"topic": "lifecycle",
|
||||||
"title": "Services, connection intent and boot policy",
|
"title": "Services, connection intent and boot policy",
|
||||||
@@ -256,12 +256,12 @@
|
|||||||
],
|
],
|
||||||
"read_only": true,
|
"read_only": true,
|
||||||
"capabilities_verified": false,
|
"capabilities_verified": false,
|
||||||
"guide_sha256": "e6aca4ba3fc004cc2475ea141a1f645070a5bde306387e89ec904daec2b2f317"
|
"guide_sha256": "df3515c6c874353edcce43e4869021409ad0b7f28f194dd93296911228746cdd"
|
||||||
},
|
},
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"product": "FrameBridge",
|
"product": "FrameBridge",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2~compat.20261027.1",
|
||||||
"guide_schema": 1,
|
"guide_schema": 1,
|
||||||
"topic": "nodes",
|
"topic": "nodes",
|
||||||
"title": "Central and node operation",
|
"title": "Central and node operation",
|
||||||
@@ -310,12 +310,12 @@
|
|||||||
],
|
],
|
||||||
"read_only": true,
|
"read_only": true,
|
||||||
"capabilities_verified": false,
|
"capabilities_verified": false,
|
||||||
"guide_sha256": "440ed8b07ab2d7c89d4383d91342801a36f46dba72692482d419f457bb412186"
|
"guide_sha256": "3f98ed2af8adb3b0cd50967fe98fac90d26c3c2034fc7945593d846b4b6c3910"
|
||||||
},
|
},
|
||||||
"terminals": {
|
"terminals": {
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"product": "FrameBridge",
|
"product": "FrameBridge",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2~compat.20261027.1",
|
||||||
"guide_schema": 1,
|
"guide_schema": 1,
|
||||||
"topic": "terminals",
|
"topic": "terminals",
|
||||||
"title": "Persistent Bash terminals",
|
"title": "Persistent Bash terminals",
|
||||||
@@ -376,12 +376,12 @@
|
|||||||
],
|
],
|
||||||
"read_only": true,
|
"read_only": true,
|
||||||
"capabilities_verified": false,
|
"capabilities_verified": false,
|
||||||
"guide_sha256": "d58f36e8b96bf4687d99fe009e0061d0e76435ddb30a1601826c9eb941b64e9d"
|
"guide_sha256": "b2fdf20610cf7fdfeecaa822ae5ac4297e55c9a0ba7d5e86c06ba50aca9588a2"
|
||||||
},
|
},
|
||||||
"screens": {
|
"screens": {
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"product": "FrameBridge",
|
"product": "FrameBridge",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2~compat.20261027.1",
|
||||||
"guide_schema": 1,
|
"guide_schema": 1,
|
||||||
"topic": "screens",
|
"topic": "screens",
|
||||||
"title": "Authentic screen observation and input",
|
"title": "Authentic screen observation and input",
|
||||||
@@ -393,7 +393,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"heading": "Automatic graphical discovery",
|
"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": [
|
"commands": [
|
||||||
"fb status",
|
"fb status",
|
||||||
"fb doctor"
|
"fb doctor"
|
||||||
@@ -426,12 +426,12 @@
|
|||||||
],
|
],
|
||||||
"read_only": true,
|
"read_only": true,
|
||||||
"capabilities_verified": false,
|
"capabilities_verified": false,
|
||||||
"guide_sha256": "b186eb36570b870490eedf424b30eabb4c130ee2e2c72d658fe09b77549bbc59"
|
"guide_sha256": "1ce747282da1d9d1504790607888af131666758dd28b4a47799ed4c59427436b"
|
||||||
},
|
},
|
||||||
"diagnostics": {
|
"diagnostics": {
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"product": "FrameBridge",
|
"product": "FrameBridge",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2~compat.20261027.1",
|
||||||
"guide_schema": 1,
|
"guide_schema": 1,
|
||||||
"topic": "diagnostics",
|
"topic": "diagnostics",
|
||||||
"title": "Status and diagnosis",
|
"title": "Status and diagnosis",
|
||||||
@@ -448,7 +448,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"heading": "Doctor",
|
"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": [
|
"commands": [
|
||||||
"fb doctor"
|
"fb doctor"
|
||||||
]
|
]
|
||||||
@@ -476,12 +476,12 @@
|
|||||||
],
|
],
|
||||||
"read_only": true,
|
"read_only": true,
|
||||||
"capabilities_verified": false,
|
"capabilities_verified": false,
|
||||||
"guide_sha256": "0e55b9cda6b528926ca2a297d8464908a300122edbc1550b841ab8c1b7673029"
|
"guide_sha256": "1e85490e5b9925afb588bf536b155cf3cbfaef3997d21be94856fd1997372497"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"product": "FrameBridge",
|
"product": "FrameBridge",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2~compat.20261027.1",
|
||||||
"guide_schema": 1,
|
"guide_schema": 1,
|
||||||
"topic": "security",
|
"topic": "security",
|
||||||
"title": "Authorization, distribution and safe operation",
|
"title": "Authorization, distribution and safe operation",
|
||||||
@@ -516,12 +516,12 @@
|
|||||||
],
|
],
|
||||||
"read_only": true,
|
"read_only": true,
|
||||||
"capabilities_verified": false,
|
"capabilities_verified": false,
|
||||||
"guide_sha256": "2be17b3d7cb261f6cb5fbb94bfd027ca34147b8a4ead902f6a53b477fb4c201c"
|
"guide_sha256": "2982ca7b623a972097b5e04c309b682fefd91754d0c4c50fc802420c14c9c68d"
|
||||||
},
|
},
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"product": "FrameBridge",
|
"product": "FrameBridge",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2~compat.20261027.1",
|
||||||
"guide_schema": 1,
|
"guide_schema": 1,
|
||||||
"topic": "compatibility",
|
"topic": "compatibility",
|
||||||
"title": "Compatibility with earlier commands",
|
"title": "Compatibility with earlier commands",
|
||||||
@@ -564,8 +564,8 @@
|
|||||||
],
|
],
|
||||||
"read_only": true,
|
"read_only": true,
|
||||||
"capabilities_verified": false,
|
"capabilities_verified": false,
|
||||||
"guide_sha256": "a6aa6d12e37563866404fa5834bdd7e758f3782a93b59d454316a8c9359b7258"
|
"guide_sha256": "54b70093617e6992de04d41ddd367ffdd9a6bd6c317c99a886d6391277c55f6b"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"guide_sha256": "504c010697017b50b26de89f793944072ca214ae92d772e8e282ad44a6645634"
|
"guide_sha256": "b6060e0572a612d8d7d8a988bc9b0bca7aa8db8ef267004e6a1495e9ab5f34a4"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# FrameBridge — command guide
|
# FrameBridge — command guide
|
||||||
|
|
||||||
Version: 0.3.1 | Guide schema: 1
|
Version: 0.3.2~compat.20261027.1 | Guide schema: 1
|
||||||
|
|
||||||
This reference is generated from the shared CLI/MCP guide. Use the matching installed version.
|
This reference is generated from the shared CLI/MCP guide. Use the matching installed version.
|
||||||
|
|
||||||
@@ -33,16 +33,16 @@ fb --json status
|
|||||||
|
|
||||||
### Install and connect
|
### Install and connect
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
~~~sh
|
~~~sh
|
||||||
sudo apt install ./FrameBridge-0.3.1.deb
|
sudo apt install ./framebridge-expanded_0.3.2~compat.20261027.1_amd64.deb
|
||||||
fb connect
|
fb connect
|
||||||
~~~
|
~~~
|
||||||
|
|
||||||
### Updating and removal
|
### Updating and removal
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
~~~sh
|
~~~sh
|
||||||
sudo dpkg --remove framebridge-original-expanded
|
sudo dpkg --remove framebridge-original-expanded
|
||||||
@@ -303,7 +303,7 @@ A CLI tty transcript describes a terminal and explicitly does not claim authenti
|
|||||||
|
|
||||||
### Automatic graphical discovery
|
### Automatic graphical discovery
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
~~~sh
|
~~~sh
|
||||||
fb status
|
fb status
|
||||||
@@ -340,7 +340,7 @@ fb status boot
|
|||||||
|
|
||||||
### Doctor
|
### Doctor
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
~~~sh
|
~~~sh
|
||||||
fb doctor
|
fb doctor
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Protected connection credentials
|
||||||
|
|
||||||
|
The existing systemd 256+ backend uses owner-scoped encrypted credentials and
|
||||||
|
`systemd-creds decrypt --user`. Existing installation records without a backend
|
||||||
|
field retain that behavior. The systemd 255 backend preserves the same user
|
||||||
|
service and uses a separately installed, privileged credential helper. Node
|
||||||
|
startup without a ChatGPT connection does not need a runtime connection key.
|
||||||
|
|
||||||
|
## Installation and authorization
|
||||||
|
|
||||||
|
The package supplies these root-owned files:
|
||||||
|
|
||||||
|
* `/usr/lib/framebridge-expanded/bin/framebridge-credential-helper`
|
||||||
|
* `/usr/lib/framebridge-expanded/expanded/framebridge_expanded/credential_helper.py`
|
||||||
|
|
||||||
|
The launcher executes the fixed system interpreter with `-I -S`; the helper uses
|
||||||
|
only the standard library and absolute systemd-creds/visudo paths. Merely
|
||||||
|
installing the package does not enable a connection or install a sudoers grant.
|
||||||
|
|
||||||
|
On systemd 255, connection setup requires ordinary administrative sudo
|
||||||
|
authorization. It writes host-encrypted ciphertext and principal metadata under
|
||||||
|
`/var/lib/framebridge-expanded/credentials`, owned by root with private access.
|
||||||
|
It also installs one validated file in `/etc/sudoers.d` per credential version.
|
||||||
|
That file permits exactly the recorded owner's literal helper `decrypt` command
|
||||||
|
and fixed profile/cipher digest arguments, with `NOSETENV`. It does not permit
|
||||||
|
arbitrary systemd-creds commands, paths, shell execution, account selection,
|
||||||
|
provisioning, revocation or unrestricted root execution without authentication.
|
||||||
|
This narrow permission is necessary for unattended reconnection after reboot.
|
||||||
|
|
||||||
|
The owner must review this permission change before production setup is
|
||||||
|
activated. Building the package and running synthetic unit tests do not activate
|
||||||
|
the helper, create a host encryption key, alter sudoers or enable a service.
|
||||||
|
|
||||||
|
## Scope and recovery
|
||||||
|
|
||||||
|
The helper derives the caller from sudo's UID, username and primary group; it
|
||||||
|
does not accept a caller-selected UID. Its records bind the UID, username, home
|
||||||
|
identity, profile digest, ciphertext digest and randomized cryptographic name.
|
||||||
|
Only fixed root-owned paths are opened. Symlinks, unexpected hardlinks, writable
|
||||||
|
parents, broad metadata permissions and unrecognized arguments are rejected.
|
||||||
|
|
||||||
|
Provisioning keeps the old credential usable until the replacement connection
|
||||||
|
commits. Retired versions remain listed until revocation succeeds. Directory
|
||||||
|
fsync orders durable ciphertext registration before the decrypt grant, and
|
||||||
|
grant revocation before ciphertext removal. Failed output delivery rolls back a
|
||||||
|
new grant. After SIGKILL or power loss, administrative `prune` reconciles orphan
|
||||||
|
versions even if the owner never received their ciphertext digest. New setup
|
||||||
|
preserves known current versions while pruning orphans; explicit disable prunes
|
||||||
|
all versions for its owner/profile, including when no manifest was committed.
|
||||||
|
This reconciliation also runs after an upgrade to the systemd 256+ backend.
|
||||||
|
Uninstall uses administrative `purge-all` after the user workers have stopped.
|
||||||
|
Recognized root-owned staging files from interrupted publication are recovered.
|
||||||
|
|
||||||
|
Administrative interface (run the installed launcher, never a user checkout):
|
||||||
|
|
||||||
|
* `protect PROFILE_SHA256`: read a bounded printable synthetic/runtime key from
|
||||||
|
stdin and return only encrypted ciphertext on stdout.
|
||||||
|
* `decrypt PROFILE_SHA256 CIPHER_SHA256`: return the registered owner's key on a
|
||||||
|
pipe; this is the only operation granted passwordless sudo.
|
||||||
|
* `revoke PROFILE_SHA256 CIPHER_SHA256`: idempotently revoke one owner version.
|
||||||
|
* `prune PROFILE_SHA256 KEEP_DIGESTS`: remove other versions for this owner and
|
||||||
|
profile; `KEEP_DIGESTS` is a comma-separated digest allowlist, or `-` for none.
|
||||||
|
* `purge-all`: root administrative removal of all helper-owned records/grants.
|
||||||
|
|
||||||
|
## Security limits
|
||||||
|
|
||||||
|
No plaintext key is intentionally stored in an environment variable, command
|
||||||
|
argument, log or disk file. Plaintext exists in process and pipe memory while
|
||||||
|
used; this is not a guarantee against memory inspection, swap, a compromised
|
||||||
|
owner session or root. Both credential backends trust the owner Unix principal.
|
||||||
|
|
||||||
|
UID reuse with another username or home is rejected. Recreating exactly the
|
||||||
|
same UID, username and home can recreate the same Unix principal; Linux does
|
||||||
|
not supply an account-generation identity here. Administrators must revoke or
|
||||||
|
purge the grants before deleting/recycling an account, and before changing its
|
||||||
|
identity/home in a way that would prevent ordinary owner cleanup. A randomized
|
||||||
|
credential name is not proof of an account's generation.
|
||||||
|
|
||||||
|
The helper backend is not a Wayland capture/input backend and does not remove
|
||||||
|
Python ABI, architecture, libc, PAM, user-systemd, cgroup, sudo or X11 capability
|
||||||
|
requirements. Synthetic tests validate interfaces and failure handling; a
|
||||||
|
support claim additionally requires clean-system installation, real systemd
|
||||||
|
credential operations, boot/lifecycle and feature-specific acceptance tests.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# FrameBridge — Debian installation and first use
|
# FrameBridge — Debian, Ubuntu and Linux Mint installation and first use
|
||||||
|
|
||||||
Version: 0.3.1 | Guide schema: 1
|
Version: 0.3.2~compat.20261027.1 | Guide schema: 1
|
||||||
|
|
||||||
This reference is generated from the shared CLI/MCP guide. Use the matching installed version.
|
This reference is generated from the shared CLI/MCP guide. Use the matching installed version.
|
||||||
|
|
||||||
@@ -33,16 +33,16 @@ fb --json status
|
|||||||
|
|
||||||
### Install and connect
|
### Install and connect
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
~~~sh
|
~~~sh
|
||||||
sudo apt install ./FrameBridge-0.3.1.deb
|
sudo apt install ./framebridge-expanded_0.3.2~compat.20261027.1_amd64.deb
|
||||||
fb connect
|
fb connect
|
||||||
~~~
|
~~~
|
||||||
|
|
||||||
### Updating and removal
|
### Updating and removal
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
~~~sh
|
~~~sh
|
||||||
sudo dpkg --remove framebridge-original-expanded
|
sudo dpkg --remove framebridge-original-expanded
|
||||||
@@ -303,7 +303,7 @@ A CLI tty transcript describes a terminal and explicitly does not claim authenti
|
|||||||
|
|
||||||
### Automatic graphical discovery
|
### Automatic graphical discovery
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
~~~sh
|
~~~sh
|
||||||
fb status
|
fb status
|
||||||
@@ -340,7 +340,7 @@ fb status boot
|
|||||||
|
|
||||||
### Doctor
|
### Doctor
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
~~~sh
|
~~~sh
|
||||||
fb doctor
|
fb doctor
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.TH FRAMEBRIDGE 1 "2026-10-26" "FrameBridge 0.3.1" "User Commands"
|
.TH FRAMEBRIDGE 1 "2026-10-27" "FrameBridge 0.3.2~compat.20261027.1" "User Commands"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
framebridge, fb \- use local Linux tools and enrolled nodes with ChatGPT
|
framebridge, fb \- use local Linux tools and enrolled nodes with ChatGPT
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -18,14 +18,14 @@ fb status
|
|||||||
fb \-\-json status
|
fb \-\-json status
|
||||||
.fi
|
.fi
|
||||||
.SS Install and connect
|
.SS Install and connect
|
||||||
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.
|
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.
|
||||||
.PP
|
.PP
|
||||||
.nf
|
.nf
|
||||||
sudo apt install ./FrameBridge\-0.3.1.deb
|
sudo apt install ./framebridge\-expanded_0.3.2~compat.20261027.1_amd64.deb
|
||||||
fb connect
|
fb connect
|
||||||
.fi
|
.fi
|
||||||
.SS Updating and removal
|
.SS Updating and removal
|
||||||
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.
|
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.
|
||||||
.PP
|
.PP
|
||||||
.nf
|
.nf
|
||||||
sudo dpkg \-\-remove framebridge\-original\-expanded
|
sudo dpkg \-\-remove framebridge\-original\-expanded
|
||||||
@@ -210,7 +210,7 @@ fb status console
|
|||||||
.SS Text transcripts and pixels
|
.SS Text transcripts and pixels
|
||||||
A CLI tty transcript describes a terminal and explicitly does not claim authentic pixels. MCP real\-screen CUA\-TTY and graphical screen tools operate through the real screen client and capture\-linked interaction. Use those supported observation tools when authentic complete screen evidence is required. Never present a reconstructed terminal, animation or partial invented view as a real screenshot.
|
A CLI tty transcript describes a terminal and explicitly does not claim authentic pixels. MCP real\-screen CUA\-TTY and graphical screen tools operate through the real screen client and capture\-linked interaction. Use those supported observation tools when authentic complete screen evidence is required. Never present a reconstructed terminal, animation or partial invented view as a real screenshot.
|
||||||
.SS Automatic graphical discovery
|
.SS Automatic graphical discovery
|
||||||
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.
|
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.
|
||||||
.PP
|
.PP
|
||||||
.nf
|
.nf
|
||||||
fb status
|
fb status
|
||||||
@@ -235,7 +235,7 @@ fb status runtime
|
|||||||
fb status boot
|
fb status boot
|
||||||
.fi
|
.fi
|
||||||
.SS Doctor
|
.SS Doctor
|
||||||
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.
|
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.
|
||||||
.PP
|
.PP
|
||||||
.nf
|
.nf
|
||||||
fb doctor
|
fb doctor
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.3.1"
|
__version__ = "0.3.2~compat.20261027.1"
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Explicit user-service startup with host-bound encrypted systemd credentials."""
|
"""Explicit user-service startup with host-bound encrypted systemd credentials."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
|
from contextlib import contextmanager
|
||||||
import fcntl
|
import fcntl
|
||||||
import getpass
|
import getpass
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -20,6 +21,112 @@ import time
|
|||||||
from .config import load_config, save_config, private_dir, state_path, default_config, validate
|
from .config import load_config, save_config, private_dir, state_path, default_config, validate
|
||||||
|
|
||||||
CREDENTIAL = "chatgpt-runtime-key"
|
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
|
_STOP = False
|
||||||
|
|
||||||
|
|
||||||
@@ -148,6 +255,8 @@ def status(state):
|
|||||||
from . import runtime, connection_background
|
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)}
|
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"):
|
if manifest and not manifest.get("node"):
|
||||||
|
result["credential_backend"] = _credential_backend(manifest)
|
||||||
|
result["credential_cleanup_pending"] = bool(_retired_helpers(manifest))
|
||||||
try:
|
try:
|
||||||
cipher = _read(directory / "credential.cred")
|
cipher = _read(directory / "credential.cred")
|
||||||
result["encrypted_credential_present"] = hashlib.sha256(cipher).hexdigest() == manifest.get("credential_sha256")
|
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.")
|
raise RuntimeError("The profile backup changed outside FrameBridge; nothing was replaced.")
|
||||||
elif any(originals[path] is not None for path in (unit, blob, backup)):
|
elif any(originals[path] is not None for path in (unit, blob, backup)):
|
||||||
raise RuntimeError("Existing unowned connection files; nothing was replaced.")
|
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)
|
_check_unit_directory(unit)
|
||||||
version = _command(["systemd-creds", "--version"]).stdout.decode(errors="replace")
|
backend = _credential_backend()
|
||||||
match = re.match(r"systemd (\d+)", version)
|
|
||||||
if not match or int(match.group(1)) < 256:
|
|
||||||
raise RuntimeError("Protected connections require systemd 256 or later.")
|
|
||||||
_command(["sudo", "-v"], timeout=180)
|
_command(["sudo", "-v"], timeout=180)
|
||||||
user = pwd.getpwuid(os.getuid()).pw_name
|
user = pwd.getpwuid(os.getuid()).pw_name
|
||||||
linger_before = _linger(user)
|
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)
|
values = dict(line.split("=", 1) for line in properties.stdout.decode(errors="replace").splitlines() if "=" in line)
|
||||||
was_enabled = values.get("UnitFileState") == "enabled"
|
was_enabled = values.get("UnitFileState") == "enabled"
|
||||||
was_active = values.get("ActiveState") in {"active", "activating", "reloading"}
|
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:
|
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:
|
finally:
|
||||||
runtime_key = ""
|
runtime_key = ""
|
||||||
if len(encrypted) < 80 or len(encrypted) > 131072:
|
with _credential_transaction(state, encrypted, backend) as transaction:
|
||||||
raise RuntimeError("Connection protection did not complete.")
|
if len(encrypted) < 80 or len(encrypted) > 131072:
|
||||||
# Recheck the exact originals after authentication/encryption and before any stop.
|
raise RuntimeError("Connection protection did not complete.")
|
||||||
if any(_optional_read(path) != data for path, data in originals.items()):
|
# Recheck the exact originals after authentication/encryption and before any stop.
|
||||||
raise RuntimeError("The profile changed during connection setup; nothing was replaced.")
|
if any(_optional_read(path) != data for path, data in originals.items()):
|
||||||
text = unit_text(state, blob).encode()
|
raise RuntimeError("The profile changed during connection setup; nothing was replaced.")
|
||||||
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 ""}
|
text = unit_text(state, blob).encode()
|
||||||
backup_bytes = originals[backup]
|
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 ""}
|
||||||
if changed_headless and not record.get("headless_changed"):
|
backup_bytes = originals[backup]
|
||||||
record.update(headless_before=headless_before, headless_changed=True, config_before_sha256=hashlib.sha256(original_config).hexdigest())
|
if changed_headless and not record.get("headless_changed"):
|
||||||
backup_bytes = original_config
|
record.update(headless_before=headless_before, headless_changed=True, config_before_sha256=hashlib.sha256(original_config).hexdigest())
|
||||||
elif not previous and changed_headless:
|
backup_bytes = original_config
|
||||||
backup_bytes = original_config
|
elif not previous and changed_headless:
|
||||||
if record.get("headless_changed"):
|
backup_bytes = original_config
|
||||||
# Disabling changes only the desktop preference, preserving the newly selected connection.
|
if record.get("headless_changed"):
|
||||||
restored_profile = dict(config)
|
# Disabling changes only the desktop preference, preserving the newly selected connection.
|
||||||
restored_profile["headless"] = record["headless_before"]
|
restored_profile = dict(config)
|
||||||
backup_bytes = (json.dumps(restored_profile, indent=2, sort_keys=True) + "\n").encode()
|
restored_profile["headless"] = record["headless_before"]
|
||||||
record["config_before_sha256"] = hashlib.sha256(backup_bytes).hexdigest()
|
backup_bytes = (json.dumps(restored_profile, indent=2, sort_keys=True) + "\n").encode()
|
||||||
record["changed_config_sha256"] = hashlib.sha256(config_bytes).hexdigest()
|
record["config_before_sha256"] = hashlib.sha256(backup_bytes).hexdigest()
|
||||||
record.update(unit_sha256=hashlib.sha256(text).hexdigest(), credential_sha256=hashlib.sha256(encrypted).hexdigest(), connection_settings_sha256=_connection_public_digest(config))
|
record["changed_config_sha256"] = hashlib.sha256(config_bytes).hexdigest()
|
||||||
from . import connection_background
|
retired = _retired_helpers(previous or {})
|
||||||
background_before = connection_background.status(state)
|
if previous and _credential_backend(previous) == HOST_BACKEND:
|
||||||
changed = {}
|
retired.append(previous["credential_sha256"])
|
||||||
service_touched = False
|
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))
|
||||||
manual_connection_stopped = False
|
from . import connection_background
|
||||||
def write(path, data):
|
background_before = connection_background.status(state)
|
||||||
changed[path] = data
|
changed = {}
|
||||||
_write(path, data, replace=originals.get(path) is not None)
|
service_touched = False
|
||||||
try:
|
manual_connection_stopped = False
|
||||||
if previous:
|
def write(path, data):
|
||||||
service_touched = True
|
changed[path] = data
|
||||||
_systemctl("stop", unit.name)
|
_write(path, data, replace=originals.get(path) is not None)
|
||||||
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:
|
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:
|
if previous:
|
||||||
result = _systemctl("enable" if was_enabled else "disable", unit.name, check=False)
|
service_touched = True
|
||||||
rollback_failed = rollback_failed or bool(result.returncode)
|
_systemctl("stop", unit.name)
|
||||||
if was_active:
|
disconnected = connection_background.disconnect(state)
|
||||||
restarted = _systemctl("start", unit.name, check=False)
|
if disconnected.get("ok") is False or disconnected.get("alive"):
|
||||||
rollback_failed = rollback_failed or bool(restarted.returncode)
|
raise RuntimeError("The previous connection could not be stopped safely; nothing was replaced.")
|
||||||
if not linger_before and _enabled_units() == units_before:
|
manual_connection_stopped = not previous and background_before.get("alive") is True
|
||||||
restored_linger = _command(["sudo", "-n", "--", "loginctl", "disable-linger", user], check=False)
|
if connection_background.status(state).get("alive"):
|
||||||
rollback_failed = rollback_failed or bool(restored_linger.returncode)
|
raise RuntimeError("The previous connection is still stopping; nothing was replaced.")
|
||||||
except Exception:
|
write(blob, encrypted)
|
||||||
rollback_failed = True
|
write(unit, text)
|
||||||
if rollback_failed:
|
write(config_path, config_bytes)
|
||||||
raise RuntimeError("Connection setup did not complete, and restoration could not be fully verified. Inspect fb autostart status before retrying.") from None
|
if backup_bytes is not None and backup_bytes != originals[backup]:
|
||||||
if manual_connection_stopped:
|
write(backup, backup_bytes)
|
||||||
raise RuntimeError("Connection setup did not complete. The previous manually started connection was stopped; reconnect it using its original key.") from None
|
write(record_path, (json.dumps(record, sort_keys=True, indent=2) + "\n").encode())
|
||||||
raise
|
write(intent, (json.dumps({"schema": 1, "connect": True, "revision": connection_request_revision(state) + 1}, sort_keys=True) + "\n").encode())
|
||||||
return _committed_connection_status(state)
|
_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):
|
def _enable(state, *, start_now=True, confirm=True):
|
||||||
@@ -475,11 +597,8 @@ def _enable(state, *, start_now=True, confirm=True):
|
|||||||
else:
|
else:
|
||||||
_systemctl("enable", unit.name)
|
_systemctl("enable", unit.name)
|
||||||
return status(state)
|
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"
|
node = config["role"] == "node"
|
||||||
|
backend = None if node else _credential_backend()
|
||||||
if not node and not config.get("tunnel_id"):
|
if not node and not config.get("tunnel_id"):
|
||||||
raise RuntimeError("Save a tunnel with fb connect chatgpt before enabling automatic startup.")
|
raise RuntimeError("Save a tunnel with fb connect chatgpt before enabling automatic startup.")
|
||||||
print("FrameBridge will start automatically when this computer starts.")
|
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):
|
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.")
|
raise ValueError("The runtime key must be nonempty and contain no whitespace or control characters.")
|
||||||
try:
|
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:
|
finally:
|
||||||
runtime_key = ""
|
runtime_key = ""
|
||||||
if len(encrypted) < 80 or len(encrypted) > 131072:
|
if len(encrypted) < 80 or len(encrypted) > 131072:
|
||||||
raise RuntimeError("Credential encryption returned an invalid result.")
|
raise RuntimeError("Credential encryption returned an invalid result.")
|
||||||
blob = directory / "credential.cred"
|
elif _helper_present():
|
||||||
text = unit_text(state, blob, node=node).encode()
|
_prune_helpers(state)
|
||||||
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()}
|
with _credential_transaction(state, encrypted, backend) as transaction:
|
||||||
created = []
|
blob = directory / "credential.cred"
|
||||||
try:
|
text = unit_text(state, blob, node=node).encode()
|
||||||
if encrypted:
|
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()}
|
||||||
_write(blob, encrypted); created.append(blob)
|
created = []
|
||||||
_write(unit, text); created.append(unit)
|
try:
|
||||||
if changed_headless:
|
if encrypted:
|
||||||
if _read(state / "config.json") != original_config:
|
_write(blob, encrypted); created.append(blob)
|
||||||
raise RuntimeError("The profile changed during configuration; no profile settings were overwritten.")
|
_write(unit, text); created.append(unit)
|
||||||
_write(directory / "config-before.json", original_config)
|
if changed_headless:
|
||||||
created.append(directory / "config-before.json")
|
if _read(state / "config.json") != original_config:
|
||||||
config["headless"] = "auto"
|
raise RuntimeError("The profile changed during configuration; no profile settings were overwritten.")
|
||||||
save_config(state, config)
|
_write(directory / "config-before.json", original_config)
|
||||||
record["changed_config_sha256"] = hashlib.sha256(_read(state / "config.json")).hexdigest()
|
created.append(directory / "config-before.json")
|
||||||
_json(directory / "installation.json", record); created.append(directory / "installation.json")
|
config["headless"] = "auto"
|
||||||
print("[3/3] Enabling automatic startup...", flush=True)
|
save_config(state, config)
|
||||||
_command(["sudo", "-n", "--", "loginctl", "enable-linger", user])
|
record["changed_config_sha256"] = hashlib.sha256(_read(state / "config.json")).hexdigest()
|
||||||
_systemctl("daemon-reload")
|
_json(directory / "installation.json", record); created.append(directory / "installation.json")
|
||||||
if start_now:
|
print("[3/3] Enabling automatic startup...", flush=True)
|
||||||
_systemctl("enable", "--now", unit.name)
|
_command(["sudo", "-n", "--", "loginctl", "enable-linger", user])
|
||||||
else:
|
_systemctl("daemon-reload")
|
||||||
_systemctl("enable", unit.name)
|
if start_now:
|
||||||
except BaseException:
|
_systemctl("enable", "--now", unit.name)
|
||||||
if unit in created:
|
else:
|
||||||
_systemctl("disable", "--now", unit.name, check=False)
|
_systemctl("enable", unit.name)
|
||||||
for path in reversed(created):
|
except BaseException:
|
||||||
try: path.unlink()
|
if unit in created:
|
||||||
except FileNotFoundError: pass
|
_systemctl("disable", "--now", unit.name, check=False)
|
||||||
if changed_headless and record.get("changed_config_sha256") == hashlib.sha256(_read(state / "config.json")).hexdigest():
|
for path in reversed(created):
|
||||||
_write(state / "config.json", original_config, replace=True)
|
try: path.unlink()
|
||||||
_systemctl("daemon-reload", check=False)
|
except FileNotFoundError: pass
|
||||||
if not linger_before:
|
if changed_headless and record.get("changed_config_sha256") == hashlib.sha256(_read(state / "config.json")).hexdigest():
|
||||||
try:
|
_write(state / "config.json", original_config, replace=True)
|
||||||
if _enabled_units() == units_before:
|
_systemctl("daemon-reload", check=False)
|
||||||
_command(["sudo", "-n", "--", "loginctl", "disable-linger", user], check=False)
|
if not linger_before:
|
||||||
except Exception:
|
try:
|
||||||
pass
|
if _enabled_units() == units_before:
|
||||||
raise
|
_command(["sudo", "-n", "--", "loginctl", "disable-linger", user], check=False)
|
||||||
return status(state)
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
transaction["committed"] = True
|
||||||
|
return status(state)
|
||||||
|
|
||||||
|
|
||||||
def _disable(state):
|
def _disable(state):
|
||||||
state, directory, unit = _paths(state)
|
state, directory, unit = _paths(state)
|
||||||
record = _manifest(directory)
|
record = _manifest(directory)
|
||||||
if not record:
|
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"):
|
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.")
|
raise RuntimeError("The service changed outside FrameBridge; refusing to remove it.")
|
||||||
blob = directory / "credential.cred"
|
blob = directory / "credential.cred"
|
||||||
if not record.get("node") and hashlib.sha256(_read(blob)).hexdigest() != record.get("credential_sha256"):
|
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.")
|
raise RuntimeError("The encrypted credential changed outside FrameBridge; refusing to remove it.")
|
||||||
|
retired = _retired_helpers(record)
|
||||||
backup = None
|
backup = None
|
||||||
if record.get("headless_changed"):
|
if record.get("headless_changed"):
|
||||||
backup = _read(directory / "config-before.json")
|
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")
|
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:
|
if restore_linger:
|
||||||
_command(["sudo", "-v"], timeout=180)
|
_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)
|
_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()
|
unit.unlink()
|
||||||
if not record.get("node"):
|
if not record.get("node"):
|
||||||
blob.unlink()
|
blob.unlink()
|
||||||
@@ -609,7 +750,11 @@ def _credential(state):
|
|||||||
if hashlib.sha256(cipher).hexdigest() != record.get("credential_sha256"):
|
if hashlib.sha256(cipher).hexdigest() != record.get("credential_sha256"):
|
||||||
raise RuntimeError("The encrypted runtime credential changed outside FrameBridge.")
|
raise RuntimeError("The encrypted runtime credential changed outside FrameBridge.")
|
||||||
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
|
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:
|
try:
|
||||||
key = result.stdout.decode("ascii")
|
key = result.stdout.decode("ascii")
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import sys
|
|||||||
import shlex
|
import shlex
|
||||||
|
|
||||||
from .config import default_config, load_config, save_config
|
from .config import default_config, load_config, save_config
|
||||||
from . import tunnel
|
from . import tunnel, __version__
|
||||||
|
|
||||||
_EMAIL = re.compile(r"[^\s@]+@[^\s@]+\.[^\s@]+\Z")
|
_EMAIL = re.compile(r"[^\s@]+@[^\s@]+\.[^\s@]+\Z")
|
||||||
|
|
||||||
@@ -317,7 +317,7 @@ _OUTPUT = None
|
|||||||
|
|
||||||
def _structured_result(value):
|
def _structured_result(value):
|
||||||
"""Versioned envelope used only when --json was explicitly requested."""
|
"""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,
|
"command": _OUTPUT.get("command") if _OUTPUT else None,
|
||||||
"ok": not isinstance(value, dict) or value.get("ok") is not False,
|
"ok": not isinstance(value, dict) or value.get("ok") is not False,
|
||||||
"result": value}
|
"result": value}
|
||||||
@@ -394,7 +394,7 @@ def _human_status(value, scope=None):
|
|||||||
connection = connection if isinstance(connection, dict) else {}
|
connection = connection if isinstance(connection, dict) else {}
|
||||||
boot = value.get("boot", {})
|
boot = value.get("boot", {})
|
||||||
boot = boot if isinstance(boot, dict) else {}
|
boot = boot if isinstance(boot, dict) else {}
|
||||||
print("FrameBridge 0.3.1")
|
print("FrameBridge " + __version__)
|
||||||
print("Role: " + str(runtime.get("role", "unavailable")))
|
print("Role: " + str(runtime.get("role", "unavailable")))
|
||||||
print("Local tools: " + _runtime_label(runtime))
|
print("Local tools: " + _runtime_label(runtime))
|
||||||
if runtime.get("role") == "node":
|
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."""
|
"""Shared English guide content. Regenerate command docs after editing."""
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
|
||||||
GUIDE = {'start': {'title': 'Start with FrameBridge',
|
GUIDE = {'start': {'title': 'Start with FrameBridge',
|
||||||
'sections': [{'heading': 'One interface for people and agents',
|
'sections': [{'heading': 'One interface for people and agents',
|
||||||
'body': 'FrameBridge provides tools and a shared command-line interface '
|
'body': 'FrameBridge provides tools and a shared command-line interface '
|
||||||
@@ -14,20 +16,10 @@ GUIDE = {'start': {'title': 'Start with FrameBridge',
|
|||||||
'installed program.',
|
'installed program.',
|
||||||
'commands': ['fb help', 'fb status', 'fb --json status']},
|
'commands': ['fb help', 'fb status', 'fb --json status']},
|
||||||
{'heading': 'Install and connect',
|
{'heading': 'Install and connect',
|
||||||
'body': 'On a supported Debian system, install the downloaded package '
|
'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.',
|
||||||
'with the package manager. Normal first connection does not '
|
'commands': ["sudo apt install ./framebridge-expanded_" + __version__ + "_amd64.deb", 'fb connect']},
|
||||||
'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']},
|
|
||||||
{'heading': 'Updating and removal',
|
{'heading': 'Updating and removal',
|
||||||
'body': 'Install an official update through the normal package manager. '
|
'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.',
|
||||||
'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.',
|
|
||||||
'commands': ['sudo dpkg --remove framebridge-original-expanded']},
|
'commands': ['sudo dpkg --remove framebridge-original-expanded']},
|
||||||
{'heading': 'Human authentication',
|
{'heading': 'Human authentication',
|
||||||
'body': 'The normal connection asks for an optional account label, a '
|
'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.',
|
'partial invented view as a real screenshot.',
|
||||||
'commands': []},
|
'commands': []},
|
||||||
{'heading': 'Automatic graphical discovery',
|
{'heading': 'Automatic graphical discovery',
|
||||||
'body': 'The installer and control console stay text interfaces in both '
|
'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.',
|
||||||
'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.',
|
|
||||||
'commands': ['fb status', 'fb doctor']},
|
'commands': ['fb status', 'fb doctor']},
|
||||||
{'heading': 'Frame-buffer read access',
|
{'heading': 'Frame-buffer read access',
|
||||||
'body': 'screen-access is a narrow, explicit administrative permission '
|
'body': 'screen-access is a narrow, explicit administrative permission '
|
||||||
@@ -351,11 +333,7 @@ GUIDE = {'start': {'title': 'Start with FrameBridge',
|
|||||||
'fb status runtime',
|
'fb status runtime',
|
||||||
'fb status boot']},
|
'fb status boot']},
|
||||||
{'heading': 'Doctor',
|
{'heading': 'Doctor',
|
||||||
'body': 'doctor diagnoses prerequisites and reports findings with '
|
'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.',
|
||||||
'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.',
|
|
||||||
'commands': ['fb doctor']},
|
'commands': ['fb doctor']},
|
||||||
{'heading': 'Logs and progress',
|
{'heading': 'Logs and progress',
|
||||||
'body': 'Normal connection presents concise progress and a final '
|
'body': 'Normal connection presents concise progress and a final '
|
||||||
|
|||||||
@@ -159,10 +159,10 @@ def doctor(state):
|
|||||||
missing.extend(graphical_missing)
|
missing.extend(graphical_missing)
|
||||||
elif config.get("headless") in ("yes", True):
|
elif config.get("headless") in ("yes", True):
|
||||||
missing.extend(graphical_missing + virtual_missing)
|
missing.extend(graphical_missing + virtual_missing)
|
||||||
os_release = platform.freedesktop_os_release()
|
from .compatibility import assess, display_support
|
||||||
compatible = (sys.version_info[:2] == (3, 13) and platform.machine() == "x86_64"
|
compatibility = assess(PREFIX)
|
||||||
and os_release.get("ID") == "debian" and os_release.get("VERSION_ID") == "13")
|
compatible = compatibility["ok"]
|
||||||
issues = []
|
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 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"):
|
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.")
|
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)
|
display_context = describe(env)
|
||||||
if config.get("headless") in ("no", False) and display_context.get("kind") != "physical-x11":
|
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.")
|
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,
|
"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": {"physical-x11": "existing physical X11", "virtual-x11": "explicit private virtual display", "text": "text tools"}.get(display_context.get("kind"), "unavailable"),
|
||||||
"display_context": display_context,
|
"display_context": display_context,
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import stat
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
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"
|
MODULE_ROOT = "/usr/lib/framebridge-expanded/expanded"
|
||||||
UNIT_NAME = re.compile(r"framebridge-autostart-[0-9a-f]{16}\.service\Z")
|
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:
|
if len(starts) != 1:
|
||||||
return False
|
return False
|
||||||
args = shlex.split(starts[0])
|
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
|
return False
|
||||||
if args[1:6] != ["-B", "-m", "framebridge_expanded.autostart", "_run", "--state"]:
|
if args[1:6] != ["-B", "-m", "framebridge_expanded.autostart", "_run", "--state"]:
|
||||||
return False
|
return False
|
||||||
@@ -105,6 +108,45 @@ def cleanup_user(home=None, *, run=None, bus_available=None):
|
|||||||
return len(units)
|
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):
|
def cleanup_all_users(*, run=None, accounts=None):
|
||||||
if os.getuid() != 0:
|
if os.getuid() != 0:
|
||||||
raise RuntimeError("Package service cleanup requires the normal package manager's root context.")
|
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
|
failed = True
|
||||||
if failed:
|
if failed:
|
||||||
raise RuntimeError("Some FrameBridge services could not be stopped; package removal was paused.")
|
raise RuntimeError("Some FrameBridge services could not be stopped; package removal was paused.")
|
||||||
|
purge_credential_helpers(run=run)
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
"""Archive the reviewed community source layout without runtime profiles or QA logs."""
|
"""Archive the reviewed community source layout without runtime profiles or QA logs."""
|
||||||
|
import argparse
|
||||||
import calendar
|
import calendar
|
||||||
import gzip
|
import gzip
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -7,12 +8,15 @@ import io
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tarfile
|
import tarfile
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from build_profiles import CANDIDATE_VERSION, SOURCE_VENDOR, target_profile, validate_vendor, vendor_versions
|
||||||
|
|
||||||
BASE = Path(__file__).resolve().parents[1]
|
BASE = Path(__file__).resolve().parents[1]
|
||||||
ROOT = BASE.parent
|
ROOT = BASE.parent
|
||||||
EPOCH = calendar.timegm((2026, 10, 26, 0, 0, 0))
|
EPOCH = calendar.timegm((2026, 10, 27, 0, 0, 0))
|
||||||
ARCHIVE_ROOT = "FrameBridge-0.3.1"
|
ARCHIVE_ROOT = "FrameBridge-" + CANDIDATE_VERSION
|
||||||
ARCHIVE_NAME = "FrameBridge-0.3.1-source.tar.gz"
|
ARCHIVE_NAME = ARCHIVE_ROOT + "-source.tar.gz"
|
||||||
SOURCE_FOLDERS = ("sources", "expanded", "portable", "packaging", "docs", "tests")
|
SOURCE_FOLDERS = ("sources", "expanded", "portable", "packaging", "docs", "tests")
|
||||||
ROOT_FILES = ("README.md", "LICENSE", "LICENSING.md", "COPYRIGHT.md",
|
ROOT_FILES = ("README.md", "LICENSE", "LICENSING.md", "COPYRIGHT.md",
|
||||||
"LICENSE-STATUS.md", "OFFICIAL-RELEASES.md", "CONTRIBUTING.md", "SECURITY.md", "RELEASE-NOTES.md")
|
"LICENSE-STATUS.md", "OFFICIAL-RELEASES.md", "CONTRIBUTING.md", "SECURITY.md", "RELEASE-NOTES.md")
|
||||||
@@ -20,7 +24,7 @@ ROOT_FOLDERS = ("docs", "provenance", "licensing")
|
|||||||
BLOG_POLICY_FILES = ("README.md", "LICENSING.md", "POST-TEMPLATE.md", "LICENSE-CC-BY-SA-4.0.txt")
|
BLOG_POLICY_FILES = ("README.md", "LICENSING.md", "POST-TEMPLATE.md", "LICENSE-CC-BY-SA-4.0.txt")
|
||||||
ARCHIVE_README_NAME = "README-SOURCE-ARCHIVE.md"
|
ARCHIVE_README_NAME = "README-SOURCE-ARCHIVE.md"
|
||||||
DEPENDENCY_SOURCE_ASSETS = [{'filename': 'FrameBridge-0.3.1-Community-Preview1-dependency-sources.tar.gz', 'bytes': 82606998, 'sha256': '64540a85bd65eac3f394d8c0a967cacbc00e112676d57d644dcd1bb691c1926a'}, {'filename': 'FrameBridge-0.3.1-tunnel-client-dependency-sources.tar.gz', 'bytes': 26941745, 'sha256': '6445d4f0e773aba833fe5c610fbae54102b92de2ce636438f769f21e890c2bd9'}]
|
DEPENDENCY_SOURCE_ASSETS = [{'filename': 'FrameBridge-0.3.1-Community-Preview1-dependency-sources.tar.gz', 'bytes': 82606998, 'sha256': '64540a85bd65eac3f394d8c0a967cacbc00e112676d57d644dcd1bb691c1926a'}, {'filename': 'FrameBridge-0.3.1-tunnel-client-dependency-sources.tar.gz', 'bytes': 26941745, 'sha256': '6445d4f0e773aba833fe5c610fbae54102b92de2ce636438f769f21e890c2bd9'}]
|
||||||
ARCHIVE_README = "# FrameBridge 0.3.1 source archive\n\nThis archive preserves the repository layout needed by the offline builder.\nFrom this directory, run python3 source/packaging/build_expanded.py to build\nFrameBridge-0.3.1.deb. The builder writes source/build and source/release;\nit does not install or start FrameBridge. Debian's dpkg-deb and the documented\nbuild prerequisites must already be available.\n\nOriginal FrameBridge material is GNU GPL version 3 only. Read LICENSE,\nLICENSING.md, COPYRIGHT.md, LICENSE-STATUS.md and OFFICIAL-RELEASES.md for\nscope, attribution, third-party terms and the official-release trust model.\n\nThe project source asset is accompanied by these separate dependency assets:\n\n- FrameBridge-0.3.1-Community-Preview1-dependency-sources.tar.gz\n 82,606,998 bytes\n SHA-256 64540a85bd65eac3f394d8c0a967cacbc00e112676d57d644dcd1bb691c1926a\n- FrameBridge-0.3.1-tunnel-client-dependency-sources.tar.gz\n 26,941,745 bytes\n SHA-256 6445d4f0e773aba833fe5c610fbae54102b92de2ce636438f769f21e890c2bd9\n\nThe Preview1 filename deliberately preserves the identity of an immutable\nshared asset. Retain licensing/third-party/R2-SUPPLEMENTS.json and its referenced\nsource notices with the dependency sources. The source indices and\nSOURCE-AVAILABILITY.md there record scope and provenance. Do not substitute\nunrelated archives. The retained tunnel executable is not claimed to have been\nindependently rebuilt from an identified own-source revision.\n\nThe blog directory contains licensing policies and the post template.\nEditorial article drafts are omitted because they are not source build inputs.\n\nOnly source/evidence/BASELINE.json is included from technical evidence. Runtime\nprofiles, private QA logs, generated build/release files and Git history are\noutside the source archive allowlist.\n\nA completed archive or builder run is not a declaration of public readiness.\nRelease checksums, signatures and publication status are recorded separately.\n".encode("utf-8")
|
ARCHIVE_README = f"# FrameBridge {CANDIDATE_VERSION} compatibility candidate source\n\nThis source archive is a QA candidate, not a signed public release or evidence\nthat an OS/desktop lifecycle has been tested. Original signed 0.3.1 assets retain\ntheir separate immutable identity. Check TARGET-VENDOR-PROVENANCE.json for the\nnative ABI inputs actually included in this archive.\n\nThe offline builder requires Python 3, Debian dpkg/dpkg-deb and documented host\nbuild prerequisites. It writes source/build/<profile> and source/release/<profile>;\nit never installs a package, fetches dependencies, changes system Python or starts\nFrameBridge services. Run from this archive's top-level directory:\n\n python3 source/packaging/build_expanded.py --target debian13-amd64-py313\n python3 source/packaging/build_expanded.py --target lmde7-amd64-py313\n\nIf the cp312 input is recorded as included:\n\n python3 source/packaging/build_expanded.py --target ubuntu24.04-amd64-py312 --vendor-dir source/target-vendors/cp312\n python3 source/packaging/build_expanded.py --target mint22-amd64-py312 --vendor-dir source/target-vendors/cp312\n\nIf the cp314 input is recorded as included:\n\n python3 source/packaging/build_expanded.py --target ubuntu26.04-amd64-py314 --vendor-dir source/target-vendors/cp314\n\nThe default archive includes only the reviewed original cp313 vendor tree.\nThe archive producer can include reviewed alternate ABI trees using:\n\n python3 source/packaging/archive_sources.py --vendor-cp312 /reviewed/vendor-cp312 --vendor-cp314 /reviewed/vendor-cp314\n\nThose arguments must identify complete reviewed trees with the same 29 pinned\ndistribution names/versions as the original. The producer validates WHEEL/RECORD,\nCPython ABI, amd64 ELF and glibc baseline before including them. Local preparation\nmarkers and executable entry-point scripts are omitted. All included files and\ntheir hashes appear in SOURCE-ARCHIVE-MANIFEST.json alongside the finished archive;\nthe embedded provenance records input ABI identity without workstation paths.\n--check-vendor on build_expanded.py validates inputs without building a package.\nAn absent alternate tree must be supplied separately with equivalent provenance;\nchanging Depends cannot convert the original cp313 extensions into cp312/cp314.\n\nPackage output names contain the candidate version and a separate profile\nfolder. release-profile.json inside the installed prefix declares the exact\ncandidate profile. Build success and profile eligibility do not establish a\nvalidated installation, graphical control, real TTY or reboot lifecycle.\n\nOriginal FrameBridge material is GNU GPL version 3 only. Read LICENSE,\nLICENSING.md, COPYRIGHT.md, LICENSE-STATUS.md and OFFICIAL-RELEASES.md for scope,\nattribution, third-party terms and the official-release trust model.\n\nThe project source asset is accompanied by these separate unchanged dependency\nsource assets; they are not embedded or renamed by this helper:\n\n- FrameBridge-0.3.1-Community-Preview1-dependency-sources.tar.gz\n 82,606,998 bytes\n SHA-256 64540a85bd65eac3f394d8c0a967cacbc00e112676d57d644dcd1bb691c1926a\n- FrameBridge-0.3.1-tunnel-client-dependency-sources.tar.gz\n 26,941,745 bytes\n SHA-256 6445d4f0e773aba833fe5c610fbae54102b92de2ce636438f769f21e890c2bd9\n\nThe Preview1 name preserves an immutable shared asset's identity. Retain the R2\nsource-notice supplements and both dependency indices under licensing/third-party.\nMatching package versions do not constitute an independent source rebuild of the\nretained tunnel executable; its exact own-source revision is not asserted.\n\nThe blog directory contains licensing policies and the post template. Draft\narticles, private runtime profiles, QA logs, build outputs and Git history are\noutside the source allowlist. Only source/evidence/BASELINE.json is included from\ntechnical evidence. Release signatures and publication status are separate\nrecords; this candidate archiver does not sign or publish anything.\n".encode("utf-8")
|
||||||
GPL_SHA256 = "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986"
|
GPL_SHA256 = "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986"
|
||||||
|
|
||||||
def sha(path):
|
def sha(path):
|
||||||
@@ -33,7 +37,7 @@ def source_files():
|
|||||||
relative = path.relative_to(ROOT)
|
relative = path.relative_to(ROOT)
|
||||||
if path.is_symlink():
|
if path.is_symlink():
|
||||||
raise RuntimeError("Source archives must not include symbolic links: " + str(relative))
|
raise RuntimeError("Source archives must not include symbolic links: " + str(relative))
|
||||||
if "__pycache__" in relative.parts or path.suffix == ".pyc":
|
if any(part in {"__pycache__", ".git", "build", "release", "qa"} for part in relative.parts) or path.suffix in {".pyc", ".log"}:
|
||||||
return
|
return
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
selected[relative.as_posix()] = path
|
selected[relative.as_posix()] = path
|
||||||
@@ -75,22 +79,54 @@ def source_files():
|
|||||||
raise RuntimeError("Required third-party source/notice input missing: " + name)
|
raise RuntimeError("Required third-party source/notice input missing: " + name)
|
||||||
return selected
|
return selected
|
||||||
|
|
||||||
def main():
|
def main(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--vendor-cp312", type=Path, help="Reviewed cp312 vendor input to include")
|
||||||
|
parser.add_argument("--vendor-cp314", type=Path, help="Reviewed cp314 vendor input to include")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
names = source_files()
|
names = source_files()
|
||||||
release = BASE / "release"
|
expected_versions = vendor_versions(BASE / SOURCE_VENDOR)
|
||||||
release.mkdir(exist_ok=True)
|
provenance = {"schema": 1, "version": CANDIDATE_VERSION, "validation_status": "candidate",
|
||||||
|
"vendors": {"cp313": {"path": "source/" + SOURCE_VENDOR.as_posix(),
|
||||||
|
**validate_vendor(BASE / SOURCE_VENDOR, target_profile("debian13-amd64-py313"), expected_versions)}}}
|
||||||
|
for abi, directory, profile_id in (("cp312", args.vendor_cp312, "ubuntu24.04-amd64-py312"),
|
||||||
|
("cp314", args.vendor_cp314, "ubuntu26.04-amd64-py314")):
|
||||||
|
if directory is None:
|
||||||
|
continue
|
||||||
|
evidence = validate_vendor(directory, target_profile(profile_id), expected_versions)
|
||||||
|
archive_path = "source/target-vendors/" + abi
|
||||||
|
provenance["vendors"][abi] = {"path": archive_path, **evidence}
|
||||||
|
for path in sorted(directory.rglob("*")):
|
||||||
|
relative = path.relative_to(directory)
|
||||||
|
if path.is_symlink():
|
||||||
|
raise RuntimeError("Alternate vendor source symlink rejected: " + str(relative))
|
||||||
|
if (not path.is_file() or any(part in {"__pycache__", ".git", "build", "release", "qa", "bin"} for part in relative.parts)
|
||||||
|
or path.suffix in {".pyc", ".log"} or path.name == "FB-ABI-PREPARED.json"):
|
||||||
|
continue
|
||||||
|
name = archive_path + "/" + relative.as_posix()
|
||||||
|
if name in names:
|
||||||
|
raise RuntimeError("Archive input path collision: " + name)
|
||||||
|
names[name] = path
|
||||||
|
provenance["omitted_vendor_abis"] = sorted({"cp312", "cp314"} - set(provenance["vendors"]))
|
||||||
|
if provenance["omitted_vendor_abis"]:
|
||||||
|
print("Candidate source excludes alternate vendor ABIs: " + ", ".join(provenance["omitted_vendor_abis"]), file=sys.stderr)
|
||||||
|
generated = {ARCHIVE_README_NAME: ARCHIVE_README,
|
||||||
|
"TARGET-VENDOR-PROVENANCE.json": (json.dumps(provenance, indent=2, sort_keys=True) + "\n").encode()}
|
||||||
|
release = BASE / "release" / "candidate-source"
|
||||||
|
release.mkdir(parents=True, exist_ok=True)
|
||||||
output = release / ARCHIVE_NAME
|
output = release / ARCHIVE_NAME
|
||||||
with output.open("wb") as raw:
|
with output.open("wb") as raw:
|
||||||
with gzip.GzipFile(filename="", mode="wb", fileobj=raw,
|
with gzip.GzipFile(filename="", mode="wb", fileobj=raw,
|
||||||
mtime=EPOCH, compresslevel=6) as zipped:
|
mtime=EPOCH, compresslevel=6) as zipped:
|
||||||
with tarfile.open(fileobj=zipped, mode="w", format=tarfile.GNU_FORMAT) as archive:
|
with tarfile.open(fileobj=zipped, mode="w", format=tarfile.GNU_FORMAT) as archive:
|
||||||
info = tarfile.TarInfo(ARCHIVE_ROOT + "/" + ARCHIVE_README_NAME)
|
for relative, data in sorted(generated.items()):
|
||||||
info.uid = info.gid = 0
|
info = tarfile.TarInfo(ARCHIVE_ROOT + "/" + relative)
|
||||||
info.uname = info.gname = ""
|
info.uid = info.gid = 0
|
||||||
info.mtime = EPOCH
|
info.uname = info.gname = ""
|
||||||
info.mode = 0o644
|
info.mtime = EPOCH
|
||||||
info.size = len(ARCHIVE_README)
|
info.mode = 0o644
|
||||||
archive.addfile(info, io.BytesIO(ARCHIVE_README))
|
info.size = len(data)
|
||||||
|
archive.addfile(info, io.BytesIO(data))
|
||||||
for relative, path in sorted(names.items()):
|
for relative, path in sorted(names.items()):
|
||||||
info = archive.gettarinfo(str(path), arcname=ARCHIVE_ROOT + "/" + relative)
|
info = archive.gettarinfo(str(path), arcname=ARCHIVE_ROOT + "/" + relative)
|
||||||
info.uid = info.gid = 0
|
info.uid = info.gid = 0
|
||||||
@@ -99,10 +135,10 @@ def main():
|
|||||||
info.mode = 0o755 if path.stat().st_mode & 0o111 else 0o644
|
info.mode = 0o755 if path.stat().st_mode & 0o111 else 0o644
|
||||||
with path.open("rb") as stream:
|
with path.open("rb") as stream:
|
||||||
archive.addfile(info, stream)
|
archive.addfile(info, stream)
|
||||||
manifest = {"status": "COMMUNITY_RELEASE_BUILD", "archive": output.name,
|
manifest = {"status": "COMPATIBILITY_CANDIDATE_BUILD", "version": CANDIDATE_VERSION, "vendor_inputs": provenance, "archive": output.name,
|
||||||
"bytes": output.stat().st_size, "sha256": sha(output),
|
"bytes": output.stat().st_size, "sha256": sha(output),
|
||||||
"files": [{"path": ARCHIVE_README_NAME,
|
"files": [{"path": name, "sha256": hashlib.sha256(data).hexdigest(), "generated": True}
|
||||||
"sha256": hashlib.sha256(ARCHIVE_README).hexdigest(), "generated": True}]
|
for name, data in sorted(generated.items())]
|
||||||
+ [{"path": relative, "sha256": sha(path)}
|
+ [{"path": relative, "sha256": sha(path)}
|
||||||
for relative, path in sorted(names.items())],
|
for relative, path in sorted(names.items())],
|
||||||
"draft_blog_articles_included": False,
|
"draft_blog_articles_included": False,
|
||||||
@@ -111,7 +147,7 @@ def main():
|
|||||||
"dependency_source_archives": DEPENDENCY_SOURCE_ASSETS}
|
"dependency_source_archives": DEPENDENCY_SOURCE_ASSETS}
|
||||||
(release / "SOURCE-ARCHIVE-MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
(release / "SOURCE-ARCHIVE-MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||||
print(json.dumps({"archive": str(output), "bytes": output.stat().st_size,
|
print(json.dumps({"archive": str(output), "bytes": output.stat().st_size,
|
||||||
"sha256": manifest["sha256"], "files": len(names) + 1,
|
"sha256": manifest["sha256"], "files": len(names) + len(generated),
|
||||||
"status": manifest["status"]}))
|
"status": manifest["status"]}))
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
"""Reproducible Debian package builder; never installs or starts the payload."""
|
"""Reproducible Debian package builder; never installs or starts the payload."""
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
import calendar
|
import calendar
|
||||||
import gzip
|
import gzip
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
from build_profiles import CANDIDATE_VERSION, PROFILES, SOURCE_VENDOR, VENDOR_RELATIVE, target_profile, validate_vendor, vendor_versions
|
||||||
|
|
||||||
BASE = Path(__file__).resolve().parents[1]
|
BASE = Path(__file__).resolve().parents[1]
|
||||||
ROOT = BASE.parent
|
ROOT = BASE.parent
|
||||||
VERSION = "0.3.1"
|
VERSION = CANDIDATE_VERSION
|
||||||
PUBLIC_FILENAME = "FrameBridge-0.3.1.deb"
|
PUBLIC_FILENAME = "FrameBridge-0.3.1.deb"
|
||||||
GPL_SHA256 = "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986"
|
GPL_SHA256 = "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986"
|
||||||
EPOCH = calendar.timegm((2026, 10, 26, 0, 0, 0))
|
EPOCH = calendar.timegm((2026, 10, 27, 0, 0, 0))
|
||||||
PACKAGE = "framebridge-original-expanded"
|
PACKAGE = "framebridge-original-expanded"
|
||||||
RELEASE = BASE / "release"
|
RELEASE = BASE / "release"
|
||||||
STAGE = BASE / "build/package-root"
|
STAGE = BASE / "build/package-root"
|
||||||
@@ -92,7 +97,7 @@ def install_legal_material(docs):
|
|||||||
shutil.copy2(ROOT / "blog" / name, docs / "blog" / name)
|
shutil.copy2(ROOT / "blog" / name, docs / "blog" / name)
|
||||||
shutil.copy2(ROOT / "RELEASE-NOTES.md", docs / "RELEASE-NOTES.md")
|
shutil.copy2(ROOT / "RELEASE-NOTES.md", docs / "RELEASE-NOTES.md")
|
||||||
(docs / "copyright").write_text(
|
(docs / "copyright").write_text(
|
||||||
"FrameBridge Original Expanded — Community Release 0.3.1\n\n"
|
"FrameBridge Original Expanded — compatibility candidate " + VERSION + "\n\n"
|
||||||
"Original FrameBridge material: GNU GPL version 3 only (GPL-3.0-only).\n"
|
"Original FrameBridge material: GNU GPL version 3 only (GPL-3.0-only).\n"
|
||||||
"The complete license is included in LICENSE. COPYRIGHT.md and LICENSING.md\n"
|
"The complete license is included in LICENSE. COPYRIGHT.md and LICENSING.md\n"
|
||||||
"identify attribution, scope and third-party exceptions.\n\n"
|
"identify attribution, scope and third-party exceptions.\n\n"
|
||||||
@@ -105,8 +110,8 @@ def install_legal_material(docs):
|
|||||||
"The assembled notices and license texts are included under\n"
|
"The assembled notices and license texts are included under\n"
|
||||||
"[licensing/third-party](licensing/third-party/THIRD-PARTY-NOTICES.md).\n"
|
"[licensing/third-party](licensing/third-party/THIRD-PARTY-NOTICES.md).\n"
|
||||||
"Original vendored notices are preserved with their components.\n")
|
"Original vendored notices are preserved with their components.\n")
|
||||||
lines = ["# Source availability for FrameBridge 0.3.1", "",
|
lines = ["# Source availability for FrameBridge " + VERSION, "",
|
||||||
"Project source archive: FrameBridge-0.3.1-source.tar.gz.", "",
|
"Candidate source must be retained with its build identity; no public archive is asserted.", "",
|
||||||
"The release source set also includes these separate assets:", ""]
|
"The release source set also includes these separate assets:", ""]
|
||||||
for item in DEPENDENCY_SOURCE_ASSETS:
|
for item in DEPENDENCY_SOURCE_ASSETS:
|
||||||
lines.extend(["- " + item["filename"],
|
lines.extend(["- " + item["filename"],
|
||||||
@@ -122,32 +127,70 @@ def install_legal_material(docs):
|
|||||||
"signature status are external release records, not builder claims.", ""])
|
"signature status are external release records, not builder claims.", ""])
|
||||||
(docs / "SOURCE-AVAILABILITY.md").write_text("\n".join(lines))
|
(docs / "SOURCE-AVAILABILITY.md").write_text("\n".join(lines))
|
||||||
(docs / "COMMUNITY-RELEASE-STATUS.json").write_text(json.dumps({
|
(docs / "COMMUNITY-RELEASE-STATUS.json").write_text(json.dumps({
|
||||||
"schema_version": 1, "version": VERSION, "status": "COMMUNITY_RELEASE_BUILD",
|
"schema_version": 1, "version": VERSION, "status": "COMPATIBILITY_CANDIDATE_BUILD",
|
||||||
"certified_as_RC7": False,
|
"certified_as_RC7": False,
|
||||||
"public_readiness": "EXTERNAL_RELEASE_RECORD",
|
"public_readiness": "EXTERNAL_RELEASE_RECORD",
|
||||||
"signature_status": "EXTERNAL_RELEASE_RECORD",
|
"signature_status": "EXTERNAL_RELEASE_RECORD",
|
||||||
"vm_lifecycle_validation": "NOT_ASSERTED_BY_BUILDER",
|
"vm_lifecycle_validation": "NOT_ASSERTED_BY_BUILDER",
|
||||||
"source_archive": "FrameBridge-0.3.1-source.tar.gz",
|
"source_archive": "CANDIDATE_SOURCE_REQUIRED_BEFORE_PUBLICATION",
|
||||||
"dependency_source_archives": DEPENDENCY_SOURCE_ASSETS
|
"dependency_source_archives": DEPENDENCY_SOURCE_ASSETS
|
||||||
}, indent=2) + "\n")
|
}, indent=2) + "\n")
|
||||||
|
|
||||||
def main():
|
def main(argv=None):
|
||||||
|
global VERSION, PUBLIC_FILENAME, RELEASE, STAGE, PREFIX
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--target", required=True, choices=sorted(PROFILES))
|
||||||
|
parser.add_argument("--version", default=CANDIDATE_VERSION)
|
||||||
|
parser.add_argument("--vendor-dir", type=Path, help="Reviewed target-ABI Fleet Hub vendor tree; never installed globally")
|
||||||
|
parser.add_argument("--check-vendor", action="store_true", help="Validate inputs without staging or building a package")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
if not re.fullmatch(r"[0-9][0-9A-Za-z.+:~\-]*", args.version) or args.version == "0.3.1":
|
||||||
|
parser.error("A distinct valid Debian candidate version is required")
|
||||||
|
subprocess.run(["dpkg", "--validate-version", args.version], check=True)
|
||||||
|
VERSION = args.version
|
||||||
|
version_assignments = [node for node in ast.parse((BASE / "expanded/framebridge_expanded/__init__.py").read_text()).body
|
||||||
|
if isinstance(node, ast.Assign) and any(isinstance(item, ast.Name) and item.id == "__version__" for item in node.targets)]
|
||||||
|
if len(version_assignments) != 1 or ast.literal_eval(version_assignments[0].value) != VERSION:
|
||||||
|
raise RuntimeError("Candidate package version differs from application __version__")
|
||||||
|
profile = target_profile(args.target)
|
||||||
|
profile["package_version"] = VERSION
|
||||||
|
original_vendor = BASE / SOURCE_VENDOR
|
||||||
|
vendor = args.vendor_dir.resolve() if args.vendor_dir else original_vendor
|
||||||
|
if args.vendor_dir is None and profile["python_abi"] != "cp313":
|
||||||
|
parser.error("This target requires --vendor-dir with reviewed " + profile["python_abi"] + " wheels")
|
||||||
|
vendor_evidence = validate_vendor(vendor, profile, vendor_versions(original_vendor))
|
||||||
|
profile["vendor"] = vendor_evidence
|
||||||
|
PUBLIC_FILENAME = "FrameBridge-" + VERSION + "-" + args.target + ".deb"
|
||||||
|
RELEASE = BASE / "release" / args.target
|
||||||
|
STAGE = BASE / "build" / args.target / "package-root"
|
||||||
|
PREFIX = STAGE / "usr/lib/framebridge-expanded"
|
||||||
legal_preflight()
|
legal_preflight()
|
||||||
baseline = json.loads((BASE / "evidence/BASELINE.json").read_text())
|
baseline = json.loads((BASE / "evidence/BASELINE.json").read_text())
|
||||||
for relative, expected in baseline["source_hashes"].items():
|
for relative, expected in baseline["source_hashes"].items():
|
||||||
assert sha(BASE / "sources" / relative) == expected, "Original source changed: " + relative
|
if sha(BASE / "sources" / relative) != expected:
|
||||||
|
raise RuntimeError("Original source changed: " + relative)
|
||||||
|
if args.check_vendor:
|
||||||
|
print(json.dumps(profile, sort_keys=True))
|
||||||
|
return
|
||||||
if STAGE.exists():
|
if STAGE.exists():
|
||||||
shutil.rmtree(STAGE)
|
shutil.rmtree(STAGE)
|
||||||
PREFIX.mkdir(parents=True)
|
PREFIX.mkdir(parents=True)
|
||||||
RELEASE.mkdir(exist_ok=True)
|
RELEASE.mkdir(parents=True, exist_ok=True)
|
||||||
for name, dest in [("original-asus", "asus"), ("original-geranas", "geranas"), ("original-gerameg", "gerameg")]:
|
for name, dest in [("original-asus", "asus"), ("original-geranas", "geranas"), ("original-gerameg", "gerameg")]:
|
||||||
source = BASE / "sources" / name
|
source = BASE / "sources" / name
|
||||||
assert source.is_dir(), "Required public source snapshot missing: " + name
|
if not source.is_dir():
|
||||||
|
raise RuntimeError("Required public source snapshot missing: " + name)
|
||||||
shutil.copytree(source, PREFIX / "original" / dest, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
shutil.copytree(source, PREFIX / "original" / dest, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
||||||
|
staged_vendor = PREFIX / VENDOR_RELATIVE
|
||||||
|
shutil.rmtree(staged_vendor)
|
||||||
|
shutil.copytree(vendor, staged_vendor, ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "bin", "FB-ABI-PREPARED.json"))
|
||||||
|
if validate_vendor(staged_vendor, profile, vendor_versions(original_vendor)) != vendor_evidence:
|
||||||
|
raise RuntimeError("Staged vendor tree differs from reviewed input")
|
||||||
|
(PREFIX / "release-profile.json").write_text(json.dumps(profile, indent=2, sort_keys=True) + "\n")
|
||||||
for source in ("portable", "expanded", "docs"):
|
for source in ("portable", "expanded", "docs"):
|
||||||
shutil.copytree(BASE / source, PREFIX / source, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
shutil.copytree(BASE / source, PREFIX / source, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
||||||
(PREFIX / "bin").mkdir()
|
(PREFIX / "bin").mkdir()
|
||||||
for name in ("framebridge", "framebridge-original", "framebridge-native-expanded", "framebridge-screen-access"):
|
for name in ("framebridge", "framebridge-original", "framebridge-native-expanded", "framebridge-screen-access", "framebridge-credential-helper"):
|
||||||
shutil.copy2(BASE / "packaging" / name, PREFIX / "bin" / name)
|
shutil.copy2(BASE / "packaging" / name, PREFIX / "bin" / name)
|
||||||
(PREFIX / "bin" / name).chmod(0o755)
|
(PREFIX / "bin" / name).chmod(0o755)
|
||||||
rules = STAGE / "usr/lib/udev/rules.d"
|
rules = STAGE / "usr/lib/udev/rules.d"
|
||||||
@@ -193,7 +236,7 @@ def main():
|
|||||||
"Maintainer: FrameBridge <framebridge@localhost>\n"
|
"Maintainer: FrameBridge <framebridge@localhost>\n"
|
||||||
"Homepage: https://framebridge.betancourt.one/\n"
|
"Homepage: https://framebridge.betancourt.one/\n"
|
||||||
"Installed-Size: " + str((installed + 1023) // 1024) + "\n"
|
"Installed-Size: " + str((installed + 1023) // 1024) + "\n"
|
||||||
"Depends: python3 (>= 3.13), python3 (<< 3.14), libc6 (>= 2.34), libgcc-s1, python3-cryptography, sudo, libpam-systemd, dbus-user-session, udev, libx11-6, ca-certificates, iproute2, procps, systemd, bash, tmux, coreutils, util-linux, mawk | gawk, gnome-terminal, wmctrl, xdotool, x11-utils, xclip, imagemagick, xterm, xvfb, openbox, dbus-x11, xauth, x11-xkb-utils, fonts-dejavu-core\n"
|
"Depends: python3 (>= " + profile["python_minor"] + "), python3 (<< 3." + str(int(profile["python_minor"].split(".")[1]) + 1) + "), libc6 (>= 2.34), libgcc-s1, python3-cryptography, sudo, libpam-systemd, dbus-user-session, udev, libx11-6, ca-certificates, iproute2, procps, systemd, bash, tmux, coreutils, util-linux, mawk | gawk, gnome-terminal, wmctrl, xdotool, x11-utils, xclip, imagemagick, xterm, xvfb, openbox, dbus-x11, xauth, x11-xkb-utils, fonts-dejavu-core\n"
|
||||||
"Description: FrameBridge with standalone, central and node modes\n"
|
"Description: FrameBridge with standalone, central and node modes\n"
|
||||||
" Original execution and desktop programs, a real persistent terminal,\n"
|
" Original execution and desktop programs, a real persistent terminal,\n"
|
||||||
" authenticated node enrollment, and a manual ChatGPT tunnel wizard.\n"
|
" authenticated node enrollment, and a manual ChatGPT tunnel wizard.\n"
|
||||||
@@ -230,12 +273,13 @@ def main():
|
|||||||
os.utime(path, (EPOCH, EPOCH), follow_symlinks=False)
|
os.utime(path, (EPOCH, EPOCH), follow_symlinks=False)
|
||||||
manifest = [{"path": str(p.relative_to(STAGE)), "sha256": sha(p), "bytes": p.stat().st_size}
|
manifest = [{"path": str(p.relative_to(STAGE)), "sha256": sha(p), "bytes": p.stat().st_size}
|
||||||
for p in sorted(STAGE.rglob("*")) if p.is_file() and not p.is_symlink()]
|
for p in sorted(STAGE.rglob("*")) if p.is_file() and not p.is_symlink()]
|
||||||
|
(RELEASE / "RELEASE-PROFILE.json").write_text(json.dumps(profile, indent=2, sort_keys=True) + "\n")
|
||||||
(RELEASE / "PACKAGE-MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
(RELEASE / "PACKAGE-MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||||
output = RELEASE / ("framebridge-expanded_" + VERSION + "_amd64.deb")
|
output = RELEASE / ("framebridge-expanded_" + VERSION + "_amd64.deb")
|
||||||
env = dict(os.environ, SOURCE_DATE_EPOCH=str(EPOCH))
|
env = dict(os.environ, SOURCE_DATE_EPOCH=str(EPOCH))
|
||||||
subprocess.run(["dpkg-deb", "--root-owner-group", "-Zxz", "-z6", "--build", str(STAGE), str(output)], check=True, env=env)
|
subprocess.run(["dpkg-deb", "--root-owner-group", "-Zxz", "-z6", "--build", str(STAGE), str(output)], check=True, env=env)
|
||||||
shutil.copy2(output, RELEASE / PUBLIC_FILENAME)
|
shutil.copy2(output, RELEASE / PUBLIC_FILENAME)
|
||||||
print(json.dumps({"package": str(output), "bytes": output.stat().st_size, "sha256": sha(output), "payload_entries": len(manifest), "public_filename": PUBLIC_FILENAME, "status": "COMMUNITY_RELEASE_BUILD"}))
|
print(json.dumps({"package": str(output), "bytes": output.stat().st_size, "sha256": sha(output), "payload_entries": len(manifest), "profile_id": args.target, "validation_status": "candidate", "public_filename": PUBLIC_FILENAME, "status": "COMPATIBILITY_CANDIDATE_BUILD"}))
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""Explicit Debian-family candidate targets and offline vendor validation.
|
||||||
|
|
||||||
|
This module never installs dependencies or imports the target's native modules.
|
||||||
|
Profiles describe eligible build inputs, not completed OS/desktop validation.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import csv
|
||||||
|
from email.parser import Parser
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
CANDIDATE_VERSION = "0.3.2~compat.20261027.1"
|
||||||
|
VENDOR_RELATIVE = Path("original/geranas/FrameBridge-Fleet-Hub/vendor")
|
||||||
|
SOURCE_VENDOR = Path("sources/original-geranas/FrameBridge-Fleet-Hub/vendor")
|
||||||
|
GLIBC_BASELINE = (2, 34)
|
||||||
|
PROFILES = {
|
||||||
|
"debian13-amd64-py313": {"os_id": "debian", "os_version": "13", "python_minor": "3.13"},
|
||||||
|
"ubuntu24.04-amd64-py312": {"os_id": "ubuntu", "os_version": "24.04", "python_minor": "3.12"},
|
||||||
|
"mint22-amd64-py312": {"os_id": "linuxmint", "os_version": "22", "python_minor": "3.12"},
|
||||||
|
"ubuntu26.04-amd64-py314": {"os_id": "ubuntu", "os_version": "26.04", "python_minor": "3.14"},
|
||||||
|
"lmde7-amd64-py313": {"os_id": "linuxmint", "os_version": "7", "python_minor": "3.13"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def target_profile(profile_id):
|
||||||
|
profile = dict(PROFILES[profile_id])
|
||||||
|
profile.update(schema=1, profile_id=profile_id, architecture="amd64",
|
||||||
|
python_abi="cp" + profile["python_minor"].replace(".", ""),
|
||||||
|
validation_status="candidate", glibc_minimum="2.34")
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized(name):
|
||||||
|
return re.sub(r"[-_.]+", "-", name).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def distribution_metadata(directory):
|
||||||
|
directory = Path(directory)
|
||||||
|
if not directory.is_dir() or directory.is_symlink():
|
||||||
|
raise RuntimeError("Vendor input must be a real directory: " + str(directory))
|
||||||
|
for path in directory.rglob("*"):
|
||||||
|
if path.is_symlink():
|
||||||
|
raise RuntimeError("Vendor symlinks are not supported: " + str(path))
|
||||||
|
result = {}
|
||||||
|
for info in sorted(directory.glob("*.dist-info")):
|
||||||
|
metadata = info / "METADATA"
|
||||||
|
if not metadata.is_file():
|
||||||
|
raise RuntimeError("Missing vendor METADATA: " + str(info))
|
||||||
|
msg = Parser().parsestr(metadata.read_text(encoding="utf-8"))
|
||||||
|
name, version = msg.get("Name"), msg.get("Version")
|
||||||
|
if not name or not version or _normalized(name) in result:
|
||||||
|
raise RuntimeError("Missing or duplicate vendor distribution identity: " + str(info))
|
||||||
|
result[_normalized(name)] = (version, info, msg)
|
||||||
|
if not result:
|
||||||
|
raise RuntimeError("Vendor input contains no distribution metadata")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def vendor_versions(directory):
|
||||||
|
return {name: value[0] for name, value in distribution_metadata(directory).items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _python_allowed(requirement, minor):
|
||||||
|
# Fail closed on unsupported specifier syntax rather than silently omitting it.
|
||||||
|
version = tuple(map(int, minor.split("."))) + (0,)
|
||||||
|
for part in requirement.split(","):
|
||||||
|
match = re.fullmatch(r"\s*(>=|<=|!=|==|>|<)\s*(\d+(?:\.\d+){0,2})(\.\*)?\s*", part)
|
||||||
|
if not match:
|
||||||
|
raise RuntimeError("Unsupported Requires-Python specifier: " + requirement)
|
||||||
|
op, number, wildcard = match.groups()
|
||||||
|
components = tuple(map(int, number.split(".")))
|
||||||
|
wanted = components + (0,) * (3 - len(components))
|
||||||
|
if wildcard:
|
||||||
|
if op not in ("==", "!="):
|
||||||
|
raise RuntimeError("Invalid Requires-Python wildcard: " + requirement)
|
||||||
|
equal = version[:len(components)] == components
|
||||||
|
allowed = equal if op == "==" else not equal
|
||||||
|
else:
|
||||||
|
allowed = {">=": version >= wanted, "<=": version <= wanted,
|
||||||
|
">": version > wanted, "<": version < wanted,
|
||||||
|
"==": version == wanted, "!=": version != wanted}[op]
|
||||||
|
if not allowed:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _wheel_tag_allowed(tag, profile):
|
||||||
|
parts = tag.split("-")
|
||||||
|
if len(parts) != 3:
|
||||||
|
return False
|
||||||
|
interpreters, abis, platforms = [part.split(".") for part in parts]
|
||||||
|
target = profile["python_abi"]
|
||||||
|
target_number = int(target[2:])
|
||||||
|
for interpreter in interpreters:
|
||||||
|
for abi in abis:
|
||||||
|
if abi == "none" and interpreter in ("py3", target):
|
||||||
|
python_ok = True
|
||||||
|
elif abi == target and interpreter == target:
|
||||||
|
python_ok = True
|
||||||
|
elif abi == "abi3" and re.fullmatch(r"cp3\d+", interpreter):
|
||||||
|
python_ok = int(interpreter[2:]) <= target_number
|
||||||
|
else:
|
||||||
|
python_ok = False
|
||||||
|
if not python_ok:
|
||||||
|
continue
|
||||||
|
for platform in platforms:
|
||||||
|
if platform == "any" and abi == "none":
|
||||||
|
return True
|
||||||
|
match = re.fullmatch(r"manylinux_(\d+)_(\d+)_x86_64", platform)
|
||||||
|
legacy = {"manylinux1_x86_64": (2, 5), "manylinux2010_x86_64": (2, 12),
|
||||||
|
"manylinux2014_x86_64": (2, 17)}
|
||||||
|
baseline = tuple(map(int, match.groups())) if match else legacy.get(platform)
|
||||||
|
if baseline and baseline <= GLIBC_BASELINE and abi != "none":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_vendor(directory, profile, expected_versions=None):
|
||||||
|
directory = Path(directory)
|
||||||
|
distributions = distribution_metadata(directory)
|
||||||
|
versions = {name: value[0] for name, value in distributions.items()}
|
||||||
|
if expected_versions is not None and versions != expected_versions:
|
||||||
|
raise RuntimeError("Vendor distribution names/versions differ from reviewed source inputs")
|
||||||
|
owners = {}
|
||||||
|
native_owners = set()
|
||||||
|
packages = []
|
||||||
|
for name, (version, info, metadata) in distributions.items():
|
||||||
|
requires_python = metadata.get("Requires-Python")
|
||||||
|
if requires_python and not _python_allowed(requires_python, profile["python_minor"]):
|
||||||
|
raise RuntimeError("Vendor Requires-Python rejects target: " + name)
|
||||||
|
wheel, record = info / "WHEEL", info / "RECORD"
|
||||||
|
if not wheel.is_file() or not record.is_file():
|
||||||
|
raise RuntimeError("Missing vendor WHEEL/RECORD: " + name)
|
||||||
|
wheel_metadata = Parser().parsestr(wheel.read_text(encoding="utf-8"))
|
||||||
|
tags = wheel_metadata.get_all("Tag", [])
|
||||||
|
if not tags or not any(_wheel_tag_allowed(tag, profile) for tag in tags):
|
||||||
|
raise RuntimeError("Vendor wheel ABI/platform rejects target: " + name + " " + repr(tags))
|
||||||
|
if (wheel_metadata.get("Root-Is-Purelib", "").lower() == "false"
|
||||||
|
and any(_wheel_tag_allowed(tag, profile) and not tag.endswith("-any") for tag in tags)):
|
||||||
|
native_owners.add(name)
|
||||||
|
for row in csv.reader(record.read_text(encoding="utf-8").splitlines()):
|
||||||
|
if row:
|
||||||
|
owners.setdefault(row[0], []).append(name)
|
||||||
|
relative = Path(row[0])
|
||||||
|
if relative.is_absolute():
|
||||||
|
raise RuntimeError("Absolute vendor RECORD path: " + row[0])
|
||||||
|
# Wheel entry-point scripts are not shipped in this module tree.
|
||||||
|
if ".." in relative.parts or "bin" in relative.parts or "__pycache__" in relative.parts:
|
||||||
|
continue
|
||||||
|
file = directory / relative
|
||||||
|
if len(row) < 3 or not file.is_file():
|
||||||
|
raise RuntimeError("Missing or malformed vendor RECORD file: " + row[0])
|
||||||
|
if not row[1] and file != record:
|
||||||
|
raise RuntimeError("Vendor RECORD digest missing: " + row[0])
|
||||||
|
if row[1]:
|
||||||
|
algorithm, separator, encoded = row[1].partition("=")
|
||||||
|
wanted = base64.urlsafe_b64encode(hashlib.sha256(file.read_bytes()).digest()).decode().rstrip("=")
|
||||||
|
if algorithm != "sha256" or not separator or wanted != encoded:
|
||||||
|
raise RuntimeError("Vendor RECORD digest mismatch: " + row[0])
|
||||||
|
if row[2] and int(row[2]) != file.stat().st_size:
|
||||||
|
raise RuntimeError("Vendor RECORD size mismatch: " + row[0])
|
||||||
|
packages.append({"name": name, "version": version, "wheel_tags": tags})
|
||||||
|
native = []
|
||||||
|
for path in sorted(directory.rglob("*.so")):
|
||||||
|
relative = path.relative_to(directory).as_posix()
|
||||||
|
if len(owners.get(relative, [])) != 1:
|
||||||
|
raise RuntimeError("Native vendor file is not owned by one RECORD: " + relative)
|
||||||
|
if owners[relative][0] not in native_owners:
|
||||||
|
raise RuntimeError("Native vendor file lacks matching native WHEEL metadata: " + relative)
|
||||||
|
match = re.search(r"\.cpython-(\d+)-x86_64-linux-gnu\.so$", path.name)
|
||||||
|
if match:
|
||||||
|
if "cp" + match.group(1) != profile["python_abi"]:
|
||||||
|
raise RuntimeError("Native vendor extension ABI rejects target: " + relative)
|
||||||
|
elif not path.name.endswith(".abi3.so"):
|
||||||
|
raise RuntimeError("Native vendor extension has unrecognized ABI suffix: " + relative)
|
||||||
|
data = path.read_bytes()
|
||||||
|
if len(data) < 20 or data[:6] != b"\x7fELF\x02\x01" or int.from_bytes(data[18:20], "little") != 62:
|
||||||
|
raise RuntimeError("Native vendor extension is not ELF amd64: " + relative)
|
||||||
|
needed = [tuple(map(int, values)) for values in re.findall(rb"GLIBC_(\d+)\.(\d+)", data)]
|
||||||
|
if needed and max(needed) > GLIBC_BASELINE:
|
||||||
|
raise RuntimeError("Native vendor extension exceeds glibc 2.34 baseline: " + relative)
|
||||||
|
native.append(relative)
|
||||||
|
required = ("_cffi_backend.cpython-", "pydantic_core/_pydantic_core.cpython-", "rpds/rpds.cpython-")
|
||||||
|
if any(not any(path.startswith(prefix) for path in native) for prefix in required):
|
||||||
|
raise RuntimeError("Required cffi/pydantic_core/rpds native vendor extensions missing")
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
for path in sorted(directory.rglob("*")):
|
||||||
|
if path.is_file() and "__pycache__" not in path.parts and path.suffix != ".pyc" and "bin" not in path.relative_to(directory).parts and path.name != "FB-ABI-PREPARED.json":
|
||||||
|
digest.update(path.relative_to(directory).as_posix().encode() + b"\0")
|
||||||
|
digest.update(hashlib.sha256(path.read_bytes()).digest())
|
||||||
|
return {"vendor_sha256": digest.hexdigest(), "packages": packages, "native_extensions": native}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Installed root-owned; never run a privileged helper from an owner checkout.
|
||||||
|
exec /usr/bin/python3 -I -S /usr/lib/framebridge-expanded/expanded/framebridge_expanded/credential_helper.py "$@"
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
"""Generate installed, MCP-export and web-guide inputs from the shared contract."""
|
||||||
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
SOURCE=Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0,str(SOURCE/'expanded'))
|
||||||
|
from framebridge_expanded.guide import export_guide, GUIDE_SCHEMA
|
||||||
|
from framebridge_expanded import __version__
|
||||||
|
|
||||||
|
def markdown(data, title):
|
||||||
|
lines=['# '+title,'','Version: '+data['version']+' | Guide schema: '+str(GUIDE_SCHEMA),
|
||||||
|
'','This reference is generated from the shared CLI/MCP guide. Use the matching installed version.',
|
||||||
|
'','## Contents','']
|
||||||
|
for i,(name,entry) in enumerate(data['topics'].items(),1):
|
||||||
|
lines.append(str(i)+'. ['+entry['title']+'](#'+str(i)+'-'+name+')')
|
||||||
|
for i,(name,entry) in enumerate(data['topics'].items(),1):
|
||||||
|
lines.extend(['','<a id="'+str(i)+'-'+name+'"></a>','','## '+str(i)+'. '+entry['title'],''])
|
||||||
|
for section in entry['sections']:
|
||||||
|
lines.extend(['### '+section['heading'],'',section['body'],''])
|
||||||
|
if section['commands']:
|
||||||
|
lines.extend(['~~~sh',*section['commands'],'~~~',''])
|
||||||
|
return '\n'.join(lines).rstrip()+'\n'
|
||||||
|
|
||||||
|
def roff(text):
|
||||||
|
return text.replace('\\','\\e').replace('-','\\-')
|
||||||
|
def man(data):
|
||||||
|
lines=['.TH FRAMEBRIDGE 1 "2026-10-27" "FrameBridge '+__version__+'" "User Commands"',
|
||||||
|
'.SH NAME','framebridge, fb \\- use local Linux tools and enrolled nodes with ChatGPT',
|
||||||
|
'.SH SYNOPSIS','.B framebridge',
|
||||||
|
'[\\-\\-json] [\\-\\-state\\-dir DIRECTORY] COMMAND [ARGUMENTS]',
|
||||||
|
'.SH DESCRIPTION',
|
||||||
|
'FrameBridge provides one command-line interface for human operators and authorized agents.',
|
||||||
|
'The names fb and framebridge execute the same program.',
|
||||||
|
'The tools run on the selected Linux computers; the ChatGPT model remains hosted.']
|
||||||
|
for entry in data['topics'].values():
|
||||||
|
lines.extend(['.SH '+entry['title'].upper()])
|
||||||
|
for section in entry['sections']:
|
||||||
|
lines.extend(['.SS '+roff(section['heading']),roff(section['body'])])
|
||||||
|
if section['commands']:
|
||||||
|
lines.extend(['.PP','.nf',*(roff(x) for x in section['commands']),'.fi'])
|
||||||
|
lines.extend(['.SH AUTHORS','Programming: ChatGPT.',
|
||||||
|
'Concept and project coordination: Gerardo Betancourt.',
|
||||||
|
'.SH LICENSE','GNU GPL version 3 only, with the applicable third-party license notices.',
|
||||||
|
'See the installed copyright and source license files.',
|
||||||
|
'.SH SEE ALSO','.BR systemctl (1), .BR dpkg (1), .BR apt (8)'])
|
||||||
|
return '\n'.join(lines)+'\n'
|
||||||
|
def main():
|
||||||
|
data=export_guide();doc=SOURCE/'docs';doc.mkdir(exist_ok=True)
|
||||||
|
(doc/'COMMAND-GUIDE.json').write_text(json.dumps(data,indent=2,ensure_ascii=False)+'\n')
|
||||||
|
ref=markdown(data,'FrameBridge — command guide')
|
||||||
|
(doc/'COMMAND-REFERENCE.md').write_text(ref)
|
||||||
|
(SOURCE.parent/'docs/COMMAND-REFERENCE.md').write_text(ref)
|
||||||
|
(doc/'DEBIAN-TUTORIAL.md').write_text(markdown(data,'FrameBridge — Debian, Ubuntu and Linux Mint installation and first use'))
|
||||||
|
(doc/'framebridge.1').write_text(man(data))
|
||||||
|
print(json.dumps({'version':data['version'],'guide_schema':GUIDE_SCHEMA,'topics':len(data['topics']),'guide_sha256':data['guide_sha256'],'generated_files':5}))
|
||||||
|
if __name__=='__main__':main()
|
||||||
@@ -120,9 +120,13 @@ class AdaptiveCoreTests(unittest.TestCase):
|
|||||||
|
|
||||||
def tearDown(self): self.temp.cleanup()
|
def tearDown(self): self.temp.cleanup()
|
||||||
|
|
||||||
def doctor(self,missing):
|
def doctor(self,missing,*,compatibility=None):
|
||||||
def which(name,path=None): return None if name in missing else '/fixture/bin/'+name
|
def which(name,path=None): return None if name in missing else '/fixture/bin/'+name
|
||||||
with patch.object(runtime,'environment',return_value=dict(self.env)),patch.object(runtime.shutil,'which',side_effect=which),patch('framebridge_expanded.network.inspect',return_value={}):
|
# Graphical-capability tests use an eligible artifact fixture. Source
|
||||||
|
# checkouts do not contain the installed vendor layout or manifest.
|
||||||
|
eligibility = compatibility if compatibility is not None else {
|
||||||
|
'ok': True, 'profile_id': 'debian13-amd64-py313', 'issues': [], 'warnings': []}
|
||||||
|
with patch.object(runtime,'environment',return_value=dict(self.env)),patch.object(runtime.shutil,'which',side_effect=which),patch('framebridge_expanded.network.inspect',return_value={}),patch('framebridge_expanded.compatibility.assess',return_value=eligibility):
|
||||||
return runtime.doctor(self.state)
|
return runtime.doctor(self.state)
|
||||||
|
|
||||||
def test_optional_gui_dependencies_do_not_block_text_tools(self):
|
def test_optional_gui_dependencies_do_not_block_text_tools(self):
|
||||||
@@ -135,6 +139,14 @@ class AdaptiveCoreTests(unittest.TestCase):
|
|||||||
self.assertFalse(result['capabilities']['graphical_cua'])
|
self.assertFalse(result['capabilities']['graphical_cua'])
|
||||||
self.assertTrue(result['optional_missing_commands']['graphical_cua'])
|
self.assertTrue(result['optional_missing_commands']['graphical_cua'])
|
||||||
|
|
||||||
|
def test_text_mode_does_not_bypass_incompatible_artifact(self):
|
||||||
|
rejected = {'ok': False, 'profile_id': None, 'issues': ['Fixture artifact ABI mismatch'], 'warnings': []}
|
||||||
|
result = self.doctor(set(dc.GRAPHICAL_COMMANDS + dc.VIRTUAL_COMMANDS), compatibility=rejected)
|
||||||
|
self.assertFalse(result['ok'])
|
||||||
|
self.assertIn('Fixture artifact ABI mismatch', result['issues'])
|
||||||
|
self.assertEqual([], result['missing_commands'])
|
||||||
|
self.assertFalse(result['capabilities']['graphical_cua'])
|
||||||
|
|
||||||
def test_missing_core_command_still_blocks_startup(self):
|
def test_missing_core_command_still_blocks_startup(self):
|
||||||
result=self.doctor({'bash'})
|
result=self.doctor({'bash'})
|
||||||
self.assertFalse(result['ok']);self.assertIn('bash',result['missing_commands'])
|
self.assertFalse(result['ok']);self.assertIn('bash',result['missing_commands'])
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Candidate archive input selection; no host package or runtime changes."""
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "packaging"))
|
||||||
|
import archive_sources
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateArchiveTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.root = Path(self.tmp.name)
|
||||||
|
self.base = self.root / "source"
|
||||||
|
self.base.mkdir()
|
||||||
|
self.input = self.base / "example.py"
|
||||||
|
self.input.write_text("# candidate source\n")
|
||||||
|
|
||||||
|
def run_archive(self, args):
|
||||||
|
evidence = {"vendor_sha256": "a" * 64, "packages": [], "native_extensions": []}
|
||||||
|
with mock.patch.object(archive_sources, "BASE", self.base), \
|
||||||
|
mock.patch.object(archive_sources, "source_files", return_value={"source/example.py": self.input}), \
|
||||||
|
mock.patch.object(archive_sources, "vendor_versions", return_value={}), \
|
||||||
|
mock.patch.object(archive_sources, "validate_vendor", return_value=evidence), \
|
||||||
|
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()) as stderr:
|
||||||
|
archive_sources.main(args)
|
||||||
|
return self.base / "release/candidate-source" / archive_sources.ARCHIVE_NAME, stderr.getvalue()
|
||||||
|
|
||||||
|
def test_default_archive_declares_missing_alternate_abis(self):
|
||||||
|
output, warning = self.run_archive([])
|
||||||
|
self.assertIn("cp312", warning)
|
||||||
|
with tarfile.open(output) as archive:
|
||||||
|
data = json.load(archive.extractfile(archive_sources.ARCHIVE_ROOT + "/TARGET-VENDOR-PROVENANCE.json"))
|
||||||
|
readme = archive.extractfile(archive_sources.ARCHIVE_ROOT + "/README-SOURCE-ARCHIVE.md").read().decode()
|
||||||
|
self.assertEqual(data["omitted_vendor_abis"], ["cp312", "cp314"])
|
||||||
|
self.assertIn("--target debian13-amd64-py313", readme)
|
||||||
|
self.assertIn("not a signed public release", readme)
|
||||||
|
self.assertEqual(data["validation_status"], "candidate")
|
||||||
|
|
||||||
|
def test_explicit_vendors_included_without_private_markers_or_bin(self):
|
||||||
|
args = []
|
||||||
|
for abi in ("cp312", "cp314"):
|
||||||
|
vendor = self.root / abi; vendor.mkdir()
|
||||||
|
(vendor / "module.py").write_text("# reviewed input\n")
|
||||||
|
(vendor / "private.log").write_text("private canary\n")
|
||||||
|
(vendor / "FB-ABI-PREPARED.json").write_text('{"private_path":"/home/example"}')
|
||||||
|
(vendor / "bin").mkdir(); (vendor / "bin/tool").write_text("#!/tmp/private/python\n")
|
||||||
|
args.extend(["--vendor-" + abi, str(vendor)])
|
||||||
|
output, warning = self.run_archive(args)
|
||||||
|
self.assertFalse(warning)
|
||||||
|
with tarfile.open(output) as archive:
|
||||||
|
names = archive.getnames()
|
||||||
|
data = json.load(archive.extractfile(archive_sources.ARCHIVE_ROOT + "/TARGET-VENDOR-PROVENANCE.json"))
|
||||||
|
self.assertIn(archive_sources.ARCHIVE_ROOT + "/source/target-vendors/cp312/module.py", names)
|
||||||
|
self.assertIn(archive_sources.ARCHIVE_ROOT + "/source/target-vendors/cp314/module.py", names)
|
||||||
|
self.assertFalse(any("FB-ABI-PREPARED" in name or name.endswith("bin/tool") or name.endswith("private.log") for name in names))
|
||||||
|
self.assertEqual(set(data["vendors"]), {"cp312", "cp313", "cp314"})
|
||||||
|
self.assertEqual(data["omitted_vendor_abis"], [])
|
||||||
|
|
||||||
|
def test_alternate_vendor_symlink_is_rejected(self):
|
||||||
|
vendor = self.root / "cp312"; vendor.mkdir()
|
||||||
|
(vendor / "unsafe.py").symlink_to(self.input)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "symlink rejected"):
|
||||||
|
self.run_archive(["--vendor-cp312", str(vendor)])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""Synthetic backend/lifecycle tests. No host credentials or sudo are used."""
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
from framebridge_expanded import autostart, config, connection_background
|
||||||
|
|
||||||
|
ID = "tunnel_0123456789abcdef0123456789abcdef"
|
||||||
|
KEY = "SYNTHETIC-FRAMEBRIDGE-HOST-BACKEND-NOT-A-REAL-KEY"
|
||||||
|
|
||||||
|
|
||||||
|
class HostCredentialAutostartTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory(); self.addCleanup(self.temp.cleanup)
|
||||||
|
self.home = Path(self.temp.name) / "home"; self.home.mkdir(mode=0o700)
|
||||||
|
self.state = self.home / "profile"
|
||||||
|
value = config.default_config(); value.update(tunnel_id=ID, headless="no")
|
||||||
|
config.save_config(self.state, value)
|
||||||
|
self.settings = {"account_label": "owner@example.test", "tunnel_id": ID,
|
||||||
|
"control_plane_organization_id": "", "control_plane_url_path": ""}
|
||||||
|
self.calls = []; self.system_calls = []; self.generation = 0
|
||||||
|
self.addCleanup(mock.patch.stopall)
|
||||||
|
mock.patch.object(Path, "home", return_value=self.home).start()
|
||||||
|
self.command = mock.patch.object(autostart, "_command", side_effect=self.fake_command).start()
|
||||||
|
self.systemctl = mock.patch.object(autostart, "_systemctl", side_effect=self.fake_systemctl).start()
|
||||||
|
self.helper = mock.patch.object(autostart, "_helper_ready").start()
|
||||||
|
mock.patch.object(autostart, "_linger", return_value=False).start()
|
||||||
|
mock.patch.object(autostart, "_enabled_units", return_value=[]).start()
|
||||||
|
mock.patch.object(autostart.os, "geteuid", return_value=1000).start()
|
||||||
|
mock.patch.object(autostart.resource, "setrlimit").start()
|
||||||
|
mock.patch("builtins.input", return_value="y").start()
|
||||||
|
self.prompt = mock.patch.object(autostart.getpass, "getpass", return_value=KEY).start()
|
||||||
|
mock.patch.object(autostart, "status", return_value={"configured": True}).start()
|
||||||
|
mock.patch.object(connection_background, "status", return_value={"alive": False}).start()
|
||||||
|
mock.patch.object(connection_background, "disconnect", return_value={"ok": True, "alive": False}).start()
|
||||||
|
mock.patch("sys.stdout", new_callable=io.StringIO).start()
|
||||||
|
|
||||||
|
def fake_command(self, args, **kwargs):
|
||||||
|
self.calls.append((args, kwargs))
|
||||||
|
output = b""
|
||||||
|
if args[:2] == ["systemd-creds", "--version"]: output = b"systemd 255\n"
|
||||||
|
elif "protect" in args or "encrypt" in args:
|
||||||
|
self.generation += 1
|
||||||
|
output = ("SYNTHETIC-HOST-CIPHER-%03d-" % self.generation).encode() * 8
|
||||||
|
elif "decrypt" in args: output = KEY.encode()
|
||||||
|
return subprocess.CompletedProcess(args, 0, output, b"")
|
||||||
|
|
||||||
|
def fake_systemctl(self, *args, **kwargs):
|
||||||
|
self.system_calls.append(args)
|
||||||
|
return subprocess.CompletedProcess(args, 0, b"", b"")
|
||||||
|
|
||||||
|
def operations(self, name):
|
||||||
|
return [(args, options) for args, options in self.calls if name in args]
|
||||||
|
|
||||||
|
def test_255_setup_uses_helper_stdin_and_preserves_user_unit(self):
|
||||||
|
autostart.configure_connection(self.state, self.settings, KEY)
|
||||||
|
_, directory, unit = autostart._paths(self.state)
|
||||||
|
record = autostart._manifest(directory)
|
||||||
|
self.assertEqual(autostart.HOST_BACKEND, record["credential_backend"])
|
||||||
|
args, options = self.operations("protect")[0]
|
||||||
|
self.assertEqual(["sudo", "-n", "--", str(autostart.HELPER), "protect", autostart._profile_id(self.state)], args)
|
||||||
|
self.assertEqual(KEY.encode(), options["data"])
|
||||||
|
self.assertFalse(self.operations("encrypt"))
|
||||||
|
self.assertNotIn(KEY, " ".join(args))
|
||||||
|
self.assertNotIn("User=", unit.read_text())
|
||||||
|
self.assertNotIn("LoadCredentialEncrypted", unit.read_text())
|
||||||
|
for path in (unit, directory / "credential.cred", directory / "installation.json", self.state / "config.json"):
|
||||||
|
self.assertNotIn(KEY.encode(), path.read_bytes())
|
||||||
|
|
||||||
|
def test_helper_missing_fails_before_privileges_or_key_prompt(self):
|
||||||
|
self.helper.side_effect = RuntimeError("Install the matching package before connecting.")
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "Install"):
|
||||||
|
autostart.enable(self.state)
|
||||||
|
self.prompt.assert_not_called()
|
||||||
|
self.assertFalse(any(args[0] == "sudo" for args, _ in self.calls))
|
||||||
|
self.assertEqual([], self.system_calls)
|
||||||
|
|
||||||
|
def test_node_255_startup_does_not_need_a_credential_backend(self):
|
||||||
|
config.save_config(self.state, config.default_config(role="node"))
|
||||||
|
self.helper.side_effect = RuntimeError("missing helper")
|
||||||
|
autostart._enable(self.state, confirm=False, start_now=False)
|
||||||
|
self.helper.assert_not_called(); self.prompt.assert_not_called()
|
||||||
|
self.assertFalse(self.operations("protect"))
|
||||||
|
self.assertFalse(any(args[:2] == ["systemd-creds", "--version"] for args, _ in self.calls))
|
||||||
|
self.assertTrue(autostart._manifest(autostart._paths(self.state)[1])["node"])
|
||||||
|
|
||||||
|
def test_255_decrypt_is_owner_bound_by_helper_not_owner_supplied_cipher(self):
|
||||||
|
autostart.configure_connection(self.state, self.settings, KEY)
|
||||||
|
self.calls.clear()
|
||||||
|
self.assertEqual(KEY, autostart._credential(self.state))
|
||||||
|
args, options = self.operations("decrypt")[0]
|
||||||
|
record = autostart._manifest(autostart._paths(self.state)[1])
|
||||||
|
self.assertEqual(["sudo", "-n", "--", str(autostart.HELPER), "decrypt", autostart._profile_id(self.state), record["credential_sha256"]], args)
|
||||||
|
self.assertNotIn("data", options)
|
||||||
|
self.assertFalse(any(a.startswith("--uid") for a in args))
|
||||||
|
|
||||||
|
def test_legacy_manifest_keeps_256_decryption_without_autodetection(self):
|
||||||
|
_, directory, _ = autostart._paths(self.state)
|
||||||
|
cipher = b"LEGACY-CIPHERTEXT-" * 8
|
||||||
|
autostart._write(directory / "credential.cred", cipher)
|
||||||
|
autostart._json(directory / "installation.json", {"schema": 1, "node": False, "credential_sha256": hashlib.sha256(cipher).hexdigest()})
|
||||||
|
self.assertEqual(KEY, autostart._credential(self.state))
|
||||||
|
args, options = self.operations("decrypt")[0]
|
||||||
|
self.assertEqual(["systemd-creds", "decrypt", "--user", "--name=chatgpt-runtime-key", "-", "-"], args)
|
||||||
|
self.assertEqual(cipher, options["data"])
|
||||||
|
self.helper.assert_not_called()
|
||||||
|
|
||||||
|
def test_changed_cipher_fails_before_any_privileged_decrypt(self):
|
||||||
|
autostart.configure_connection(self.state, self.settings, KEY)
|
||||||
|
(autostart._paths(self.state)[1] / "credential.cred").write_bytes(b"tampered")
|
||||||
|
self.calls.clear()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "changed outside"):
|
||||||
|
autostart._credential(self.state)
|
||||||
|
self.assertEqual([], self.calls)
|
||||||
|
|
||||||
|
def test_failed_service_setup_revokes_new_helper_grant(self):
|
||||||
|
def fail(*args, **kwargs):
|
||||||
|
if args[0] == "enable": raise RuntimeError("synthetic service failure")
|
||||||
|
return self.fake_systemctl(*args, **kwargs)
|
||||||
|
self.systemctl.side_effect = fail
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
autostart.configure_connection(self.state, self.settings, KEY)
|
||||||
|
self.assertEqual(1, len(self.operations("revoke")))
|
||||||
|
self.assertIsNone(autostart._manifest(autostart._paths(self.state)[1]))
|
||||||
|
|
||||||
|
def test_input_race_after_protect_revokes_before_stopping_any_service(self):
|
||||||
|
def changed(args, **kwargs):
|
||||||
|
result = self.fake_command(args, **kwargs)
|
||||||
|
if "protect" in args:
|
||||||
|
settings = config.load_config(self.state); settings["name"] = "changed-during-protect"
|
||||||
|
config.save_config(self.state, settings)
|
||||||
|
return result
|
||||||
|
self.command.side_effect = changed
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "changed during"):
|
||||||
|
autostart.configure_connection(self.state, self.settings, KEY)
|
||||||
|
self.assertEqual(1, len(self.operations("revoke")))
|
||||||
|
self.assertFalse(any(args[0] == "stop" for args in self.system_calls))
|
||||||
|
|
||||||
|
def test_rotation_revokes_old_grant_only_after_commit(self):
|
||||||
|
autostart.configure_connection(self.state, self.settings, KEY)
|
||||||
|
directory = autostart._paths(self.state)[1]
|
||||||
|
old = autostart._manifest(directory)["credential_sha256"]
|
||||||
|
self.calls.clear(); self.system_calls.clear()
|
||||||
|
autostart.configure_connection(self.state, self.settings, KEY)
|
||||||
|
current = autostart._manifest(directory)
|
||||||
|
self.assertNotEqual(old, current["credential_sha256"])
|
||||||
|
self.assertEqual([], current["retired_helper_credentials"])
|
||||||
|
self.assertEqual(old, self.operations("revoke")[0][0][-1])
|
||||||
|
|
||||||
|
def test_retired_limit_rejects_rotation_before_protect_or_service_change(self):
|
||||||
|
autostart.configure_connection(self.state, self.settings, KEY)
|
||||||
|
directory = autostart._paths(self.state)[1]
|
||||||
|
record = autostart._manifest(directory)
|
||||||
|
record["retired_helper_credentials"] = [format(n, "064x") for n in range(128)]
|
||||||
|
autostart._json(directory / "installation.json", record, replace=True)
|
||||||
|
before = (directory / "installation.json").read_bytes()
|
||||||
|
self.calls.clear(); self.system_calls.clear()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "must be cleaned"):
|
||||||
|
autostart.configure_connection(self.state, self.settings, KEY)
|
||||||
|
self.assertFalse(self.operations("protect"))
|
||||||
|
self.assertEqual([], self.system_calls)
|
||||||
|
self.assertEqual(before, (directory / "installation.json").read_bytes())
|
||||||
|
|
||||||
|
def test_retirement_failure_is_durable_and_disable_revokes_both_versions(self):
|
||||||
|
autostart.configure_connection(self.state, self.settings, KEY)
|
||||||
|
directory = autostart._paths(self.state)[1]
|
||||||
|
old = autostart._manifest(directory)["credential_sha256"]
|
||||||
|
def fail_revoke(args, **kwargs):
|
||||||
|
if "revoke" in args: raise RuntimeError("synthetic revocation failure")
|
||||||
|
return self.fake_command(args, **kwargs)
|
||||||
|
self.command.side_effect = fail_revoke
|
||||||
|
autostart.configure_connection(self.state, self.settings, KEY)
|
||||||
|
record = autostart._manifest(directory)
|
||||||
|
self.assertEqual([old], record["retired_helper_credentials"])
|
||||||
|
self.command.side_effect = self.fake_command; self.calls.clear()
|
||||||
|
autostart.disable(self.state)
|
||||||
|
self.assertEqual("-", self.operations("prune")[0][0][-1])
|
||||||
|
self.assertIsNone(autostart._manifest(directory))
|
||||||
|
|
||||||
|
def test_failed_revoke_keeps_manifest_for_retry_after_service_stop(self):
|
||||||
|
autostart.configure_connection(self.state, self.settings, KEY)
|
||||||
|
directory = autostart._paths(self.state)[1]
|
||||||
|
def fail_revoke(args, **kwargs):
|
||||||
|
if "prune" in args: raise RuntimeError("synthetic revocation failure")
|
||||||
|
return self.fake_command(args, **kwargs)
|
||||||
|
self.command.side_effect = fail_revoke
|
||||||
|
with self.assertRaises(RuntimeError): autostart.disable(self.state)
|
||||||
|
self.assertIsNotNone(autostart._manifest(directory))
|
||||||
|
self.assertTrue(autostart._paths(self.state)[2].exists())
|
||||||
|
|
||||||
|
def test_disable_recovers_protect_without_an_owner_manifest(self):
|
||||||
|
installed = self.home / "installed-helper"
|
||||||
|
installed.write_text("synthetic trusted helper placeholder")
|
||||||
|
with mock.patch.object(autostart, "HELPER", installed):
|
||||||
|
result = autostart.disable(self.state)
|
||||||
|
self.assertTrue(result["orphan_credential_cleanup_verified"])
|
||||||
|
self.assertEqual("-", self.operations("prune")[0][0][-1])
|
||||||
|
self.assertTrue(any(args[:2] == ["sudo", "-v"] for args, _ in self.calls))
|
||||||
|
|
||||||
|
def test_disable_without_installed_helper_does_not_claim_verified_cleanup(self):
|
||||||
|
with mock.patch.object(autostart, "HELPER", self.home / "missing-helper"):
|
||||||
|
result = autostart.disable(self.state)
|
||||||
|
self.assertFalse(result["orphan_credential_cleanup_verified"])
|
||||||
|
|
||||||
|
def test_256_setup_and_disable_reconcile_orphans_after_os_upgrade(self):
|
||||||
|
installed = self.home / "installed-helper"
|
||||||
|
installed.write_text("synthetic trusted helper placeholder")
|
||||||
|
def systemd256(args, **kwargs):
|
||||||
|
if args[:2] == ["systemd-creds", "--version"]:
|
||||||
|
self.calls.append((args, kwargs))
|
||||||
|
return subprocess.CompletedProcess(args, 0, b"systemd 256\n", b"")
|
||||||
|
return self.fake_command(args, **kwargs)
|
||||||
|
self.command.side_effect = systemd256
|
||||||
|
with mock.patch.object(autostart, "HELPER", installed):
|
||||||
|
autostart.configure_connection(self.state, self.settings, KEY)
|
||||||
|
self.assertEqual(1, len(self.operations("prune")))
|
||||||
|
self.assertTrue(self.operations("encrypt")); self.assertFalse(self.operations("protect"))
|
||||||
|
autostart.disable(self.state)
|
||||||
|
self.assertEqual(2, len(self.operations("prune")))
|
||||||
|
|
||||||
|
def test_unknown_backend_never_invokes_helper_or_decrypt(self):
|
||||||
|
_, directory, _ = autostart._paths(self.state)
|
||||||
|
cipher = b"CIPHERTEXT-" * 10
|
||||||
|
autostart._write(directory / "credential.cred", cipher)
|
||||||
|
autostart._json(directory / "installation.json", {"schema": 1, "node": False, "credential_backend": "unexpected", "credential_sha256": hashlib.sha256(cipher).hexdigest()})
|
||||||
|
self.calls.clear()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "Unsupported"):
|
||||||
|
autostart._credential(self.state)
|
||||||
|
self.assertEqual([], self.calls)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Negative build-input tests; no package installation or target imports."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "packaging"))
|
||||||
|
from build_profiles import target_profile, validate_vendor
|
||||||
|
|
||||||
|
|
||||||
|
class VendorValidationTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.root = Path(self.tmp.name)
|
||||||
|
self.profile = target_profile("mint22-amd64-py312")
|
||||||
|
self.make_package("cffi", "_cffi_backend.cpython-312-x86_64-linux-gnu.so")
|
||||||
|
self.make_package("pydantic_core", "pydantic_core/_pydantic_core.cpython-312-x86_64-linux-gnu.so")
|
||||||
|
self.make_package("rpds_py", "rpds/rpds.cpython-312-x86_64-linux-gnu.so")
|
||||||
|
|
||||||
|
def make_package(self, name, relative, tag="cp312-cp312-manylinux_2_28_x86_64", abi_extra=b""):
|
||||||
|
native = self.root / relative
|
||||||
|
native.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
native.write_bytes(b"\x7fELF\x02\x01" + b"\0" * 12 + (62).to_bytes(2, "little") + abi_extra)
|
||||||
|
info = self.root / (name + "-1.0.dist-info")
|
||||||
|
info.mkdir(exist_ok=True)
|
||||||
|
(info / "METADATA").write_text("Name: " + name + "\nVersion: 1.0\nRequires-Python: >=3.10\n")
|
||||||
|
(info / "WHEEL").write_text("Wheel-Version: 1.0\nRoot-Is-Purelib: false\nTag: " + tag + "\n")
|
||||||
|
self.write_record(info, native)
|
||||||
|
return info, native
|
||||||
|
|
||||||
|
def write_record(self, info, native):
|
||||||
|
rows = []
|
||||||
|
for path in (native, info / "METADATA", info / "WHEEL"):
|
||||||
|
digest = base64.urlsafe_b64encode(hashlib.sha256(path.read_bytes()).digest()).decode().rstrip("=")
|
||||||
|
rows.append(path.relative_to(self.root).as_posix() + ",sha256=" + digest + "," + str(path.stat().st_size))
|
||||||
|
rows.append((info / "RECORD").relative_to(self.root).as_posix() + ",,")
|
||||||
|
(info / "RECORD").write_text("\n".join(rows) + "\n")
|
||||||
|
|
||||||
|
def test_valid_target_and_abi3(self):
|
||||||
|
self.make_package("cryptography", "cryptography/_rust.abi3.so", "cp311-abi3-manylinux_2_34_x86_64")
|
||||||
|
result = validate_vendor(self.root, self.profile)
|
||||||
|
self.assertEqual(len(result["native_extensions"]), 4)
|
||||||
|
self.assertEqual(self.profile["validation_status"], "candidate")
|
||||||
|
|
||||||
|
def test_wrong_wheel_abi_rejected(self):
|
||||||
|
self.make_package("cffi", "_cffi_backend.cpython-312-x86_64-linux-gnu.so", "cp313-cp313-manylinux_2_28_x86_64")
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "wheel ABI"):
|
||||||
|
validate_vendor(self.root, self.profile)
|
||||||
|
|
||||||
|
def test_wrong_extension_abi_rejected(self):
|
||||||
|
old = self.root / "_cffi_backend.cpython-312-x86_64-linux-gnu.so"
|
||||||
|
old.unlink()
|
||||||
|
self.make_package("cffi", "_cffi_backend.cpython-313-x86_64-linux-gnu.so")
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "extension ABI"):
|
||||||
|
validate_vendor(self.root, self.profile)
|
||||||
|
|
||||||
|
def test_wrong_elf_cpu_rejected(self):
|
||||||
|
info, path = self.make_package("cffi", "_cffi_backend.cpython-312-x86_64-linux-gnu.so")
|
||||||
|
data = bytearray(path.read_bytes()); data[18:20] = (183).to_bytes(2, "little")
|
||||||
|
path.write_bytes(data); self.write_record(info, path)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "ELF amd64"):
|
||||||
|
validate_vendor(self.root, self.profile)
|
||||||
|
|
||||||
|
def test_new_glibc_rejected(self):
|
||||||
|
self.make_package("cffi", "_cffi_backend.cpython-312-x86_64-linux-gnu.so", abi_extra=b"GLIBC_2.35")
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "glibc 2.34"):
|
||||||
|
validate_vendor(self.root, self.profile)
|
||||||
|
|
||||||
|
def test_requires_python_rejected(self):
|
||||||
|
info, path = self.make_package("cffi", "_cffi_backend.cpython-312-x86_64-linux-gnu.so")
|
||||||
|
with (info / "METADATA").open("a") as stream: stream.write("Requires-Python: >=3.13\n")
|
||||||
|
# Replace the original field to avoid duplicate-header ambiguity.
|
||||||
|
(info / "METADATA").write_text("Name: cffi\nVersion: 1.0\nRequires-Python: >=3.13\n")
|
||||||
|
self.write_record(info, path)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "Requires-Python"):
|
||||||
|
validate_vendor(self.root, self.profile)
|
||||||
|
|
||||||
|
def test_version_set_change_rejected(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "names/versions"):
|
||||||
|
validate_vendor(self.root, self.profile, {"cffi": "9.9"})
|
||||||
|
|
||||||
|
def test_modified_native_record_rejected(self):
|
||||||
|
with (self.root / "_cffi_backend.cpython-312-x86_64-linux-gnu.so").open("ab") as stream:
|
||||||
|
stream.write(b"altered")
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "RECORD digest"):
|
||||||
|
validate_vendor(self.root, self.profile)
|
||||||
|
|
||||||
|
def test_missing_wheel_rejected(self):
|
||||||
|
(self.root / "cffi-1.0.dist-info/WHEEL").unlink()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "WHEEL/RECORD"):
|
||||||
|
validate_vendor(self.root, self.profile)
|
||||||
|
|
||||||
|
def test_native_record_requires_digest(self):
|
||||||
|
record = self.root / "cffi-1.0.dist-info/RECORD"
|
||||||
|
rows = record.read_text().splitlines()
|
||||||
|
rows[0] = rows[0].split(",")[0] + ",,"
|
||||||
|
record.write_text("\n".join(rows) + "\n")
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "RECORD digest missing"):
|
||||||
|
validate_vendor(self.root, self.profile)
|
||||||
|
|
||||||
|
def test_pure_metadata_on_native_rejected(self):
|
||||||
|
self.make_package("cffi", "_cffi_backend.cpython-312-x86_64-linux-gnu.so", "py3-none-any")
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "native WHEEL"):
|
||||||
|
validate_vendor(self.root, self.profile)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -7,7 +7,7 @@ import tempfile
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from framebridge_expanded import cli
|
from framebridge_expanded import cli, __version__
|
||||||
from framebridge_expanded.config import default_config, save_config
|
from framebridge_expanded.config import default_config, save_config
|
||||||
|
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ class CommunityCliContractTests(unittest.TestCase):
|
|||||||
value=json.loads(text)
|
value=json.loads(text)
|
||||||
self.assertEqual(code,0)
|
self.assertEqual(code,0)
|
||||||
self.assertEqual(value["schema_version"],1)
|
self.assertEqual(value["schema_version"],1)
|
||||||
self.assertEqual(value["version"],"0.3.1")
|
self.assertEqual(value["version"], __version__)
|
||||||
self.assertEqual(value["result"]["desired_connection"],"disconnected")
|
self.assertEqual(value["result"]["desired_connection"],"disconnected")
|
||||||
self.assertFalse(value["result"]["connection"]["verified"])
|
self.assertFalse(value["result"]["connection"]["verified"])
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"""Synthetic, unprivileged tests; never run sudo, visudo or systemd-creds."""
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
from framebridge_expanded import credential_helper as helper
|
||||||
|
|
||||||
|
KEY = b"SYNTHETIC-FRAMEBRIDGE-TEST-NOT-A-REAL-KEY"
|
||||||
|
PROFILE = "a" * 64
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialHelperTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
self.registry = self.root / "registry"
|
||||||
|
self.policies = self.root / "sudoers"
|
||||||
|
self.registry.mkdir(mode=0o700)
|
||||||
|
self.policies.mkdir(mode=0o700)
|
||||||
|
self.owner = SimpleNamespace(pw_uid=1234, pw_gid=1234, pw_name="testowner", pw_dir="/home/testowner")
|
||||||
|
self.calls = []
|
||||||
|
self.generation = 0
|
||||||
|
self.addCleanup(mock.patch.stopall)
|
||||||
|
mock.patch.object(helper, "ROOT", self.registry).start()
|
||||||
|
mock.patch.object(helper, "SUDOERS", self.policies).start()
|
||||||
|
# Functional tests run as an ordinary user. Trust-path guards have
|
||||||
|
# dedicated stat-driven tests below rather than requiring real root.
|
||||||
|
mock.patch.object(helper, "_root_path", side_effect=lambda p, **kw: Path(p)).start()
|
||||||
|
original_fstat = os.fstat
|
||||||
|
def root_stat(fd):
|
||||||
|
values = list(original_fstat(fd)); values[4] = 0
|
||||||
|
return os.stat_result(values)
|
||||||
|
mock.patch.object(helper.os, "fstat", side_effect=root_stat).start()
|
||||||
|
self.run_patcher = mock.patch.object(helper, "_run", side_effect=self.fake_run)
|
||||||
|
self.run = self.run_patcher.start()
|
||||||
|
|
||||||
|
def fake_run(self, args, data=None):
|
||||||
|
self.calls.append((args, data))
|
||||||
|
if args[1] == "encrypt":
|
||||||
|
self.generation += 1
|
||||||
|
return ("SYNTHETIC-CIPHERTEXT-%03d-" % self.generation).encode() * 6
|
||||||
|
if args[1] == "decrypt":
|
||||||
|
return KEY
|
||||||
|
return b""
|
||||||
|
|
||||||
|
def protect(self):
|
||||||
|
cipher = helper.protect(self.owner, PROFILE, KEY)
|
||||||
|
return cipher, hashlib.sha256(cipher).hexdigest()
|
||||||
|
|
||||||
|
def test_protect_has_literal_owner_rule_and_no_plaintext_in_files_or_argv(self):
|
||||||
|
cipher, digest = self.protect()
|
||||||
|
record, policy = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||||
|
self.assertEqual(0o600, stat.S_IMODE(record.stat().st_mode))
|
||||||
|
self.assertEqual(0o440, stat.S_IMODE(policy.stat().st_mode))
|
||||||
|
text = policy.read_text()
|
||||||
|
self.assertIn("testowner ALL=(root) NOSETENV: NOPASSWD:", text)
|
||||||
|
self.assertTrue(text.endswith(" decrypt " + PROFILE + " " + digest + "\n"))
|
||||||
|
self.assertNotIn("*", text)
|
||||||
|
self.assertNotIn("systemd-creds", text)
|
||||||
|
for path in (record, policy):
|
||||||
|
self.assertNotIn(KEY, path.read_bytes())
|
||||||
|
encryption = self.calls[0]
|
||||||
|
self.assertEqual(helper.CREDS, encryption[0][0])
|
||||||
|
self.assertEqual(KEY, encryption[1])
|
||||||
|
self.assertIn("--with-key=host", encryption[0])
|
||||||
|
self.assertFalse(any(a.startswith("--uid") or a == "--user" for a in encryption[0]))
|
||||||
|
self.assertNotIn(KEY.decode(), " ".join(encryption[0]))
|
||||||
|
self.assertEqual(KEY, helper.decrypt(self.owner, PROFILE, digest))
|
||||||
|
|
||||||
|
def test_wrong_owner_or_profile_never_decrypts(self):
|
||||||
|
_, digest = self.protect()
|
||||||
|
self.calls.clear()
|
||||||
|
other = SimpleNamespace(pw_uid=5678, pw_gid=5678, pw_name="another", pw_dir="/home/another")
|
||||||
|
for owner, profile in ((other, PROFILE), (self.owner, "b" * 64)):
|
||||||
|
with self.subTest(owner=owner.pw_uid, profile=profile):
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
helper.decrypt(owner, profile, digest)
|
||||||
|
self.assertEqual([], self.calls)
|
||||||
|
|
||||||
|
def test_reused_uid_with_different_username_or_home_is_rejected(self):
|
||||||
|
_, digest = self.protect()
|
||||||
|
for field in ("pw_name", "pw_dir"):
|
||||||
|
values = vars(self.owner).copy(); values[field] = "changed"
|
||||||
|
with self.subTest(field=field), self.assertRaises(RuntimeError):
|
||||||
|
helper.decrypt(SimpleNamespace(**values), PROFILE, digest)
|
||||||
|
|
||||||
|
def test_cipher_or_name_or_principal_tampering_is_rejected_before_decrypt(self):
|
||||||
|
_, digest = self.protect()
|
||||||
|
record, _ = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||||
|
original = json.loads(record.read_bytes())
|
||||||
|
for field, value in (("cipher", "AAAA"), ("name", "another-secret"), ("uid", 0), ("profile", "b" * 64)):
|
||||||
|
changed = dict(original); changed[field] = value
|
||||||
|
record.write_text(json.dumps(changed)); self.calls.clear()
|
||||||
|
with self.subTest(field=field), self.assertRaises(RuntimeError):
|
||||||
|
helper.decrypt(self.owner, PROFILE, digest)
|
||||||
|
self.assertEqual([], self.calls)
|
||||||
|
|
||||||
|
def test_symlink_hardlink_and_broad_permissions_are_rejected(self):
|
||||||
|
_, digest = self.protect()
|
||||||
|
record, _ = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||||
|
backup = record.with_suffix(".backup"); record.rename(backup)
|
||||||
|
record.symlink_to(backup)
|
||||||
|
with self.assertRaises(OSError): helper.decrypt(self.owner, PROFILE, digest)
|
||||||
|
record.unlink(); os.link(backup, record)
|
||||||
|
with self.assertRaises(RuntimeError): helper.decrypt(self.owner, PROFILE, digest)
|
||||||
|
record.unlink(); backup.rename(record); record.chmod(0o644)
|
||||||
|
with self.assertRaises(RuntimeError): helper.decrypt(self.owner, PROFILE, digest)
|
||||||
|
|
||||||
|
def test_invalid_policy_does_not_grant_or_leave_a_record(self):
|
||||||
|
def fail_visudo(args, data=None):
|
||||||
|
if args[0] == helper.VISUDO: raise RuntimeError("invalid policy")
|
||||||
|
return self.fake_run(args, data)
|
||||||
|
self.run.side_effect = fail_visudo
|
||||||
|
with self.assertRaises(RuntimeError): self.protect()
|
||||||
|
self.assertEqual([], list(self.registry.iterdir()))
|
||||||
|
self.assertEqual([], list(self.policies.iterdir()))
|
||||||
|
|
||||||
|
def test_rotation_revoke_and_idempotent_partial_cleanup(self):
|
||||||
|
_, first = self.protect(); _, second = self.protect()
|
||||||
|
self.assertNotEqual(first, second)
|
||||||
|
helper.revoke(self.owner, PROFILE, first)
|
||||||
|
helper.revoke(self.owner, PROFILE, first)
|
||||||
|
self.assertEqual(KEY, helper.decrypt(self.owner, PROFILE, second))
|
||||||
|
_, policy = helper._paths(self.owner.pw_uid, PROFILE, second)
|
||||||
|
policy.unlink() # simulate interruption after authorization removal
|
||||||
|
helper.revoke(self.owner, PROFILE, second)
|
||||||
|
self.assertEqual([], list(self.registry.iterdir()))
|
||||||
|
self.assertEqual([], list(self.policies.iterdir()))
|
||||||
|
|
||||||
|
def test_purge_prevalidates_all_policies_before_deleting_any(self):
|
||||||
|
_, first = self.protect(); _, second = self.protect()
|
||||||
|
record, policy = helper._paths(self.owner.pw_uid, PROFILE, second)
|
||||||
|
policy.chmod(0o600); policy.write_text("unrelated rule\n")
|
||||||
|
with self.assertRaises(RuntimeError): helper.purge_all()
|
||||||
|
self.assertTrue(helper._paths(self.owner.pw_uid, PROFILE, first)[0].exists())
|
||||||
|
self.assertTrue(record.exists())
|
||||||
|
|
||||||
|
def test_purge_handles_orphan_rule_after_interrupted_revoke(self):
|
||||||
|
_, digest = self.protect()
|
||||||
|
record, policy = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||||
|
record.unlink()
|
||||||
|
helper.purge_all()
|
||||||
|
self.assertFalse(policy.exists())
|
||||||
|
|
||||||
|
def test_prune_recovers_lost_protect_receipt_and_preserves_committed_cipher(self):
|
||||||
|
_, current = self.protect(); _, lost_receipt = self.protect()
|
||||||
|
helper.prune(self.owner, PROFILE, {current})
|
||||||
|
self.assertEqual(KEY, helper.decrypt(self.owner, PROFILE, current))
|
||||||
|
self.assertFalse(helper._paths(self.owner.pw_uid, PROFILE, lost_receipt)[0].exists())
|
||||||
|
helper.prune(self.owner, PROFILE, set())
|
||||||
|
self.assertEqual([], list(self.registry.iterdir()))
|
||||||
|
self.assertEqual([], list(self.policies.iterdir()))
|
||||||
|
|
||||||
|
def test_revoke_persists_authorization_removal_before_deleting_cipher(self):
|
||||||
|
_, digest = self.protect()
|
||||||
|
record, policy = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||||
|
observed = []
|
||||||
|
with mock.patch.object(helper, "_sync", side_effect=lambda p: observed.append((p, record.exists(), policy.exists()))):
|
||||||
|
helper.revoke(self.owner, PROFILE, digest)
|
||||||
|
self.assertEqual([(self.policies, True, False), (self.registry, False, False)], observed)
|
||||||
|
|
||||||
|
def test_purge_recovers_only_recognized_root_owned_staging_files(self):
|
||||||
|
_, digest = self.protect()
|
||||||
|
record, _ = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||||
|
temporary = record.with_name("." + record.name + "." + "d" * 24)
|
||||||
|
temporary.write_bytes(b"incomplete-staging-json"); temporary.chmod(0o600)
|
||||||
|
helper.purge_all()
|
||||||
|
self.assertFalse(temporary.exists())
|
||||||
|
self.assertEqual([], list(self.registry.iterdir()))
|
||||||
|
|
||||||
|
def test_staging_hardlink_after_publish_is_recovered_before_reading_record(self):
|
||||||
|
_, digest = self.protect()
|
||||||
|
record, _ = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||||
|
temporary = record.with_name("." + record.name + "." + "d" * 24)
|
||||||
|
os.link(record, temporary)
|
||||||
|
original_lstat = Path.lstat
|
||||||
|
def root_lstat(path):
|
||||||
|
values = list(original_lstat(path)); values[4] = 0
|
||||||
|
return os.stat_result(values)
|
||||||
|
with mock.patch.object(Path, "lstat", root_lstat):
|
||||||
|
helper._cleanup_temporaries()
|
||||||
|
self.assertFalse(temporary.exists())
|
||||||
|
self.assertEqual(KEY, helper.decrypt(self.owner, PROFILE, digest))
|
||||||
|
|
||||||
|
def test_broken_receipt_pipe_rolls_back_published_protect_grant(self):
|
||||||
|
stdout = mock.Mock(); stdout.buffer.write.side_effect = BrokenPipeError("synthetic closed pipe")
|
||||||
|
with mock.patch.object(helper, "_installation"), mock.patch.object(helper, "_caller", return_value=self.owner), mock.patch.object(helper, "_prepare_root"), mock.patch.object(helper.resource, "setrlimit"), mock.patch.object(helper.os, "umask"), mock.patch("sys.stdin", SimpleNamespace(buffer=io.BytesIO(KEY))), mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", new_callable=io.StringIO) as stderr:
|
||||||
|
self.assertEqual(1, helper.main(["protect", PROFILE]))
|
||||||
|
self.assertNotIn(KEY.decode(), stderr.getvalue())
|
||||||
|
self.assertEqual([".lock"], [p.name for p in self.registry.iterdir()])
|
||||||
|
self.assertEqual([], list(self.policies.iterdir()))
|
||||||
|
|
||||||
|
def test_ids_reject_paths_flags_and_extra_arguments(self):
|
||||||
|
for value in ("../secret", "--uid=0", "A" * 64, "a" * 63, PROFILE + "\n"):
|
||||||
|
with self.subTest(value=value), self.assertRaises(RuntimeError):
|
||||||
|
helper.protect(self.owner, value, KEY)
|
||||||
|
for key in (b"", b"space key", b"\x00", b"a" * (helper.MAX_KEY + 1)):
|
||||||
|
with self.subTest(size=len(key)), self.assertRaises(RuntimeError):
|
||||||
|
helper.protect(self.owner, PROFILE, key)
|
||||||
|
self.assertEqual([], self.calls)
|
||||||
|
|
||||||
|
def test_caller_is_derived_from_sudo_identity_not_a_uid_argument(self):
|
||||||
|
with mock.patch.object(helper.pwd, "getpwuid", return_value=self.owner):
|
||||||
|
with mock.patch.dict(os.environ, {"SUDO_UID": "1234", "SUDO_GID": "1234", "SUDO_USER": "testowner"}, clear=True):
|
||||||
|
self.assertEqual(self.owner, helper._caller())
|
||||||
|
for bad in ({}, {"SUDO_UID": "0"}, {"SUDO_UID": "01234"}, {"SUDO_UID": "1234", "SUDO_GID": "1234", "SUDO_USER": "another"}):
|
||||||
|
with mock.patch.dict(os.environ, bad, clear=True), self.assertRaises(RuntimeError):
|
||||||
|
helper._caller()
|
||||||
|
|
||||||
|
def test_subprocess_has_fixed_environment_and_no_shell(self):
|
||||||
|
self.run_patcher.stop()
|
||||||
|
with mock.patch.object(helper.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, b"cipher", b"")) as run:
|
||||||
|
self.assertEqual(b"cipher", helper._run([helper.CREDS, "encrypt", "-", "-"], KEY))
|
||||||
|
_, kwargs = run.call_args
|
||||||
|
self.assertEqual(helper.ENV, kwargs["env"])
|
||||||
|
self.assertEqual("/", kwargs["cwd"])
|
||||||
|
self.assertTrue(kwargs["close_fds"])
|
||||||
|
self.assertNotIn("shell", kwargs)
|
||||||
|
self.assertNotIn(KEY.decode(), repr(kwargs["env"]))
|
||||||
|
|
||||||
|
def test_main_redacts_failures_and_rejects_extra_arguments(self):
|
||||||
|
with mock.patch.object(helper, "_installation"), mock.patch.object(helper.resource, "setrlimit"), mock.patch.object(helper.os, "umask"), mock.patch("sys.stderr", new_callable=io.StringIO) as stderr, mock.patch("sys.stdout", new_callable=io.StringIO) as stdout:
|
||||||
|
self.assertEqual(1, helper.main(["decrypt", PROFILE, "b" * 64, "--uid=0"]))
|
||||||
|
self.assertEqual("", stdout.getvalue())
|
||||||
|
self.assertNotIn(KEY.decode(), stderr.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialTrustGuardTests(unittest.TestCase):
|
||||||
|
def test_root_path_rejects_each_untrusted_component(self):
|
||||||
|
target = Path("/rootowned/helper")
|
||||||
|
def info(mode, uid=0, links=1):
|
||||||
|
return SimpleNamespace(st_mode=mode, st_uid=uid, st_nlink=links)
|
||||||
|
for bad in (info(stat.S_IFDIR | 0o777), info(stat.S_IFDIR | 0o755, uid=1000), info(stat.S_IFLNK | 0o777)):
|
||||||
|
def fake(path):
|
||||||
|
if path == Path("/rootowned"): return bad
|
||||||
|
return info((stat.S_IFREG | 0o755) if path == target else (stat.S_IFDIR | 0o755))
|
||||||
|
with mock.patch.object(Path, "lstat", fake), self.assertRaises(RuntimeError):
|
||||||
|
helper._root_path(target)
|
||||||
|
|
||||||
|
def test_installed_helper_requires_isolated_no_site_interpreter(self):
|
||||||
|
with mock.patch.object(helper.os, "geteuid", return_value=0):
|
||||||
|
for isolated, no_site in ((0, 1), (1, 0)):
|
||||||
|
with mock.patch.object(helper.sys, "flags", SimpleNamespace(isolated=isolated, no_site=no_site)), self.assertRaises(RuntimeError):
|
||||||
|
helper._installation()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""A stalled unprivileged stdout reader must not retain the registry lock.
|
||||||
|
|
||||||
|
The subprocess disables installation/caller/crypto operations and uses only its
|
||||||
|
own temporary directory. No sudo, host files, accounts or credentials are used.
|
||||||
|
"""
|
||||||
|
import array
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import select
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import termios
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from framebridge_expanded import credential_helper
|
||||||
|
|
||||||
|
CHILD = r"""
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
spec = importlib.util.spec_from_file_location("pipe_canary_helper", sys.argv[1])
|
||||||
|
helper = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(helper)
|
||||||
|
helper.ROOT = Path(sys.argv[2])
|
||||||
|
helper._installation = lambda: None
|
||||||
|
helper._prepare_root = lambda: None
|
||||||
|
helper._caller = lambda: SimpleNamespace(pw_uid=12345, pw_name="synthetic", pw_dir="/synthetic-only")
|
||||||
|
helper.decrypt = lambda *args: b"X" * 16384
|
||||||
|
# The only file is our own temporary lock; simulate its expected root metadata.
|
||||||
|
actual_fstat = helper.os.fstat
|
||||||
|
helper.os.fstat = lambda fd: SimpleNamespace(st_mode=actual_fstat(fd).st_mode,
|
||||||
|
st_uid=0, st_nlink=actual_fstat(fd).st_nlink)
|
||||||
|
raise SystemExit(helper.main(["decrypt", "a" * 64, "b" * 64]))
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialHelperPipeTests(unittest.TestCase):
|
||||||
|
@unittest.skipUnless(hasattr(fcntl, "F_SETPIPE_SZ"), "Linux pipe-size controls required")
|
||||||
|
def test_stalled_stdout_reader_does_not_hold_global_registry_lock(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="framebridge-pipe-canary-") as directory:
|
||||||
|
read_fd, write_fd = os.pipe()
|
||||||
|
child = None
|
||||||
|
lock_fd = None
|
||||||
|
try:
|
||||||
|
capacity = fcntl.fcntl(write_fd, fcntl.F_SETPIPE_SZ, 4096)
|
||||||
|
child = subprocess.Popen([sys.executable, "-I", "-S", "-c", CHILD,
|
||||||
|
str(Path(credential_helper.__file__).resolve()), directory],
|
||||||
|
stdin=subprocess.DEVNULL, stdout=write_fd,
|
||||||
|
stderr=subprocess.PIPE, close_fds=True)
|
||||||
|
os.close(write_fd)
|
||||||
|
write_fd = None
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
buffered = array.array("i", [0])
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
fcntl.ioctl(read_fd, termios.FIONREAD, buffered, True)
|
||||||
|
if buffered[0] == capacity:
|
||||||
|
break
|
||||||
|
if child.poll() is not None:
|
||||||
|
self.fail("Synthetic helper exited before filling the canary pipe: " + child.stderr.read().decode())
|
||||||
|
time.sleep(0.01)
|
||||||
|
self.assertEqual(capacity, buffered[0], "Synthetic pipe did not fill")
|
||||||
|
self.assertIsNone(child.poll(), "Helper should be waiting for the synthetic stdout reader")
|
||||||
|
lock_fd = os.open(Path(directory) / ".lock", os.O_RDWR)
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError:
|
||||||
|
self.fail("Helper retained the registry lock while stdout was blocked")
|
||||||
|
finally:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||||
|
received = bytearray()
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while True:
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
self.assertGreater(remaining, 0, "Synthetic helper pipe did not finish")
|
||||||
|
ready, _, _ = select.select([read_fd], [], [], remaining)
|
||||||
|
self.assertTrue(ready, "Synthetic helper pipe timed out")
|
||||||
|
block = os.read(read_fd, 4096)
|
||||||
|
if not block:
|
||||||
|
break
|
||||||
|
received.extend(block)
|
||||||
|
self.assertEqual(b"X" * 16384, bytes(received))
|
||||||
|
self.assertEqual(0, child.wait(timeout=3), child.stderr.read().decode())
|
||||||
|
finally:
|
||||||
|
if child is not None:
|
||||||
|
if child.poll() is None:
|
||||||
|
child.kill()
|
||||||
|
child.wait(timeout=3)
|
||||||
|
child.stderr.close()
|
||||||
|
os.close(read_fd)
|
||||||
|
if write_fd is not None:
|
||||||
|
os.close(write_fd)
|
||||||
|
if lock_fd is not None:
|
||||||
|
os.close(lock_fd)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""Candidate profile/ABI selection; tests use inert native-file fixtures."""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import stat
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from framebridge_expanded import compatibility as compat
|
||||||
|
from framebridge_expanded import runtime, uninstall
|
||||||
|
from framebridge_expanded import display_context, network
|
||||||
|
|
||||||
|
|
||||||
|
class LinuxCompatibilityTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.prefix = Path(self.temp.name)
|
||||||
|
|
||||||
|
def artifact(self, profile_id):
|
||||||
|
_distro, _release, minor, abi = compat.PROFILES[profile_id]
|
||||||
|
vendor = self.prefix / compat.VENDOR_RELATIVE
|
||||||
|
vendor.mkdir(parents=True, exist_ok=True)
|
||||||
|
for old in vendor.rglob("*.so"):
|
||||||
|
old.unlink()
|
||||||
|
# These inert files test selection only; package tests inspect real ELF.
|
||||||
|
suffix = ".cpython-" + abi[2:] + "-x86_64-linux-gnu.so"
|
||||||
|
for rel in ["_cffi_backend", "pydantic_core/_pydantic_core", "rpds/rpds"]:
|
||||||
|
path = vendor / (rel + suffix)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(b"synthetic ABI filename fixture, never imported")
|
||||||
|
manifest = dict(schema=1, profile_id=profile_id, architecture="amd64",
|
||||||
|
python_minor=minor, python_abi=abi, validation_status="candidate")
|
||||||
|
(self.prefix / "release-profile.json").write_text(json.dumps(manifest))
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
def check(self, distro, version, python, **options):
|
||||||
|
return compat.assess(self.prefix, os_release={"ID": distro, "VERSION_ID": version},
|
||||||
|
machine=options.pop("machine", "x86_64"), python_version=python,
|
||||||
|
libc=options.pop("libc", ("glibc", "2.39")), **options)
|
||||||
|
|
||||||
|
def test_explicit_candidate_profiles_require_matching_artifacts(self):
|
||||||
|
targets = [("debian13-amd64-py313", "debian", "13", (3, 13)),
|
||||||
|
("ubuntu24.04-amd64-py312", "ubuntu", "24.04", (3, 12)),
|
||||||
|
("ubuntu26.04-amd64-py314", "ubuntu", "26.04", (3, 14)),
|
||||||
|
("mint22-amd64-py312", "linuxmint", "22.3", (3, 12)),
|
||||||
|
("lmde7-amd64-py313", "linuxmint", "7", (3, 13))]
|
||||||
|
for name, distro, version, python in targets:
|
||||||
|
with self.subTest(name=name):
|
||||||
|
self.artifact(name)
|
||||||
|
result = self.check(distro, version, python)
|
||||||
|
self.assertTrue(result["ok"], result)
|
||||||
|
self.assertEqual(name, result["profile_id"])
|
||||||
|
self.assertEqual("candidate", result["validation_status"])
|
||||||
|
self.assertTrue(result["warnings"])
|
||||||
|
|
||||||
|
def test_version_and_derivative_identity_do_not_silently_opt_in(self):
|
||||||
|
self.artifact("ubuntu24.04-amd64-py312")
|
||||||
|
for distro, version in [("ubuntu", "22.04"), ("ubuntu", "24.10"),
|
||||||
|
("linuxmint", "21.3"), ("debian", "12"), ("pop", "24.04")]:
|
||||||
|
with self.subTest(distro=distro, version=version):
|
||||||
|
self.assertFalse(self.check(distro, version, (3, 12))["ok"])
|
||||||
|
self.assertIsNone(compat.profile_for({"ID": "other", "ID_LIKE": "ubuntu", "VERSION_ID": "24.04"}))
|
||||||
|
|
||||||
|
def test_interpreter_artifact_and_cpu_must_all_match(self):
|
||||||
|
self.artifact("ubuntu24.04-amd64-py312")
|
||||||
|
self.assertFalse(self.check("ubuntu", "24.04", (3, 13))["ok"])
|
||||||
|
self.assertFalse(self.check("linuxmint", "22.1", (3, 12))["ok"])
|
||||||
|
self.assertFalse(self.check("ubuntu", "24.04", (3, 12), machine="aarch64")["ok"])
|
||||||
|
|
||||||
|
def test_nonstandard_interpreter_abi_is_rejected(self):
|
||||||
|
self.artifact("ubuntu24.04-amd64-py312")
|
||||||
|
self.assertFalse(self.check("ubuntu", "24.04", (3, 12), implementation="PyPy")["ok"])
|
||||||
|
self.assertFalse(self.check("ubuntu", "24.04", (3, 12), abi_flags="t")["ok"])
|
||||||
|
|
||||||
|
def test_mixed_native_abi_and_missing_extensions_are_rejected(self):
|
||||||
|
self.artifact("ubuntu24.04-amd64-py312")
|
||||||
|
vendor = self.prefix / compat.VENDOR_RELATIVE
|
||||||
|
path = next(vendor.glob("_cffi_backend*.so"))
|
||||||
|
path.rename(path.with_name(path.name.replace("cpython-312", "cpython-313")))
|
||||||
|
result = self.check("ubuntu", "24.04", (3, 12))
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertTrue(any("native extension does not match" in issue for issue in result["issues"]))
|
||||||
|
self.artifact("ubuntu24.04-amd64-py312")
|
||||||
|
path = next(vendor.glob("_cffi_backend*.so"))
|
||||||
|
path.rename(path.with_name(path.name.replace("cpython-312", "cpython-312t")))
|
||||||
|
self.assertFalse(self.check("ubuntu", "24.04", (3, 12))["ok"])
|
||||||
|
self.artifact("ubuntu24.04-amd64-py312")
|
||||||
|
next((vendor / "rpds").glob("*.so")).unlink()
|
||||||
|
self.assertFalse(self.check("ubuntu", "24.04", (3, 12))["ok"])
|
||||||
|
|
||||||
|
def test_libc_floor_and_libc_family_are_checked(self):
|
||||||
|
self.artifact("ubuntu24.04-amd64-py312")
|
||||||
|
for libc in [("glibc", "2.33"), ("musl", "1.2.5"), ("", "")]:
|
||||||
|
with self.subTest(libc=libc):
|
||||||
|
self.assertFalse(self.check("ubuntu", "24.04", (3, 12), libc=libc)["ok"])
|
||||||
|
self.assertTrue(self.check("ubuntu", "24.04", (3, 12), libc=("glibc", "2.34"))["ok"])
|
||||||
|
|
||||||
|
def test_invalid_or_inconsistent_manifest_fails_closed(self):
|
||||||
|
original = self.artifact("ubuntu24.04-amd64-py312")
|
||||||
|
path = self.prefix / "release-profile.json"
|
||||||
|
for value in ["{", "[]", json.dumps({**original, "schema": True}),
|
||||||
|
json.dumps({**original, "python_abi": "cp313"}),
|
||||||
|
json.dumps({**original, "validation_status": "unknown"})]:
|
||||||
|
with self.subTest(value=value):
|
||||||
|
path.write_text(value)
|
||||||
|
result = self.check("ubuntu", "24.04", (3, 12))
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertTrue(any("Invalid release-profile" in issue for issue in result["issues"]))
|
||||||
|
|
||||||
|
def test_missing_manifest_cannot_silently_relabel_legacy_vendor(self):
|
||||||
|
self.artifact("debian13-amd64-py313")
|
||||||
|
(self.prefix / "release-profile.json").unlink()
|
||||||
|
self.assertFalse(self.check("linuxmint", "22.1", (3, 13))["ok"])
|
||||||
|
self.assertTrue(self.check("debian", "13", (3, 13))["ok"])
|
||||||
|
with patch.object(compat, "INSTALLED_PREFIX", self.prefix):
|
||||||
|
self.assertFalse(self.check("debian", "13", (3, 13))["ok"])
|
||||||
|
|
||||||
|
def test_wayland_diagnostic_never_claims_native_desktop_control(self):
|
||||||
|
result = compat.display_support({"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"}, {"kind": "text"})
|
||||||
|
self.assertFalse(result["native_wayland_cua"])
|
||||||
|
self.assertFalse(result["physical_x11_verified"])
|
||||||
|
self.assertIn("XWayland", result["notes"][0])
|
||||||
|
virtual = compat.display_support({"WAYLAND_DISPLAY": "wayland-0"}, {"kind": "virtual-x11"})
|
||||||
|
self.assertTrue(virtual["private_virtual_x11"])
|
||||||
|
self.assertFalse(virtual["native_wayland_cua"])
|
||||||
|
|
||||||
|
def test_doctor_uses_profile_result_and_start_stops_before_creating_files(self):
|
||||||
|
rejected = dict(ok=False, profile_id="ubuntu24.04-amd64-py312", issues=["ABI mismatch"], warnings=[])
|
||||||
|
with patch.object(runtime, "state_path", return_value=self.prefix), \
|
||||||
|
patch.object(runtime, "load_config", return_value={"role": "node", "headless": "auto"}), \
|
||||||
|
patch.object(runtime, "environment", return_value={"XDG_SESSION_TYPE": "wayland"}), \
|
||||||
|
patch.object(runtime.shutil, "which", return_value="/fixture/command"), \
|
||||||
|
patch.object(compat, "assess", return_value=rejected), \
|
||||||
|
patch.object(display_context, "missing_graphical_commands", return_value=[]), \
|
||||||
|
patch.object(display_context, "describe", return_value={"kind": "text"}), \
|
||||||
|
patch.object(network, "inspect", return_value={}), \
|
||||||
|
patch.object(runtime, "status", return_value={"running": False}):
|
||||||
|
doctor = runtime.doctor(self.prefix)
|
||||||
|
self.assertFalse(doctor["ok"])
|
||||||
|
self.assertIn("ABI mismatch", doctor["issues"])
|
||||||
|
self.assertFalse(doctor["compatible_debian_13_amd64"])
|
||||||
|
self.assertEqual("wayland", doctor["display_support"]["session_type"])
|
||||||
|
self.assertFalse(runtime.start(self.prefix)["ok"])
|
||||||
|
self.assertFalse((self.prefix / "logs").exists())
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedInterpreterValidationTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
self.state = "/home/fixture/profile"
|
||||||
|
self.unit = self.root / ("framebridge-autostart-" + hashlib.sha256(self.state.encode()).hexdigest()[:16] + ".service")
|
||||||
|
|
||||||
|
def unit_text(self, interpreter):
|
||||||
|
return '\n'.join(["# FrameBridge managed autostart v1", 'Environment="PYTHONPATH=' + uninstall.MODULE_ROOT + '"',
|
||||||
|
"ExecStart=" + interpreter + " -B -m framebridge_expanded.autostart _run --state " + self.state])
|
||||||
|
|
||||||
|
def test_matching_minor_system_interpreters_preserve_cleanup(self):
|
||||||
|
self.assertEqual(compat.SYSTEM_PYTHONS, uninstall.SYSTEM_PYTHONS)
|
||||||
|
for interpreter in sorted(compat.SYSTEM_PYTHONS):
|
||||||
|
with self.subTest(interpreter=interpreter):
|
||||||
|
self.unit.write_text(self.unit_text(interpreter))
|
||||||
|
self.assertTrue(uninstall.managed_unit(self.unit, os.getuid()))
|
||||||
|
|
||||||
|
def test_arbitrary_interpreters_duplicate_commands_and_links_remain_rejected(self):
|
||||||
|
for interpreter in ["/tmp/python3.12", "/usr/bin/python3.11", "/usr/local/bin/python3.14"]:
|
||||||
|
self.unit.write_text(self.unit_text(interpreter))
|
||||||
|
self.assertFalse(uninstall.managed_unit(self.unit, os.getuid()))
|
||||||
|
self.unit.write_text(self.unit_text("/usr/bin/python3.12") + "\nExecStart=/bin/true")
|
||||||
|
self.assertFalse(uninstall.managed_unit(self.unit, os.getuid()))
|
||||||
|
real = self.root / "real"
|
||||||
|
real.write_text(self.unit_text("/usr/bin/python3.12"))
|
||||||
|
self.unit.unlink()
|
||||||
|
self.unit.symlink_to(real)
|
||||||
|
self.assertFalse(uninstall.managed_unit(self.unit, os.getuid()))
|
||||||
|
|
||||||
|
|
||||||
|
class RootHelperCleanupTests(unittest.TestCase):
|
||||||
|
def test_root_purge_uses_only_fixed_helper_and_bounded_environment(self):
|
||||||
|
calls = []
|
||||||
|
def run(args, **options):
|
||||||
|
calls.append((args, options))
|
||||||
|
return SimpleNamespace(returncode=0)
|
||||||
|
with patch.object(uninstall.os, "getuid", return_value=0), \
|
||||||
|
patch.object(uninstall.os.path, "lexists", return_value=True), \
|
||||||
|
patch.object(uninstall, "_trusted_root_file", return_value=True):
|
||||||
|
uninstall.purge_credential_helpers(run=run)
|
||||||
|
self.assertEqual([str(uninstall.CREDENTIAL_HELPER), "purge-all"], calls[0][0])
|
||||||
|
self.assertEqual({"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8"}, calls[0][1]["env"])
|
||||||
|
self.assertEqual(30, calls[0][1]["timeout"])
|
||||||
|
|
||||||
|
def test_nonroot_or_unsafe_helper_never_executes(self):
|
||||||
|
calls = []
|
||||||
|
with patch.object(uninstall.os, "getuid", return_value=1000):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
uninstall.purge_credential_helpers(run=lambda *a, **kw: calls.append(a))
|
||||||
|
with patch.object(uninstall.os, "getuid", return_value=0), \
|
||||||
|
patch.object(uninstall.os.path, "lexists", return_value=True), \
|
||||||
|
patch.object(uninstall, "_trusted_root_file", return_value=False):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
uninstall.purge_credential_helpers(run=lambda *a, **kw: calls.append(a))
|
||||||
|
self.assertEqual([], calls)
|
||||||
|
|
||||||
|
def test_failure_to_purge_pauses_removal(self):
|
||||||
|
with patch.object(uninstall.os, "getuid", return_value=0), \
|
||||||
|
patch.object(uninstall.os.path, "lexists", return_value=True), \
|
||||||
|
patch.object(uninstall, "_trusted_root_file", return_value=True):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
uninstall.purge_credential_helpers(run=lambda *a, **kw: SimpleNamespace(returncode=1))
|
||||||
|
|
||||||
|
def test_fixed_helper_trust_rejects_links_nonroot_and_writable_parents(self):
|
||||||
|
target = uninstall.CREDENTIAL_HELPER
|
||||||
|
for mode, owner in [(stat.S_IFLNK | 0o777, 0), (stat.S_IFREG | 0o755, 1000),
|
||||||
|
(stat.S_IFREG | 0o777, 0)]:
|
||||||
|
with self.subTest(mode=mode, owner=owner), \
|
||||||
|
patch.object(Path, "lstat", return_value=SimpleNamespace(st_mode=mode, st_uid=owner)):
|
||||||
|
self.assertFalse(uninstall._trusted_root_file(target))
|
||||||
|
def info(path):
|
||||||
|
return SimpleNamespace(st_uid=0, st_mode=(stat.S_IFREG | 0o755) if path == target else (stat.S_IFDIR | 0o775))
|
||||||
|
with patch.object(Path, "lstat", autospec=True, side_effect=info):
|
||||||
|
self.assertFalse(uninstall._trusted_root_file(target))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user