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:
Xvnc :1 -SecurityTypes None -geometry 1024x720 -rfbport 5900 -interface 0.0.0.0openbox-sessionunderDISPLAY=:1firefox —no-remote —profile /tmp/firefox-desktop-profile —new-window about:blankxclock,xterm,xeyes(left + right) — signs of life on first paintxrdp-sesman —nodaemonxrdp —nodaemon
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:
tigervnc-server-1.16.2—Xvncbinary. Launched on display:1, binding RFB port5900. Demo-only:-SecurityTypes None— the security model is the jail boundary plus the gateway’s WS proxy.xrdp-0.10.6,1+xorgxrdp-0.10.5—xrdp(RDP listener on3389) and its Xorg driver thatxrdp-sesmanspawns for Xorg-session back-ends.openbox-3.6_14— a featherweight window manager. The VNC and RDP views both startopenbox-sessionso client windows draw frames and the default menu/binds are present.firefox— a real GUI workload the views can open on first connect so the session starts as a desktop, not just a terminal.xterm-407,xclock-1.0.9_1— signs-of- life clients the views launch automatically alongside Firefox so the canvas isn’t blank on first connect.ffmpeg—x11grabcapture for desktop screen recordings.dbus-1.16.2_4— xrdp-sesman needs a system bus present at auth time, otherwise PAM logs “No protocol specified” and drops the connection.
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:
- VNC: noVNC via
https://esm.sh/novnc-core@0.3.0. - RDP: vendored IronRDP modules under
e2b-compat/ui-src/public/vendor/ironrdp/, built from upstream commit3fe6d157e0b55bddfdac20af290a6cfa6e550576. The older npm releases did not handle xrdp’s slow-pathUpdate PDU, and the GitHub-on-esm.sh fallback could not serve the generated wasm package reliably on the tunneled/ui/path.
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
- 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 secondXvncwill refuse to start. - No audio. The RDP client is audio-capable
(
pulseaudio-module-xrdpexists on FreeBSD ports) but the template doesn’t install or configure pulseaudio. Deliberate — keeps the image small. Add if you need it. - Smallish canvas.
Xvncstarts at1280x800. The noVNC client scales it withscaleViewport = trueand now also enablesresizeSession = true. RDP negotiates the client-side resolution on connect and issues dynamic resizes as the pane changes. - Cold-start. First connect to a fresh desktop
sandbox takes ~3 s for the rc.d
coppice-desktopservice 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 apkillon unmount).