diff --git a/Makefile b/Makefile index 445d0a9..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/*' \) -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/*' \) -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 104e8eb..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): @@ -549,9 +561,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 +614,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/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 new file mode 100644 index 0000000..87a62a3 --- /dev/null +++ b/extensions/features/vnc/README.md @@ -0,0 +1,143 @@ +# 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 … +``` + +The feature needs a base image whose archive ships Chromium as a deb. The +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. 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 +enclave exec --name -- cat /tmp/enclave-vnc/vnc-password +vncviewer 127.0.0.1: +``` + +## 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 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 + 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 +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 (`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`. + +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 +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) + +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 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 + +Environment variables read by the supervisor (set via a consuming feature's +`environment.variables` or `-e`): + +| Variable | Default | Meaning | +|----------|---------|---------| +| `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 `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 + 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 + 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/bin/vnc-chromium b/extensions/features/vnc/bin/vnc-chromium new file mode 100755 index 0000000..85c4ce5 --- /dev/null +++ b/extensions/features/vnc/bin/vnc-chromium @@ -0,0 +1,38 @@ +#!/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. +# +# 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 \ + --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/bin/vnc-open b/extensions/features/vnc/bin/vnc-open new file mode 100755 index 0000000..64f4302 --- /dev/null +++ b/extensions/features/vnc/bin/vnc-open @@ -0,0 +1,55 @@ +#!/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 (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. +# 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 would then park its own +# browser until this one exits. +set -u + +if [ "$#" -lt 1 ]; then + echo "usage: vnc-open " >&2 + exit 2 +fi + +export DISPLAY="${ENCLAVE_VNC_DISPLAY:-:99}" +STATE_DIR=/tmp/enclave-vnc +PROFILE="$STATE_DIR/chromium" +LOG_DIR="$STATE_DIR/log" + +# 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 /usr/local/bin/vnc-chromium "$@" + ) >> "$LOG_DIR/vnc-open.log" 2>&1 & +) diff --git a/extensions/features/vnc/bin/vnc-supervisor b/extensions/features/vnc/bin/vnc-supervisor new file mode 100755 index 0000000..f400752 --- /dev/null +++ b/extensions/features/vnc/bin/vnc-supervisor @@ -0,0 +1,266 @@ +#!/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 $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. Everything runs as the sandbox user and dies with the +# container. +# +# 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="${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="${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). $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" + +# 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. 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. 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 + 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 + if ! 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 + 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 + log "no vncpasswd binary and no python3 to generate VNC auth file" + 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 +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 $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 URL is set, where sessions drive the +# browser on demand via vnc-open, so the one-shot forward below only runs when +# a URL is given. +BROWSER_START="about:blank" +if [ -f "$WAITING_PAGE" ]; then + BROWSER_START="file://$WAITING_PAGE" +fi + +# 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 +) & + +# 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://*) + 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 & + fi +fi + +log "stack started (display $DISPLAY_NUM, RFB port $RFB_PORT)" + +wait diff --git a/extensions/features/vnc/enclave-vnc-open.desktop b/extensions/features/vnc/enclave-vnc-open.desktop new file mode 100644 index 0000000..f5cbde6 --- /dev/null +++ b/extensions/features/vnc/enclave-vnc-open.desktop @@ -0,0 +1,18 @@ +# 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, 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 +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..d2cfcbf --- /dev/null +++ b/extensions/features/vnc/feature-entrypoint.d/setup.sh @@ -0,0 +1,18 @@ +# 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. +# +# 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 new file mode 100755 index 0000000..88efd04 --- /dev/null +++ b/extensions/features/vnc/install.sh @@ -0,0 +1,58 @@ +#!/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 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 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" \ + "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 +rm -rf /var/lib/apt/lists/* + +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). +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 +if [ ! -f "$mimeapps" ]; then + printf '[Default Applications]\n' > "$mimeapps" +elif ! grep -q '^\[Default Applications\]' "$mimeapps"; then + printf '\n[Default Applications]\n' >> "$mimeapps" +fi +# 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" + +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 new file mode 100644 index 0000000..b6e1034 --- /dev/null +++ b/extensions/features/vnc/spec.yaml @@ -0,0 +1,43 @@ +# 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: + # 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 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 + # 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: + 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/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..799a813 --- /dev/null +++ b/internal/config/testdata/golden/feature-vnc.json @@ -0,0 +1,25 @@ +{ + "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", + "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}" + } + ] +} 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 814e8cf..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/*) + *.sh | runtime-assets/build-scripts/bin/* | extensions/*/bin/*) [ -f "$f" ] && sh_files="$sh_files $f" ;; esac