Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0242d21
feat: migrate viewer to native KasmVNC 1.5 client behind an nginx dat…
cjangrist Jul 31, 2026
5fb4bea
docs: add WORKLOG.md covering the migration, verification and open work
cjangrist Jul 31, 2026
7bc41bf
fix(backend): identify browsers by pid, not by a recycled CDP port
cjangrist Aug 1, 2026
4471f51
fix(dataplane): stop reaping idle CDP tunnels, and give nginx its fir…
cjangrist Aug 1, 2026
496709d
fix(frontend): make the lifecycle exhaustive, and give headless a home
cjangrist Aug 1, 2026
b83b586
fix(viewer): stop a stale iframe message from defeating the reconnect…
cjangrist Aug 1, 2026
a228ba1
docs: drop WORKLOG.md and correct the README's claims
cjangrist Aug 1, 2026
c52a32b
docs: state what the README describes, not how it got there
cjangrist Aug 1, 2026
f9fb09e
fix(security): contain the SPA catch-all to the build directory
cjangrist Aug 1, 2026
c3b3cfb
fix(viewer): rate-limit the resume probe instead of one per tab switch
cjangrist Aug 1, 2026
cdd95b8
fix(viewer): detect a half-open viewer socket instead of trusting /st…
cjangrist Aug 1, 2026
79ed15c
fix(backend): report browser liveness by pid identity, not by its port
cjangrist Aug 1, 2026
e87ff71
test(backend): pin the viewer-token staleness guard and the premise i…
cjangrist Aug 1, 2026
e970dff
feat(viewer): dim the last frame on reconnect instead of a black pane
cjangrist Aug 1, 2026
98fc9ba
test(frontend): cover useProfiles' failure paths and gate them at 100%
cjangrist Aug 1, 2026
7e7ebb8
test(frontend): cover the ProfileViewer toolbar and raise its threshold
cjangrist Aug 1, 2026
b8fc5d8
test(dataplane): assert shutdown actually stops the children, uvicorn…
cjangrist Aug 1, 2026
7b37f46
fix(vnc): correct the codec claim — it holds for the shipped client
cjangrist Aug 1, 2026
cc80631
feat(vnc): log the whole Xvnc startup path, and pin why ICE is pinned
cjangrist Aug 1, 2026
f8e7465
feat(vnc): accept the NVENC codecs KasmVNC 1.5.0 implements but never…
cjangrist Aug 1, 2026
6c6d0dd
feat(browser): render Chromium on the NVIDIA GPU when one is passed in
cjangrist Aug 1, 2026
826476e
feat(viewer): name the NVENC streaming mode the bundled client cannot…
cjangrist Aug 1, 2026
4c1acb2
feat(docker): add the NVIDIA GPU image and compose overlay
cjangrist Aug 1, 2026
ba09fb8
test(gpu): add an end-to-end GPU acceleration probe
cjangrist Aug 1, 2026
6763c80
docs(readme): document the NVIDIA GPU path and its two silent failure…
cjangrist Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@ frontend/dist/

# Deploy
deploy.sh
.env

# Claude Code
.claude/
CLAUDE.md
publish-docker.sh
.beads
debug

