Files

26 KiB
Raw Permalink Blame History

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 — creates Session (Display, initShm, initDrm) + WS dispatcher
  • launch_app — spawns the actual app binary via subprocess.Popen + bubblewrap + seccomp + systemd-run --scope
  • ws_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)

  1. Browser sends GET http://proxy:8081/<path> with Authorization: Bearer <jwt> header.
  2. Main process validates JWT (HS256, via pyjwt).
  3. Main process forks a SessionProcess if none exists for that compositor_session_id, sends start IPC.
  4. Main sends launch_app IPC with {name, executable, args, env, serial}.
  5. SessionProcess calls launch_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)
  6. On spawn event, captures pid, creates NativeAppContext, registers exit listener.
  7. Replies to main with {pid, key} → main returns HTTP 201 with {baseURL, signalURL, key, pid, name}.
  8. Browser opens signalURL as WebSocket → control channel established.
  9. App connects to $XDG_RUNTIME_DIR/wayland-N → libwayland fires wl_client creation → pywayland's per-resource dispatchers route requests to {native, browser}.
  10. For each new wl_client, creates a PROTOCOL ARQ channel and sends CONNECT_CHANNEL over the signaling WS; browser opens a new WS at /channel?id=….
  11. 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.

  • 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