Skip to content

Repository files navigation

Puppetflow

Pinokio

Minimal Chromium gateway written in Rust. It lets remote Puppeteer and Playwright clients connect to isolated Chromium instances over WebSocket, with token authentication, a concurrency limit, a FIFO queue and robust process cleanup.

Quick start

docker build -t pinokio .
docker run --rm -p 3000:3000 --shm-size=1g -e TOKEN=secret pinokio

Then connect:

const puppeteer = require("puppeteer-core");
const browser = await puppeteer.connect({
  browserWSEndpoint: "ws://localhost:3000?token=secret",
});

Architecture

src/
  main.rs        bootstrap, tracing, SIGTERM/SIGINT handling
  config.rs      env var parsing and validation (refuses to start on invalid values)
  errors.rs      error types mapped to HTTP status codes
  server.rs      Axum routes: / and /chromium (WebSocket), /health, /ready, /status
  auth.rs        token check (?token= or Authorization: Bearer), constant-time compare
  queue.rs       concurrency gate: session slots + FIFO queue
  chromium.rs    process launch, DevToolsActivePort discovery, SIGTERM/SIGKILL teardown
  browser_archive.rs  first-start download of an operator-chosen browser archive
  session.rs     active session lifecycle and guaranteed cleanup
  proxy.rs       transparent bidirectional WebSocket relay (no CDP interpretation)

Session flow:

  1. The client opens a WebSocket to / (or /chromium) with its token.
  2. Before the upgrade is answered, the server authenticates the client, waits for a free slot (or queues the request), launches an isolated Chromium and connects to its local CDP endpoint. Any failure at this stage returns a proper HTTP status code.
  3. The upgrade completes and the server relays frames between the client and Chromium without touching them.
  4. When the client disconnects, Chromium exits, a timeout fires or the server shuts down, the session is torn down: Chromium process group killed (SIGTERM then SIGKILL), temp profile dir removed, slot released, next queued request served.

Concurrency invariants

  • Slots are a tokio::sync::Semaphore with MAX_CONCURRENT_SESSIONS permits. Tokio semaphores wake waiters in FIFO order, which provides the fair queue and makes it impossible to bypass.
  • Queue admission uses an atomic counter with a compare-and-swap loop: once MAX_QUEUE_LENGTH waiters are registered, the next request is rejected immediately with 429. With 10 slots and a queue of 20, the 31st concurrent request is refused.
  • A permit is owned by its session and released exactly once when the session ends, including on panic (drop guard). A client that disconnects while queued is removed from the queue automatically because its request future is dropped.
  • No status messages are sent on the main endpoint before proxying: standard CDP clients (Puppeteer, Playwright) would break. Queue visibility is provided by GET /status instead.

Chromium lifecycle

Each session launches its own process with --headless=new, --remote-debugging-port=0 and a unique temp --user-data-dir. The CDP port is read from the DevToolsActivePort file that Chromium writes in that directory; logs are never parsed. The CDP endpoint listens on 127.0.0.1 only and is never exposed.

Headless sessions are made to look like a desktop Chrome, since the differences are exactly what bot checks score: navigator.webdriver is turned off (--disable-blink-features=AutomationControlled), the emulated screen is a standard desktop resolution that fits the window instead of 800x600 (--screen-info), the window is sized so the inner viewport matches the client's viewport (plus 87px of toolbar), media queries report a fine pointer with hover, and a microphone, camera and speaker are enumerable (--use-fake-device-for-media-stream). The User-Agent is set with --user-agent so pages, dedicated, shared and service workers all report the same string: the client's userAgent launch option, or the binary's own UA without its Headless marker. What remains visible is the software WebGL renderer (SwiftShader), which has no fix short of a GPU; the image ships Mesa's EGL libraries so CHROME_EXTRA_ARGS=--use-gl=angle --use-angle=gl-egl --ignore-gpu-blocklist --enable-unsafe-swiftshader reports a Mesa llvmpipe renderer instead, at the cost of about two seconds to create the first WebGL context in a session.

Each Chromium runs in its own process group (setsid). Teardown signals only that group: SIGTERM, 3 s grace, then SIGKILL, then the child is reaped. In Docker, tini (PID 1) reaps any re-parented grandchildren. No global pkill is ever used.

Endpoints

Endpoint Description
GET / or GET /chromium WebSocket upgrade, creates a session and proxies CDP
GET /health Liveness: {"status":"ok"}
GET /ready Readiness: 200 when accepting requests, 503 when shutting down or saturated
GET /status Counters: active/queued sessions vs limits, plus the browser identity block (engine, name, version, sha256, path); token-protected when auth is on