# Agent scratch
tmp/
trash/
frontend/coverage/
49 changes: 44 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,40 @@ RUN echo "deb http://deb.debian.org/debian trixie contrib" >> /etc/apt/sources.l
&& fc-cache -f \
&& rm -rf /var/lib/apt/lists/*

# Install KasmVNC (auto-selects amd64 or arm64 based on build platform)
# nginx data plane + VA-API (AMD/Intel) + FFmpeg runtime libs for KasmVNC 1.5
# in-band H.264/H.265/AV1 video streaming (dlopen'd at runtime; JPEG/WebP
# fallback if absent). The codec path only engages under
# KASM_ENCODING_POLICY=video — the default server-authoritative policy never
# negotiates a video codec, so these libs sit unused there.
RUN apt-get update && apt-get install -y --no-install-recommends \
nginx \
libva2 libva-drm2 mesa-va-drivers vainfo \
libavcodec61 libavformat61 libavutil59 libswscale8 \
&& rm -rf /var/lib/apt/lists/*

# KasmVNC dlopens UNVERSIONED FFmpeg names (libavcodec.so etc.) which Debian
# ships only in -dev packages — symlink them to the installed runtime libs.
# Multi-arch safe: paths come from ldconfig, not hardcoded.
RUN set -e; \
for lib in libavcodec libavformat libavutil libswscale; do \
target="$(ldconfig -p | grep -m1 "${lib}\.so\.[0-9]" | awk '{print $NF}')"; \
test -n "$target"; \
ln -sf "$target" "$(dirname "$target")/${lib}.so"; \
done; \
ldconfig

# Install KasmVNC 1.5.0 (auto-selects amd64 or arm64 based on build platform),
# SHA256-verified — build fails on mismatch
ARG TARGETARCH
RUN wget -q https://github.com/kasmtech/KasmVNC/releases/download/v1.3.3/kasmvncserver_bookworm_1.3.3_${TARGETARCH}.deb \
&& apt-get update && apt-get install -y -f ./kasmvncserver_bookworm_1.3.3_${TARGETARCH}.deb \
&& rm kasmvncserver_bookworm_1.3.3_${TARGETARCH}.deb \
RUN case "${TARGETARCH}" in \
amd64) KASM_SHA256=80b241de7dfe53bba2b7e1cc5ac8c5246d72271efa16be2d4f76607f30fab1c4 ;; \
arm64) KASM_SHA256=fbb11589958a2acccd2d67f67944be79ac1e8e3a1d6172c0e6db6dc59e55a919 ;; \
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac \
&& wget -q https://github.com/kasmtech/KasmVNC/releases/download/v1.5.0/kasmvncserver_trixie_1.5.0_${TARGETARCH}.deb \
&& echo "${KASM_SHA256} kasmvncserver_trixie_1.5.0_${TARGETARCH}.deb" | sha256sum -c - \
&& apt-get update && apt-get install -y -f ./kasmvncserver_trixie_1.5.0_${TARGETARCH}.deb \
&& rm kasmvncserver_trixie_1.5.0_${TARGETARCH}.deb \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app
Expand All @@ -56,11 +85,21 @@ RUN python -c "from cloakbrowser.download import ensure_binary; ensure_binary()"

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
# --start-period: the probe goes through nginx to uvicorn, and uvicorn's
# lifespan runs cleanup_stale plus auto_launch_all (LAUNCH_TIMEOUT_S=60 per
# auto-launch profile) before it accepts. Without a grace period those early
# refusals count against --retries and a container that is merely still booting
# is reported `unhealthy` to operators and to `depends_on: service_healthy`.
# Target stays /api/status, not /healthz, so the probe covers nginx AND uvicorn.
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=90s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/api/status')" || exit 1

VOLUME /data

# Config and entrypoint last: both are tiny and change often, and anything
# copied above them invalidates the 119MB pip layer and the 337MB
# ensure_binary download on every edit (~2.5 min of a 3 min build).
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

Expand Down
51 changes: 51 additions & 0 deletions Dockerfile.nvidia
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# NVIDIA GPU variant of the manager image.
#
# docker compose -f docker-compose.yml -f docker-compose.nvidia.yml up -d --build
#
# `FROM base` is not a public image — the compose overlay binds it to the CPU
# image built from ./Dockerfile (additional_contexts: base: service:manager-base),
# so both images build from one command and the recipe stays in one file.
#
# This is a THIN layer on that image on purpose, and specifically NOT a
# nvidia/cuda base. Nothing CUDA has to be baked in: KasmVNC's NVENC encoder
# reaches the GPU through libnvidia-encode.so.1 + libcuda.so.1, and Chromium
# reaches it through libEGL_nvidia.so.0. All three are *driver* libraries that
# the NVIDIA container runtime injects at `docker run` time and that must match
# the host kernel module exactly, so baking in a CUDA userspace would add
# gigabytes and invite precisely the version skew the injection exists to avoid.
# Measured: h264_nvenc probes and opens on the stock python:3.12-slim base with
# nothing added but `--gpus all`. A CUDA base would also drag the image off
# Debian trixie, which is where the KasmVNC 1.5.0 .deb and the libavcodec61
# soname the base image symlinks against both come from.
FROM base

# libvulkan1 is the load-bearing package, and the only one this image strictly
# needs. --use-angle=vulkan is the backend that actually reaches the GPU for a
# HEADED browser on KasmVNC's X server (the reasoning and the measurements are
# in backend/browser_manager.py), and the Vulkan loader is what reads the
# /etc/vulkan/icd.d/nvidia_icd.json the container runtime injects. It happens to
# arrive today as a Mesa dependency; naming it explicitly is what stops a future
# base-image change from silently dropping the GPU path back to llvmpipe.
#
# The GLVND loaders are for the FALLBACK path only, and are deliberately kept
# despite not being needed by the Vulkan one. ANGLE ships its own libEGL.so
# beside the Chromium binary, but its gl-egl backend resolves the *system*
# libEGL.so.1 to reach the injected libEGL_nvidia.so.0 via
# /usr/share/glvnd/egl_vendor.d/10_nvidia.json — and the base image has no
# libEGL.so.1 at all. Without them, a host where Vulkan is unavailable does not
# degrade to "GPU with readback", it degrades to SwiftShader on the CPU with the
# GPU attached and idle. These are the packages CloakBrowser PR #476 adds.
RUN apt-get update && apt-get install -y --no-install-recommends \
libvulkan1 \
libglvnd0 libegl1 libgles2 libglx0 libopengl0 \
&& rm -rf /var/lib/apt/lists/*

# Read by the NVIDIA container runtime when it computes the injection set.
# `video` is the load-bearing one and is NOT in the runtime's default of
# `utility,compute`: it supplies libnvidia-encode.so.1, and without it KasmVNC's
# probe drops every *_nvenc candidate and logs "Hardware video encoding
# acceleration capability: none" while the session quietly encodes on the CPU.
# `graphics` supplies libEGL_nvidia/libnvidia-eglcore for Chromium. Set here
# rather than in compose so `docker run --gpus all <image>` is correct too.
ENV NVIDIA_VISIBLE_DEVICES=all
ENV NVIDIA_DRIVER_CAPABILITIES=compute,graphics,utility,video
129 changes: 125 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,19 +60,115 @@ Each CloakBrowser profile generates a completely different device identity. To t
- **Per-profile settings** — fingerprint seed, proxy, timezone, locale, user agent, screen size, platform
- **One-click launch/stop** — each profile runs as an isolated CloakBrowser instance
- **Session persistence** — cookies, localStorage, and cache survive browser restarts
- **In-browser viewing** — interact with launched browsers via noVNC, directly in the web GUI
- **In-browser viewing** — interact with launched browsers via KasmVNC's native web client, directly in the web GUI (server-authoritative JPEG/WebP by default, opt-in H.264/H.265/AV1 WebCodecs streaming)
- **Playwright/Puppeteer API** — connect to any running profile programmatically via CDP, while still watching it live in the browser
- **Optional authentication** — protect the web UI and API with a single token, or run wide open locally
- **Powered by CloakBrowser** — 32 source-level C++ patches, passes Cloudflare Turnstile, 0.9 reCAPTCHA v3 score

## Stack

- **Backend**: FastAPI (Python)
- **Backend**: FastAPI (Python) — control plane only (profiles, lifecycle, auth, CDP proxy)
- **Frontend**: React + Tailwind CSS
- **Browser viewer**: noVNC (WebSocket-based VNC client)
- **Browser viewer**: KasmVNC 1.5 native web client (matched server/client release)
- **Data plane**: nginx — proxies the KasmVNC HTTP/WebSocket path directly; no framebuffer bytes pass through Python
- **Database**: SQLite
- **Browser engine**: [CloakBrowser](https://github.com/CloakHQ/CloakBrowser) (stealth Chromium binary)

## Architecture

```
CloakBrowser/Chromium
→ KasmVNC 1.5 Xvnc (virtual X11 display, loopback-only WebSocket/HTTP)
→ nginx :8080
├── /api/*, SPA → FastAPI (control plane, 127.0.0.1:8081)
└── /viewer/<token>/* → that profile's KasmVNC port (auth_request-gated)
→ your browser (React SPA + native KasmVNC client in an iframe)
```

- The native KasmVNC client and server come from the **same pinned 1.5.0 package** — no protocol translation anywhere.
- Viewer access uses short-lived, per-profile opaque tokens issued by `POST /api/profiles/<id>/viewer-token`; nginx validates every viewer request (page, assets, WebSocket upgrade) through FastAPI's `/api/viewer-auth`. Kasm ports never leave loopback.
- The Manager owns a reconnect state machine (backoff + jitter, offline/visibility handling, session-status classification). A viewer disconnect never stops the browser — reconnecting returns you to the same running session.
- Encoding policy is a single knob (`KASM_ENCODING_POLICY`), because KasmVNC 1.5.0 gates video-codec negotiation and client quality overrides behind the *same* switch. `server-authoritative` keeps the server in charge — 30 FPS cap, dynamic JPEG/WebP quality, video-mode downscale under motion — and no H.264/H.265/AV1 codec is negotiated. `video` enables WebCodecs streaming and makes every quality setting client-overridable.
- Under `video`, `KASM_VIDEO_CODEC` decides the encoder for the bundled viewer: it narrows the server's probe, the server advertises only that probed set, and the client can only select from what it is offered. It is not *enforced*, though — KasmVNC accepts any streaming-mode pseudo-encoding a client sends, even for an encoder it rejected at probe time, so a client other than the bundled one can still land on software AV1 (roughly 0.4s of a CPU core per 1080p keyframe, then a silent drop back to Tight). `video` is opt-in for the other reason: it also hands every quality setting to the client, so `KASM_QUALITY_PRESET` stops binding.

### Headless profiles

A profile with **Headless** enabled starts no Xvnc and allocates no display or WebSocket port — there is nothing to view, so the viewer is not offered and `POST /api/profiles/<id>/viewer-token` answers `409`. Drive it over CDP instead (`/api/profiles/<id>/cdp`). Everything else — profiles, proxies, fingerprints, lifecycle — behaves identically.

### Environment variables

| Variable | Default | Purpose |
|----------|---------|---------|
| `AUTH_TOKEN` | *(unset = open)* | Protect the web UI + API with a token |
| `KASM_QUALITY_PRESET` | `balanced` | Encoding preset: `text`, `balanced`, `low`, `motion` |
| `KASM_ENCODING_POLICY` | `server-authoritative` | `server-authoritative`: clients cannot override encoding/quality; JPEG/WebP only, no video codec can engage. `video`: in-band H.264/H.265/AV1 WebCodecs streaming, at the cost of client-authoritative quality settings — so `KASM_QUALITY_PRESET` no longer binds. See the note above. |
| `KASM_VIDEO_CODEC` | `h264` | Encoder offered under `KASM_ENCODING_POLICY=video`. Software: `h264`, `h265`, `av1`. VAAPI (Intel/AMD): `h264_vaapi`, `h265_vaapi`, `av1_vaapi`. NVENC (NVIDIA): `h264_nvenc`, `h265_nvenc`/`hevc_nvenc`, `av1_nvenc`. `auto` is refused — it lets the client pick, including software AV1. |
| `KASM_XVNC_LOG_LEVEL` | `30` | Xvnc log verbosity (0-100). Raise to `100` to see per-connection encoder decisions in `/tmp/xvnc-<display>.log`. |
| `KASM_HW3D` | `auto` | DRI3 GPU acceleration: `auto` (enable unless NVIDIA proprietary), `1` (force), `0` (disable) |
| `KASM_DRINODE` | `/dev/dri/renderD128` | GPU render node for DRI3/VAAPI |
| `CHROME_GPU_ACCEL` | `auto` | Chromium GL backend. `auto` uses the NVIDIA GPU when one is present in the container (`/dev/nvidiactl`), else SwiftShader. `1`/`nvidia` forces it without the device check, `0` forces SwiftShader. |
| `KASM_RECT_THREADS` | *(ignored)* | No effect on KasmVNC 1.5.0 — `-RectThreads` parses but nothing reads it; encoder threads are sized to the host core count. Cap encoder CPU with the container's `--cpus`/cpuset. |

### GPU acceleration (optional)

Pass the host GPU into the container to enable KasmVNC DRI3 screen capture (AMD/Intel open-source drivers; closed-source NVIDIA does not support DRI3):

```bash
docker run --device /dev/dri:/dev/dri -p 8080:8080 -v cloakprofiles:/data cloakhq/cloakbrowser-manager
```

With Compose, GPU passthrough is an opt-in overlay (Docker refuses to create a container when a mapped device node is missing, so the default file stays GPU-free):

```bash
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build
```

Without a GPU everything still works (software encoding).

VAAPI hardware *video* encode only comes into play with `KASM_ENCODING_POLICY=video`; under the default policy the GPU accelerates screen capture only, because no video codec is negotiated.

### NVIDIA GPU (NVENC encode + Chromium acceleration)

Needs an NVIDIA GPU and the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) on the host. One command builds both the base image and the NVIDIA layer:

```bash
docker compose -f docker-compose.yml -f docker-compose.nvidia.yml up -d --build
```

This overlay defaults to `KASM_ENCODING_POLICY=video` and `KASM_VIDEO_CODEC=h264_nvenc,h264`, because under the repo-wide default policy no video codec is ever negotiated and the GPU encoder could not engage at all.

What it turns on:

| | Accelerated? | Notes |
|---|---|---|
| KasmVNC video encode | **Yes — NVENC** | Xvnc encodes the framebuffer on the GPU. Undocumented in `Xvnc -help` but fully implemented in 1.5.0. |
| Chromium WebGL / raster / compositing | **Yes — ANGLE + Vulkan** | `--use-angle=vulkan`. Vulkan is the only backend that works headed on a virtual X server (see below). |
| Chromium video *decode* | No | NVDEC via VA-API needs DRI3 to import the decoded frame for compositing; no virtual X server has it. Enabling it breaks playback rather than accelerating it. |
| KasmVNC `-hw3d` screen capture | No | DRI3, which the closed NVIDIA driver does not implement. Auto-detected and left off — forcing it (`KASM_HW3D=1`) makes Xvnc exit at startup. NVENC does not need it. |

Two things are easy to get wrong here, and both fail silently:

- **`--use-angle=gl-egl` is the software path.** NVIDIA's EGL declines the X11 platform on an X server it does not drive, so GLVND falls through to Mesa and you get llvmpipe on the CPU with the GPU attached and idle. Measured on the same host: `gl-egl` → `ANGLE (Mesa, llvmpipe)`; `vulkan` → `ANGLE (NVIDIA, Vulkan 1.4.312 (RTX 3080 Ti))` with GPU compositing enabled. `--use-gl=egl` is worse still — it is deprecated and silently disables WebGL.
- **The bundled KasmVNC client cannot select NVENC by itself.** Its automatic candidate list is hardcoded to the VAAPI and software variants, so against an NVENC server it quietly settles on JPEG/WebP while Xvnc still reports `capability: h264_nvenc`. The Manager works around this by sending `kasmvnc_mode_preference` in the viewer URL, derived from `KASM_VIDEO_CODEC`.

Verify it end to end with the bundled probe (expected to fail without a GPU):

```bash
docker run --rm --gpus all -v "$PWD":/repo:ro \
--entrypoint python cloakbrowser-manager-manager:latest /repo/scripts/gpu_probe.py
```

It asserts the driver libraries, the EGL loader, the encoder KasmVNC actually selected, and the renderer Chromium actually bound — not `chrome://gpu`'s feature table, which reports "enabled" for llvmpipe and SwiftShader alike.

Two live checks on a running session:

```bash
docker exec <container> grep "acceleration capability" /tmp/xvnc-100.log # must not say "none"
nvidia-smi --query-gpu=utilization.encoder,encoder.stats.sessionCount --format=csv
```

Caveats: `av1_nvenc` needs Ada (RTX 40-series) or newer — on Ampere it probes out to `capability: none`. Consumer GeForce cards cap concurrent NVENC sessions (8 on recent drivers), and beyond that KasmVNC falls back to software encoding per session; datacenter cards are unrestricted.

## Development

### Backend
Expand All @@ -98,6 +194,31 @@ npm run dev
docker compose up --build
```

### Data plane (nginx + entrypoint)

`docker/nginx.conf` and `entrypoint.sh` have their own test suite. The static
config lint runs anywhere; the behavioural half boots the **shipped** config and
entrypoint inside the image against stub upstreams, and is skipped when the
image is not built.

```bash
docker build -t cloakbrowser-manager:kasm15 .
python -m pytest backend/tests/test_dataplane.py -v # ~25s
```

It covers the auth_request status mapping, the `/viewer/<token>` 308 and its
relative `Location`, the 403 on Kasm's management `/api`, the token-prefix
strip, WebSocket upgrade passthrough on both legs, the no-idle-reaping
guarantee for CDP tunnels, and entrypoint.sh's signal handling. Run the probe
directly for the full transcript, or point it at another image with
`DATAPLANE_IMAGE=`:

```bash
docker run --rm --network none -v "$PWD":/repo:ro \
--entrypoint python cloakbrowser-manager:kasm15 \
/repo/scripts/dataplane_probe.py --repo /repo
```

## Requirements

- Docker (20.10+)
Expand Down Expand Up @@ -172,7 +293,7 @@ When `AUTH_TOKEN` is set:

- The web UI shows a login page. Enter the token to unlock.
- API consumers pass the token via `Authorization: Bearer <token>` header.
- VNC WebSocket connections are authenticated via the login cookie.
- Viewer page + WebSocket connections are authorized through short-lived per-profile viewer tokens issued after login.
- The `/api/status` endpoint remains unauthenticated (for Docker healthcheck).

> **Note**: The auth token is transmitted in cleartext over HTTP. If you expose the Manager to the internet, put it behind a reverse proxy with HTTPS (Caddy, nginx, Traefik).
Expand Down
Loading