From 15bc84e06d1bff1ed8012e33ca7598a3b0a5d5c9 Mon Sep 17 00:00:00 2001 From: Stefan Dirix Date: Fri, 24 Jul 2026 12:27:35 +0000 Subject: [PATCH 1/3] feat: vnc feature serving a contained GUI over VNC Opt-in `vnc` mixin: a virtual X display (Xvnc) running a fullscreened Chromium, served over VNC (RFB). The raw RFB port is published on the host loopback so any VNC client can attach directly. Find the port with `enclave ps --json` and read the per-session password from /tmp/enclave-vnc/vnc-password. A supervisor keeps Xvnc, matchbox-window-manager, and a headful Chromium at $VNC_URL alive with restart loops. Xvnc listens on all container interfaces so the published port reaches it, and enforces a random password at the RFB layer (VncAuth). That password is what shapes the boundary: holding it is what grants control of the display, and because it is generated per session it reaches exactly one session's display, which is why the agent knowing it is harmless. Exporting DISPLAY and BROWSER=vnc-open routes "open in browser" flows onto the contained display, where all real browsing stays on the session's gateway-restricted network. --- docs/extensions/README.md | 5 +- extensions/features/vnc/README.md | 116 +++++++++++ .../features/vnc/enclave-vnc-open.desktop | 20 ++ .../vnc/feature-entrypoint.d/setup.sh | 13 ++ extensions/features/vnc/install.sh | 32 +++ extensions/features/vnc/spec.yaml | 40 ++++ extensions/features/vnc/vnc-open | 49 +++++ extensions/features/vnc/vnc-supervisor | 186 ++++++++++++++++++ extensions/features/vnc/waiting.html | 66 +++++++ .../config/testdata/golden/feature-vnc.json | 26 +++ 10 files changed, 551 insertions(+), 2 deletions(-) create mode 100644 extensions/features/vnc/README.md create mode 100644 extensions/features/vnc/enclave-vnc-open.desktop create mode 100644 extensions/features/vnc/feature-entrypoint.d/setup.sh create mode 100755 extensions/features/vnc/install.sh create mode 100644 extensions/features/vnc/spec.yaml create mode 100755 extensions/features/vnc/vnc-open create mode 100755 extensions/features/vnc/vnc-supervisor create mode 100644 extensions/features/vnc/waiting.html create mode 100644 internal/config/testdata/golden/feature-vnc.json diff --git a/docs/extensions/README.md b/docs/extensions/README.md index 104e8eb..b962134 100644 --- a/docs/extensions/README.md +++ b/docs/extensions/README.md @@ -549,9 +549,9 @@ Opt-in features require an explicit list; additive-only entries do not change th The next time you run `./enclave --rebuild`, only the specified features will be installed. -**Available features:** `devtools`, `github-cli`, `gitlab-cli`, `node-dev`, `playwright`, `python-dev`, `debug-tools`, `shell-extras` +**Available features:** `devtools`, `github-cli`, `gitlab-cli`, `node-dev`, `playwright`, `python-dev`, `debug-tools`, `shell-extras`, `vnc` -**Opt-in features (not installed unless explicitly listed):** `debug-tools`, `gitlab-cli`, `playwright`, `shell-extras` +**Opt-in features (not installed unless explicitly listed):** `debug-tools`, `gitlab-cli`, `playwright`, `shell-extras`, `vnc` ### Installation Order @@ -602,6 +602,7 @@ resolved port appears in the printed `openUrl` and in `enclave ps`. | `node-dev` | 70 | Node.js dev tools: typescript, eslint, prettier | | `python-dev` | 70 | Python dev tools: black, ruff, mypy, pytest | | `playwright` | 75 | Playwright browsers and MCP server for UI testing (opt-in) | +| `vnc` | 75 | Contained GUI (Xvnc + Chromium) served over VNC, reachable by attaching any VNC client to the published RFB port (opt-in) | | `debug-tools` | 80 | Debug tools: gdb, strace, ltrace, tcpdump (opt-in) | | `shell-extras` | 90 | Shell enhancements: zsh, oh-my-zsh, direnv (opt-in) | diff --git a/extensions/features/vnc/README.md b/extensions/features/vnc/README.md new file mode 100644 index 0000000..505b1bc --- /dev/null +++ b/extensions/features/vnc/README.md @@ -0,0 +1,116 @@ +# vnc feature + +Opt-in feature that gives a session a **contained GUI**: a virtual X display +(TigerVNC's `Xvnc`) running a fullscreened Chromium, served over **VNC (RFB)**. +The raw RFB port is published on the host loopback, so you can attach any VNC +client of your choice. + +Enable it: + +```bash +enclave --features +vnc … +``` + +## Connecting a VNC client + +The RFB port (container `5900`) is published with an OS-assigned host port on +the loopback interface, so concurrent sessions get distinct ports. Find the +published port with `enclave ps --json` (look for the `5900` container port), +then read the per-session password out of the container and point your client +at it: + +```bash +docker exec cat /tmp/enclave-vnc/vnc-password +vncviewer 127.0.0.1: +``` + +The password is required (see [Access control](#access-control)). It only ever +gates that one session's display, so reading it out of the container is safe. + +## What runs in the container + +`commands.startup` launches `vnc-supervisor` (installed to `/usr/local/bin`) +as the sandbox user. It keeps three components alive with per-component restart +loops, logging to `/tmp/enclave-vnc/log/`: + +1. **Xvnc**: virtual X display `:99`, RFB server on the published port + (default `5900`), VncAuth required. It listens on all container interfaces + (no `-localhost`) so the host's loopback-published port reaches it. +2. **matchbox-window-manager**: fullscreens every window (kiosk-style). +3. **Chromium**: headful on the virtual display. It starts on a local + waiting page and stays there until a page is opened. When `$VNC_URL` is set + (default `about:blank`, meaning no target), a one-shot watcher probes it and + forwards it into the running browser once its TCP port accepts connections. + Loading the URL directly would instead park the display on a + connection-refused error page whenever the target server starts later than + the stack. Sessions can also drive the browser on demand via `vnc-open`, + which is how a consuming feature opens a URL it only knows at runtime. + Chromium's own sandbox is disabled, because the default Docker seccomp + profile blocks the unprivileged user namespaces it needs, so the container + remains the isolation boundary. + +The feature entrypoint additionally exports `DISPLAY=:99` and +`BROWSER=/usr/local/bin/vnc-open`, and `install.sh` registers `vnc-open` as +the image-wide `x-scheme-handler` for http/https (desktop entry plus +`/etc/xdg/mimeapps.list`), so X clients and "open in browser" flows land on +the contained display. The handler registration matters because `xdg-open` +resolves scheme handlers via `xdg-mime` *before* falling back to `$BROWSER`: +without it, the apt-installed `chromium.desktop` wins, crashes sandbox-less, +and silently drops the URL. `vnc-open ` reuses the supervisor's Chromium +profile, waiting briefly for its singleton if the stack is still booting, so +URLs open in the running browser instead of racing it. + +## Access control + +The supervisor generates a random password on first start and enforces it at +the RFB layer (VncAuth), so Xvnc demands it from every client. It writes two +copies: + +- obfuscated auth file: `/tmp/enclave-vnc/rfb-passwd` (Xvnc) +- plaintext: `/tmp/enclave-vnc/vnc-password` (mode 0600) + +That password is what shapes the boundary. Holding it is what grants control +of the display, and nothing else does. It is generated per session, so it +grants control of exactly one session's display and no other. + +The plaintext path is what a VNC client user reads (see +[Connecting a VNC client](#connecting-a-vnc-client)), and it doubles as the +**integration contract** for a trusted host-side viewer, which can pick the +password up with `docker exec cat /tmp/enclave-vnc/vnc-password`. +Because its reach stops at that one session's own display, the (untrusted) +agent knowing it is harmless. + +## Configuration + +Environment variables read by the supervisor (set via a consuming feature's +`environment.variables` or `-e`): + +| Variable | Default | Meaning | +|----------|---------|---------| +| `VNC_URL` | `about:blank` | Optional URL auto-forwarded into the browser once its port is reachable. Left unset (the default), the display stays on the waiting page and sessions open pages on demand via `vnc-open`. | +| `VNC_GEOMETRY` | `1600x1000` | Initial display size (a resize-capable client can change it) | +| `VNC_DISPLAY` | `:99` | X display number | +| `VNC_RFB_PORT` | `5900` | RFB port (must match the spec's `ports:` declaration if changed) | + +A consuming feature should leave `VNC_URL` unset whenever the page it wants is +only determined at runtime, and call `vnc-open` with the full URL instead. +Auto-forwarding a bare server root in that situation lands the display on a +default view, which can clobber whatever state the intended URL would have +selected. + +## Residual risks + +- Holding the password is sufficient to drive the display, so keep it to + trusted local viewers. +- A human can be phished by what the streamed page *shows*. A viewer cannot + vouch for the session's content. +- Clipboard crossing: Xvnc syncs the display's X selections with the RFB + clipboard natively (`SendCutText`/`AcceptCutText`/`SetPrimary`/`SendPrimary`, + all on by default), so any authenticated RFB client can exchange clipboard + text with the session. Nothing in this feature gates that. A viewer built on + top of it has to mediate the clipboard itself if it wants to. + +## Cost + +Chromium + X + VNC + fonts add roughly 600 MB to the image and a persistent +browser process to the session, hence `defaultEnabled: false`. diff --git a/extensions/features/vnc/enclave-vnc-open.desktop b/extensions/features/vnc/enclave-vnc-open.desktop new file mode 100644 index 0000000..b953e48 --- /dev/null +++ b/extensions/features/vnc/enclave-vnc-open.desktop @@ -0,0 +1,20 @@ +# Copyright (C) 2026 EclipseSource GmbH and others. +# +# This program and the accompanying materials are made available under the +# terms of the MIT License, which is available in the project root. +# +# SPDX-License-Identifier: MIT +# +# Default x-scheme-handler for http/https inside the session image, so +# xdg-open (and tools built on it, like the npm "open" package) route URLs +# into the VNC feature's Chromium instead of the apt-installed +# chromium.desktop, whose plain `chromium %U` crashes without --no-sandbox +# and silently drops the URL. Wired up via /etc/xdg/mimeapps.list in +# install.sh. +[Desktop Entry] +Type=Application +Name=Enclave VNC display +NoDisplay=true +Terminal=false +Exec=/usr/local/bin/vnc-open %U +MimeType=x-scheme-handler/http;x-scheme-handler/https; diff --git a/extensions/features/vnc/feature-entrypoint.d/setup.sh b/extensions/features/vnc/feature-entrypoint.d/setup.sh new file mode 100644 index 0000000..d3523ea --- /dev/null +++ b/extensions/features/vnc/feature-entrypoint.d/setup.sh @@ -0,0 +1,13 @@ +# Copyright (C) 2026 EclipseSource GmbH and others. +# +# This program and the accompanying materials are made available under the +# terms of the MIT License, which is available in the project root. +# +# SPDX-License-Identifier: MIT + +# shellcheck shell=bash +# Point the session at the VNC feature's virtual display so X clients and +# "open in browser" flows (xdg-open, $BROWSER consumers) land on the contained +# Chromium. The stack itself is started by commands.startup in spec.yaml. +export DISPLAY="${VNC_DISPLAY:-:99}" +export BROWSER=/usr/local/bin/vnc-open diff --git a/extensions/features/vnc/install.sh b/extensions/features/vnc/install.sh new file mode 100755 index 0000000..d07974d --- /dev/null +++ b/extensions/features/vnc/install.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Copyright (C) 2026 EclipseSource GmbH and others. +# +# This program and the accompanying materials are made available under the +# terms of the MIT License, which is available in the project root. +# +# SPDX-License-Identifier: MIT + +# Install the VNC runtime scripts. The display/VNC/browser packages +# themselves come from aptPackages in spec.yaml. +set -euo pipefail + +dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +install -m 755 "$dir/vnc-supervisor" /usr/local/bin/vnc-supervisor +install -m 755 "$dir/vnc-open" /usr/local/bin/vnc-open +install -D -m 644 "$dir/waiting.html" /usr/local/share/enclave-vnc/waiting.html + +# Make vnc-open the image-wide default handler for http/https URLs. xdg-open +# resolves the scheme handler via xdg-mime *before* falling back to $BROWSER, +# and the apt-installed chromium.desktop (plain `chromium`, no --no-sandbox) +# would otherwise win, crash on the blocked user namespaces, and silently +# drop the URL. +install -D -m 644 "$dir/enclave-vnc-open.desktop" /usr/share/applications/enclave-vnc-open.desktop +mkdir -p /etc/xdg +printf '%s\n' \ + '[Default Applications]' \ + 'x-scheme-handler/http=enclave-vnc-open.desktop' \ + 'x-scheme-handler/https=enclave-vnc-open.desktop' \ + > /etc/xdg/mimeapps.list + +echo "vnc: installed supervisor, vnc-open, waiting page, and URL handler" diff --git a/extensions/features/vnc/spec.yaml b/extensions/features/vnc/spec.yaml new file mode 100644 index 0000000..79e1212 --- /dev/null +++ b/extensions/features/vnc/spec.yaml @@ -0,0 +1,40 @@ +# Copyright (C) 2026 EclipseSource GmbH and others. +# +# This program and the accompanying materials are made available under the +# terms of the MIT License, which is available in the project root. +# +# SPDX-License-Identifier: MIT + +schemaVersion: "1" +kind: mixin +name: vnc +displayName: VNC +description: >- + Virtual X display with a contained Chromium browser, served over VNC (RFB). + Opt-in. The raw RFB port is published so any VNC client can attach. +defaultEnabled: false +needsRoot: true +failOnInstallError: true +priority: 75 +aptPackages: + - tigervnc-standalone-server + - chromium + - matchbox-window-manager + - xdg-utils + - fonts-liberation + - fonts-dejavu-core + # Generates the RFB VncAuth file: TigerVNC 1.15 (Debian 13) no longer ships a + # vncpasswd binary, so the supervisor derives it via python3-cryptography. + - python3 + - python3-cryptography +commands: + startup: + - command: [/usr/local/bin/vnc-supervisor] + background: true + description: VNC stack (Xvnc, window manager, Chromium) +ports: + - container: 5900 + hostAllocation: auto + publish: true + label: VNC (RFB) + openUrl: "vnc://localhost:{host_port}" diff --git a/extensions/features/vnc/vnc-open b/extensions/features/vnc/vnc-open new file mode 100755 index 0000000..8ccdf1a --- /dev/null +++ b/extensions/features/vnc/vnc-open @@ -0,0 +1,49 @@ +#!/bin/bash +# Copyright (C) 2026 EclipseSource GmbH and others. +# +# This program and the accompanying materials are made available under the +# terms of the MIT License, which is available in the project root. +# +# SPDX-License-Identifier: MIT + +# vnc-open opens a URL in the session's contained (VNC) browser. It +# shares the supervisor's Chromium profile, so a running instance adopts the +# URL as a new (fullscreened) window instead of a second browser starting. +# +# Two paths route here so "open in browser" flows land on the contained +# display instead of failing headless: +# - the feature entrypoint exports BROWSER=/usr/local/bin/vnc-open +# - install.sh registers this script as the x-scheme-handler for http/https, +# which xdg-open (and the npm "open" package) resolves *before* $BROWSER. +# The apt-installed chromium.desktop would otherwise win and crash +# sandbox-less, silently dropping the URL. +# +# The stack may still be booting when the first open arrives, so the open runs +# in a detached subshell that first does a bounded wait for the supervisor's +# Chromium to hold the profile singleton. Launching earlier would make this +# process the singleton owner, and the supervisor's restart loop would then +# forward its start URL into it every 2 seconds forever. +set -u + +if [ "$#" -lt 1 ]; then + echo "usage: vnc-open " >&2 + exit 2 +fi + +export DISPLAY="${VNC_DISPLAY:-:99}" +PROFILE=/tmp/enclave-vnc/chromium + +# --no-sandbox matches the supervisor: the container blocks the unprivileged +# user namespaces Chromium's sandbox needs, and a sandbox failure here would +# silently drop the URL before it reaches the running instance. After the +# bounded wait the launch proceeds regardless, because with no live singleton, +# becoming the instance is the best remaining way to show the URL. +( + ( + for _ in $(seq 1 100); do + [ -e "$PROFILE/SingletonSocket" ] && break + sleep 0.3 + done + exec chromium --no-sandbox --user-data-dir="$PROFILE" "$@" + ) >/dev/null 2>&1 & +) diff --git a/extensions/features/vnc/vnc-supervisor b/extensions/features/vnc/vnc-supervisor new file mode 100755 index 0000000..c3e9e4e --- /dev/null +++ b/extensions/features/vnc/vnc-supervisor @@ -0,0 +1,186 @@ +#!/bin/bash +# Copyright (C) 2026 EclipseSource GmbH and others. +# +# This program and the accompanying materials are made available under the +# terms of the MIT License, which is available in the project root. +# +# SPDX-License-Identifier: MIT + +# vnc-supervisor runs the in-container VNC stack: +# +# Xvnc (virtual X display + VNC server, RFB served on all container +# interfaces so the published host port reaches it) +# -> matchbox-window-manager (fullscreens every window) +# -> Chromium on a waiting page, forwarded to $VNC_URL once it is set +# and reachable (all browsing stays on the container's +# gateway-restricted network) +# +# Each component runs in its own restart loop so a crash never takes the +# session down. Logs go to $STATE_DIR/log/. Everything runs as the sandbox +# user and dies with the container. +# +# Access to the display is gated by a per-session random VNC password +# (RFB VncAuth). The plaintext copy at $STATE_DIR/vnc-password is what a +# native VNC client needs (fetch it with `docker exec cat +# $STATE_DIR/vnc-password`) and is also the integration contract a trusted +# host-side viewer reads the same way. That password is what shapes the +# boundary: holding it is what grants control of the display, and because it is +# generated per session it reaches exactly one session's display. +set -u + +DISPLAY_NUM="${VNC_DISPLAY:-:99}" +GEOMETRY="${VNC_GEOMETRY:-1600x1000}" +RFB_PORT="${VNC_RFB_PORT:-5900}" +START_URL="${VNC_URL:-about:blank}" +STATE_DIR="/tmp/enclave-vnc" +LOG_DIR="$STATE_DIR/log" + +mkdir -p "$STATE_DIR" "$LOG_DIR" || exit 1 +chmod 700 "$STATE_DIR" + +# Refuse to double-start (the entrypoint runs startup commands exactly once, +# but a manual invocation must not spawn a second stack). +if [ -f "$STATE_DIR/supervisor.pid" ] && kill -0 "$(cat "$STATE_DIR/supervisor.pid")" 2>/dev/null; then + echo "vnc: supervisor already running" >&2 + exit 0 +fi +echo $$ > "$STATE_DIR/supervisor.pid" + +# Per-session password. RFB VncAuth only uses the first 8 characters, but the +# longer value costs nothing and keeps the file format obvious. +umask 077 +if [ ! -s "$STATE_DIR/vnc-password" ]; then + tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 20 > "$STATE_DIR/vnc-password" +fi + +# Write the RFB VncAuth file the Xvnc `-rfbauth` flag consumes. Prefer a +# vncpasswd binary when the base ships one. TigerVNC 1.15 (Debian 13) dropped +# it, so fall back to generating the file ourselves. The format is the password +# (truncated/NUL-padded to 8 bytes) DES-ECB encrypted under VNC's fixed key with +# each key byte bit-reversed. Single DES is TripleDES with an 8-byte key. +if VNCPASSWD="$(command -v tigervncpasswd || command -v vncpasswd)"; then + "$VNCPASSWD" -f < "$STATE_DIR/vnc-password" > "$STATE_DIR/rfb-passwd" || { + echo "vnc: failed to write VNC auth file" >&2 + exit 1 + } +elif command -v python3 >/dev/null 2>&1; then + python3 - "$STATE_DIR/vnc-password" "$STATE_DIR/rfb-passwd" <<'PY' || { +import os +import sys + +try: + from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES +except ImportError: # cryptography < 43 + from cryptography.hazmat.primitives.ciphers.algorithms import TripleDES +from cryptography.hazmat.primitives.ciphers import Cipher, modes + +src, dst = sys.argv[1], sys.argv[2] +with open(src, "rb") as f: + plaintext = f.read().rstrip(b"\r\n")[:8].ljust(8, b"\x00") +key = bytes([0xE8, 0x4A, 0xD6, 0x60, 0xC4, 0x72, 0x1A, 0xE0]) +enc = Cipher(TripleDES(key), modes.ECB()).encryptor() +obfuscated = enc.update(plaintext) + enc.finalize() +fd = os.open(dst, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) +with os.fdopen(fd, "wb") as f: + f.write(obfuscated) +PY + echo "vnc: failed to write VNC auth file" >&2 + exit 1 + } +else + echo "vnc: no vncpasswd binary and no python3 to generate VNC auth file" >&2 + exit 1 +fi + +# run_forever : restart loop with a short backoff. The loop +# lives in a subshell so `wait` below blocks on all of them. +run_forever() { + _name="$1" + shift + ( + while :; do + "$@" >> "$LOG_DIR/$_name.log" 2>&1 + echo "vnc: $_name exited (status $?), restarting in 2s" >> "$LOG_DIR/$_name.log" + sleep 2 + done + ) & +} + +# No -localhost: the RFB port is published on the host loopback, and Docker +# forwards that to the container's routable interface, so Xvnc must listen on +# all interfaces to be reachable. VncAuth (above) is the access gate. +run_forever xvnc Xvnc "$DISPLAY_NUM" \ + -geometry "$GEOMETRY" -depth 24 \ + -rfbport "$RFB_PORT" \ + -SecurityTypes VncAuth -rfbauth "$STATE_DIR/rfb-passwd" \ + -AlwaysShared -desktop enclave + +# Wait for the X socket before starting display clients. +_socket="/tmp/.X11-unix/X${DISPLAY_NUM#:}" +for _ in $(seq 1 50); do + [ -S "$_socket" ] && break + sleep 0.2 +done + +export DISPLAY="$DISPLAY_NUM" + +run_forever wm matchbox-window-manager -use_titlebar no + +# Chromium starts on a static waiting page, never on $VNC_URL directly: a +# server the session starts later would otherwise park the display on a +# connection-refused error page that nothing ever reloads. The waiting page is +# also the neutral idle state when no $VNC_URL is set, where sessions drive the +# browser on demand via vnc-open, so the one-shot forward below only runs when +# a $VNC_URL is given. +WAITING_PAGE=/usr/local/share/enclave-vnc/waiting.html +BROWSER_START="about:blank" +if [ -f "$WAITING_PAGE" ]; then + BROWSER_START="file://$WAITING_PAGE" +fi + +# The container is the sandbox. Chromium's own sandbox needs unprivileged +# user namespaces, which the default Docker seccomp profile blocks, so it is +# disabled here. Everything Chromium loads egresses via the session gateway. +run_forever browser chromium \ + --no-sandbox \ + --disable-gpu \ + --disable-dev-shm-usage \ + --no-first-run \ + --no-default-browser-check \ + --hide-crash-restore-bubble \ + --password-store=basic \ + --start-maximized \ + --user-data-dir="$STATE_DIR/chromium" \ + "$BROWSER_START" + +# Forward $VNC_URL once its TCP port accepts connections. vnc-open +# waits for the browser singleton, so ordering against the Chromium start +# above does not matter. Probing only covers http(s), so other schemes open +# immediately and take their chances. +if [ "$START_URL" != "about:blank" ]; then + case "$START_URL" in + http://* | https://*) + _hostport="${START_URL#*://}" + _hostport="${_hostport%%/*}" + _host="${_hostport%%:*}" + _port="${_hostport##*:}" + if [ "$_port" = "$_hostport" ]; then + case "$START_URL" in + https://*) _port=443 ;; + *) _port=80 ;; + esac + fi + ( + until (exec 3<> "/dev/tcp/$_host/$_port") 2>/dev/null; do + sleep 1 + done + /usr/local/bin/vnc-open "$START_URL" + ) >> "$LOG_DIR/url-watch.log" 2>&1 & + ;; + *) + /usr/local/bin/vnc-open "$START_URL" >> "$LOG_DIR/url-watch.log" 2>&1 & + ;; + esac +fi + +wait diff --git a/extensions/features/vnc/waiting.html b/extensions/features/vnc/waiting.html new file mode 100644 index 0000000..87d5921 --- /dev/null +++ b/extensions/features/vnc/waiting.html @@ -0,0 +1,66 @@ + + + + + +Enclave contained display + + + +
+