HTTP errors before the upgrade:

Code Meaning
401 Missing or invalid token
429 Queue full
503 Server shutting down or Chromium unavailable
504 Queue timeout or Chromium startup timeout

After the upgrade, sessions are closed with WebSocket codes 1000 (Chromium closed normally), 1001 (session timeout or server shutdown) or 1011 (Chromium error).

Configuration

All configuration is done through environment variables, validated at startup. Invalid values prevent the server from starting.

Variable Default Description
HOST 0.0.0.0 Bind address
PORT 3000 Listen port
TOKEN empty Shared secret; empty disables authentication
MAX_CONCURRENT_SESSIONS 10 Maximum simultaneously active Chromium sessions
MAX_QUEUE_LENGTH 20 Maximum requests waiting for a slot; beyond that, 429
CONNECTION_TIMEOUT_MS 600000 Maximum lifetime of an active session
QUEUE_TIMEOUT_MS 600000 Maximum wait in the queue
CHROME_STARTUP_TIMEOUT_MS 15000 Time Chromium gets to publish its CDP endpoint
SHUTDOWN_GRACE_PERIOD_MS 10000 Time given to active sessions after SIGTERM/SIGINT
BROWSER_ARCHIVE_URL empty .tar.gz or .zip containing a chrome executable, downloaded into /opt/browsers on first start and launched instead of the bundled Chromium
BROWSER_ARCHIVE_SHA256 empty Optional hex SHA-256 the archive must match; the actual hash is logged either way
CHROME_PATH auto Browser binary. Unset: /opt/browser/chrome if present, else the downloaded archive, else /usr/bin/chromium
CHROME_HEADLESS true Run with --headless=new
CHROME_NO_SANDBOX true Add --no-sandbox (see security notes)
CHROME_DISABLE_DEV_SHM_USAGE true Add --disable-dev-shm-usage
CHROME_EXTRA_ARGS empty Extra Chromium args, whitespace-separated
LOG_LEVEL info trace, debug, info, warn, error
TZ system Default time zone of every session. Unset, a zone is derived from the session language (fr-FR gives Europe/Paris) so containers do not report UTC
LANGUAGE system Default browser language (fr-FR or fr-FR:fr), passed to Chromium as --accept-lang and as its own LANGUAGE environment

