Desktop template (Xvnc + xrdp)

The desktop template is a FreeBSD jail pre-loaded with tigervnc-server, xrdp, openbox, firefox, xterm, xclock, and ffmpeg. It’s the substrate the demo portal’s VNC and RDP inner tabs (#82) clone from. Every POST /sandboxes {templateID:“desktop”} lands a fresh zfs clone of zroot/jails/desktop-template@base, and the baked coppice-desktop rc.d service brings the full session up at jail boot — no client-side orchestration required.

How the session starts

The @base snapshot ships a single rc.d script, /usr/local/etc/rc.d/coppice-desktop, sourced from e2b-compat/jail-assets/desktop/coppice-desktop.rc. /etc/rc.conf.d/coppice-desktop sets coppice_desktop_enable=“YES” and the gateway’s exec.start fires every executable /usr/local/etc/rc.d/coppice-* via daemon -f after the VNET interface is up. The result is that ~3 s after a fresh desktop sandbox is created, all of the following are running and bound:

Each child runs under daemon(8) with a per-name pidfile in /var/run/coppice-desktop/<name>.pid, so service coppice-desktop restart reaps and respawns the whole stack idempotently. service coppice-desktop status prints the per-component PID/state.

The React VNC and RDP inner views (VncView.tsx, RdpView.tsx) no longer spawn anything: they precheck sockstat -l -p 5900 / 3389 and connect as soon as the listener is bound. The legacy on-demand daemon -f LAUNCH_CMD path is gone for desktop-template guests; sandboxes whose @base predates the rc.d-baked snapshot still work via waitFor*() polling plus the binary-present precheck branch, but new clones never need it.

Why a separate template

The browser template started out carrying tigervnc-server as a hack (see the original VncView.tsx comment). Baking VNC + RDP into browser would have bloated the image with ~400 MB of X deps that a CDP-only sandbox never touches. A separate desktop template keeps the browser image lean and makes the desktop-demo packaging its own discrete unit: operators who don’t need the VNC/RDP tabs never download those bits.

Package set

Resolved on honor from pkg.freebsd.org/FreeBSD:14:amd64:

Transitive deps (xorg-server, mesa-dri, llvm19, etc.) pull the total to 106 packages / ~1.3 GB installed, ~409 MB downloaded. The @base snapshot after clean-up sits around 1.7 GB on ZFS — comparable to the browser template.

How the gateway reaches it

  browser                gateway (e2b-compat)          jail (desktop clone)
  -------                --------------------          --------------------
  noVNC   ---- wss ---->  /vnc-proxy/:id/websockify  >  10.78.0.<M>:5900  (Xvnc :1)
  IronRDP ---- wss ---->  /rdp-proxy/:id/            >  10.78.0.<M>:3389  (xrdp)

The VNC side is the simple one: ws_only_proxy treats every WS binary frame as an RFB/TCP payload and forwards it straight to 10.78.0.<M>:5900. RDP is different. IronRDP-web does not speak raw RDP immediately; it starts with an RDCleanPath handshake. rdp_proxy in e2b-compat/src/sandbox_proxy.rs terminates that handshake, validates the demo auth token, opens TLS to the jail-side xrdp listener on 3389, returns an RDCleanPath response with xrdp’s certificate chain, and then bridges the post-handshake byte stream. TCP_NODELAY is still set on the upstream socket so mouse input does not queue behind framebuffer traffic.

VncView.tsx and RdpView.tsx do not launch anything in-jail any more — the rc.d coppice-desktop service has already bound :5900 and :3389 by the time the views mount. Each view just polls sockstat -l -p <port> inside the jail until the listener is up, then connects. If you extend the template, keep Xvnc, xrdp, xrdp-sesman, openbox-session, firefox, and xterm all resolvable — and keep ffmpeg installed if you want recording support. The views still precheck which Xvnc / which xrdp and short-circuit with a clear error if a non-desktop template is opened.

Screen recording

The gateway exposes a small recording API over the same desktop substrate:

POST /sandboxes/:id/recordings
{
"display": ":1.0",
"geometry": "1024x720",
"fps": 12,
"format": "mkv"
}

GET  /sandboxes/:id/recordings
GET  /sandboxes/:id/recordings/:recording_id
POST /sandboxes/:id/recordings/:recording_id/stop

The implementation runs ffmpeg -f x11grab inside the jail against the Xvnc display and writes artifacts under /tmp/coppice-recordings/<recording-id>.mkv or /tmp/coppice-recordings/<recording-id>.mp4. The response returns that in-jail path plus the ffmpeg log path; callers retrieve the artifact through the existing files API. The default display is :1.0, so this records the VNC desktop. RDP’s xrdp-backed Xorg display is session-assigned; pass its display explicitly once that is exposed in the UI.

Default credentials (demo-only)

The build script writes a custom /etc/pam.d/xrdp-sesman that permits any authentication attempt (pam_permit.so across auth, account, session, password). RdpView.tsx then sends username: “root”, password: "" and expects the login to pass.

This is fine for a network-isolated demo sandbox (the jail is reachable only via coppicenet0, which the gateway’s pf anchors fence off from the LAN), and wrong for anything persistent. The .rdp-warn strip at the top of the RDP pane makes that clear in the UI.

VNC’s Xvnc runs with -SecurityTypes None — no auth handshake at all. Same reasoning: the gateway’s WS proxy is what keeps the RFB socket off the LAN.

Client-side assets

The React portal loads its desktop clients lazily:

The vendored copy keeps the RDP modules same-origin with the portal and removes a CDN packaging dependency from the live demo path. Refresh instructions live next to the assets in e2b-compat/ui-src/public/vendor/ironrdp/README.md.

How to rebuild

mise run desktop-template:build-honor

Under the hood:

rsync -az benchmarks/rigs/desktop-template-build.sh honor:/tmp/
ssh honor sudo sh /tmp/desktop-template-build.sh build

The script is idempotent. If zroot/jails/desktop-template@base already exists, it’s a no-op. Destroy the dataset (sudo sh /tmp/desktop-template-build.sh destroy) first if you want a clean rebuild after editing the package list.

After a fresh build, either restart the gateway or POST /templates/reload so the new template appears in GET /templates without a restart.

Sanity check

Clone a probe jail off the snapshot without going through the gateway — useful when debugging a rebuild:

sudo jail -c path=/jails/desktop-template \
  host.hostname=dt-probe name=dt-probe \
  ip4=inherit persist
sudo jexec dt-probe pkg info | grep -E 'xrdp|tigervnc|openbox|firefox|ffmpeg'
sudo jexec dt-probe which Xvnc xrdp xrdp-sesman openbox-session firefox ffmpeg
sudo jail -r dt-probe

Live diagnosis of a sandbox-hosted desktop goes through the gateway’s log:

ssh honor tail -f /var/log/e2b-compat.log

Look for ws_only_proxy: ws upgrade → raw TCP — that’s the VNC proxy accepting a noVNC connection, or rdp_proxy: ws upgrade → RDCleanPath / rdp_proxy: RDCleanPath response sent for RDP. If the precheck fails, you’ll instead see a 400 Bad Request from the /exec endpoint because the view’s which Xvnc or which xrdp returned missing.

Known limits

  1. Single-display. The views hard-code DISPLAY=:1 (VNC) and :10.0 (RDP’s Xorg session). Opening two VNC tabs on the same sandbox would collide; the UI doesn’t stop you but the second Xvnc will refuse to start.
  2. No audio. The RDP client is audio-capable (pulseaudio-module-xrdp exists on FreeBSD ports) but the template doesn’t install or configure pulseaudio. Deliberate — keeps the image small. Add if you need it.
  3. Smallish canvas. Xvnc starts at 1280x800. The noVNC client scales it with scaleViewport = true and now also enables resizeSession = true. RDP negotiates the client-side resolution on connect and issues dynamic resizes as the pane changes.
  4. Cold-start. First connect to a fresh desktop sandbox takes ~3 s for the rc.d coppice-desktop service to bring Xvnc + xrdp up, then ~1 s for the WS bridge to connect. Subsequent reconnects are instant; the desktop session lives for the lifetime of the sandbox (no per-tab teardown — the inner views no longer fire a pkill on unmount).