diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index f0c6f53..d606805 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -139,7 +139,7 @@ jobs: sudo chown -R "$(id -u):$(id -g)" image-artifacts python3 deploy/verify-image.py "image-artifacts/raft-dev-${{ matrix.architecture }}.tar.gz" --architecture '${{ matrix.architecture }}' --fingerprint "$fingerprint" cd image-artifacts - sha256sum raft-dev-* > SHA256SUMS-${{ matrix.architecture }} + sha256sum raft-dev-* voxl-* > SHA256SUMS-${{ matrix.architecture }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: raft-dev-${{ matrix.architecture }} @@ -216,7 +216,7 @@ jobs: cd release-assets sha256sum -c SHA256SUMS-arm64 sha256sum -c SHA256SUMS-amd64 - sha256sum raft-*.whl raft-*.tar.gz microck-raft-*.tgz manifest-*.json packages-*.tsv npm-packages-*.json boot-*.json > SHA256SUMS + sha256sum raft-*.whl raft-*.tar.gz microck-raft-*.tgz manifest-*.json packages-*.tsv npm-packages-*.json boot-*.json voxl-* > SHA256SUMS cd .. python3 - <<'PY' import os @@ -239,4 +239,4 @@ jobs: git config user.email "$(git show -s --format=%ae "$GITHUB_SHA")" git tag -a "$RELEASE_TAG" "$GITHUB_SHA" -F release-notes.md git push origin "refs/tags/$RELEASE_TAG" - gh release create "$RELEASE_TAG" release-assets/raft-*.whl release-assets/raft-*.tar.gz release-assets/microck-raft-*.tgz release-assets/SHA256SUMS release-assets/manifest-*.json release-assets/packages-*.tsv release-assets/npm-packages-*.json release-assets/boot-*.json --verify-tag --title "Raft $RELEASE_TAG" --notes-file release-notes.md --latest + gh release create "$RELEASE_TAG" release-assets/raft-*.whl release-assets/raft-*.tar.gz release-assets/microck-raft-*.tgz release-assets/SHA256SUMS release-assets/manifest-*.json release-assets/packages-*.tsv release-assets/npm-packages-*.json release-assets/boot-*.json release-assets/voxl-* --verify-tag --title "Raft $RELEASE_TAG" --notes-file release-notes.md --latest diff --git a/README.md b/README.md index 1c2229c..4ede42e 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ see [defaults and host policies](docs/guides/config-policy.mdx). ## image and workspace control the image builder starts with Debian 13 and installs language runtimes, -build tools, Docker, Chromium, FFmpeg and a noVNC desktop. build your own image +build tools, Docker, Chromium, FFmpeg and a Voxl desktop with optional noVNC. build your own image or download an image for your host architecture from [versioned releases](https://github.com/Microck/raft/releases). pin its fingerprint in your configuration. see @@ -168,7 +168,7 @@ records differences, sources and test coverage. | Portable backup/recovery | ❓ | ✅ | Raft native archives; Boat whole-instance archive import is not established | | Docker and development tools | ✅ | ✅ | Native ARM64 and AMD64 images. See [image tests](docs/boot-performance.mdx) | | Private port forwarding | ✅ | ✅ | Local SSH tunnel | -| Desktop access | ✅ | ✅ | noVNC; see verification limits | +| Desktop access | ✅ | ✅ | Voxl by default; optional noVNC; see verification limits | | Reverse forwarding | ✅ | ❌ | Not implemented | | Public workspace hosting and browser-only streaming | ✅ | ❌ | Not implemented | | Resize on resume/fork | ✅ | ✅ | Optional CPU/RAM overrides; disks keep their size | diff --git a/deploy/benchmark-boot.py b/deploy/benchmark-boot.py index 01e120d..10b4f04 100644 --- a/deploy/benchmark-boot.py +++ b/deploy/benchmark-boot.py @@ -27,16 +27,15 @@ time.sleep(0.1) else: raise SystemExit('Docker did not become ready within 60 seconds') """ -DESKTOP_READY = """import socket,time,urllib.request +DESKTOP_READY = """import time,urllib.request end=time.monotonic()+30 while time.monotonic()= 2 * 1024**3: raise ValueError("Image exceeds GitHub's per-release-asset limit") @@ -29,6 +89,11 @@ def verify(archive, architecture, fingerprint): wanted = { "rootfs/opt/raft/packages.tsv": f"packages-{architecture}.tsv", "rootfs/opt/raft/npm-packages.json": f"npm-packages-{architecture}.json", + "rootfs/opt/raft/voxl-source.tar.gz": f"voxl-source-{architecture}.tar.gz", + "rootfs/opt/raft/voxl-GPL-2": f"voxl-GPL-2-{architecture}.txt", + "rootfs/opt/raft/voxl-LICENSE": f"voxl-LICENSE-{architecture}.txt", + "rootfs/opt/raft/voxl-NOTICE": f"voxl-NOTICE-{architecture}.txt", + "rootfs/opt/raft/voxl.tsv": f"voxl-{architecture}.tsv", "metadata.yaml": None, "rootfs/etc/machine-id": None, } @@ -59,14 +124,21 @@ def verify(archive, architecture, fingerprint): ): raise ValueError("Credential setting in npm config: " + name) if name in wanted: - content = image.extractfile(member).read() found.add(name) - if name.endswith("machine-id") and content.strip(): + if name.endswith("machine-id") and image.extractfile(member).read().strip(): raise ValueError("Template has a populated machine ID") if wanted[name]: - (archive.parent / wanted[name]).write_bytes(content) + if not member.size: + raise ValueError("Empty template resource: " + name) + with ( + image.extractfile(member) as source, + (archive.parent / wanted[name]).open("wb") as destination, + ): + shutil.copyfileobj(source, destination) if found != set(wanted): raise ValueError("Missing template resources: " + str(set(wanted) - found)) + version = (archive.parent / f"voxl-{architecture}.tsv").read_text().split("\t")[1] + verify_voxl_source(archive.parent / f"voxl-source-{architecture}.tar.gz", version) manifest = { "architecture": architecture, "filename": archive.name, @@ -78,7 +150,8 @@ def verify(archive, architecture, fingerprint): "base": "Debian 13 default system container", "isolation": "Unprivileged system container; shares the host kernel", "checks": ["extended lifecycle", "31 developer tools", "same-host full-image recovery"], - "licensing": "Raft is MIT; bundled third-party packages retain their licenses in /usr/share/doc and upstream distributions", + "licensing": "Raft is MIT; the x264-linked Voxl executable is GPL-2.0. Its notices and corresponding source accompany the image and release. Other bundled packages retain their licenses in /usr/share/doc and upstream distributions.", + "voxlSource": f"voxl-source-{architecture}.tar.gz", } (archive.parent / f"manifest-{architecture}.json").write_text( json.dumps(manifest, indent=2) + "\n" diff --git a/deploy/verify-live.py b/deploy/verify-live.py index d13d761..e4a5d86 100644 --- a/deploy/verify-live.py +++ b/deploy/verify-live.py @@ -127,7 +127,7 @@ def verify_peers(box, child): def desktop_session(box): # Check the browser transport too, before using a complete raw VNC viewer. websocket_proof = """const assert = require('node:assert/strict'); -const socket = new WebSocket('ws://127.0.0.1:6080/websockify', {headers: {'User-Agent': process.argv[1]}}); +const socket = new WebSocket('ws://127.0.0.1:6081/websockify', {headers: {'User-Agent': process.argv[1]}}); socket.binaryType = 'arraybuffer'; let incoming = Buffer.alloc(0), wake; const timeout = setTimeout(() => { console.error('WebSocket timeout'); process.exit(1); }, 10000); @@ -311,7 +311,7 @@ class KeyEvent(ctypes.Structure): assert execute(box, "python3", "-c", proof) == "desktop-frame-and-input-ok" -def browser_session(box): +def browser_session(box, backend="novnc"): # Chromium's DOM/window protocol reports modern captions more reliably than XFetchName. execute( box, @@ -341,6 +341,11 @@ def browser_session(box): "--user-agent=" + USER_AGENT, "file:///tmp/raft-gui-proof.html", ).stdout.strip() + viewer_url = ( + "http://127.0.0.1:6080/" + if backend == "voxl" + else "http://127.0.0.1:6081/vnc.html?autoconnect=true&resize=scale" + ) proof = """const assert = require('node:assert/strict'); (async () => { let page; @@ -348,7 +353,8 @@ def browser_session(box): while (Date.now() < deadline) { try { const response = await fetch('http://127.0.0.1:9222/json/list', {headers: {'User-Agent': process.argv[1]}}); - page = (await response.json()).find(target => target.type === 'page' && target.url === 'file:///tmp/raft-gui-proof.html'); + page = (await response.json()).find(target => target.type === 'page' && + target.url === 'file:///tmp/raft-gui-proof.html' && target.title === 'raft-gui-proof'); if (page) break; } catch {} await new Promise(resolve => setTimeout(resolve, 200)); @@ -357,7 +363,9 @@ def browser_session(box): const socket = new WebSocket(page.webSocketDebuggerUrl, {headers: {'User-Agent': process.argv[1]}}); const viewerProof = {id: 4, method: 'Runtime.evaluate', params: {expression: `JSON.stringify({connected: document.documentElement.classList.contains('noVNC_connected'), - canvases: Array.from(document.querySelectorAll('canvas')).map(c => ({width: c.width, height: c.height}))})`}}; + canvases: Array.from(document.querySelectorAll('canvas')).map(c => ({width: c.width, height: c.height})), + decoded: Array.from(document.querySelectorAll('video')).some(v => + v.videoWidth === 1280 && v.videoHeight === 720 && v.getVideoPlaybackQuality().totalVideoFrames > 0)})`}}; const timeout = setTimeout(() => process.exit(1), 15000); socket.addEventListener('error', () => process.exit(1)); socket.addEventListener('open', () => socket.send(JSON.stringify({id: 1, method: 'Runtime.evaluate', params: {expression: 'document.title'}}))); @@ -371,7 +379,7 @@ def browser_session(box): } else if (response.id === 2) { assert.ok(response.result.bounds.width > 0 && response.result.bounds.height > 0); socket.send(JSON.stringify({id: 3, method: 'Page.navigate', params: - {url: 'http://127.0.0.1:6080/vnc.html?autoconnect=true&resize=scale'}})); + {url: process.argv[2]}})); } else if (response.id === 3) { assert.equal(response.result.errorText, undefined); socket.send(JSON.stringify(viewerProof)); @@ -379,18 +387,22 @@ def browser_session(box): // While noVNC is still loading, the evaluation can return no value. const value = response.result.result.value; const viewer = typeof value === 'string' ? JSON.parse(value) : {connected: false}; - if (!viewer.connected) { + if (!(process.argv[3] === 'voxl' ? viewer.decoded : viewer.connected)) { setTimeout(() => socket.send(JSON.stringify(viewerProof)), 200); return; } - assert.ok(viewer.canvases.some(c => c.width === 1280 && c.height === 720)); - clearTimeout(timeout); socket.close(); console.log('developer-gui-and-novnc-ok'); + if (process.argv[3] !== 'voxl') + assert.ok(viewer.canvases.some(c => c.width === 1280 && c.height === 720)); + clearTimeout(timeout); socket.close(); console.log('developer-gui-and-viewer-ok'); } } catch (error) { console.error(error); process.exit(1); } }); })().catch(error => { console.error(error); process.exit(1); }); """ try: - assert execute(box, "node", "-e", proof, USER_AGENT) == "developer-gui-and-novnc-ok" + assert ( + execute(box, "node", "-e", proof, USER_AGENT, viewer_url, backend) + == "developer-gui-and-viewer-ok" + ) except (RuntimeError, AssertionError): print(raft("logs", box, job, check=False).stdout, file=sys.stderr, flush=True) raise @@ -834,9 +846,20 @@ def verify(location, extended=False, restart_incus=False, reboot_host=False): == b"saved" ) assert json.loads(raft("status", box, server).stdout)["state"] == "running" - assert b"noVNC" in fetch_through_tunnel(["desktop", box], "/vnc.html", timeout=20) + assert b"Voxl" in fetch_through_tunnel(["desktop", box], "/", timeout=20) + browser_session(box, "voxl") + assert execute(box, "systemctl", "is-active", "raft-desktop-voxl") == "active" + assert ( + raft( + "exec", box, "--", "systemctl", "is-active", "raft-desktop-novnc", check=False + ).stdout.strip() + == "inactive" + ) + assert b"noVNC" in fetch_through_tunnel( + ["desktop", box, "--backend", "novnc"], "/vnc.html", timeout=20 + ) assert b"noVNC" in fetch_through_tunnel( - ["desktop", box], "/vnc.html", timeout=20, default_port=6080 + ["desktop", box, "--backend", "novnc"], "/vnc.html", timeout=20, default_port=6080 ) assert execute(box, "systemctl", "is-active", "raft-desktop") == "active" assert "noVNC" in execute( @@ -850,7 +873,7 @@ def verify(location, extended=False, restart_incus=False, reboot_host=False): "--retry-delay", "1", "-fsS", - "http://127.0.0.1:6080/vnc.html", + "http://127.0.0.1:6081/vnc.html", ) assert "raft-browser-ok" in execute( box, diff --git a/docs/boat-capabilities.md b/docs/boat-capabilities.md index 1226a46..2bdd983 100644 --- a/docs/boat-capabilities.md +++ b/docs/boat-capabilities.md @@ -70,7 +70,7 @@ Tests verify Raft behavior, not hosted Boat infrastructure. See [verification](. | Development tools | ✅ | ✅ | 31 real developer-user checks. Debian/Chromium, not identical Ubuntu/Chrome versions; VS Code, Ghostty and GitHub CLI are not bundled; version-manager coverage differs | | Private forward | ✅ | ✅ | Foreground authenticated SSH tunnel to controller loopback. Guest service must listen on its managed NIC; no reverse forwarding | | Public workspace hosting | ✅ | ❌ | Missing. Deferred; requires a defined ingress and access policy | -| Desktop | ✅ | ✅ | On-demand noVNC, framebuffer, pointer and browser transport checks. See verification for desktop interaction coverage | +| Desktop | ✅ | ✅ | On-demand Voxl (default) or noVNC; see verification for desktop interaction coverage | | Browser-only streaming | ✅ | ❌ | Missing. Full desktop only; browser confinement deferred | | Network separation | ❓ | ✅ | Raft tests host/private/metadata and IPv4/IPv6 peer restrictions with startup-gated filtering. Boat's equivalent filtering policy is not established; its VM boundary is listed separately | | Bounded host storage pools | ❓ | ✅ | Raft configures a shared 60 GiB Btrfs pool and, when enabled, a fully allocated LVM thin pool for own box disks that it never overcommits, and rejects size drift. Boat does not document an operator-managed host storage pool | @@ -111,7 +111,7 @@ Raft's latest published release was verified as [0.7.0](https://github.com/Micro The new workspace-control suite passed on ARM64 and AMD64 at runtime commit `a7fef1f5` in [run 37603576786](https://github.com/Microck/raft/actions/runs/37603576786). The baseline native architecture and disposable AMD64 host tests passed in [run 37496577945](https://github.com/Microck/raft/actions/runs/37496577945). This audit compares documented features, not a live differential test against Boat. Managed agent workflows remain out of scope. -Recursive transfers, resume/fork sizing, snapshot templates/deletion and disposable lifecycle are implemented. Directory transfers require a running box; templates stay on the source host. Public hosting, VM isolation, organization sharing, and remote APIs require additional infrastructure. noVNC tests do not establish parity for Boat frame rate, browser confinement or application set. +Recursive transfers, resume/fork sizing, snapshot templates/deletion and disposable lifecycle are implemented. Directory transfers require a running box; templates stay on the source host. Public hosting, VM isolation, organization sharing, and remote APIs require additional infrastructure. Desktop tests do not establish parity for Boat frame rate, browser confinement or application set. CI comparison sources (reviewed 2026-10-22): [Boat CI](https://docs.boat.dev/ci), [CLI run](https://docs.boat.dev/cli-reference#boat-run-command), [scoped API keys](https://docs.boat.dev/api-keys/scopes), and [snapshot contents](https://docs.boat.dev/snapshots/whats-saved). Matching workflows do not imply matching security boundaries or costs. diff --git a/docs/boat-parity-design.md b/docs/boat-parity-design.md index 652a87a..bc8da65 100644 --- a/docs/boat-parity-design.md +++ b/docs/boat-parity-design.md @@ -11,7 +11,7 @@ Raft is an independent open-source alternative to [boat.dev](https://boat.dev). [Incus](https://github.com/lxc/incus) manages unprivileged system containers, images, snapshots, clones, and file transfers on native ARM64 and AMD64 Ubuntu hosts with Debian 13 guest images. Containers share the host kernel. KVM is not required, and VM deployment is not implemented. -Host SSH authenticates the operator. Incus listens strictly on its local Unix socket without a public management API. Guest systemd manages detached jobs and the on-demand Xvfb, Openbox, and noVNC desktop. A host timer enforces running TTL. +Host SSH authenticates the operator. Incus listens strictly on its local Unix socket without a public management API. Guest systemd manages detached jobs and the on-demand Xvfb/Openbox desktop with Voxl by default and optional noVNC. A host timer enforces running TTL. ## Image distribution diff --git a/docs/boot-performance.mdx b/docs/boot-performance.mdx index ebc1a47..ca36b24 100644 --- a/docs/boot-performance.mdx +++ b/docs/boot-performance.mdx @@ -25,6 +25,8 @@ Five samples per architecture from [native CI on 2026-10-06](https://github.com/ Read the [ARM64 samples](./benchmarks/arm64-native-ci.json) and [AMD64 samples](./benchmarks/amd64-native-ci.json) for distributions. Hardware and SSH latency differ between runners, so these numbers do not establish an architecture advantage or compare Raft with Boat. +These samples used the noVNC desktop before Voxl was added. Their desktop timings and image sizes do not measure the Voxl image. + ## Measure your own host Use a dedicated test location with a pinned image and a matching source checkout. The benchmark creates, stops, resumes and destroys its own boxes. It also starts the desktop when requested; do not target a shared or daily-use test channel. @@ -33,7 +35,7 @@ Use a dedicated test location with a pinned image and a matching source checkout python3 deploy/benchmark-boot.py --location lab --samples 5 --desktop > boot.json ``` -You can request 1 to 20 samples. Each sample measures request-to-return, command readiness and Docker API readiness, then repeats after stop/resume. Desktop timing starts at its separate startup request and waits for HTTP 200 and an RFB greeting. It does not measure remote browser rendering. +You can request 1 to 20 samples. Each sample measures request-to-return, command readiness and Docker API readiness, then repeats after stop/resume. Desktop timing starts at its separate startup request and waits for a Voxl PNG screenshot. It does not measure remote browser rendering. Reports include median, minimum, maximum, nearest-rank p95, SSH round trip, image size and guest boot information. With three or five samples, p95 is effectively the maximum, not a stable production percentile. diff --git a/docs/contract.md b/docs/contract.md index c8a1ee6..d25a8c6 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -123,7 +123,8 @@ No host filesystem path is exposed: rsync runs through Incus inside the guest. - **Detached jobs**: Launching requires a running guest and rejects unavailable guests before allocating a record. Commands run as guest systemd units. A transient host supervisor captures the first 1 MiB of combined stdout/stderr in a root-private host store and records completion. `status` returns JSON with state, exitCode, timestamps and droppedBytes; `logs` returns the retained bytes. A confirmed normal exit records its status; signal termination records 128 plus the signal number. Completed results survive workspace stop, deletion and host reboot. Running jobs do not survive guest stop or host reboot; a stopping guest or missing supervisor is reported as interrupted, even if a shutdown handler returns zero. Cancel stops the guest unit's control group and records cancellation even if its host supervisor has already exited. Cancellation waits at most 120 seconds; a timeout preserves the cancellation request and requires inspection or retry. Unknown IDs and mismatched workspace handles are rejected without starting the guest. - **Job supervision bounds**: Host submission is asynchronous and waits at most 10 seconds. A submission timeout preserves the record for status inspection because the supervisor may already be queued. Post-run guest inspection waits at most 60 seconds; an unconfirmed result is interrupted. Host reconciliation waits at most 10 seconds and reports an error without changing job states if systemd does not respond. Failed pre-submission setup removes its incomplete record. - **Job retention**: `/var/lib/raft-jobs` holds at most 512 records, with one 1 MiB log per record. Results expire seven days after completion. Commands prune expired records when the store is accessed; an idle host may keep expired files until that next access. Full stores reject new jobs rather than evicting unexpired results. Completed records and captured logs are flushed to disk before publication. Unreadable records remain on the host for inspection and count toward the record limit, but do not block access to other jobs. Logs can contain secrets and stay root-private. No historical guest-journal fallback is provided; jobs created by an older controller must be inspected with that version before upgrading. -- **Desktop service**: On-demand Xvfb, Openbox, and noVNC stack on display `:99` without a login manager. Clipboard exchange is available without login-manager delay. Desktop provides full workspace access, not browser confinement. Closing the tunnel leaves the service running. +- **Desktop service**: On-demand Xvfb and Openbox on display `:99` without a login manager. `raft desktop` starts Voxl by default on guest port 6080; `--backend novnc` starts noVNC on guest port 6081. Both share the same desktop and may run together. The controller tunnel defaults to local port 6080 for either backend and prints the viewer URL (`/` for Voxl, `/vnc.html` for noVNC). No automatic fallback or existing-image migration occurs. Voxl 0.3.2 is built from a checksum-pinned source crate with its locked dependencies. It runs as `developer`, has its file root at `/workspace`, disables audio/microphone and password login, and retains host/origin checks. SSH authenticates private tunnel access; neither backend port is public ingress. Voxl needs H.264 WebCodecs in a secure browser context such as localhost. Closing a tunnel leaves services running; stopping `raft-desktop` stops both streaming backends. Desktop provides full workspace access, not browser confinement. +- **Voxl distribution**: The x264-linked executable is distributed under GPL-2.0; Voxl's own source retains its MIT license. Images include GPL/MIT texts, a notice, and `/opt/raft/voxl-source.tar.gz` with vendored locked Rust sources, matching Debian native-library sources and patches, and build instructions. Native release assets include the extracted source bundle and notices, with checksums. Publication rejects images missing these materials. ## Backup and recovery diff --git a/docs/guides/desktop-services.mdx b/docs/guides/desktop-services.mdx index 509e18e..d521cfe 100644 --- a/docs/guides/desktop-services.mdx +++ b/docs/guides/desktop-services.mdx @@ -30,8 +30,22 @@ If forwarding fails, check the guest listener and `raft logs "$box" "$job"`. If raft desktop "$box" --local 6080 ``` -Open `http://127.0.0.1:6080/vnc.html` in your controller's browser. Include `/vnc.html`; `/` displays a directory index. The desktop runs as `developer` at 1280×720 on display `:99`. Use the noVNC sidebar for text clipboard exchange. +Open `http://127.0.0.1:6080/` in your controller's browser. Voxl is the default viewer and streams H.264 using WebCodecs. Use a browser with H.264 WebCodecs decoding support, and keep the URL on `localhost` or `127.0.0.1` so it is a secure context. Plain HTTP on a remote IP does not work with WebCodecs. -The command starts `raft-desktop.service` if needed, then opens a foreground tunnel. It does not wait for noVNC HTTP readiness; retry the browser request if startup is still in progress. Press Ctrl+C to close only the tunnel. Reconnect with the same command without closing guest windows. +The desktop runs as `developer` at 1280×720 on display `:99`. The command starts the selected streaming service and the shared desktop, then opens a foreground tunnel. Press Ctrl+C to close only the tunnel. Reconnect with the same command without closing guest windows. -To stop the desktop itself, run `raft exec "$box" -- systemctl stop raft-desktop`. Stopping or expiring the box also ends the session. If startup fails, inspect `raft exec "$box" -- journalctl -u raft-desktop -n 50 --no-pager`. +### Use noVNC instead + +If your browser cannot decode Voxl's stream, select noVNC explicitly: + +```sh +raft desktop "$box" --backend novnc --local 6080 +``` + +Open `http://127.0.0.1:6080/vnc.html`. Use the noVNC sidebar for text clipboard exchange. Raft does not switch backends automatically. + +Both backends show the same desktop. Selecting another backend leaves existing guest windows and streaming services running. To keep both tunnels open, give the second one a different `--local` port. Voxl listens on guest port 6080; noVNC listens on guest port 6081. Neither requires a viewer password: host SSH authenticates access through the private tunnel. Do not expose either guest port publicly. Voxl keeps its host and origin checks enabled, runs as `developer`, and roots its file browser at `/workspace`; desktop audio and microphone are disabled. + +To stop one backend, run `raft exec "$box" -- systemctl stop raft-desktop-voxl` or `raft exec "$box" -- systemctl stop raft-desktop-novnc`. To stop the desktop and both backends, run `raft exec "$box" -- systemctl stop raft-desktop`. Stopping or expiring the box also ends the session. + +These commands require an image built with both backend services. Updating the CLI does not modify existing images or boxes. Build and pin the updated image, then create a fresh box; a missing backend service fails instead of falling back. If startup fails, inspect `raft exec "$box" -- journalctl -u raft-desktop -u raft-desktop-voxl -u raft-desktop-novnc -n 50 --no-pager`. diff --git a/docs/guides/troubleshooting.mdx b/docs/guides/troubleshooting.mdx index d0b9d93..9d1b1d1 100644 --- a/docs/guides/troubleshooting.mdx +++ b/docs/guides/troubleshooting.mdx @@ -42,7 +42,7 @@ A configured `defaults.disk` can cause creation failures even without `--disk`; ## Diagnose an unavailable service -For forwarding, bind the guest service to `0.0.0.0` or its managed address, not guest loopback. Check its logs and choose a different local port if occupied. For the desktop, include `/vnc.html` and retry the browser request if noVNC is still starting. See [private services](./desktop-services.mdx). +For forwarding, bind the guest service to `0.0.0.0` or its managed address, not guest loopback. Check its logs and choose a different local port if occupied. For Voxl, use the printed localhost URL and a browser with H.264 WebCodecs support. Select `--backend novnc` explicitly if needed, then include `/vnc.html` in the viewer URL. Missing backend services require a fresh box from an updated image, not a CLI-only update. See [private services](./desktop-services.mdx). Boxes cannot contact peers on the same bridge by design. An open tunnel, SSH session or detached job does not extend TTL. For a stopped box use `resume`, not `extend`. diff --git a/docs/publishing.md b/docs/publishing.md index 42d2c37..1b3952f 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -20,6 +20,7 @@ gh workflow run images.yml --ref main -f release_tag=v0.7.0 - **Validation gating**: Publication requires native lifecycle and full-image recovery jobs alongside fresh AMD64 KVM provisioning and reboot verification. - **Asset immutability**: The workflow refuses existing tags and never overwrites release assets. - **Provenance records**: Release assets include `SHA256SUMS`, architecture manifests, package inventories, and boot reports. Manifests identify the source commit, CI run, and Incus image fingerprint. +- **Voxl source and licensing**: Voxl's source is MIT licensed, but its x264-linked executable is distributed under GPL-2.0. Each image includes `/opt/raft/voxl-GPL-2`, `voxl-LICENSE`, `voxl-NOTICE`, and `voxl-source.tar.gz`. The source archive contains the pinned Voxl source, locked vendored Rust dependencies, exact Debian sources and patches for its native libraries, build instructions, architecture, and installed package versions. The image inspector requires non-empty notices and checks the source bundle's build inputs, locked dependency manifests, and Debian source archive members before emitting a release manifest. Releases publish `voxl-source-ARCH.tar.gz`, license/notice files, and the Voxl version inventory beside each native image, covered by `SHA256SUMS`. Source ships with the binary rather than through a written offer. ## Repository history hygiene diff --git a/docs/reference/cli.md b/docs/reference/cli.md index e10a5ee..da16744 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -204,9 +204,9 @@ Open a foreground SSH tunnel bound to controller `127.0.0.1`. The guest service ### `desktop` -`raft desktop [-h] [--local LOCAL] box` +`raft desktop [-h] [--backend {voxl,novnc}] [--local LOCAL] box` -Start `raft-desktop.service`, then open a foreground private tunnel. `--local` defaults to 6080; open `http://127.0.0.1:6080/vnc.html`. Does not poll HTTP readiness. Closing the tunnel leaves the desktop running. See [desktop access](../guides/desktop-services.mdx). +Start the selected streaming service and its shared desktop, then open a foreground private tunnel. Voxl is the default; `--backend novnc` selects noVNC. `--local` defaults to 6080 for either backend. Open the printed viewer URL: `/` for Voxl, `/vnc.html` for noVNC. Guest ports are 6080 and 6081 respectively. Closing the tunnel leaves the desktop and selected backend running. No automatic fallback occurs. See [desktop access](../guides/desktop-services.mdx). ### `logs` diff --git a/docs/releases/0.8.0.md b/docs/releases/0.8.0.md new file mode 100644 index 0000000..3433fa0 --- /dev/null +++ b/docs/releases/0.8.0.md @@ -0,0 +1,26 @@ +--- +title: Raft 0.8.0 +description: Voxl desktop streaming by default, with optional noVNC. +--- + +# Raft 0.8.0 + +## Desktop streaming + +`raft desktop BOX` now opens [Voxl](https://github.com/tembo/voxl), which streams the desktop as H.264 to your browser. Open the printed viewer URL at `/` instead of `/vnc.html`. Voxl needs a browser with H.264 WebCodecs support and a secure context such as HTTPS or localhost. + +You can still select noVNC explicitly: + +```sh +raft desktop BOX --backend novnc +``` + +That command prints a viewer URL ending in `/vnc.html`. Both backends use the same desktop, so switching preserves open windows. They can run together with different `--local` ports. Raft does not switch backends automatically. + +The desktop and streamers run as `developer`. Voxl supports file transfer under `/workspace`, with uploads in `/workspace/Downloads`. Desktop audio and microphone are disabled. Access stays behind the private SSH tunnel; do not expose the guest streaming ports publicly. + +## Upgrade + +Updating the CLI does not install the desktop services into existing boxes. Use the updated native image and create a fresh box. Existing boxes are not migrated, and a missing backend service fails with guidance instead of falling back. + +See [upgrades](../guides/upgrades.mdx) for image pinning and [desktop access](../guides/desktop-services.mdx) for backend selection and service controls. Historical noVNC startup timings and image sizes are not measurements of the Voxl image. diff --git a/docs/releases/meta.json b/docs/releases/meta.json index d6e350f..fa64a99 100644 --- a/docs/releases/meta.json +++ b/docs/releases/meta.json @@ -4,6 +4,7 @@ "defaultOpen": false, "pages": [ "index", + "0.8.0", "0.7.1", "0.7.0", "0.6.0", diff --git a/docs/setup-explained.html b/docs/setup-explained.html index 048041e..1e66d46 100644 --- a/docs/setup-explained.html +++ b/docs/setup-explained.html @@ -1 +1 @@ -Raft architecture

Raft

An independent open-source alternative to boat.dev for persistent Linux workspaces on your own hosts.

The controller sends commands through authenticated host SSH. Incus manages unprivileged system containers. A host timer stops expired workspaces and retains their files.

Containers share the host kernel. Desktop access uses a private SSH tunnel to noVNC. Public hosting and managed coding-agent conversations are not implemented.

Installation · Capability table · Tests

+Raft architecture

Raft

An independent open-source alternative to boat.dev for persistent Linux workspaces on your own hosts.

The controller sends commands through authenticated host SSH. Incus manages unprivileged system containers. A host timer stops expired workspaces and retains their files.

Containers share the host kernel. Desktop access uses a private SSH tunnel to Voxl by default, with noVNC available through an explicit backend choice. Public hosting and managed coding-agent conversations are not implemented.

Installation · Capability table · Tests

diff --git a/docs/verification.mdx b/docs/verification.mdx index 49c7b62..6ab8546 100644 --- a/docs/verification.mdx +++ b/docs/verification.mdx @@ -38,6 +38,10 @@ For host-independent checks, omit `--location` from `verify-features.py`, `verif The suites check file rollback, copied network identity, retained output, binary and recursive transfers, Docker execution, private HTTP and desktop input, and network separation. Own-disk checks cover capped guest/Docker writes and reservations; shared-pool boxes have no per-box quota. Tests verify Raft behavior, not conformance with the hosted Boat service. +Desktop checks open Voxl through the default tunnel and require decoded 1280×720 video frames in the guest's packaged Chromium. They also select noVNC explicitly and exercise its framebuffer, keyboard, clipboard and reconnect behavior. The linked native runs above predate Voxl and do not establish Voxl frame rate or latency. A controller browser without H.264 WebCodecs support needs the explicit noVNC backend. + +Desktop browser checks wait for the application page to load before checking window bounds and opening the viewer. An advertised debugging target alone does not establish page readiness. + These results do not certify arbitrary long-running workloads, host loss, multi-tenant security, cross-architecture recovery, ARM64 host reboot or physical storage capacity. VM tests with sparse virtual disks do not prove real free-disk capacity. Boxes share the host kernel, and Raft has not had a security audit. For release gates and implementation details, see [publishing](./publishing.md) and the [runtime contract](./contract.md). diff --git a/images/incus/build.sh b/images/incus/build.sh index 87801f2..e7f19e8 100644 --- a/images/incus/build.sh +++ b/images/incus/build.sh @@ -30,10 +30,12 @@ awk -v archive="$node_archive" '$2 == archive {print $1 " raft-node.tar.xz"}' r tar -xJf /tmp/raft-node.tar.xz -C /usr/local --strip-components=1 PIPX_HOME=/opt/raft/pipx PIPX_BIN_DIR=/usr/local/bin pipx install uv==0.12.23 npm install -g pnpm@12.9.1 bun@1.4.2 deno@2.9.6 +bash /root/raft-install-voxl.sh curl -fsSL -A 'OpenAI File Downloader, XaiImageApiFetch/1.0' https://dot.net/v1/dotnet-install.sh -o /tmp/raft-dotnet-install.sh bash /tmp/raft-dotnet-install.sh --version 10.0.401 --install-dir /usr/local/share/dotnet ln -s /usr/local/share/dotnet/dotnet /usr/local/bin/dotnet useradd -m -s /bin/bash developer +install -d -o developer -g developer /workspace/Downloads printf 'developer ALL=(ALL) NOPASSWD:ALL\n' > /etc/sudoers.d/raft-developer chmod 440 /etc/sudoers.d/raft-developer mkdir -p /etc/ssh/sshd_config.d /etc/systemd/system/ssh.service.d @@ -46,6 +48,7 @@ ExecStartPre= ExecStartPre=/bin/sh -ec 'test -f /etc/ssh/ssh_host_ed25519_key || ssh-keygen -q -t ed25519 -N "" -f /etc/ssh/ssh_host_ed25519_key' ExecStartPre=/usr/sbin/sshd -t UNIT +systemctl daemon-reload systemctl enable docker ssh systemctl start docker docker info --format '{{.ServerVersion}}' diff --git a/images/incus/desktop-novnc.sh b/images/incus/desktop-novnc.sh new file mode 100755 index 0000000..603fd1b --- /dev/null +++ b/images/incus/desktop-novnc.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -euo pipefail +export DISPLAY=:99 +# This dedicated Xvfb display has no login manager to wait for. +X11VNC_AVOID_WINDOWS=never x11vnc -display :99 -localhost -nopw -forever -shared -rfbport 5900 & +vnc_pid=$! +websockify_pid= +trap 'kill "$vnc_pid" ${websockify_pid:+"$websockify_pid"} 2>/dev/null || true' EXIT +websockify --web=/usr/share/novnc 0.0.0.0:6081 127.0.0.1:5900 & +websockify_pid=$! +# A failed VNC server must not leave an apparently healthy web frontend running. +wait -n "$vnc_pid" "$websockify_pid" || true +exit 1 diff --git a/images/incus/desktop.sh b/images/incus/desktop.sh index 28300dc..e10b939 100644 --- a/images/incus/desktop.sh +++ b/images/incus/desktop.sh @@ -1,9 +1,10 @@ -#!/bin/sh -set -eu +#!/bin/bash +set -euo pipefail export DISPLAY=:99 -Xvfb :99 -screen 0 1280x720x24 -nolisten tcp & +Xvfb :99 -screen 0 1280x720x24 -nolisten tcp -shmem & xvfb_pid=$! -trap 'kill "$xvfb_pid" 2>/dev/null || true' EXIT +openbox_pid= +trap 'kill "$xvfb_pid" ${openbox_pid:+"$openbox_pid"} 2>/dev/null || true' EXIT # Start clients as soon as X accepts requests, rather than sleeping on every launch. attempt=0 until xdpyinfo >/dev/null 2>&1; do @@ -14,8 +15,10 @@ until xdpyinfo >/dev/null 2>&1; do fi sleep 0.05 done -runuser -u developer -- env DISPLAY=:99 openbox & -# This dedicated Xvfb display has no login manager to wait for. -X11VNC_AVOID_WINDOWS=never x11vnc -display :99 -localhost -nopw -forever -shared -rfbport 5900 & -websockify --web=/usr/share/novnc 0.0.0.0:6080 127.0.0.1:5900 & -wait +openbox & +openbox_pid=$! +# Type=notify keeps dependent streaming services behind X readiness. +systemd-notify --ready +# Any child exit, including a clean one, means this desktop is no longer usable. +wait -n "$xvfb_pid" "$openbox_pid" || true +exit 1 diff --git a/images/incus/install-voxl.sh b/images/incus/install-voxl.sh new file mode 100755 index 0000000..7c10774 --- /dev/null +++ b/images/incus/install-voxl.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# Run as root inside a native Debian 13 guest. No host credentials are needed. +set -euo pipefail +case "$(dpkg --print-architecture)" in + arm64|amd64) ;; + *) echo 'Raft images support ARM64 and AMD64' >&2; exit 1 ;; +esac +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y --no-install-recommends ca-certificates curl tar build-essential rustc cargo pkg-config clang libclang-dev libxcb1-dev libx264-dev libyuv-dev libopus-dev libpulse-dev esbuild + +version=0.3.2 +checksum=5a7563fe3f84d90ae29cd63772d057f7891aeb1073ccafdc1bc1c393791c5893 +build_dir=$(mktemp -d /tmp/raft-voxl.XXXXXX) +trap 'rm -rf "$build_dir"' EXIT +source_dir="$build_dir/source" +mkdir -p "$source_dir" +install -m 755 "$0" "$source_dir/install-voxl.sh" +curl -fsSL -A 'OpenAI File Downloader, XaiImageApiFetch/1.0' "https://static.crates.io/crates/voxl-server/voxl-server-${version}.crate" -o "$build_dir/voxl.crate" +printf '%s %s\n' "$checksum" "$build_dir/voxl.crate" | sha256sum -c - +tar -xzf "$build_dir/voxl.crate" -C "$source_dir" + +# Debian 13 ships libyuv's headers and shared library but no pkg-config file. +# Describe the installed package for upstream's build.rs without patching it or +# leaving extra system-wide metadata in the image. +mkdir "$source_dir/pkgconfig" +multiarch=$(dpkg-architecture -qDEB_HOST_MULTIARCH) +yuv_version=$(dpkg-query -W -f='${Version}' libyuv-dev) +cat > "$source_dir/pkgconfig/libyuv.pc" < .cargo/config.toml +rm -rf "$CARGO_HOME/registry" +cargo build --release --locked --offline --bin voxl +install -m 755 -s "$CARGO_TARGET_DIR/release/voxl" /usr/local/bin/voxl +install -d /opt/raft +install -m 644 LICENSE /opt/raft/voxl-LICENSE +printf 'voxl-server\t%s\t%s\n' "$version" "$checksum" > /opt/raft/voxl.tsv +install -m 644 /usr/share/common-licenses/GPL-2 /opt/raft/voxl-GPL-2 +cat > /opt/raft/voxl-NOTICE < "$build_dir/deb-src.list" <<'SOURCES' +deb-src http://deb.debian.org/debian trixie main +deb-src http://deb.debian.org/debian trixie-updates main +deb-src http://deb.debian.org/debian-security trixie-security main +SOURCES +source_options=(-o "Dir::Etc::sourcelist=$build_dir/deb-src.list" -o Dir::Etc::sourceparts=-) +apt-get "${source_options[@]}" update -qq +dpkg-query -W -f='${source:Package}=${source:Version}\n' \ + libx264-dev libyuv-dev libopus-dev libpulse-dev libxcb1-dev > "$source_dir/native-packages.txt" +mkdir "$source_dir/native" +cd "$source_dir/native" +mapfile -t native_packages < "$source_dir/native-packages.txt" +apt-get "${source_options[@]}" source --download-only --only-source "${native_packages[@]}" +{ + dpkg --print-architecture + rustc --version + cargo --version + esbuild --version + dpkg-query -W +} > "$source_dir/build-environment.txt" +cat > "$source_dir/README.txt" <<'README' +Voxl corresponding source + +voxl-server-0.3.2/ contains the published source, Cargo.lock, and a Cargo source +replacement config. vendor/ contains its locked Rust dependency sources and +licenses. native/ contains the exact Debian source packages (including patches) +for x264, libyuv, Opus, PulseAudio, and libxcb; see native-packages.txt. +install-voxl.sh is the image's build/install recipe. build-environment.txt records +the architecture, compiler/bundler versions, and all installed Debian package +versions. pkgconfig/libyuv.pc describes the installed Debian libyuv library, +which ships no pkg-config metadata. + +To rebuild on the matching native Debian 13 architecture, install the build +dependencies listed in install-voxl.sh at the versions in build-environment.txt. +Then, from this extracted source directory: + + export PKG_CONFIG_PATH="$PWD/pkgconfig" + cd voxl-server-0.3.2 + cargo build --release --locked --offline --bin voxl + +Rust and esbuild are build tools; no credentials or online Cargo registry are +needed for that build. Native library sources can be unpacked with dpkg-source -x +and built using their Debian build instructions. See voxl-server-0.3.2/LICENSE, +the licenses in vendor/, and each native package's copyright/license files. +The x264-linked executable is distributed under GPL-2.0; the image includes +/opt/raft/voxl-GPL-2 and /opt/raft/voxl-NOTICE. +README +tar -czf /opt/raft/voxl-source.tar.gz -C "$source_dir" . diff --git a/images/incus/raft-desktop-novnc.service b/images/incus/raft-desktop-novnc.service new file mode 100644 index 0000000..e251e0d --- /dev/null +++ b/images/incus/raft-desktop-novnc.service @@ -0,0 +1,14 @@ +[Unit] +Description=Private Raft desktop streaming with noVNC +BindsTo=raft-desktop.service +PartOf=raft-desktop.service +After=raft-desktop.service + +[Service] +User=developer +WorkingDirectory=/workspace +Environment=DISPLAY=:99 +ExecStart=/usr/local/bin/raft-desktop-novnc +Restart=on-failure +RestartSec=1 +KillMode=control-group diff --git a/images/incus/raft-desktop-voxl.service b/images/incus/raft-desktop-voxl.service new file mode 100644 index 0000000..18aa00e --- /dev/null +++ b/images/incus/raft-desktop-voxl.service @@ -0,0 +1,15 @@ +[Unit] +Description=Private Raft desktop streaming with Voxl +BindsTo=raft-desktop.service +PartOf=raft-desktop.service +After=raft-desktop.service + +[Service] +User=developer +WorkingDirectory=/workspace +Environment=DISPLAY=:99 +# Raft authenticates the private SSH tunnel. Keep Voxl's default host/origin checks. +ExecStart=/usr/local/bin/voxl --addr=0.0.0.0 --port=6080 --enable-basic-auth=false --audio-enabled=false --microphone-enabled=false --files-root=/workspace +Restart=always +RestartSec=1 +KillMode=control-group diff --git a/images/incus/raft-desktop.service b/images/incus/raft-desktop.service index d15713a..07efa33 100644 --- a/images/incus/raft-desktop.service +++ b/images/incus/raft-desktop.service @@ -1,7 +1,14 @@ [Unit] -Description=Private Raft desktop on demand +Description=Shared private Raft X11 desktop After=network.target [Service] +Type=notify +NotifyAccess=all +User=developer +WorkingDirectory=/workspace +Environment=DISPLAY=:99 ExecStart=/usr/local/bin/raft-desktop Restart=on-failure +RestartSec=1 +TimeoutStartSec=10 KillMode=control-group diff --git a/package.json b/package.json index 7a37f14..8673dfb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@microck/raft", - "version": "0.7.1", + "version": "0.8.0", "description": "Self-hosted Linux workspaces powered by Incus, an open-source alternative to boat.dev", "license": "MIT", "repository": { diff --git a/raft.py b/raft.py index 29d6a72..c6e118e 100755 --- a/raft.py +++ b/raft.py @@ -28,7 +28,7 @@ import raft_jobs import raft_output -__version__ = "0.7.1" +__version__ = "0.8.0" CONFIG = Path.home() / ".config/raft/incus.json" # Controller defaults for new live apart from the location inventory, so location @@ -2108,7 +2108,7 @@ def parser(): "restore": "Replace disk contents from a named snapshot of a stopped box. Keep current instance configuration, resource limits and MAC address. Requires an explicitly requested rollback.", "fork": "Copy a stopped box on the same host, including files, secrets and note. Defaults to persistent. CPU and memory retain source limits unless overridden. Update the copy note for its new task.", "forward": "Open a foreground SSH tunnel bound to controller 127.0.0.1. Guest service must listen on its managed NIC or 0.0.0.0. Ctrl+C closes the tunnel.", - "desktop": "Start the full guest desktop and open its private foreground tunnel. Closing the tunnel leaves the desktop service running until explicitly stopped or the box stops.", + "desktop": "Start the full guest desktop with Voxl (default) or noVNC and open its private foreground tunnel. Closing the tunnel leaves the desktop service running until explicitly stopped or the box stops.", "logs": "Print retained detached-job output, including after workspace stop or deletion. First 1 MiB retained for seven days.", "status": "Print retained job state, exit code, timestamps and dropped bytes as JSON, even after workspace deletion.", "cancel": "Stop the guest job control group. Host-retained output remains available for seven days.", @@ -2158,6 +2158,12 @@ def parser(): command.add_argument("--local", type=int, required=True) if action == "desktop": command.add_argument("--local", type=int, default=6080) + command.add_argument( + "--backend", + choices=["voxl", "novnc"], + default="voxl", + help="Desktop streamer (default: voxl); novnc uses /vnc.html", + ) return cli @@ -2507,13 +2513,22 @@ def dispatch(args): ) print(location + ":" + child) elif args.action in ["forward", "desktop"]: - port = args.remote if args.action == "forward" else 6080 + port = ( + args.remote if args.action == "forward" else (6080 if args.backend == "voxl" else 6081) + ) if not 1 <= port <= 65535 or not 1 <= args.local <= 65535: raise ValueError("Ports must be between 1 and 65535") if args.action == "desktop": - response = execute(location, name, ["systemctl", "start", "raft-desktop"], capture=True) + service = f"raft-desktop-{args.backend}" + response = execute(location, name, ["systemctl", "start", service], capture=True) if response.returncode: - raise RuntimeError(response.stderr.decode().strip()) + raise RuntimeError( + response.stderr.decode().strip() + + f"\nInspect journalctl -u raft-desktop -u {service} in the box. " + "Desktop backends require an updated Raft image; create a fresh box from it." + ) + path = "/" if args.backend == "voxl" else "/vnc.html" + print(f"{args.backend} viewer: http://127.0.0.1:{args.local}{path}", flush=True) return forward(location, name, port, args.local) return 0 diff --git a/website/scripts/verify-site.mjs b/website/scripts/verify-site.mjs index 563407e..b868206 100644 --- a/website/scripts/verify-site.mjs +++ b/website/scripts/verify-site.mjs @@ -70,6 +70,7 @@ try { '/docs/releases/0.6.0', '/docs/releases/0.7.0', '/docs/releases/0.7.1', + '/docs/releases/0.8.0', ]; const targets = new Set(); const fragments = new Map();