Nothing open yet

+

This is your enclave session’s contained display. Pages the session + serves or opens appear here.

+
+ + diff --git a/internal/config/testdata/golden/feature-vnc.json b/internal/config/testdata/golden/feature-vnc.json new file mode 100644 index 0000000..cbce00b --- /dev/null +++ b/internal/config/testdata/golden/feature-vnc.json @@ -0,0 +1,26 @@ +{ + "type": "mixin", + "name": "vnc", + "description": "Virtual X display with a contained Chromium browser, served over VNC (RFB). Opt-in. The raw RFB port is published so any VNC client can attach.", + "apt_packages": [ + "tigervnc-standalone-server", + "chromium", + "matchbox-window-manager", + "xdg-utils", + "fonts-liberation", + "fonts-dejavu-core", + "python3", + "python3-cryptography" + ], + "needs_root": true, + "priority": 75, + "ports": [ + { + "container": 5900, + "host_allocation": "auto", + "publish": true, + "label": "VNC (RFB)", + "open_url": "vnc://localhost:{host_port}" + } + ] +} From b93af3dcb4fb3aa905f73df6c37ea348de154f17 Mon Sep 17 00:00:00 2001 From: Stefan Dirix Date: Thu, 6 Aug 2026 11:09:06 +0000 Subject: [PATCH 2/3] fix: address review comments on the vnc feature - share one Chromium invocation (new vnc-chromium wrapper) between the supervisor and vnc-open, and quiet Chromium's background networking - park the supervisor's browser restart loop while another live process holds the profile singleton instead of respawning every 2s - install chromium via install.sh instead of the shared aptPackages stage so an Ubuntu base fails only this feature, with a clear error - log vnc-open output to /tmp/enclave-vnc/log/ instead of /dev/null - merge into /etc/xdg/mimeapps.list instead of truncating it - drop the unusable VNC_RFB_PORT knob (spec.yaml pins the port) - cover the extension-less vnc scripts with shellcheck in make lint, lint-report, and lint-changed - document bridge-level RFB reachability under residual risks and keep the xdg-mime rationale in one place (vnc-open's header) --- Makefile | 4 +- extensions/features/vnc/README.md | 38 ++++++++++------ .../features/vnc/enclave-vnc-open.desktop | 10 ++--- extensions/features/vnc/install.sh | 43 +++++++++++++------ extensions/features/vnc/spec.yaml | 5 ++- extensions/features/vnc/vnc-chromium | 33 ++++++++++++++ extensions/features/vnc/vnc-open | 28 +++++++----- extensions/features/vnc/vnc-supervisor | 42 +++++++++++------- .../config/testdata/golden/feature-vnc.json | 1 - scripts/lint-changed.sh | 2 +- 10 files changed, 145 insertions(+), 61 deletions(-) create mode 100755 extensions/features/vnc/vnc-chromium diff --git a/Makefile b/Makefile index 445d0a9..631003d 100644 --- a/Makefile +++ b/Makefile @@ -164,7 +164,7 @@ lint: lint-tools @echo "Running gosec..." GOCACHE="$(CURDIR)/$(LINT_GO_CACHE)" gosec -quiet -exclude-dir=vendor ./... @echo "Running shellcheck..." - @scripts="$$(find . -type f \( -name '*.sh' -o -path '*/build-scripts/bin/*' \) -not -path './vendor/*' | sort)"; \ + @scripts="$$(find . -type f \( -name '*.sh' -o -path '*/build-scripts/bin/*' -o -path '*/extensions/features/vnc/vnc-*' \) -not -path './vendor/*' | sort)"; \ if [ -z "$$scripts" ]; then \ echo "No shell scripts found"; \ else \ @@ -222,7 +222,7 @@ lint-report: lint-tools @GOCACHE="$(CURDIR)/$(LINT_GO_CACHE)" gosec -exclude-dir=vendor ./... > $(REPORTS_DIR)/gosec.txt 2>&1 || true @echo " -> $(REPORTS_DIR)/gosec.txt" @echo "Running shellcheck..." - @scripts="$$(find . -type f \( -name '*.sh' -o -path '*/build-scripts/bin/*' \) -not -path './vendor/*' | sort)"; \ + @scripts="$$(find . -type f \( -name '*.sh' -o -path '*/build-scripts/bin/*' -o -path '*/extensions/features/vnc/vnc-*' \) -not -path './vendor/*' | sort)"; \ if [ -z "$$scripts" ]; then \ echo "No shell scripts found" > $(REPORTS_DIR)/shellcheck.txt; \ else \ diff --git a/extensions/features/vnc/README.md b/extensions/features/vnc/README.md index 505b1bc..9e0f9e9 100644 --- a/extensions/features/vnc/README.md +++ b/extensions/features/vnc/README.md @@ -11,6 +11,11 @@ Enable it: enclave --features +vnc … ``` +The feature needs a base image whose archive ships Chromium as a deb. The +default Debian base does; on Ubuntu bases, which only ship Chromium as a +snap, the feature's install fails (scoped to this feature, with a clear +error). + ## Connecting a VNC client The RFB port (container `5900`) is published with an OS-assigned host port on @@ -33,8 +38,8 @@ gates that one session's display, so reading it out of the container is safe. as the sandbox user. It keeps three components alive with per-component restart loops, logging to `/tmp/enclave-vnc/log/`: -1. **Xvnc**: virtual X display `:99`, RFB server on the published port - (default `5900`), VncAuth required. It listens on all container interfaces +1. **Xvnc**: virtual X display `:99`, RFB server on the published container + port `5900`, VncAuth required. It listens on all container interfaces (no `-localhost`) so the host's loopback-published port reaches it. 2. **matchbox-window-manager**: fullscreens every window (kiosk-style). 3. **Chromium**: headful on the virtual display. It starts on a local @@ -45,20 +50,22 @@ loops, logging to `/tmp/enclave-vnc/log/`: connection-refused error page whenever the target server starts later than the stack. Sessions can also drive the browser on demand via `vnc-open`, which is how a consuming feature opens a URL it only knows at runtime. - Chromium's own sandbox is disabled, because the default Docker seccomp - profile blocks the unprivileged user namespaces it needs, so the container - remains the isolation boundary. + Both the supervisor and `vnc-open` launch it through the shared + `/usr/local/bin/vnc-chromium` wrapper, which disables Chromium's own + sandbox (the default Docker seccomp profile blocks the unprivileged user + namespaces it needs, so the container remains the isolation boundary) and + its background networking (component updater, safe browsing, sync), which + would otherwise keep hitting gateway-denied domains. The feature entrypoint additionally exports `DISPLAY=:99` and `BROWSER=/usr/local/bin/vnc-open`, and `install.sh` registers `vnc-open` as the image-wide `x-scheme-handler` for http/https (desktop entry plus `/etc/xdg/mimeapps.list`), so X clients and "open in browser" flows land on -the contained display. The handler registration matters because `xdg-open` -resolves scheme handlers via `xdg-mime` *before* falling back to `$BROWSER`: -without it, the apt-installed `chromium.desktop` wins, crashes sandbox-less, -and silently drops the URL. `vnc-open ` reuses the supervisor's Chromium -profile, waiting briefly for its singleton if the stack is still booting, so -URLs open in the running browser instead of racing it. +the contained display (`vnc-open`'s header explains why the scheme-handler +registration, not just `$BROWSER`, is load-bearing). `vnc-open ` reuses +the supervisor's Chromium profile, waiting briefly for its singleton if the +stack is still booting, so URLs open in the running browser instead of racing +it, and logs to `/tmp/enclave-vnc/log/vnc-open.log`. ## Access control @@ -90,7 +97,9 @@ Environment variables read by the supervisor (set via a consuming feature's | `VNC_URL` | `about:blank` | Optional URL auto-forwarded into the browser once its port is reachable. Left unset (the default), the display stays on the waiting page and sessions open pages on demand via `vnc-open`. | | `VNC_GEOMETRY` | `1600x1000` | Initial display size (a resize-capable client can change it) | | `VNC_DISPLAY` | `:99` | X display number | -| `VNC_RFB_PORT` | `5900` | RFB port (must match the spec's `ports:` declaration if changed) | + +The RFB port is not configurable: it must match the `ports:` declaration in +`spec.yaml` (container port `5900`), so the supervisor hardcodes it. A consuming feature should leave `VNC_URL` unset whenever the page it wants is only determined at runtime, and call `vnc-open` with the full URL instead. @@ -102,6 +111,11 @@ selected. - Holding the password is sufficient to drive the display, so keep it to trusted local viewers. +- The host publish is loopback-only, but Xvnc listens on all interfaces + inside the container's network namespace. Under network isolation that + namespace belongs to the session's gateway container on a shared Docker + bridge, so other containers on that bridge (including other sessions' + gateways) can reach the RFB port directly, with VncAuth as the only gate. - A human can be phished by what the streamed page *shows*. A viewer cannot vouch for the session's content. - Clipboard crossing: Xvnc syncs the display's X selections with the RFB diff --git a/extensions/features/vnc/enclave-vnc-open.desktop b/extensions/features/vnc/enclave-vnc-open.desktop index b953e48..f5cbde6 100644 --- a/extensions/features/vnc/enclave-vnc-open.desktop +++ b/extensions/features/vnc/enclave-vnc-open.desktop @@ -5,12 +5,10 @@ # # SPDX-License-Identifier: MIT # -# Default x-scheme-handler for http/https inside the session image, so -# xdg-open (and tools built on it, like the npm "open" package) route URLs -# into the VNC feature's Chromium instead of the apt-installed -# chromium.desktop, whose plain `chromium %U` crashes without --no-sandbox -# and silently drops the URL. Wired up via /etc/xdg/mimeapps.list in -# install.sh. +# Default x-scheme-handler for http/https inside the session image, routing +# xdg-open flows into vnc-open (whose header explains why this registration +# must beat the apt-installed chromium.desktop). Wired up via +# /etc/xdg/mimeapps.list in install.sh. [Desktop Entry] Type=Application Name=Enclave VNC display diff --git a/extensions/features/vnc/install.sh b/extensions/features/vnc/install.sh index d07974d..518efb2 100755 --- a/extensions/features/vnc/install.sh +++ b/extensions/features/vnc/install.sh @@ -6,27 +6,46 @@ # # SPDX-License-Identifier: MIT -# Install the VNC runtime scripts. The display/VNC/browser packages +# Install Chromium and the VNC runtime scripts. The display/VNC packages # themselves come from aptPackages in spec.yaml. set -euo pipefail dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Chromium is installed here rather than via aptPackages: all selected +# features' aptPackages go through a single shared apt-get install, and +# `chromium` is only a real deb on Debian archives (Ubuntu ships a snap +# stub), so listing it there would fail every selected feature's packages on +# an Ubuntu base instead of just this feature. +apt-get update +if ! apt-get install -y --no-install-recommends chromium; then + echo "vnc: cannot install chromium: this feature needs a base image whose" \ + "archive ships Chromium as a deb (the default Debian base does;" \ + "Ubuntu only ships it as a snap)" >&2 + exit 1 +fi +apt-get clean + install -m 755 "$dir/vnc-supervisor" /usr/local/bin/vnc-supervisor install -m 755 "$dir/vnc-open" /usr/local/bin/vnc-open +install -m 755 "$dir/vnc-chromium" /usr/local/bin/vnc-chromium install -D -m 644 "$dir/waiting.html" /usr/local/share/enclave-vnc/waiting.html -# Make vnc-open the image-wide default handler for http/https URLs. xdg-open -# resolves the scheme handler via xdg-mime *before* falling back to $BROWSER, -# and the apt-installed chromium.desktop (plain `chromium`, no --no-sandbox) -# would otherwise win, crash on the blocked user namespaces, and silently -# drop the URL. +# Register vnc-open as the image-wide default handler for http/https URLs +# (vnc-open's header explains why the scheme-handler registration matters). +# Merge into an existing mimeapps.list rather than truncating it, so handler +# registrations from other extensions survive; for http/https, vnc-open wins. install -D -m 644 "$dir/enclave-vnc-open.desktop" /usr/share/applications/enclave-vnc-open.desktop +mimeapps=/etc/xdg/mimeapps.list mkdir -p /etc/xdg -printf '%s\n' \ - '[Default Applications]' \ - 'x-scheme-handler/http=enclave-vnc-open.desktop' \ - 'x-scheme-handler/https=enclave-vnc-open.desktop' \ - > /etc/xdg/mimeapps.list +if [ ! -f "$mimeapps" ]; then + printf '[Default Applications]\n' > "$mimeapps" +elif ! grep -q '^\[Default Applications\]' "$mimeapps"; then + printf '\n[Default Applications]\n' >> "$mimeapps" +fi +sed -i -e '/^x-scheme-handler\/http=/d' -e '/^x-scheme-handler\/https=/d' "$mimeapps" +sed -i '/^\[Default Applications\]/a\ +x-scheme-handler/http=enclave-vnc-open.desktop\ +x-scheme-handler/https=enclave-vnc-open.desktop' "$mimeapps" -echo "vnc: installed supervisor, vnc-open, waiting page, and URL handler" +echo "vnc: installed chromium, supervisor, vnc-open, waiting page, and URL handler" diff --git a/extensions/features/vnc/spec.yaml b/extensions/features/vnc/spec.yaml index 79e1212..1cc524b 100644 --- a/extensions/features/vnc/spec.yaml +++ b/extensions/features/vnc/spec.yaml @@ -17,8 +17,11 @@ needsRoot: true failOnInstallError: true priority: 75 aptPackages: + # Chromium itself is installed by install.sh, not listed here: it is only a + # real deb on Debian archives (Ubuntu ships a snap stub), and all selected + # features' aptPackages share one apt-get install, so one unavailable + # package would fail every feature's packages instead of just this one. - tigervnc-standalone-server - - chromium - matchbox-window-manager - xdg-utils - fonts-liberation diff --git a/extensions/features/vnc/vnc-chromium b/extensions/features/vnc/vnc-chromium new file mode 100755 index 0000000..7c30932 --- /dev/null +++ b/extensions/features/vnc/vnc-chromium @@ -0,0 +1,33 @@ +#!/bin/bash +# Copyright (C) 2026 EclipseSource GmbH and others. +# +# This program and the accompanying materials are made available under the +# terms of the MIT License, which is available in the project root. +# +# SPDX-License-Identifier: MIT + +# vnc-chromium is the single Chromium invocation shared by vnc-supervisor and +# vnc-open, so the browser behaves the same no matter which path started it. +# +# The container is the sandbox: Chromium's own sandbox needs unprivileged user +# namespaces, which the default Docker seccomp profile blocks, so it runs with +# --no-sandbox and everything it loads egresses via the session gateway. The +# background-networking/component-update/sync switches keep Chromium's own +# phone-home traffic (component updater, safe browsing, optimization guide) +# from hammering gateway-denied domains and filling the network log. +set -u + +exec chromium \ + --no-sandbox \ + --disable-gpu \ + --disable-dev-shm-usage \ + --disable-background-networking \ + --disable-component-update \ + --disable-sync \ + --no-first-run \ + --no-default-browser-check \ + --hide-crash-restore-bubble \ + --password-store=basic \ + --start-maximized \ + --user-data-dir=/tmp/enclave-vnc/chromium \ + "$@" diff --git a/extensions/features/vnc/vnc-open b/extensions/features/vnc/vnc-open index 8ccdf1a..55af50a 100755 --- a/extensions/features/vnc/vnc-open +++ b/extensions/features/vnc/vnc-open @@ -11,7 +11,8 @@ # URL as a new (fullscreened) window instead of a second browser starting. # # Two paths route here so "open in browser" flows land on the contained -# display instead of failing headless: +# display instead of failing headless (canonical rationale; install.sh, the +# desktop entry, and the README point back at this comment): # - the feature entrypoint exports BROWSER=/usr/local/bin/vnc-open # - install.sh registers this script as the x-scheme-handler for http/https, # which xdg-open (and the npm "open" package) resolves *before* $BROWSER. @@ -21,8 +22,8 @@ # The stack may still be booting when the first open arrives, so the open runs # in a detached subshell that first does a bounded wait for the supervisor's # Chromium to hold the profile singleton. Launching earlier would make this -# process the singleton owner, and the supervisor's restart loop would then -# forward its start URL into it every 2 seconds forever. +# process the singleton owner, and the supervisor would then park its own +# browser until this one exits. set -u if [ "$#" -lt 1 ]; then @@ -31,19 +32,24 @@ if [ "$#" -lt 1 ]; then fi export DISPLAY="${VNC_DISPLAY:-:99}" -PROFILE=/tmp/enclave-vnc/chromium +STATE_DIR=/tmp/enclave-vnc +PROFILE="$STATE_DIR/chromium" +LOG_DIR="$STATE_DIR/log" -# --no-sandbox matches the supervisor: the container blocks the unprivileged -# user namespaces Chromium's sandbox needs, and a sandbox failure here would -# silently drop the URL before it reaches the running instance. After the -# bounded wait the launch proceeds regardless, because with no live singleton, -# becoming the instance is the best remaining way to show the URL. +# The supervisor normally creates the log dir, but an open can arrive before +# the stack is up, and a failed open must stay diagnosable. +mkdir -p "$LOG_DIR" || exit 1 + +# After the bounded wait the launch proceeds regardless: with no live +# singleton, becoming the instance is the best remaining way to show the URL. +# The launch goes through the shared vnc-chromium wrapper, so a fallback +# instance behaves identically to a supervisor-started one. ( ( for _ in $(seq 1 100); do [ -e "$PROFILE/SingletonSocket" ] && break sleep 0.3 done - exec chromium --no-sandbox --user-data-dir="$PROFILE" "$@" - ) >/dev/null 2>&1 & + exec /usr/local/bin/vnc-chromium "$@" + ) >> "$LOG_DIR/vnc-open.log" 2>&1 & ) diff --git a/extensions/features/vnc/vnc-supervisor b/extensions/features/vnc/vnc-supervisor index c3e9e4e..cb6045c 100755 --- a/extensions/features/vnc/vnc-supervisor +++ b/extensions/features/vnc/vnc-supervisor @@ -30,7 +30,9 @@ set -u DISPLAY_NUM="${VNC_DISPLAY:-:99}" GEOMETRY="${VNC_GEOMETRY:-1600x1000}" -RFB_PORT="${VNC_RFB_PORT:-5900}" +# Fixed, not a knob: the published container port in spec.yaml's `ports:` is +# 5900, so any other value would only make the display unreachable. +RFB_PORT=5900 START_URL="${VNC_URL:-about:blank}" STATE_DIR="/tmp/enclave-vnc" LOG_DIR="$STATE_DIR/log" @@ -138,20 +140,30 @@ if [ -f "$WAITING_PAGE" ]; then BROWSER_START="file://$WAITING_PAGE" fi -# The container is the sandbox. Chromium's own sandbox needs unprivileged -# user namespaces, which the default Docker seccomp profile blocks, so it is -# disabled here. Everything Chromium loads egresses via the session gateway. -run_forever browser chromium \ - --no-sandbox \ - --disable-gpu \ - --disable-dev-shm-usage \ - --no-first-run \ - --no-default-browser-check \ - --hide-crash-restore-bubble \ - --password-store=basic \ - --start-maximized \ - --user-data-dir="$STATE_DIR/chromium" \ - "$BROWSER_START" +# The browser launches via the shared vnc-chromium wrapper (also used by +# vnc-open) so it behaves the same no matter which path started it. It cannot +# use run_forever: a chromium that finds a live profile singleton (e.g. the +# fallback instance vnc-open starts once its bounded wait runs out) just hands +# its URL to that instance and exits 0, so a blind loop would pop a fresh +# waiting-page window every 2 seconds forever. Park while another live process +# holds the singleton and reclaim the display once it is gone. Chromium's +# SingletonLock is a symlink to "-". +singleton_live() { + _target="$(readlink "$STATE_DIR/chromium/SingletonLock" 2>/dev/null)" || return 1 + _pid="${_target##*-}" + [ -n "$_pid" ] && kill -0 "$_pid" 2>/dev/null +} +( + while :; do + if singleton_live; then + sleep 2 + continue + fi + /usr/local/bin/vnc-chromium "$BROWSER_START" >> "$LOG_DIR/browser.log" 2>&1 + echo "vnc: browser exited (status $?), restarting in 2s" >> "$LOG_DIR/browser.log" + sleep 2 + done +) & # Forward $VNC_URL once its TCP port accepts connections. vnc-open # waits for the browser singleton, so ordering against the Chromium start diff --git a/internal/config/testdata/golden/feature-vnc.json b/internal/config/testdata/golden/feature-vnc.json index cbce00b..799a813 100644 --- a/internal/config/testdata/golden/feature-vnc.json +++ b/internal/config/testdata/golden/feature-vnc.json @@ -4,7 +4,6 @@ "description": "Virtual X display with a contained Chromium browser, served over VNC (RFB). Opt-in. The raw RFB port is published so any VNC client can attach.", "apt_packages": [ "tigervnc-standalone-server", - "chromium", "matchbox-window-manager", "xdg-utils", "fonts-liberation", diff --git a/scripts/lint-changed.sh b/scripts/lint-changed.sh index 814e8cf..a93cf15 100755 --- a/scripts/lint-changed.sh +++ b/scripts/lint-changed.sh @@ -66,7 +66,7 @@ for f in $changed; do done fi ;; - *.sh | runtime-assets/build-scripts/bin/*) + *.sh | runtime-assets/build-scripts/bin/* | extensions/features/vnc/vnc-*) [ -f "$f" ] && sh_files="$sh_files $f" ;; esac From 57727b88041c097a691531c12551b0543c80ec13 Mon Sep 17 00:00:00 2001 From: Stefan Dirix Date: Fri, 7 Aug 2026 13:29:36 +0000 Subject: [PATCH 3/3] fix: address review comments on the vnc feature Namespace the feature's environment variables with ENCLAVE_, move the installed scripts under bin/ so the shellcheck globs generalize to any extension, bound the startup URL wait, and mirror supervisor diagnostics into a log file since the entrypoint discards stdout and stderr. --- Makefile | 4 +- docs/extensions/README.md | 20 ++- docs/security/README.md | 21 +++ extensions/features/vnc/README.md | 93 +++++----- .../features/vnc/{ => bin}/vnc-chromium | 7 +- extensions/features/vnc/{ => bin}/vnc-open | 2 +- .../features/vnc/{ => bin}/vnc-supervisor | 160 +++++++++++++----- .../vnc/feature-entrypoint.d/setup.sh | 7 +- extensions/features/vnc/install.sh | 31 ++-- extensions/features/vnc/spec.yaml | 10 +- internal/runtime/vnc_feature_test.go | 118 +++++++++++++ scripts/lint-changed.sh | 2 +- 12 files changed, 362 insertions(+), 113 deletions(-) rename extensions/features/vnc/{ => bin}/vnc-chromium (79%) rename extensions/features/vnc/{ => bin}/vnc-open (97%) rename extensions/features/vnc/{ => bin}/vnc-supervisor (53%) create mode 100644 internal/runtime/vnc_feature_test.go diff --git a/Makefile b/Makefile index 631003d..2e75bb7 100644 --- a/Makefile +++ b/Makefile @@ -164,7 +164,7 @@ lint: lint-tools @echo "Running gosec..." GOCACHE="$(CURDIR)/$(LINT_GO_CACHE)" gosec -quiet -exclude-dir=vendor ./... @echo "Running shellcheck..." - @scripts="$$(find . -type f \( -name '*.sh' -o -path '*/build-scripts/bin/*' -o -path '*/extensions/features/vnc/vnc-*' \) -not -path './vendor/*' | sort)"; \ + @scripts="$$(find . -type f \( -name '*.sh' -o -path '*/build-scripts/bin/*' -o -path './extensions/*/bin/*' \) -not -path './vendor/*' | sort)"; \ if [ -z "$$scripts" ]; then \ echo "No shell scripts found"; \ else \ @@ -222,7 +222,7 @@ lint-report: lint-tools @GOCACHE="$(CURDIR)/$(LINT_GO_CACHE)" gosec -exclude-dir=vendor ./... > $(REPORTS_DIR)/gosec.txt 2>&1 || true @echo " -> $(REPORTS_DIR)/gosec.txt" @echo "Running shellcheck..." - @scripts="$$(find . -type f \( -name '*.sh' -o -path '*/build-scripts/bin/*' -o -path '*/extensions/features/vnc/vnc-*' \) -not -path './vendor/*' | sort)"; \ + @scripts="$$(find . -type f \( -name '*.sh' -o -path '*/build-scripts/bin/*' -o -path './extensions/*/bin/*' \) -not -path './vendor/*' | sort)"; \ if [ -z "$$scripts" ]; then \ echo "No shell scripts found" > $(REPORTS_DIR)/shellcheck.txt; \ else \ diff --git a/docs/extensions/README.md b/docs/extensions/README.md index b962134..1d325cb 100644 --- a/docs/extensions/README.md +++ b/docs/extensions/README.md @@ -64,15 +64,20 @@ extensions/ │ ├── install.sh │ └── feature-entrypoint.d/ │ └── setup.sh # Runs for ALL tools - └── devtools/ + ├── devtools/ + │ ├── spec.yaml + │ └── install.sh + └── vnc/ ├── spec.yaml - └── install.sh + ├── install.sh + └── bin/ # Extension-less scripts installed onto PATH + └── vnc-supervisor ``` `spec.yaml` is the extension manifest. `install.sh`, `gateway-allowlist.conf`, `entrypoint.d/`, `feature-entrypoint.d/`, -`templates/`, and `skills/` remain sibling files rather than fields in the -manifest. The in-container build shell scripts under +`templates/`, `bin/`, and `skills/` remain sibling files rather than fields in +the manifest. The in-container build shell scripts under `runtime-assets/build-scripts/` read the metadata they need (feature/tool enablement, `priority`, `needsRoot`, `aptPackages`, `failOnInstallError`) straight from `spec.yaml` (falling back to `spec.json`) with `yq`. @@ -530,8 +535,15 @@ without help from the tool spec. |------|---------| | `install.sh` | Installation script (runs as root if `needsRoot: true`) | | `feature-entrypoint.d/*.sh` | Scripts sourced at startup for ALL tools | +| `bin/` | Extension-less runtime scripts the feature installs into the image; covered by `make lint`'s shellcheck pass and by `make lint-changed` | | `skills/` | Agent skills composed into the tool's skills directory when the feature is enabled | +`bin/` holds scripts destined for a `PATH` directory in the image, where a +`.sh` suffix would be wrong. `install.sh` must place them with an explicit mode +(`install -D -m 755 …`): source modes do not survive the embedded-asset +extraction that package installs use, so only `install.sh` itself is +mode-normalized by the build. + ### Feature Selection Configure which features to install via `~/.config/enclave/config.json` (global) or `~/.config/enclave/projects//config.json` (project): diff --git a/docs/security/README.md b/docs/security/README.md index 2fca91c..65f2c6f 100644 --- a/docs/security/README.md +++ b/docs/security/README.md @@ -49,6 +49,27 @@ boundary. The experimental QEMU backend has no restricted-egress implementation. It runs with unrestricted networking and without gateway-side HTTP secret release. +## Published ports and contained displays + +The allowlist governs egress only. Ports published by `-p`, by a tool profile, +or by an enabled feature open an inbound path into the session and are outside +that policy. Published ports bind the host loopback by default, but under +network isolation they are bound on the session's gateway container, which sits +on a shared Docker bridge: a service listening on all interfaces inside the +namespace is also reachable from other containers on that bridge, including +other sessions' gateways. Whatever the service itself enforces is the only gate +at that layer. + +A published port that carries an interactive display rather than data widens +this further. The `vnc` feature serves the session's X display over RFB, so a +client that reaches the port and passes VncAuth drives a real browser running as +the sandbox user, with the session's filesystem reachable through it and the X +clipboard bridged in both directions. Treat the per-session VNC password as the +whole boundary and keep it to trusted local viewers. A viewer also cannot vouch +for what the streamed page shows; the content is agent-influenced. See the +[vnc feature README](../../extensions/features/vnc/README.md) for the +feature-specific residual risks. + ## Secrets Host environment variables are not passed unless declared by an enabled diff --git a/extensions/features/vnc/README.md b/extensions/features/vnc/README.md index 9e0f9e9..87a62a3 100644 --- a/extensions/features/vnc/README.md +++ b/extensions/features/vnc/README.md @@ -12,26 +12,22 @@ enclave --features +vnc … ``` The feature needs a base image whose archive ships Chromium as a deb. The -default Debian base does; on Ubuntu bases, which only ship Chromium as a -snap, the feature's install fails (scoped to this feature, with a clear -error). +default Debian base does; on an Ubuntu base, which only ships Chromium as a +snap, the install fails and the build stops with an error naming this feature. ## Connecting a VNC client The RFB port (container `5900`) is published with an OS-assigned host port on -the loopback interface, so concurrent sessions get distinct ports. Find the -published port with `enclave ps --json` (look for the `5900` container port), -then read the per-session password out of the container and point your client -at it: +the loopback interface, so concurrent sessions get distinct ports. The session +prints the resolved `vnc://localhost:` at startup; `enclave ps --json` +reports it too (look for container port `5900`). Read the per-session password +out of the session and point your client at it: ```bash -docker exec cat /tmp/enclave-vnc/vnc-password +enclave exec --name -- cat /tmp/enclave-vnc/vnc-password vncviewer 127.0.0.1: ``` -The password is required (see [Access control](#access-control)). It only ever -gates that one session's display, so reading it out of the container is safe. - ## What runs in the container `commands.startup` launches `vnc-supervisor` (installed to `/usr/local/bin`) @@ -43,19 +39,17 @@ loops, logging to `/tmp/enclave-vnc/log/`: (no `-localhost`) so the host's loopback-published port reaches it. 2. **matchbox-window-manager**: fullscreens every window (kiosk-style). 3. **Chromium**: headful on the virtual display. It starts on a local - waiting page and stays there until a page is opened. When `$VNC_URL` is set - (default `about:blank`, meaning no target), a one-shot watcher probes it and - forwards it into the running browser once its TCP port accepts connections. - Loading the URL directly would instead park the display on a - connection-refused error page whenever the target server starts later than - the stack. Sessions can also drive the browser on demand via `vnc-open`, - which is how a consuming feature opens a URL it only knows at runtime. - Both the supervisor and `vnc-open` launch it through the shared - `/usr/local/bin/vnc-chromium` wrapper, which disables Chromium's own - sandbox (the default Docker seccomp profile blocks the unprivileged user - namespaces it needs, so the container remains the isolation boundary) and - its background networking (component updater, safe browsing, sync), which - would otherwise keep hitting gateway-denied domains. + waiting page and stays there until a page is opened. When + `$ENCLAVE_VNC_URL` is set, a one-shot watcher probes it and forwards it + into the running browser once its TCP port accepts connections. Loading the + URL directly would instead park the display on a connection-refused error + page whenever the target server starts later than the stack. Sessions can + also drive the browser on demand via `vnc-open`, which is how a consuming + feature opens a URL it only knows at runtime. + +Both the supervisor and `vnc-open` launch Chromium through the shared +`/usr/local/bin/vnc-chromium` wrapper, so the browser behaves the same however +it was started; that script's header documents the switches it sets and why. The feature entrypoint additionally exports `DISPLAY=:99` and `BROWSER=/usr/local/bin/vnc-open`, and `install.sh` registers `vnc-open` as @@ -67,6 +61,11 @@ the supervisor's Chromium profile, waiting briefly for its singleton if the stack is still booting, so URLs open in the running browser instead of racing it, and logs to `/tmp/enclave-vnc/log/vnc-open.log`. +A set `DISPLAY` is also how many tools decide a GUI is available, so with this +feature enabled `gpg` pinentry, `SSH_ASKPASS`, and `GIT_ASKPASS` prompts render +on the contained display rather than in the terminal. Check the VNC client if +an interactive command appears to hang. + ## Access control The supervisor generates a random password on first start and enforces it at @@ -76,16 +75,18 @@ copies: - obfuscated auth file: `/tmp/enclave-vnc/rfb-passwd` (Xvnc) - plaintext: `/tmp/enclave-vnc/vnc-password` (mode 0600) -That password is what shapes the boundary. Holding it is what grants control -of the display, and nothing else does. It is generated per session, so it -grants control of exactly one session's display and no other. +Holding that password is what grants control of the display, and nothing else +does. It is generated per session, so it reaches exactly one session's display +and no other — which is why the (untrusted) agent knowing it is harmless, and +why reading it out of the session is safe. -The plaintext path is what a VNC client user reads (see -[Connecting a VNC client](#connecting-a-vnc-client)), and it doubles as the -**integration contract** for a trusted host-side viewer, which can pick the -password up with `docker exec cat /tmp/enclave-vnc/vnc-password`. -Because its reach stops at that one session's own display, the (untrusted) -agent knowing it is harmless. +The plaintext path is the **integration contract** for a trusted host-side +viewer: `/tmp/enclave-vnc/vnc-password` inside the session, alongside the +container port `5900` binding that `enclave ps --json` reports. The path is the +contract; how a viewer reads it is up to the backend it drives. `enclave exec` +always allocates a TTY, so it serves the interactive flow above but not a +headless one — a non-interactive viewer needs a backend-level read (for Docker, +`docker exec`) until the CLI grows a non-TTY exec. ## Configuration @@ -94,21 +95,33 @@ Environment variables read by the supervisor (set via a consuming feature's | Variable | Default | Meaning | |----------|---------|---------| -| `VNC_URL` | `about:blank` | Optional URL auto-forwarded into the browser once its port is reachable. Left unset (the default), the display stays on the waiting page and sessions open pages on demand via `vnc-open`. | -| `VNC_GEOMETRY` | `1600x1000` | Initial display size (a resize-capable client can change it) | -| `VNC_DISPLAY` | `:99` | X display number | +| `ENCLAVE_VNC_URL` | unset | Optional URL auto-forwarded into the browser once its port is reachable. Left unset, the display stays on the waiting page and sessions open pages on demand via `vnc-open`. | +| `ENCLAVE_VNC_URL_WAIT_SECONDS` | `300` | How long that forward waits for the URL's port before giving up and logging. | +| `ENCLAVE_VNC_GEOMETRY` | `1600x1000` | Initial display size (a resize-capable client can change it) | +| `ENCLAVE_VNC_DISPLAY` | `:99` | X display number | The RFB port is not configurable: it must match the `ports:` declaration in `spec.yaml` (container port `5900`), so the supervisor hardcodes it. -A consuming feature should leave `VNC_URL` unset whenever the page it wants is -only determined at runtime, and call `vnc-open` with the full URL instead. -Auto-forwarding a bare server root in that situation lands the display on a -default view, which can clobber whatever state the intended URL would have +A consuming feature should leave `ENCLAVE_VNC_URL` unset whenever the page it +wants is only determined at runtime, and call `vnc-open` with the full URL +instead. Auto-forwarding a bare server root in that situation lands the display +on a default view, which can clobber whatever state the intended URL would have selected. +## Troubleshooting + +The entrypoint starts the supervisor with its stdout and stderr discarded, so +the files under `/tmp/enclave-vnc/log/` are the only record. Start with +`supervisor.log` (startup, auth-file generation, URL forwarding), then +`xvnc.log`, `wm.log`, `browser.log`, and `vnc-open.log` for the individual +components. + ## Residual risks +[Security boundaries](../../../docs/security/README.md#published-ports-and-contained-displays) +covers how a published display fits the overall threat model. Feature-specific: + - Holding the password is sufficient to drive the display, so keep it to trusted local viewers. - The host publish is loopback-only, but Xvnc listens on all interfaces diff --git a/extensions/features/vnc/vnc-chromium b/extensions/features/vnc/bin/vnc-chromium similarity index 79% rename from extensions/features/vnc/vnc-chromium rename to extensions/features/vnc/bin/vnc-chromium index 7c30932..85c4ce5 100755 --- a/extensions/features/vnc/vnc-chromium +++ b/extensions/features/vnc/bin/vnc-chromium @@ -15,6 +15,11 @@ # background-networking/component-update/sync switches keep Chromium's own # phone-home traffic (component updater, safe browsing, optimization guide) # from hammering gateway-denied domains and filling the network log. +# +# Arguments are passed after `--` so a caller-supplied string that happens to +# start with a dash lands as a URL rather than as a Chromium switch: vnc-open, +# the only caller, is reachable as $BROWSER and as the http/https scheme +# handler, so its argument is not always under this feature's control. set -u exec chromium \ @@ -30,4 +35,4 @@ exec chromium \ --password-store=basic \ --start-maximized \ --user-data-dir=/tmp/enclave-vnc/chromium \ - "$@" + -- "$@" diff --git a/extensions/features/vnc/vnc-open b/extensions/features/vnc/bin/vnc-open similarity index 97% rename from extensions/features/vnc/vnc-open rename to extensions/features/vnc/bin/vnc-open index 55af50a..64f4302 100755 --- a/extensions/features/vnc/vnc-open +++ b/extensions/features/vnc/bin/vnc-open @@ -31,7 +31,7 @@ if [ "$#" -lt 1 ]; then exit 2 fi -export DISPLAY="${VNC_DISPLAY:-:99}" +export DISPLAY="${ENCLAVE_VNC_DISPLAY:-:99}" STATE_DIR=/tmp/enclave-vnc PROFILE="$STATE_DIR/chromium" LOG_DIR="$STATE_DIR/log" diff --git a/extensions/features/vnc/vnc-supervisor b/extensions/features/vnc/bin/vnc-supervisor similarity index 53% rename from extensions/features/vnc/vnc-supervisor rename to extensions/features/vnc/bin/vnc-supervisor index cb6045c..f400752 100755 --- a/extensions/features/vnc/vnc-supervisor +++ b/extensions/features/vnc/bin/vnc-supervisor @@ -11,39 +11,61 @@ # Xvnc (virtual X display + VNC server, RFB served on all container # interfaces so the published host port reaches it) # -> matchbox-window-manager (fullscreens every window) -# -> Chromium on a waiting page, forwarded to $VNC_URL once it is set -# and reachable (all browsing stays on the container's +# -> Chromium on a waiting page, forwarded to $ENCLAVE_VNC_URL once it is +# set and reachable (all browsing stays on the container's # gateway-restricted network) # # Each component runs in its own restart loop so a crash never takes the -# session down. Logs go to $STATE_DIR/log/. Everything runs as the sandbox -# user and dies with the container. +# session down. Everything runs as the sandbox user and dies with the +# container. # -# Access to the display is gated by a per-session random VNC password -# (RFB VncAuth). The plaintext copy at $STATE_DIR/vnc-password is what a -# native VNC client needs (fetch it with `docker exec cat -# $STATE_DIR/vnc-password`) and is also the integration contract a trusted -# host-side viewer reads the same way. That password is what shapes the -# boundary: holding it is what grants control of the display, and because it is -# generated per session it reaches exactly one session's display. +# The entrypoint starts this as a background startup command with stdout and +# stderr discarded (see enclave_run_startup_command in kit-init.sh), so a +# diagnostic that only reaches stderr is lost. Every message therefore also +# goes to $LOG_DIR/supervisor.log, which is the first place to look when a +# session comes up without a display. +# +# Access control (the password, where it lives, and what it does and does not +# gate) is documented once in the feature README; this script only generates +# and enforces it. set -u -DISPLAY_NUM="${VNC_DISPLAY:-:99}" -GEOMETRY="${VNC_GEOMETRY:-1600x1000}" +DISPLAY_NUM="${ENCLAVE_VNC_DISPLAY:-:99}" +GEOMETRY="${ENCLAVE_VNC_GEOMETRY:-1600x1000}" # Fixed, not a knob: the published container port in spec.yaml's `ports:` is # 5900, so any other value would only make the display unreachable. RFB_PORT=5900 -START_URL="${VNC_URL:-about:blank}" +START_URL="${ENCLAVE_VNC_URL:-about:blank}" +# How long the one-shot forward waits for $ENCLAVE_VNC_URL's port before giving +# up. Bounded so a typo'd or never-started target reports instead of probing +# silently for the life of the session. +URL_WAIT_SECONDS="${ENCLAVE_VNC_URL_WAIT_SECONDS:-300}" STATE_DIR="/tmp/enclave-vnc" LOG_DIR="$STATE_DIR/log" +WAITING_PAGE=/usr/local/share/enclave/vnc/waiting.html mkdir -p "$STATE_DIR" "$LOG_DIR" || exit 1 chmod 700 "$STATE_DIR" +log() { + printf 'vnc: %s\n' "$*" | tee -a "$LOG_DIR/supervisor.log" >&2 +} + # Refuse to double-start (the entrypoint runs startup commands exactly once, -# but a manual invocation must not spawn a second stack). -if [ -f "$STATE_DIR/supervisor.pid" ] && kill -0 "$(cat "$STATE_DIR/supervisor.pid")" 2>/dev/null; then - echo "vnc: supervisor already running" >&2 +# but a manual invocation must not spawn a second stack). $STATE_DIR survives a +# container restart while PIDs are recycled, so a live PID alone is not proof: +# confirm it is really a supervisor before standing down. +supervisor_running() { + [ -f "$STATE_DIR/supervisor.pid" ] || return 1 + _pid="$(cat "$STATE_DIR/supervisor.pid" 2>/dev/null)" || return 1 + case "$_pid" in + '' | *[!0-9]*) return 1 ;; + esac + kill -0 "$_pid" 2>/dev/null || return 1 + grep -qsa vnc-supervisor "/proc/$_pid/cmdline" +} +if supervisor_running; then + log "supervisor already running (pid $_pid); nothing to do" exit 0 fi echo $$ > "$STATE_DIR/supervisor.pid" @@ -55,18 +77,21 @@ if [ ! -s "$STATE_DIR/vnc-password" ]; then tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 20 > "$STATE_DIR/vnc-password" fi -# Write the RFB VncAuth file the Xvnc `-rfbauth` flag consumes. Prefer a -# vncpasswd binary when the base ships one. TigerVNC 1.15 (Debian 13) dropped -# it, so fall back to generating the file ourselves. The format is the password +# Write the RFB VncAuth file the Xvnc `-rfbauth` flag consumes. Use a vncpasswd +# binary when the base ships one; some TigerVNC builds no longer do, so fall +# back to generating the file here. The format is the password # (truncated/NUL-padded to 8 bytes) DES-ECB encrypted under VNC's fixed key with -# each key byte bit-reversed. Single DES is TripleDES with an 8-byte key. +# each key byte bit-reversed. Single DES is TripleDES with an 8-byte key. The +# log line records which path ran, so it stays visible whether the python3 +# fallback (and its aptPackages entries) is load-bearing on this base. if VNCPASSWD="$(command -v tigervncpasswd || command -v vncpasswd)"; then - "$VNCPASSWD" -f < "$STATE_DIR/vnc-password" > "$STATE_DIR/rfb-passwd" || { - echo "vnc: failed to write VNC auth file" >&2 + if ! "$VNCPASSWD" -f < "$STATE_DIR/vnc-password" > "$STATE_DIR/rfb-passwd"; then + log "failed to write VNC auth file with $VNCPASSWD" exit 1 - } + fi + log "generated VNC auth file with $VNCPASSWD" elif command -v python3 >/dev/null 2>&1; then - python3 - "$STATE_DIR/vnc-password" "$STATE_DIR/rfb-passwd" <<'PY' || { + if ! python3 - "$STATE_DIR/vnc-password" "$STATE_DIR/rfb-passwd" <<'PY' import os import sys @@ -86,11 +111,13 @@ fd = os.open(dst, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "wb") as f: f.write(obfuscated) PY - echo "vnc: failed to write VNC auth file" >&2 + then + log "failed to write VNC auth file with the python3 fallback" exit 1 - } + fi + log "generated VNC auth file with the python3 fallback" else - echo "vnc: no vncpasswd binary and no python3 to generate VNC auth file" >&2 + log "no vncpasswd binary and no python3 to generate VNC auth file" exit 1 fi @@ -123,18 +150,20 @@ for _ in $(seq 1 50); do [ -S "$_socket" ] && break sleep 0.2 done +if [ ! -S "$_socket" ]; then + log "X socket $_socket did not appear; see $LOG_DIR/xvnc.log" +fi export DISPLAY="$DISPLAY_NUM" run_forever wm matchbox-window-manager -use_titlebar no -# Chromium starts on a static waiting page, never on $VNC_URL directly: a -# server the session starts later would otherwise park the display on a +# Chromium starts on a static waiting page, never on $ENCLAVE_VNC_URL directly: +# a server the session starts later would otherwise park the display on a # connection-refused error page that nothing ever reloads. The waiting page is -# also the neutral idle state when no $VNC_URL is set, where sessions drive the +# also the neutral idle state when no URL is set, where sessions drive the # browser on demand via vnc-open, so the one-shot forward below only runs when -# a $VNC_URL is given. -WAITING_PAGE=/usr/local/share/enclave-vnc/waiting.html +# a URL is given. BROWSER_START="about:blank" if [ -f "$WAITING_PAGE" ]; then BROWSER_START="file://$WAITING_PAGE" @@ -165,34 +194,73 @@ singleton_live() { done ) & -# Forward $VNC_URL once its TCP port accepts connections. vnc-open -# waits for the browser singleton, so ordering against the Chromium start -# above does not matter. Probing only covers http(s), so other schemes open -# immediately and take their chances. +# split_authority : set _host and _port from a URL's authority, tolerating +# userinfo (user:pass@host) and bracketed IPv6 literals. _host is empty when the +# URL has no authority to speak of. +split_authority() { + _rest="${1#*://}" + _rest="${_rest%%\#*}" + _rest="${_rest%%\?*}" + _auth="${_rest%%/*}" + case "$_auth" in + *@*) _auth="${_auth##*@}" ;; + esac + case "$_auth" in + \[*\]*) + _host="${_auth%%\]*}" + _host="${_host#\[}" + _port="${_auth##*\]}" + _port="${_port#:}" + ;; + *:*) + _host="${_auth%%:*}" + _port="${_auth##*:}" + ;; + *) + _host="$_auth" + _port="" + ;; + esac +} + +# Forward $ENCLAVE_VNC_URL once its TCP port accepts connections. vnc-open waits +# for the browser singleton, so ordering against the Chromium start above does +# not matter. Probing only covers http(s); other schemes, and http(s) URLs whose +# authority cannot be parsed, open immediately and take their chances. if [ "$START_URL" != "about:blank" ]; then + _host="" + _port="" case "$START_URL" in http://* | https://*) - _hostport="${START_URL#*://}" - _hostport="${_hostport%%/*}" - _host="${_hostport%%:*}" - _port="${_hostport##*:}" - if [ "$_port" = "$_hostport" ]; then + split_authority "$START_URL" + if [ -z "$_port" ]; then case "$START_URL" in https://*) _port=443 ;; *) _port=80 ;; esac fi + ;; + esac + if [ -n "$_host" ]; then ( + _waited=0 until (exec 3<> "/dev/tcp/$_host/$_port") 2>/dev/null; do + if [ "$_waited" -ge "$URL_WAIT_SECONDS" ]; then + log "gave up after ${URL_WAIT_SECONDS}s waiting for $_host:$_port; not opening $START_URL" + exit 0 + fi sleep 1 + _waited=$((_waited + 1)) done + log "$_host:$_port accepted a connection; opening $START_URL" /usr/local/bin/vnc-open "$START_URL" ) >> "$LOG_DIR/url-watch.log" 2>&1 & - ;; - *) + else + log "cannot probe $START_URL for readiness; opening it immediately" /usr/local/bin/vnc-open "$START_URL" >> "$LOG_DIR/url-watch.log" 2>&1 & - ;; - esac + fi fi +log "stack started (display $DISPLAY_NUM, RFB port $RFB_PORT)" + wait diff --git a/extensions/features/vnc/feature-entrypoint.d/setup.sh b/extensions/features/vnc/feature-entrypoint.d/setup.sh index d3523ea..d2cfcbf 100644 --- a/extensions/features/vnc/feature-entrypoint.d/setup.sh +++ b/extensions/features/vnc/feature-entrypoint.d/setup.sh @@ -9,5 +9,10 @@ # Point the session at the VNC feature's virtual display so X clients and # "open in browser" flows (xdg-open, $BROWSER consumers) land on the contained # Chromium. The stack itself is started by commands.startup in spec.yaml. -export DISPLAY="${VNC_DISPLAY:-:99}" +# +# DISPLAY is process-wide for every tool in the session, which also flips the +# behavior of anything that treats a set DISPLAY as "a GUI is available" +# (pinentry, SSH_ASKPASS, GIT_ASKPASS): those prompts render on the contained +# display instead of the terminal. The feature README documents this. +export DISPLAY="${ENCLAVE_VNC_DISPLAY:-:99}" export BROWSER=/usr/local/bin/vnc-open diff --git a/extensions/features/vnc/install.sh b/extensions/features/vnc/install.sh index 518efb2..88efd04 100755 --- a/extensions/features/vnc/install.sh +++ b/extensions/features/vnc/install.sh @@ -12,11 +12,11 @@ set -euo pipefail dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# Chromium is installed here rather than via aptPackages: all selected -# features' aptPackages go through a single shared apt-get install, and -# `chromium` is only a real deb on Debian archives (Ubuntu ships a snap -# stub), so listing it there would fail every selected feature's packages on -# an Ubuntu base instead of just this feature. +# Chromium is installed here rather than via aptPackages so the failure is +# attributable: `chromium` is only a real deb on Debian archives (Ubuntu ships a +# snap stub), and an unavailable package makes the shared apt step fail with +# apt's own message and no mention of this feature. Either way the build stops +# (failOnInstallError), but this way it stops with an actionable one. apt-get update if ! apt-get install -y --no-install-recommends chromium; then echo "vnc: cannot install chromium: this feature needs a base image whose" \ @@ -25,16 +25,15 @@ if ! apt-get install -y --no-install-recommends chromium; then exit 1 fi apt-get clean +rm -rf /var/lib/apt/lists/* -install -m 755 "$dir/vnc-supervisor" /usr/local/bin/vnc-supervisor -install -m 755 "$dir/vnc-open" /usr/local/bin/vnc-open -install -m 755 "$dir/vnc-chromium" /usr/local/bin/vnc-chromium -install -D -m 644 "$dir/waiting.html" /usr/local/share/enclave-vnc/waiting.html +install -D -m 755 "$dir/bin/vnc-supervisor" /usr/local/bin/vnc-supervisor +install -D -m 755 "$dir/bin/vnc-open" /usr/local/bin/vnc-open +install -D -m 755 "$dir/bin/vnc-chromium" /usr/local/bin/vnc-chromium +install -D -m 644 "$dir/waiting.html" /usr/local/share/enclave/vnc/waiting.html # Register vnc-open as the image-wide default handler for http/https URLs # (vnc-open's header explains why the scheme-handler registration matters). -# Merge into an existing mimeapps.list rather than truncating it, so handler -# registrations from other extensions survive; for http/https, vnc-open wins. install -D -m 644 "$dir/enclave-vnc-open.desktop" /usr/share/applications/enclave-vnc-open.desktop mimeapps=/etc/xdg/mimeapps.list mkdir -p /etc/xdg @@ -43,7 +42,15 @@ if [ ! -f "$mimeapps" ]; then elif ! grep -q '^\[Default Applications\]' "$mimeapps"; then printf '\n[Default Applications]\n' >> "$mimeapps" fi -sed -i -e '/^x-scheme-handler\/http=/d' -e '/^x-scheme-handler\/https=/d' "$mimeapps" +# Merge rather than truncate, so registrations from other extensions survive. +# Both edits are scoped to the [Default Applications] section (the range ends at +# the next section header) so http/https entries in [Added Associations] and +# friends are left alone: drop any default this section already declares, then +# insert ours right after its header. +sed -i '/^\[Default Applications\]/,/^\[/ { + /^x-scheme-handler\/http=/d + /^x-scheme-handler\/https=/d +}' "$mimeapps" sed -i '/^\[Default Applications\]/a\ x-scheme-handler/http=enclave-vnc-open.desktop\ x-scheme-handler/https=enclave-vnc-open.desktop' "$mimeapps" diff --git a/extensions/features/vnc/spec.yaml b/extensions/features/vnc/spec.yaml index 1cc524b..b6e1034 100644 --- a/extensions/features/vnc/spec.yaml +++ b/extensions/features/vnc/spec.yaml @@ -18,16 +18,16 @@ failOnInstallError: true priority: 75 aptPackages: # Chromium itself is installed by install.sh, not listed here: it is only a - # real deb on Debian archives (Ubuntu ships a snap stub), and all selected - # features' aptPackages share one apt-get install, so one unavailable - # package would fail every feature's packages instead of just this one. + # real deb on Debian archives (Ubuntu ships a snap stub), and install.sh can + # name this feature in the error where the shared apt step cannot. - tigervnc-standalone-server - matchbox-window-manager - xdg-utils - fonts-liberation - fonts-dejavu-core - # Generates the RFB VncAuth file: TigerVNC 1.15 (Debian 13) no longer ships a - # vncpasswd binary, so the supervisor derives it via python3-cryptography. + # Fallback for generating the RFB VncAuth file on bases whose TigerVNC build + # ships no vncpasswd binary. The supervisor logs which path it used, so these + # can be dropped once a base is confirmed to always provide the binary. - python3 - python3-cryptography commands: diff --git a/internal/runtime/vnc_feature_test.go b/internal/runtime/vnc_feature_test.go new file mode 100644 index 0000000..c714470 --- /dev/null +++ b/internal/runtime/vnc_feature_test.go @@ -0,0 +1,118 @@ +// Copyright (C) 2026 EclipseSource GmbH and others. +// +// This program and the accompanying materials are made available under the +// terms of the MIT License, which is available in the project root. +// +// SPDX-License-Identifier: MIT + +package runtime + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// The vnc feature is the only in-tree extension that uses commands.startup, and +// model.Extension carries no Commands field, so the golden surface snapshot in +// internal/config cannot pin that part of its spec. These tests cover the seam +// the snapshot misses: that the shipped spec is shaped the way the runtime yq +// glue expects, and that the argv it names is the path install.sh writes. + +const vncSupervisorPath = "/usr/local/bin/vnc-supervisor" + +func vncFeatureDir(t *testing.T) string { + t.Helper() + dir, err := filepath.Abs(filepath.Join("..", "..", "extensions", "features", "vnc")) + if err != nil { + t.Fatalf("abs: %v", err) + } + return dir +} + +// TestVNCStartupCommandRunsDetached feeds the real spec through +// enclave_apply_startup_commands with the supervisor path redirected at a stub, +// proving the seq-form command parses and that background: true detaches it. +func TestVNCStartupCommandRunsDetached(t *testing.T) { + if !hasMikefarahYq(t) { + t.Skip("requires mikefarah/yq v4; host has kislyuk yq") + } + + spec, err := os.ReadFile(filepath.Join(vncFeatureDir(t), "spec.yaml")) + if err != nil { + t.Fatalf("read vnc spec: %v", err) + } + if !strings.Contains(string(spec), vncSupervisorPath) { + t.Fatalf("vnc spec no longer names %s; update this test and install.sh together", vncSupervisorPath) + } + + work := t.TempDir() + extDir := t.TempDir() + gate := filepath.Join(work, "gate") + marker := filepath.Join(work, "started") + + // The stub blocks on a FIFO before touching the marker, so the marker can + // only appear after enclave_apply_startup_commands has already returned. + // A foreground regression would deadlock on the write to the gate instead. + stub := filepath.Join(work, "vnc-supervisor") + stubBody := "#!/bin/bash\nread -r _ < " + gate + "\ntouch " + marker + "\n" + if err := os.WriteFile(stub, []byte(stubBody), 0o755); err != nil { + t.Fatalf("write stub: %v", err) + } + + redirected := strings.ReplaceAll(string(spec), vncSupervisorPath, stub) + if err := os.WriteFile(filepath.Join(extDir, "spec.yaml"), []byte(redirected), 0o644); err != nil { + t.Fatalf("write redirected spec: %v", err) + } + + script := `set -e +. "$KIT" +mkfifo "$GATE" +enclave_apply_startup_commands "$EXT" +if [ -e "$MARKER" ]; then echo "MARKER_TOO_EARLY"; exit 1; fi +echo go > "$GATE" +wait +if [ ! -e "$MARKER" ]; then echo "MARKER_MISSING"; exit 1; fi +echo PASS` + cmd := exec.Command("bash", "-c", script) + cmd.Env = startupEnv(t, map[string]string{"EXT": extDir, "GATE": gate, "MARKER": marker}) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("apply vnc startup: %v\n%s", err, out) + } + if !strings.Contains(string(out), "PASS") { + t.Fatalf("expected PASS, got:\n%s", out) + } +} + +// TestVNCInstallScriptMatchesStartupCommand keeps spec.yaml's argv and the +// install destinations from drifting apart: a rename on one side would +// otherwise only surface as a session that starts without a display. +func TestVNCInstallScriptMatchesStartupCommand(t *testing.T) { + dir := vncFeatureDir(t) + install, err := os.ReadFile(filepath.Join(dir, "install.sh")) + if err != nil { + t.Fatalf("read install.sh: %v", err) + } + body := string(install) + + for _, name := range []string{"vnc-supervisor", "vnc-open", "vnc-chromium"} { + source := filepath.Join(dir, "bin", name) + if _, err := os.Stat(source); err != nil { + t.Fatalf("missing shipped script %s: %v", source, err) + } + // install.sh must set the mode explicitly: source modes are lost when + // the asset tree comes from the embedded FS or a distro package, which + // normalize only install.sh itself. + want := `install -D -m 755 "$dir/bin/` + name + `" /usr/local/bin/` + name + if !strings.Contains(body, want) { + t.Fatalf("install.sh must contain %q, got:\n%s", want, body) + } + } + + if !strings.Contains(body, "/usr/local/share/enclave/vnc/waiting.html") { + t.Fatalf("install.sh must place the waiting page under the shared enclave asset root, got:\n%s", body) + } +} diff --git a/scripts/lint-changed.sh b/scripts/lint-changed.sh index a93cf15..64ac48d 100755 --- a/scripts/lint-changed.sh +++ b/scripts/lint-changed.sh @@ -66,7 +66,7 @@ for f in $changed; do done fi ;; - *.sh | runtime-assets/build-scripts/bin/* | extensions/features/vnc/vnc-*) + *.sh | runtime-assets/build-scripts/bin/* | extensions/*/bin/*) [ -f "$f" ] && sh_files="$sh_files $f" ;; esac