Clients cannot inject arbitrary Chromium launch arguments. The only per-session knobs are the JSON launch query parameter fields proxyServer, proxyBypassList, disableWebSecurity, acceptLanguage (comma-separated BCP 47 tags, overrides LANGUAGE), userAgent (printable ASCII, at most 512 characters, applied with --user-agent), viewport ({"width","height"}, drives --window-size and --screen-info) and timezone (IANA name such as Europe/Paris, passed as TZ to the browser process so Date, Intl and workers agree; overrides the server's TZ); everything else is validated and mapped to fixed flags server-side. Other server-wide flags go in CHROME_EXTRA_ARGS.

On Linux, Chromium takes its application locale from the LANGUAGE environment variable and ignores --lang, so each session's primary tag is passed as LANGUAGE to the browser process. The image installs chromium-l10n next to the bundled Chromium so that locale has its pack: the default JavaScript Intl locale then matches Accept-Language (a French session resolves Intl.DateTimeFormat().resolvedOptions().locale to fr, a German one to de, not en-US), which is one of the consistency checks bot detection runs. --accept-lang does not depend on locale packs, so websites receive the requested Accept-Language regardless.

Browser binary

The published image bundles one browser, the Debian chromium package at /usr/bin/chromium. Pinokio does not care which Chromium-based build it launches, though: Google Chrome, Chrome for Testing, or a patched build such as CloakBrowser all work as long as the binary speaks CDP and accepts the standard flags above. Two ways to use another one, both keeping third-party binaries out of the image and under the operator's own license acceptance:

Downloaded archive. Set BROWSER_ARCHIVE_URL to a .tar.gz or .zip (format detected from the content, e.g. the Chrome for Testing chrome-linux64.zip) whose root (or single top-level directory) contains chrome. On first start Pinokio streams the archive into /opt/browsers/<url-hash-prefix>/, logs its SHA-256, extracts it and fixes permissions. Mount a volume at /opt/browsers so restarts skip the download; a new URL installs next to the previous one, re-publishing under the same URL requires clearing the volume. Optionally set BROWSER_ARCHIVE_SHA256 to the hash published by the author: Pinokio then refuses anything else. A checksum or download failure aborts the install and the process exits non-zero. While the download runs, /health answers, /ready returns 503 with browser_installing, and session requests get 503 so clients retry.

Mounted directory. Mount the whole browser directory (executable, shared libraries, resources) at /opt/browser; it takes precedence over a downloaded archive and over the bundled Chromium:

services:
  pinokio:
    volumes:
      - /home/user/my-browser:/opt/browser:ro
    environment:
      # Optional vendor-specific flags
      CHROME_EXTRA_ARGS: "--some-vendor-flag"

Binary resolution when CHROME_PATH is unset: /opt/browser/chrome if it exists, else the downloaded archive, else /usr/bin/chromium. Set CHROME_PATH explicitly only when the executable has another name. Pinokio runs as uid 10001, so mounted files must be world-readable and the executable world-executable. Vendor flags are never added implicitly; pass them through CHROME_EXTRA_ARGS. At startup Pinokio logs a browser binary line with the engine (chromium, downloaded or custom), the path, the product name and version from --version, and the SHA-256 of the executable. The same fields are returned in the browser block of GET /status, so clients can confirm which build served them: two builds of the same Chromium release (stock vs patched) share a version string but never a hash. Compare it with sha256sum on the file. Third-party binaries keep their own license terms.

Client compatibility

Puppeteer (works as-is)

const puppeteer = require("puppeteer-core");

const browser = await puppeteer.connect({
  browserWSEndpoint: "ws://localhost:3000?token=secret",
});
try {
  const page = await browser.newPage();
  await page.goto("https://example.com");
  console.log(await page.title());
} finally {
  await browser.close();
}

Playwright (CDP over a ws URL)

const { chromium } = require("playwright");

const browser = await chromium.connectOverCDP("ws://localhost:3000?token=secret");
try {
  const context = await browser.newContext();
  const page = await context.newPage();
  await page.goto("https://example.com");
  console.log(await page.title());
} finally {
  await browser.close();
}

Compatibility notes:

  • Pinokio exposes a raw CDP WebSocket endpoint, not a Playwright-native (connect()) endpoint. Playwright must use connectOverCDP, which is Chromium-only and skips some Playwright-managed niceties (it attaches to the existing browser instead of controlling launch).
  • The http:// form of connectOverCDP is not supported: it relies on a GET /json/version discovery request, which would require creating the session outside the WebSocket handshake. Use the ws:// form, verified to work.
  • wss:// is handled by your reverse proxy (Traefik, Nginx, Caddy, HAProxy); Pinokio itself does not terminate TLS.

Docker

The provided Dockerfile builds a multi-stage image: Rust builder, then a Debian slim runtime with Chromium, running as a non-root user with tini as PID 1 and a healthcheck on /health.

Example compose service:

services:
  pinokio:
    build: ./pinokio
    restart: unless-stopped
    ports:
      - "3000:3000"
    shm_size: "1gb"
    environment:
      TOKEN: "${PINOKIO_TOKEN:-}"
      MAX_CONCURRENT_SESSIONS: 10
      MAX_QUEUE_LENGTH: 20
      CONNECTION_TIMEOUT_MS: 600000
      TZ: "Europe/Paris"
      LANGUAGE: "fr-FR"

shm_size matters: Chromium uses /dev/shm for rendering buffers and the Docker default of 64 MB makes tabs crash under load. Give it 1 GB, or keep CHROME_DISABLE_DEV_SHM_USAGE=true (then Chromium falls back to /tmp, slightly slower but safe).

Security notes

  • The Chromium CDP port listens on 127.0.0.1 inside the container and is never exposed; only the authenticated proxy reaches it.
  • Each session gets a unique temp profile directory, removed at teardown.
  • Clients cannot inject launch arguments or execute anything server-side; the server never interprets CDP payloads.
  • WebSocket frames are capped at 16 MiB (64 MiB per message) in both directions.
  • --no-sandbox disables Chromium's internal sandbox. It is required in most containers (no user namespaces). Mitigations: the container runs as a non-root user, one Chromium per session, and pages you drive are the main threat, so avoid pointing sessions at untrusted content with sensitive credentials loaded. If your kernel allows it, set CHROME_NO_SANDBOX=false.
  • Tokens are never logged; neither are CDP payloads or page contents.

Known limitations

  • One WebSocket connection = one Chromium instance. There is no session reuse, prebooting or browserWSEndpoint reconnection to a running session.
  • Playwright only via connectOverCDP with a ws:// URL (no /json/version discovery, no Playwright-native protocol).
  • Metrics are limited to the /status counters.

License

Proprietary. Use is permitted only as part of the Puppetflow product; see LICENSE. Third-party dependencies (Rust crates, Chromium) keep their own licenses.

About

Lightweight Rust gateway providing authenticated, isolated, and concurrency-controlled Chromium sessions over WebSocket.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages