26 KiB
07 — Native App Bridge (pywayland + bubblewrap + seccomp + systemd-run)
This chapter covers the native app bridge in the MIT approach: how real Linux applications get launched, sandboxed, connected to the Python proxy, and routed to the browser. This is the most important chapter for the user — they want to run real Linux apps in their Odoo 20 web app.
Process model — three layers (same as Greenfield, but in Python)
Layer 1: Main process (HTTP + WS hub)
Single Python process (aiohttp) listens on :8081. Per Odoo session, it
forks one SessionProcess child — one child per compositor_session_id,
not per app. Multiple apps in the same browser tab share one child.
WebSocket upgrades are handed off to the matching child via
multiprocessing.reduction.send_handle (uses SCM_RIGHTS on Linux).
Layer 2: Session process
The forked child. Receives IPC messages:
start— createsSession(Display, initShm, initDrm) + WS dispatcherlaunch_app— spawns the actual app binary viasubprocess.Popen+bubblewrap+seccomp+systemd-run --scopews_upgrade— hands WS to the dispatcher
On session.close() the process exits.
Layer 3: Per-client session
For each new wl_client (one per Wayland connection), creates a
PROTOCOL ARQ channel + FRAME ARQ channel. Marshalls FDs as ProxyFD
JSON. Routes requests via per-resource dispatchers.
App launch flow (definitive answer)
- Browser sends
GET http://proxy:8081/<path>withAuthorization: Bearer <jwt>header. - Main process validates JWT (HS256, via
pyjwt). - Main process forks a
SessionProcessif none exists for thatcompositor_session_id, sendsstartIPC. - Main sends
launch_appIPC with{name, executable, args, env, serial}. SessionProcesscallslaunch_application():appEnv = {...os.environ, ...env, WAYLAND_DISPLAY: session.wayland_display}subprocess.Popen(['systemd-run', '--user', '--scope', '-p', 'MemoryMax=1G', '-p', 'CPUQuota=200%', '-p', 'TasksMax=256', '--', 'bwrap', '--unshare-all', '--clearenv', '--seccomp', '<memfd>', '--ro-bind', '/usr', '/usr', '--dev-bind', '/dev/dri/renderD128', '/dev/dri/renderD128', '--ro-bind', f'{host_xdg}/wayland-N', f'/run/user/{uid}/wayland-N', '--uid', str(uid), '--gid', str(gid), '--new-session', '--die-with-parent', executable, *args], env=appEnv)
- On
spawnevent, capturespid, createsNativeAppContext, registersexitlistener. - Replies to main with
{pid, key}→ main returns HTTP 201 with{baseURL, signalURL, key, pid, name}. - Browser opens
signalURLas WebSocket → control channel established. - App connects to
$XDG_RUNTIME_DIR/wayland-N→ libwayland fireswl_clientcreation → pywayland's per-resource dispatchers route requests to{native, browser}. - For each new
wl_client, creates aPROTOCOLARQ channel and sendsCONNECT_CHANNELover the signaling WS; browser opens a new WS at/channel?id=…. - Wayland wire messages now flow: app → libwayland → pywayland
dispatcher → routed to
{native, browser}.
Sandboxing (NEW — Greenfield has none)
This is the critical gap in Greenfield (pitfall M). The MIT approach ships with proper sandboxing from day one.
Recommended stack
- bubblewrap (bwrap) — LGPL-2.0+, unprivileged, setuid-free, Flatpak-grade
- seccomp BPF — libseccomp LGPL-2.1, Docker's default profile + allow
CLONE_NEWUSER - systemd-run --scope — native cgroup v2 integration (MemoryMax, CPUQuota, TasksMax)
- Linux user namespaces — UID remap to 100000-165535 via
/etc/subuid//etc/subgid - cgroups v2 — memory, CPU, PID limits per app
Reference bwrap command line (GTK4 app, GPU, no network)
systemd-run --user --scope --unit=app-odoo-calculator-$(uuid) \
-p MemoryMax=256M -p MemorySwapMax=0 -p CPUQuota=50% -p TasksMax=32 -p Delegate=yes -- \
bwrap \
--clearenv --unshare-all --hostname sandbox --new-session --die-with-parent \
--symlink usr/lib /lib --symlink usr/lib64 /lib64 \
--symlink usr/bin /bin --symlink usr/bin /sbin \
--ro-bind /usr /usr --ro-bind /opt /opt \
--ro-bind /etc/passwd /etc/passwd --ro-bind /etc/group /etc/group \
--ro-bind /etc/hosts /etc/hosts --ro-bind /etc/localtime /etc/localtime \
--ro-bind /etc/locale.conf /etc/locale.conf --ro-bind /etc/machine-id /etc/machine-id \
--ro-bind /etc/fonts /etc/fonts --ro-bind /etc/ssl /etc/ssl \
--ro-bind /etc/ca-certificates /etc/ca-certificates \
--proc /proc --dev /dev \
--dev-bind-try /dev/dri/renderD128 /dev/dri/renderD128 \
--tmpfs /tmp --tmpfs /var/tmp --tmpfs /run \
--dir /run/user/100000 \
--ro-bind "$HOST_XDG/wayland-0" /run/user/100000/wayland-0 \
--overlay /usr/share/compositor-proxy/skel/gnome-calculator:/var/lib/compositor-proxy/tenants/$TENANT/appdata/gnome-calculator-upper /home/appuser \
--setenv HOME /home/appuser \
--setenv XDG_RUNTIME_DIR /run/user/100000 \
--setenv WAYLAND_DISPLAY wayland-0 \
--setenv XDG_DATA_DIRS /usr/share --setenv PATH /usr/bin --setenv LANG en_US.UTF-8 \
--uid 100000 --gid 100000 \
--seccomp /proc/self/fd/3 \
/usr/bin/gnome-calculator
Key design decisions
| Concern | Decision | Rationale |
|---|---|---|
| Sandbox runner | bubblewrap | MIT license, unprivileged, setuid-free, Flatpak-grade |
| cgroup limits | systemd-run --user --scope wrapping bwrap |
Native cgroup v2 integration; no manual cgroupfs writes |
| Wayland socket | Single-file --ro-bind of wayland-N into fresh /run/user/<UID> tmpfs |
Critical: prevents access to PulseAudio, pipewire, D-Bus, keyring, and sibling tenants' sockets |
| GPU | --dev-bind /dev/dri/renderD128 (single render node, NOT card0) |
Kernel render-node access control already restricts to non-display ioctls |
| Network | --unshare-net by default (loopback-down only); per-app opt-in to veth+nftables filter |
Most GTK apps need no network. Firefox/Odoo clients opt in. |
| Seccomp | Docker's default profile (allowlist of ~250 syscalls, blocking ~44) + allow CLONE_NEWUSER for nested sandboxes |
Compile to BPF at proxy startup; pass via bwrap --seccomp <memfd> |
| Filesystem | /usr ro-bind, minimal /etc whitelist, /tmp tmpfs, $HOME overlay (changes discarded) |
No host write access; per-app $HOME reset on each launch kills malware persistence |
| User ns | UID remap to 100000–165535 via /etc/subuid//etc/subgid |
Even on kernel escape the app is unprivileged on host; per-app UID enables nftables filtering and audit |
| XWayland | v1: unsandboxed (documented limitation); v2: per-Xwayland bwrap sandbox | Hardest piece (3–4 days). v1 ships like Flatpak's old socket=x11. |
$XDG_RUNTIME_DIR |
--clearenv then explicit --setenv whitelist |
No host env leak (DB password, Odoo session cookies, AWS keys, etc.) |
Native addon architecture (NO native Node addons — pure Python)
No N-API addons, no .node files, no CMake/Ninja build chain. The
entire proxy is pure Python.
Replaces Greenfield's 3 native C addons
| Greenfield native | Python replacement | Notes |
|---|---|---|
wayland-server-addon.node (1231 lines C, patched libwayland) |
pywayland (Apache-2.0) + 5-line cffi shim | Vanilla upstream libwayland 1.26.0 bundled in pywayland's wheel. Apply Greenfield's changes.diff (MIT patches) optionally for fast-sync optimization. |
proxy-poll-addon.node (127 lines C, libuv uv_poll_t) |
asyncio.get_running_loop().add_reader(wl_fd, dispatch_fn) |
Built-in asyncio. NO addon needed. |
proxy-encoding-addon.node (290 lines C) + libproxy-encoding.so (~1400 lines C, GStreamer) |
PyGObject + GStreamer Python bindings (LGPL) | Same GStreamer, Python wrapper. ~700 LOC Python replaces ~1700 LOC C. |
westfield-*.c (EGL, dmabuf, xwayland helpers, ~2000 lines C) |
Vendored Greenfield C files via cffi (MIT) | Reuse verbatim. ~3000 LOC of MIT C cherry-picked from Greenfield. |
Vendored Greenfield C files (MIT, reused as-is)
These files from greenfield/packages/compositor-proxy/native/wayland/src/
are MIT-licensed (Greenfield's own work or vendored from wlroots which is
also MIT) and can be reused verbatim in our Python proxy:
| File | Lines | Purpose |
|---|---|---|
westfield-buffer.c |
~150 | Buffer helpers |
westfield-dmabuf.c |
~200 | DMA-BUF import via EGL/GBM |
westfield-egl.c |
~900 | EGL zero-copy setup |
westfield-xwayland.c |
~465 | XWayland spawn + bridge |
wlr_drm.c |
~244 | wl_drm global impl (vendored from wlroots) |
wlr_linux_dmabuf_v1.c |
~1,110 | zwp_linux_dmabuf_v1 global impl (vendored from wlroots) |
drm_format_set.c |
~239 | DRM format helpers (vendored from wlroots) |
addon.c |
~73 | wlroots addon system helper |
| TOTAL | ~3,374 lines C | All MIT, reused verbatim |
These compile into a single libwestfield.so shared library, loaded by
the Python proxy via cffi at runtime.
XWayland bridge
Native side (Python equivalent of westfield-xwayland.c)
In Python:
import subprocess, os, socket
def spawn_xwayland(display_num, wl_client_fd, wm_fd, display_fd):
# Create X11 socket
x11_socket = f"/tmp/.X11-unix/X{display_num}"
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
listener.bind(x11_socket)
listener.listen(1)
# Set DISPLAY env
os.environ["DISPLAY"] = f":{display_num}"
# Fork + exec Xwayland
subprocess.Popen([
"Xwayland", f":{display_num}", "-ac", "-rootless",
"-listen", str(listener.fileno()),
"-displayfd", str(display_fd),
"-wm", str(wm_fd),
], pass_fds=[listener.fileno(), display_fd, wm_fd])
# Xwayland itself becomes a Wayland client of the proxy
# (via wl_client_create(wl_display, wl_client_fd))
xtsb (REUSED MIT, browser side)
@gfld/xtsb (MIT) — pure-TS X11 client, no libxcb. Dual-environment
(Node net.Socket + browser WebSocket). Auto-generated from XCB XML
protocol files via xcbgen/ts_client.py. The browser-side xtsb
implements the actual XWM logic (translates X CreateNotify events →
xdg_toplevel Wayland surfaces).
Optional: skip XWayland in MVP (8 weeks of effort). Add only if you need to run X11-only apps (most modern GTK/Qt apps are Wayland-native).
SDK misconception corrected (same as Greenfield)
The sdk/ directory is NOT for native apps. It's exclusively for
WASM apps that run inside the browser via Emscripten. The
sysrootlibs/ cross-compiles wayland/gtk/cairo/etc. to wasm32. Native
Linux apps on the proxy use the host's own system libraries.
ASCII diagram — native app launch & connection
Browser (Odoo OWL shell) Python proxy host (Linux) App (sandboxed in bwrap)
========================== ==================== ==============================
[1] User clicks app in shell $ python -m compositor_proxy.cli \
│ └─── main.py listens on :8081
│ sessionProcesses: Dict[id, Process]
│
│ HTTP GET /gtk4-demo
│ Authorization: Bearer <jwt>
├──────────────────────────────► │
│ │ validate JWT (HS256, pyjwt)
│ │ if no child for <session_id>:
│ │ multiprocessing.Process(target=session_main)
│ │ IPC: {type:'start', payload:{config, session_id}}
│ │ └── create_session() → Display
│ │ ├── Display()
│ │ ├── display.add_socket() → "wayland-N"
│ │ │ (becomes WAYLAND_DISPLAY)
│ │ ├── display.init_shm() + YUV420/NV12/...
│ │ ├── init_drm("/dev/dri/renderD128")
│ │ │ (uses vendored westfield-egl.c via cffi)
│ │ │ (registers zwp_linux_dmabuf_v1 + wl_drm globals)
│ │ └── asyncio.add_reader(wl_fd, dispatch_wl)
│ │ (5-line cffi shim for wl_event_loop_get_fd)
│ │
│ │ IPC: {type:'launch_app', payload:{name, executable, args, env, serial}}
│ │ └── launch_application() (NativeAppContext.py)
│ │ ├── appEnv = {...os.environ, ...env,
│ │ │ WAYLAND_DISPLAY: "wayland-N"}
│ │ ├── subprocess.Popen([
│ │ │ 'systemd-run', '--user', '--scope',
│ │ │ '-p', 'MemoryMax=1G',
│ │ │ '-p', 'CPUQuota=200%',
│ │ │ '-p', 'TasksMax=256', '--',
│ │ │ 'bwrap',
│ │ │ '--unshare-all', '--clearenv',
│ │ │ '--seccomp', '<memfd>',
│ │ │ '--ro-bind', '/usr', '/usr',
│ │ │ '--dev-bind', '/dev/dri/renderD128', '/dev/dri/renderD128',
│ │ │ '--ro-bind', '$XDG/wayland-N', '/run/user/100000/wayland-N',
│ │ │ '--uid', '100000', '--gid', '100000',
│ │ │ '--new-session', '--die-with-parent',
│ │ │ '/usr/bin/gtk4-demo'
│ │ │ ], env=appEnv)
│ │ │ ◄── systemd-run + bwrap, NO chroot, NO container
│ │ │ ──────────────────────────────► [2] gtk4-demo (sandboxed)
│ │ │ │
│ │ │ │ libwayland-client.so
│ │ │ │ connects to
│ │ │ │ $XDG_RUNTIME_DIR/wayland-N
│ │ │ │
│ │ │ ▼
│ │ │ ┌──────────────────────────┐
│ │ │ │ wl_display event loop │
│ │ │ │ (in proxy, libwayland- │
│ │ │ │ server.so.0 — vanilla, │
│ │ │ │ NO patches needed) │
│ │ │ │ │
│ │ │ ◄── asyncio fires, │ wl_client created → │
│ │ │ dispatch_wl() called │ Client.get_credentials │
│ │ │ wl_event_loop_dispatch() │ → (pid, uid, gid) │
│ │ │ │ find_matching_app_ctx │
│ │ │ │ (pid) by PPID walk │
│ │ │ │ │
│ │ │ │ Per-resource dispatchers│
│ │ │ │ (auto-generated by │
│ │ │ │ pywayland scanner) │
│ │ │ │ route to {native, │
│ │ │ │ browser} │
│ │ │ └──────────────────────────┘
│ │ │
│ │ ├── child.on('spawn') → capture pid
│ │ │ create_native_app_context(pid, name, sandbox=True)
│ │ │ native_app_context.on_disconnect() # start 10-min SIGHUP timer
│ │ │
│ │ └── child.on('exit') → native_app_context.on_exit({exit_code|signal})
│ │
│ │ IPC reply: {type:'launch_app_success', payload:{pid, key}}
│
│ HTTP 201 Created
│ Body: { baseURL, signalURL:"ws://…/signal?key=…", key, pid, name, sandboxed:true }
◄────────────────────────────── │
│ │
│ Open WS: ws://…/signal?key=<16-hex>
│ Authorization: Bearer <jwt>
├──────────────────────────────► │
│ │ aiohttp demux /signal → native_app_context.on_connect(ws)
│ │ clears 10-min SIGHUP timer
│ │
│ │ For each new wl_client (one per wayland connection):
│ │ create_protocol_channel() → ARQChannel (kcp over WS)
│ │ sends CONNECT_CHANNEL {url:"ws://…/channel?id=…&key=…", desc}
│ ◄──── CONNECT_CHANNEL JSON ───┤
│ │
│ Open WS: ws://…/channel?id=…&key=…
│ Authorization: Bearer <jwt>
├──────────────────────────────► │ binds WS to the ARQChannel
│ │
│ Wayland wire protocol flows over the ARQ channel (KCP reliable, 100ms interval):
│
│ App → wl_surface.commit ──── proxy intercepts via dispatcher ────► browser compositor
│ App ← wl_surface.frame ──── proxy intercepts E1 (frame callback) ◄── FrameFeedback paces callback
│ App → wl_display.sync ────── proxy fast-path sync (no roundtrip if no reply expected)
│ App ← wl_keyboard.enter ─── browser → proxy → libwayland-server sends via SCM_RIGHTS
│
│ Buffer path (per wl_surface):
│ - App submits wl_buffer (dmabuf or shm)
│ - setBufferCreatedCallback → ProxyBuffer with creationSerial
│ - On commit: dispatcher → Encoder.encodeFrame()
│ └── gst_parse_launch("appsrc ! glupload ! glcolorconvert ! glshader ! "
│ "glcolorconvert ! gldownload ! queue ! vaapih264enc ! appsink")
│ (vaapih264enc — LGPL, NOT x264enc which is GPL)
│ └── GLib MainLoop in separate thread
│ └── encoded H.264 sample → asyncio.run_coroutine_threadsafe → main thread
│ └── sent over the surface's FRAME data channel (separate WS)
│
│ X11 path (xterm etc., IF XWayland enabled):
│ - App connects to /tmp/.X11-unix/X1 (DISPLAY=:1, set by spawn_xwayland())
│ - First X connect → spawn_xwayland() forks Xwayland: execlp("Xwayland", …, "-rootless", "-wm", wm_fd)
│ - Xwayland itself becomes a Wayland client of the proxy (wl_client_create)
│ - When Xwayland ready (displayFd readable):
│ └── xtsb (REUSED MIT, browser side) speaks X11 → translates X windows → xdg_toplevel surfaces
│
│ Lifecycle:
│ - User closes browser tab → /signal WS closes with code 1001 → kill('SIGHUP')
│ - User clicks "force quit" in shell → /signal WS sends KILL_APP {signal:'SIGTERM'} → kill('SIGTERM')
│ └── if not exited in 10s → SIGKILL
│ - App crashes → child 'exit' event → native_app_context.on_exit() → APP_TERMINATED msg → browser UI updates
│ - All clients gone for >1s → session.close() → process.exit() in SessionProcess.py → main deletes entry
│
│ Sandbox (NEW vs Greenfield):
│ - bwrap --unshare-all --clearenv --seccomp <memfd>
│ - --ro-bind /usr /usr, minimal /etc whitelist
│ - --dev-bind /dev/dri/renderD128 (single GPU render node, no card0)
│ - --ro-bind $HOST_XDG/wayland-N /run/user/100000/wayland-N (single socket only)
│ - --uid 100000-165535 per-app (--gid same)
│ - --unshare-net by default
│ - cgroup: MemoryMax=1G, CPUQuota=200%, TasksMax=256
│ - $HOME overlay (changes discarded on app exit)
Effort estimates
| Phase | Days | Deliverable |
|---|---|---|
| v1 shippable | 7-9 days | bubblewrap integration + seccomp + cgroup limits + Wayland socket isolation + GPU render-node passthrough + default-deny net. XWayland unsandboxed (documented). |
| v2 full | +4-9 days | + XWayland sandbox + veth+nftables networking + per-tenant hierarchical cgroups + XDG Desktop Portal |
v1 breakdown
| Component | Days |
|---|---|
| bwrap integration | 2-3 |
| seccomp profile + compile | 1-2 |
| systemd-run cgroup wrapper | 1 |
| Wayland socket ownership fix | 0.5 |
| GPU passthrough testing | 1 |
| Schema + docs | 1 |
| TOTAL | 7-9 |
File reference
Python proxy (custom, ~2880 LOC)
| File | Role |
|---|---|
proxy/main.py |
HTTP + WS hub, forks per session |
proxy/session.py |
Top-level proxy session |
proxy/session_controller.py |
aiohttp WS server with 9 endpoint paths |
proxy/native_app_context.py |
Per-app state, signaling WS, kill timers, bwrap spawn |
proxy/native_wayland_client_session.py |
Per-client message dispatcher |
proxy/native_wayland_compositor_session.py |
Display creation, initShm, initDrm |
proxy/channel.py |
ARQChannel + SimpleChannel (clean-room) |
proxy/kcp_adapter.py |
kcp PyPI package integration |
proxy/frame_feedback.py |
Receives 4-byte decode feedback, paces wl_frame.done |
proxy/proxy_buffer.py |
Proxy-side buffer representation |
proxy/surface_buffer_encoding.py |
Encoding pipeline orchestration |
proxy/xwayland_session.py |
XWayland spawn + bridge (Python equivalent of westfield-xwayland.c) |
proxy/wayland_server.py |
pywayland wrapper + cffi shim |
proxy/io/ |
WebSocket server glue |
proxy/encoding/ |
GStreamer encoder orchestration via PyGObject |
proxy/protocol/ |
pywayland-generated dispatchers (auto) |
proxy/jwt_auth.py |
JWT validation (pyjwt) |
proxy/sandbox.py |
bubblewrap + seccomp + systemd-run wrappers |
Vendored Greenfield C files (MIT, reused as-is)
| Path | Role |
|---|---|
native/westfield-buffer.c |
Buffer helpers |
native/westfield-dmabuf.c |
DMA-BUF import via EGL/GBM |
native/westfield-egl.c |
EGL zero-copy setup |
native/westfield-xwayland.c |
XWayland spawn + bridge (used as reference, ported to Python) |
native/wlr_drm.c |
wl_drm global impl (vendored from wlroots) |
native/wlr_linux_dmabuf_v1.c |
zwp_linux_dmabuf_v1 global impl (vendored from wlroots) |
native/drm_format_set.c |
DRM format helpers (vendored from wlroots) |
native/addon.c |
wlroots addon system helper |
native/changes.diff |
Patches to libwayland-server (optional, for fast-sync) |
Reused (MIT/Apache)
| Component | Role |
|---|---|
| pywayland | Python bindings to libwayland-server |
xkbcommon PyPI package |
Python bindings to libxkbcommon (optional, for XWayland) |
PyGObject (gi.repository.Gst) |
GStreamer Python bindings |
kcp PyPI package |
Cython wrapper over upstream ikcp.c |
| aiohttp | Async HTTP + WebSocket server |
| PyJWT | JWT validation |
| bubblewrap | Unprivileged sandboxing |
| libseccomp | Seccomp BPF filter compilation |