From 9835043544b9abf37b480ac9f0edfd3601abddbc Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Mon, 12 Oct 2026 21:12:03 +0300 Subject: [PATCH 1/3] feat(docker): add a driver that serves one Chrome per CDP connection A small Go server that looks like browserless v2 from the outside but runs no Puppeteer: each WebSocket connection gets its own Chrome, traffic is proxied unchanged, and Chrome's process group is killed when the connection ends. /json/version starts the session's Chrome and returns its real metadata with a reachable ws URL; unclaimed browsers are stopped after 30s. DESIGN.md lists the browserless pitfalls it avoids. --- docker/.gitignore | 1 + docker/DESIGN.md | 191 +++++++++++++++++++++++++++++ docker/go.mod | 3 + docker/go.sum | 0 docker/internal/browser/browser.go | 150 ++++++++++++++++++++++ docker/internal/browser/flags.go | 63 ++++++++++ docker/internal/config/config.go | 21 ++++ docker/internal/server/server.go | 158 ++++++++++++++++++++++++ docker/internal/server/sessions.go | 114 +++++++++++++++++ docker/main.go | 51 ++++++++ 10 files changed, 752 insertions(+) create mode 100644 docker/.gitignore create mode 100644 docker/DESIGN.md create mode 100644 docker/go.mod create mode 100644 docker/go.sum create mode 100644 docker/internal/browser/browser.go create mode 100644 docker/internal/browser/flags.go create mode 100644 docker/internal/config/config.go create mode 100644 docker/internal/server/server.go create mode 100644 docker/internal/server/sessions.go create mode 100644 docker/main.go diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 00000000..e660fd93 --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/docker/DESIGN.md b/docker/DESIGN.md new file mode 100644 index 00000000..21741c4d --- /dev/null +++ b/docker/DESIGN.md @@ -0,0 +1,191 @@ +# Design + +A small Go binary that runs as the entrypoint of a Docker container and gives each client its own Chrome over CDP. +It looks like browserless v2 (checked against v2.57.0) from the outside, but works differently inside. There's no Puppeteer, and nothing +touches the client's targets: bytes go through unchanged. It's self-hosted only (local machines, CI, your own +servers) and Docker only. + +## Why + +Most remote-Chrome bugs Ferrum and Cuprite users report come from browserless being too clever, not from CDP: + +- Its own Puppeteer resets the viewport to 800x600 (ferrum#592, #492) and sets its own download folder (cuprite#279). +- It writes a raw `HTTP 408` onto a socket that is already a WebSocket when a limit is hit (ferrum#538). +- v2 kills sessions after 30s by default (ferrum#501). +- v2 reports `0.0.0.0` as the WebSocket host (ferrum#501) and leaves `User-Agent` out of `/json/version` (cuprite#300). +- `?token=` is lost on the way (ferrum#216, #439), and v2 needs a `/chrome` or `/chromium` path (ferrum#501). +- The startup window's context can't be addressed or disposed (ferrum#578, #540). +- `quit` doesn't end the session (ferrum#418), and orphaned Chrome trees pile up (ferrum#505). +- Zombie processes pile up when the main process is PID 1 (ferrum#364), and there's no native arm64 image (ferrum#270). + +## Process model + +- The container runs dumb-init as PID 1, then `driver`, then one Chrome per session. dumb-init reaps orphans, + including crashpad's double-forked handler. That's why the driver isn't PID 1 itself: reaping everything there would race + `exec.Cmd.Wait`. +- Each session gets its own Chrome with a fresh profile directory and `--remote-debugging-port=0`. The driver reads + `DevTools listening on ws://…` from Chrome's stderr, so there are no port collisions and no startup polling. +- A session ends when the client's socket closes, which also covers `Browser.close`: Chrome exits, its socket + closes, and the proxy sees EOF. + - Both proxy directions are then torn down. + - The whole process group gets TERM, then KILL after a grace period, and the profile directory is deleted. + - Go's default TCP keepalive on accepted connections catches a client that vanished without closing. +- `CONCURRENT` limits running sessions and defaults to the number of CPUs. Up to `QUEUED` extra connections wait + for a free slot. Beyond that they get `429` with `Retry-After`, as a plain HTTP response sent before the + WebSocket upgrade, never bytes on a live socket. The names match browserless v2, so its compose files work + unchanged. +- There's no session timeout by default; browserless v2 defaults to 30s. `TIMEOUT` (env) or `?timeout=` (query), + both in milliseconds, set one. The driver logs why each session ended, so `docker logs` shows it. + +## HTTP surface + +The driver serves everything through `net/http`, and WebSocket upgrades go through `httputil.ReverseProxy`. + +| Route | Behavior | +|---|---| +| `GET /json/version` | Starts the session's Chrome, proxies Chrome's real `/json/version`, and rewrites `webSocketDebuggerUrl` to `ws(s):///devtools/browser/?` | +| WS `/devtools/browser/` | Attaches to the Chrome started by `/json/version`, `404` if there's none (as in v2) | +| WS `/`, `/chrome`, `/chromium` | Claims a pending Chrome started with the same query, or starts a new one. For `ws_url:` clients and browserless URLs | +| `GET /json/list`, `/json/new`, `/json/protocol`, `/devtools/*`, WS `/devtools/page/` | Forwarded to the Chrome that owns the target, for the DevTools viewer (v2 has the same routes) | +| `GET /health` | Driver liveness. Never starts a Chrome | + +Like v2, every route also accepts a trailing slash: `/chrome/` works like `/chrome`. + +`/json/version` is answered on the fly, so the client gets real `Browser`, `User-Agent` and protocol data, with no +Chrome started at boot or just for discovery. This differs from v2 on purpose. v2 starts a throwaway Chrome once, +caches its metadata, and always returns its generic address (`EXTERNAL`, or `HOST:PORT`) with no path and no +query, so the client connects to `/`. Clients work against either. Details: + +- **Unclaimed sessions.** A Chrome started by `/json/version` is pending until a WebSocket claims it. If none + arrives within 30 seconds, for example from a stray `curl` or a misconfigured healthcheck, it's killed. +- **Reachable URL.** The URL is built from `Host` / `X-Forwarded-Host` / `X-Forwarded-Proto`, so it's always + reachable from the client (no `0.0.0.0`), and the query string is kept. `EXTERNAL` (or `PROXY_URL`, as in v2) + overrides the scheme and host when the driver sits behind a proxy that rewrites them. +- **Host header.** Requests forwarded to Chrome carry `Host: 127.0.0.1:`, because Chrome rejects any other + host on its HTTP endpoint. +- **DevTools URLs.** In the `devtoolsFrontendUrl` returned by `/json`, the `ws=` parameter is rewritten to the + driver's host. Ferrum's `debug_url` and the browser's DevTools then give a live, clickable view of the session. + That's our answer to browserless's debug viewer. + +### Zero-copy tunnel (potential) + +After the upgrade, `ReverseProxy` copies raw bytes between the two connections in userspace and never parses +frames. A possible optimization: `Hijack()` the client connection, dial Chrome, write the rewritten handshake, +then `io.Copy` between the two `*net.TCPConn`s. On Linux that uses `splice(2)`, so the bytes never enter +userspace. Bytes already buffered by `net/http` have to be flushed first, and both connections closed when either +copy ends. It's worth doing only if profiling shows the copy matters, since Chrome's JSON serialization dominates +CDP traffic. + +## Launch options + +The query string from `/json/version` or the WebSocket URL accepts the same parameters as browserless v2: + +- `launch`: JSON or base64-encoded JSON, in v2's (Puppeteer launch) format. Supported keys: + - `args`: extra Chrome flags, e.g. `--proxy-server=…` or `--lang=…`; + - `headless`: `false` starts Chrome under Xvfb; + - `defaultViewport` `{width, height}`: becomes `--window-size`; + - `ignoreDefaultArgs`: `true`, or a list of default flags to drop. +- `timeout`: the session timeout in milliseconds. + +Other parameters (`token`, `blockAds`, `blockAdsInclude`) and other `launch` keys are ignored, so existing +browserless URLs keep working. + +Anyone who can connect can pass flags like `--renderer-cmd-prefix` and run commands in the container. There's no +authentication, so never publish the port beyond localhost or a private network. + +Default flags: + +- headless; +- `--no-startup-window`, so no implicit context; +- `--no-sandbox`, since the container is the boundary; +- `--disable-dev-shm-usage`; +- `--disable-features=site-per-process`, which makes ferrum#640 less likely. + +Never added: `--disable-web-security`, `--disable-crashpad-for-testing` (the network service crash-loops), or +`NetworkServiceInProcess`. + +## Files + +There's no file API. Mount directories at the same absolute path on the host or app container and in the driver +container. Ferrum passes its own paths to `Browser.setDownloadBehavior` and `DOM.setFileInputFiles`, and Chrome +resolves them inside the container: + +- uploads: the app source, e.g. `-v $PWD:$PWD`; +- downloads: e.g. `./tmp/downloads` mounted at the same path. + +Screenshots and PDFs need no mount: they come back over CDP and Ruby writes the file. + +The image works under `--user $(id -u):$(id -g)`: any uid, a world-writable `HOME` with the XDG directories +under it, which crashpad needs (ferrum#541, #544). Downloads then belong to the user, and Chrome can write into the mounted folder. + +## Image + +- `debian:trixie-slim`, never Alpine (Chromium on musl aborts at startup, ferrum#571). Not Ubuntu either, because + Ubuntu's `chromium` package is a snap, which doesn't work in Docker. +- Two images, `ghcr.io/rubycdp/chrome` (Google's `.deb`) and `ghcr.io/rubycdp/chromium` (Debian's package), each + for amd64 and arm64. Tags: `latest`, the browser's major version (`155`) and its full version (`155.0.8059.39`). +- The Go binary is cross-compiled in a build stage, so the image has no Node, Playwright or npm. +- `.github/workflows/docker.yml` builds each browser and architecture pair on a native runner, never through + QEMU, which crashes Chrome (ferrum#270). It smoke-tests every image and merges the architectures into one + multi-arch tag by digest. +- Fonts: Debian's font packages plus open substitutes for Microsoft fonts. Selawik replaces Segoe UI, Carlito + replaces Calibri, Caladea replaces Cambria, and DejaVu replaces Tahoma. +- Later: the Ferrum suite run against each image before it's published. +- Env, with names matching browserless v2: + - `PORT` (default 3000) and `HOST` (default `0.0.0.0`); + - `CONCURRENT`, `QUEUED` and `TIMEOUT`; + - `EXTERNAL` / `PROXY_URL`; + - plus `BROWSER_PATH`, which the image sets. +- A healthcheck on `/health`, so compose can use `condition: service_healthy`. + +## Usage with Cuprite in compose + +This follows the Evil Martians "System of a test" setup: + +- **The `chrome` service.** Use this image instead of `browserless/chrome`. `CONNECTION_TIMEOUT` and the + M1-specific tag aren't needed, and `depends_on` can use `service_healthy`. +- **Connecting.** `url: ENV["CHROME_URL"]` (`http://chrome:3333`) works as is: one `/json/version`, then the + WebSocket. The TCP availability check with fallback to a local Chrome also works as is. +- **Sessions.** Cuprite opens one connection per test process and calls `reset` between tests, so it's one Chrome + per process. Parallel workers each get their own, so set `CONCURRENT` to at least the worker count. +- **The app's address.** `Capybara.server_host = "0.0.0.0"`, with `app_host` set to the app's hostname on the + compose network. When Rails runs on the host instead, use `host.docker.internal`, which needs + `extra_hosts: ["host.docker.internal:host-gateway"]` on Linux. +- **WebMock and VCR.** Allow the `chrome` host, because `/json/version` goes through `Net::HTTP`. +- **Launch flags.** `browser_options: {"no-sandbox" => nil}` isn't needed. Ferrum doesn't send launch flags to a + remote browser anyway. +- **GitHub Actions.** Service containers start before checkout, so start the driver with `docker run` in a step + after checkout when uploads need the workspace mounted. + +## Ferrum changes + +1. Apply `window_size` to each page in remote mode. Today it only feeds the local `--window-size` flag, so a + remote browser silently ignores it. +2. `Browser#debug` in remote mode prints the DevTools URL instead of `Process.spawn(process.path, …)`, which + fails because the path is nil. +3. Never look for a local binary when `url` or `ws_url` is set (ferrum#554). +4. Optionally, send `window_size` and `browser_options` in v2's `launch` query parameter (`defaultViewport` and + `args`) when connecting remotely. + +## Status + +Done: +- Config is `BROWSER_PATH`, `HOST` and `PORT`. +- One Chrome per connection, on `--remote-debugging-port=0`, with the endpoint read from stderr. +- `/json/version` is answered on the fly, with a claim timeout. +- The WebSocket goes through `ReverseProxy`. +- The process group gets TERM, then KILL, and the profile is removed when the connection ends. +- `/health`. +- A graceful shutdown stops every Chrome. +- Trailing-slash routes (`/chrome/`, `/chromium/`, `/json/version/`), as in v2. + +With `ws_url:`, Ferrum still calls `/json/version` before connecting to `/`. Pending Chromes are therefore matched +by query string, so a connection to `/` claims the one that call started, instead of starting a second. + +Not done yet: +- `CONCURRENT`, `QUEUED` and `TIMEOUT`, plus `EXTERNAL`; +- the `launch` and `timeout` query parameters; +- the DevTools proxy (`/json/list`, `/json/new`, `/json/protocol`, `/devtools/*`); +- cleaned-up default flags; +- a `HEALTHCHECK` in the image; +- Go tests. diff --git a/docker/go.mod b/docker/go.mod new file mode 100644 index 00000000..7b0c0f1d --- /dev/null +++ b/docker/go.mod @@ -0,0 +1,3 @@ +module github.com/rubycdp/ferrum/docker + +go 1.27 diff --git a/docker/go.sum b/docker/go.sum new file mode 100644 index 00000000..e69de29b diff --git a/docker/internal/browser/browser.go b/docker/internal/browser/browser.go new file mode 100644 index 00000000..bca8e0d4 --- /dev/null +++ b/docker/internal/browser/browser.go @@ -0,0 +1,150 @@ +package browser + +import ( + "bufio" + "errors" + "fmt" + "log/slog" + "net/url" + "os" + "os/exec" + "path" + "regexp" + "strings" + "sync" + "syscall" + "time" +) + +const killTimeout = 5 * time.Second + +var listeningPattern = regexp.MustCompile(`DevTools listening on (ws://\S+)`) + +type Browser struct { + ID string + Addr string + WSPath string + + cmd *exec.Cmd + dir string + done chan struct{} + stopOnce sync.Once +} + +func Start(binary string, timeout time.Duration) (*Browser, error) { + dir, err := os.MkdirTemp("", "chrome-") + if err != nil { + return nil, fmt.Errorf("create user data dir: %w", err) + } + + stderr, writer, err := os.Pipe() + if err != nil { + os.RemoveAll(dir) + return nil, fmt.Errorf("create stderr pipe: %w", err) + } + + cmd := exec.Command(binary, Flags(dir)...) + cmd.Stderr = writer + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + err = cmd.Start() + writer.Close() + if err != nil { + stderr.Close() + os.RemoveAll(dir) + return nil, fmt.Errorf("start browser: %w", err) + } + + b := &Browser{cmd: cmd, dir: dir, done: make(chan struct{})} + go b.wait() + + wsURL := make(chan string, 1) + go b.readStderr(stderr, wsURL) + + select { + case raw := <-wsURL: + if err := b.setEndpoint(raw); err != nil { + b.Stop() + return nil, err + } + slog.Info("browser started", "pid", b.Pid(), "id", b.ID, "addr", b.Addr) + return b, nil + case <-b.done: + b.Stop() + return nil, errors.New("browser exited before it was ready") + case <-time.After(timeout): + b.Stop() + return nil, fmt.Errorf("browser did not start within %s", timeout) + } +} + +func (b *Browser) Pid() int { + return b.cmd.Process.Pid +} + +func (b *Browser) Done() <-chan struct{} { + return b.done +} + +func (b *Browser) Stop() { + b.stopOnce.Do(func() { + pgid := b.Pid() + syscall.Kill(-pgid, syscall.SIGTERM) + + select { + case <-b.done: + case <-time.After(killTimeout): + slog.Warn("browser ignored SIGTERM, killing", "pid", pgid) + } + + syscall.Kill(-pgid, syscall.SIGKILL) + <-b.done + + if err := os.RemoveAll(b.dir); err != nil { + slog.Warn("cannot remove user data dir", "dir", b.dir, "error", err) + } + + slog.Info("browser stopped", "pid", pgid, "id", b.ID) + }) +} + +func (b *Browser) wait() { + err := b.cmd.Wait() + slog.Debug("browser exited", "pid", b.Pid(), "error", err) + close(b.done) +} + +func (b *Browser) readStderr(stderr *os.File, wsURL chan<- string) { + defer stderr.Close() + + reader := bufio.NewReader(stderr) + for { + line, err := reader.ReadString('\n') + if line = strings.TrimSpace(line); line != "" { + if match := listeningPattern.FindStringSubmatch(line); match != nil { + select { + case wsURL <- match[1]: + default: + } + } else { + slog.Debug("browser stderr", "pid", b.Pid(), "line", line) + } + } + if err != nil { + return + } + } +} + +func (b *Browser) setEndpoint(raw string) error { + endpoint, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("parse browser websocket url %q: %w", raw, err) + } + + b.Addr = endpoint.Host + b.WSPath = endpoint.Path + b.ID = path.Base(endpoint.Path) + + return nil +} diff --git a/docker/internal/browser/flags.go b/docker/internal/browser/flags.go new file mode 100644 index 00000000..758115f5 --- /dev/null +++ b/docker/internal/browser/flags.go @@ -0,0 +1,63 @@ +package browser + +import ( + "fmt" +) + +const ( + chromeDebuggingPortFlag = "--remote-debugging-port" + chromeWindowSizeFlag = "--window-size" + chromeUserDataDirFlag = "--user-data-dir" + chromeResolution = "1920,1080" +) + +var ( + flags = []string{ + "--no-sandbox", + "--headless", + "--disable-gpu", + "--hide-scrollbars", + "--mute-audio", + "--enable-automation", + "--disable-web-security", + "--disable-session-crashed-bubble", + "--disable-breakpad", + "--disable-sync", + "--no-first-run", + "--use-mock-keychain", + "--keep-alive-for-test", + "--disable-popup-blocking", + "--disable-extensions", + "--disable-component-extensions-with-background-pages", + "--disable-hang-monitor", + "--disable-features=site-per-process,TranslateUI", + "--disable-translate", + "--disable-background-networking", + "--enable-features=NetworkService,NetworkServiceInProcess", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-client-side-phishing-detection", + "--disable-default-apps", + "--disable-dev-shm-usage", + "--disable-ipc-flooding-protection", + "--disable-prompt-on-repost", + "--disable-renderer-backgrounding", + "--force-color-profile=srgb", + "--metrics-recording-only", + "--safebrowsing-disable-auto-update", + "--password-store=basic", + "--no-startup-window", + } +) + +func Flags(dir string) []string { + f := make([]string, len(flags)) + copy(f, flags) + f = append(f, []string{ + fmt.Sprintf("%s=%s", chromeDebuggingPortFlag, "0"), + fmt.Sprintf("%s=%s", chromeWindowSizeFlag, chromeResolution), + fmt.Sprintf("%s=%s", chromeUserDataDirFlag, dir), + }...) + + return f +} diff --git a/docker/internal/config/config.go b/docker/internal/config/config.go new file mode 100644 index 00000000..2882df3c --- /dev/null +++ b/docker/internal/config/config.go @@ -0,0 +1,21 @@ +package config + +import ( + "net" + "os" +) + +var ( + Version string + + BrowserPath = os.Getenv("BROWSER_PATH") + Address = net.JoinHostPort(env("HOST", "0.0.0.0"), env("PORT", "3000")) +) + +func env(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok && value != "" { + return value + } + + return fallback +} diff --git a/docker/internal/server/server.go b/docker/internal/server/server.go new file mode 100644 index 00000000..1871c453 --- /dev/null +++ b/docker/internal/server/server.go @@ -0,0 +1,158 @@ +package server + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httputil" + "net/url" + "strings" + + "github.com/rubycdp/ferrum/docker/internal/browser" +) + +type Server struct { + sessions *Sessions +} + +func New(sessions *Sessions) http.Handler { + s := &Server{sessions: sessions} + + mux := http.NewServeMux() + handle(mux, "/health", s.health) + handle(mux, "/json/version", s.version) + handle(mux, "/devtools/browser/{id}", s.claim) + handle(mux, "/chrome", s.connect) + handle(mux, "/chromium", s.connect) + mux.HandleFunc("GET /{$}", s.connect) + + return mux +} + +func handle(mux *http.ServeMux, path string, handler http.HandlerFunc) { + mux.HandleFunc("GET "+path, handler) + mux.HandleFunc("GET "+path+"/{$}", handler) +} + +func (s *Server) health(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte("ok")) +} + +func (s *Server) version(w http.ResponseWriter, r *http.Request) { + b, err := s.sessions.Start() + if err != nil { + slog.Error("cannot start browser", "error", err) + http.Error(w, "cannot start browser", http.StatusServiceUnavailable) + return + } + + info, err := fetchVersion(b) + if err != nil { + slog.Error("cannot fetch browser version", "id", b.ID, "error", err) + s.sessions.Stop(b) + http.Error(w, "cannot fetch browser version", http.StatusBadGateway) + return + } + + info["webSocketDebuggerUrl"] = publicURL(r, b.WSPath) + s.sessions.Reserve(b, r.URL.RawQuery) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(info) +} + +func (s *Server) claim(w http.ResponseWriter, r *http.Request) { + if !isUpgrade(r) { + http.Error(w, "websocket upgrade required", http.StatusUpgradeRequired) + return + } + + b := s.sessions.Claim(r.PathValue("id")) + if b == nil { + http.Error(w, "browser not found", http.StatusNotFound) + return + } + + s.serve(w, r, b) +} + +func (s *Server) connect(w http.ResponseWriter, r *http.Request) { + if !isUpgrade(r) { + http.Error(w, "websocket upgrade required", http.StatusUpgradeRequired) + return + } + + b, err := s.sessions.ClaimOrStart(r.URL.RawQuery) + if err != nil { + slog.Error("cannot start browser", "error", err) + http.Error(w, "cannot start browser", http.StatusServiceUnavailable) + return + } + + s.serve(w, r, b) +} + +func (s *Server) serve(w http.ResponseWriter, r *http.Request, b *browser.Browser) { + defer s.sessions.Stop(b) + + slog.Info("session opened", "id", b.ID, "client", r.RemoteAddr) + proxyTo(b).ServeHTTP(w, r) + slog.Info("session closed", "id", b.ID, "client", r.RemoteAddr) +} + +func proxyTo(b *browser.Browser) *httputil.ReverseProxy { + return &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + pr.Out.URL.Scheme = "http" + pr.Out.URL.Host = b.Addr + pr.Out.URL.Path = b.WSPath + pr.Out.URL.RawPath = "" + pr.Out.URL.RawQuery = "" + pr.Out.Host = b.Addr + pr.Out.Header.Del("Origin") + }, + ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { + slog.Error("cannot proxy to browser", "id", b.ID, "error", err) + http.Error(w, "browser unavailable", http.StatusBadGateway) + }, + } +} + +func fetchVersion(b *browser.Browser) (map[string]any, error) { + resp, err := http.Get("http://" + b.Addr + "/json/version") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %s", resp.Status) + } + + var info map[string]any + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + + return info, nil +} + +func publicURL(r *http.Request, wsPath string) string { + scheme := "ws" + if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") { + scheme = "wss" + } + + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } + + u := url.URL{Scheme: scheme, Host: host, Path: wsPath, RawQuery: r.URL.RawQuery} + return u.String() +} + +func isUpgrade(r *http.Request) bool { + return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") +} diff --git a/docker/internal/server/sessions.go b/docker/internal/server/sessions.go new file mode 100644 index 00000000..2fab7da6 --- /dev/null +++ b/docker/internal/server/sessions.go @@ -0,0 +1,114 @@ +package server + +import ( + "log/slog" + "sync" + "time" + + "github.com/rubycdp/ferrum/docker/internal/browser" +) + +const ( + startTimeout = 30 * time.Second + claimTimeout = 30 * time.Second +) + +type Sessions struct { + binary string + + mu sync.Mutex + browsers map[string]*browser.Browser + pending map[string]reservation +} + +type reservation struct { + browser *browser.Browser + options string +} + +func NewSessions(binary string) *Sessions { + return &Sessions{ + binary: binary, + browsers: make(map[string]*browser.Browser), + pending: make(map[string]reservation), + } +} + +func (s *Sessions) Start() (*browser.Browser, error) { + b, err := browser.Start(s.binary, startTimeout) + if err != nil { + return nil, err + } + + s.mu.Lock() + s.browsers[b.ID] = b + s.mu.Unlock() + + go func() { + <-b.Done() + s.Stop(b) + }() + + return b, nil +} + +func (s *Sessions) Reserve(b *browser.Browser, options string) { + s.mu.Lock() + s.pending[b.ID] = reservation{browser: b, options: options} + s.mu.Unlock() + + time.AfterFunc(claimTimeout, func() { + if s.Claim(b.ID) != nil { + slog.Warn("browser was never claimed, stopping", "id", b.ID, "timeout", claimTimeout) + s.Stop(b) + } + }) +} + +func (s *Sessions) Claim(id string) *browser.Browser { + s.mu.Lock() + defer s.mu.Unlock() + + r := s.pending[id] + delete(s.pending, id) + + return r.browser +} + +func (s *Sessions) ClaimOrStart(options string) (*browser.Browser, error) { + s.mu.Lock() + for id, r := range s.pending { + if r.options == options { + delete(s.pending, id) + s.mu.Unlock() + return r.browser, nil + } + } + s.mu.Unlock() + + return s.Start() +} + +func (s *Sessions) Stop(b *browser.Browser) { + s.mu.Lock() + delete(s.browsers, b.ID) + delete(s.pending, b.ID) + s.mu.Unlock() + + b.Stop() +} + +func (s *Sessions) StopAll() { + s.mu.Lock() + browsers := make([]*browser.Browser, 0, len(s.browsers)) + for _, b := range s.browsers { + browsers = append(browsers, b) + } + s.mu.Unlock() + + var wg sync.WaitGroup + for _, b := range browsers { + wg.Go(func() { s.Stop(b) }) + } + wg.Wait() +} diff --git a/docker/main.go b/docker/main.go new file mode 100644 index 00000000..04e61f57 --- /dev/null +++ b/docker/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/rubycdp/ferrum/docker/internal/config" + "github.com/rubycdp/ferrum/docker/internal/server" +) + +const shutdownTimeout = 10 * time.Second + +func main() { + if config.BrowserPath == "" { + slog.Error("BROWSER_PATH is not set") + os.Exit(1) + } + + sessions := server.NewSessions(config.BrowserPath) + srv := &http.Server{ + Addr: config.Address, + Handler: server.New(sessions), + ReadHeaderTimeout: 10 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + go func() { + <-ctx.Done() + slog.Info("shutting down") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + srv.Shutdown(shutdownCtx) + }() + + slog.Info("listening", "address", config.Address, "browser", config.BrowserPath, "version", config.Version) + if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + slog.Error("server failed", "error", err) + os.Exit(1) + } + + sessions.StopAll() +} From a04485320e01106304a8cf8c0fede97bd4491d1d Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Mon, 12 Oct 2026 21:12:03 +0300 Subject: [PATCH 2/3] feat(docker): add chrome and chromium images for amd64 and arm64 --- docker/.dockerignore | 3 ++ docker/Dockerfile | 88 ++++++++++++++++++++++++++++++++++++++++ docker/Makefile | 50 +++++++++++++++++++++++ docker/deploy/fonts.conf | 12 ++++++ 4 files changed, 153 insertions(+) create mode 100644 docker/.dockerignore create mode 100644 docker/Dockerfile create mode 100644 docker/Makefile create mode 100644 docker/deploy/fonts.conf diff --git a/docker/.dockerignore b/docker/.dockerignore new file mode 100644 index 00000000..4a91c419 --- /dev/null +++ b/docker/.dockerignore @@ -0,0 +1,3 @@ +bin/ +DESIGN.md +Makefile diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..e4e67741 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,88 @@ +ARG BROWSER=chromium +ARG GO_VERSION=1.27 +ARG DEBIAN_VERSION=trixie + +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-${DEBIAN_VERSION} AS build +ARG TARGETOS +ARG TARGETARCH +ARG VERSION=dev +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath \ + -ldflags="-s -w -X github.com/rubycdp/ferrum/docker/internal/config.Version=${VERSION}" \ + -o /out/driver . + +FROM --platform=$BUILDPLATFORM debian:${DEBIAN_VERSION}-slim AS fonts +ARG SELAWIK_SHA256=3f62c51e05e3b5a1e6241cf92a371f0be2ea1183aa87b30718bbd40832a8d423 +ADD --checksum=sha256:${SELAWIK_SHA256} \ + https://github.com/microsoft/Selawik/releases/download/1.01/Selawik_Release.zip /tmp/selawik.zip +RUN apt-get update && apt-get install -y --no-install-recommends unzip \ + && mkdir -p /out/selawik && unzip -j /tmp/selawik.zip '*.ttf' -d /out/selawik + +FROM debian:${DEBIAN_VERSION}-slim AS base +LABEL org.opencontainers.image.source=https://github.com/rubycdp/ferrum +LABEL org.opencontainers.image.licenses=MIT + +ENV DEBIAN_FRONTEND=noninteractive \ + LANG=C.UTF-8 \ + TZ=UTC + +RUN printf 'APT::Install-Recommends "false";\n' > /etc/apt/apt.conf.d/99no-recommends \ + && apt-get update && apt-get install -y \ + ca-certificates \ + curl \ + dumb-init \ + tzdata \ + xvfb \ + fontconfig \ + fonts-crosextra-caladea \ + fonts-crosextra-carlito \ + fonts-dejavu-core \ + fonts-freefont-ttf \ + fonts-gfs-neohellenic \ + fonts-indic \ + fonts-ipafont-gothic \ + fonts-liberation \ + fonts-noto-cjk \ + fonts-noto-color-emoji \ + fonts-open-sans \ + fonts-roboto \ + fonts-thai-tlwg \ + fonts-wqy-zenhei \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=fonts /out/selawik /usr/share/fonts/truetype/selawik +COPY deploy/fonts.conf /etc/fonts/local.conf +RUN fc-cache -f + +FROM base AS chromium +RUN apt-get update && apt-get install -y chromium \ + && rm -rf /var/lib/apt/lists/* +ENV BROWSER_PATH=/usr/bin/chromium + +FROM base AS chrome +ARG TARGETARCH +RUN curl -fsSL --retry 5 --retry-all-errors -o /tmp/chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_${TARGETARCH}.deb \ + && apt-get update && apt-get install -y /tmp/chrome.deb \ + && rm -rf /var/lib/apt/lists/* /tmp/chrome.deb +ENV BROWSER_PATH=/usr/bin/google-chrome + +FROM ${BROWSER} + +RUN mkdir -p /home/driver && chmod 1777 /home/driver \ + && useradd --uid 999 --home-dir /home/driver --no-create-home --shell /usr/sbin/nologin driver + +ENV HOME=/home/driver \ + XDG_CONFIG_HOME=/home/driver/.config \ + XDG_CACHE_HOME=/home/driver/.cache + +COPY --from=build /out/driver /usr/local/bin/driver + +USER driver +WORKDIR /home/driver +EXPOSE 3000 +STOPSIGNAL SIGTERM + +ENTRYPOINT ["dumb-init", "--", "driver"] diff --git a/docker/Makefile b/docker/Makefile new file mode 100644 index 00000000..d35d351e --- /dev/null +++ b/docker/Makefile @@ -0,0 +1,50 @@ +VERSION := $(shell git rev-parse --short HEAD) +LDFLAGS := "-s -w -X 'github.com/rubycdp/ferrum/docker/internal/config.Version=$(VERSION)'" +NAME := "driver" +BIN := "bin/$(NAME)" + +.PHONY: build run fmt formatting-check lint vet test clean docker* version + +CGO_ENABLED ?= 0 +build: fmt + CGO_ENABLED=$(CGO_ENABLED) go build -o $(BIN) -ldflags=$(LDFLAGS) . + +run: build + $(BIN) + +fmt: + go fmt ./... + +formatting-check: + @gofmt -l -d $$(find . -not -path '*/\.*' -type f -name '*.go') | grep ^ && exit 1 || true > /dev/null 2>&1 + +lint: + staticcheck ./... + +vet: + go vet ./... + +clean: + rm -f bin/* + +BROWSER ?= chromium +IMAGE ?= ghcr.io/rubycdp/$(BROWSER) +TAG ?= latest + +docker.build: + docker buildx build --load --build-arg BROWSER=$(BROWSER) --build-arg VERSION=$(VERSION) -t $(IMAGE):$(TAG) . + +docker.run: + docker run --rm --name $(BROWSER) -p 3000:3000 $(IMAGE):$(TAG) + +docker.stop: + docker stop $(BROWSER) + +docker.sh: + docker run --rm -it --entrypoint /bin/bash $(IMAGE):$(TAG) + +docker.ssh: + docker exec -it $(BROWSER) /bin/bash + +version: + @echo $(VERSION) diff --git a/docker/deploy/fonts.conf b/docker/deploy/fonts.conf new file mode 100644 index 00000000..266288e9 --- /dev/null +++ b/docker/deploy/fonts.conf @@ -0,0 +1,12 @@ + + + + + Segoe UI + Selawik + + + Tahoma + DejaVu Sans + + From 68ce951e4f357da7a029b26ff76dac71e0fc3de4 Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Mon, 12 Oct 2026 21:12:03 +0300 Subject: [PATCH 3/3] chore(ci): build, smoke-test and publish docker images on native runners --- .github/workflows/docker.yml | 151 +++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 .github/workflows/docker.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..f030b813 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,151 @@ +name: Docker +on: + push: + branches: + - main + paths: + - "docker/**" + - ".github/workflows/docker.yml" + pull_request: + paths: + - "docker/**" + - ".github/workflows/docker.yml" + workflow_dispatch: + +jobs: + build: + name: Build ${{ matrix.browser }} (${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + browser: [chrome, chromium] + arch: [amd64, arm64] + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + permissions: + contents: read + packages: write + env: + IMAGE: ghcr.io/${{ github.repository_owner }}/${{ matrix.browser }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: docker + platforms: linux/${{ matrix.arch }} + build-args: | + BROWSER=${{ matrix.browser }} + VERSION=${{ github.sha }} + load: true + tags: ${{ matrix.browser }}:test + cache-from: type=gha,scope=${{ matrix.browser }}-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.browser }}-${{ matrix.arch }} + + - name: Smoke test + run: | + docker run -d --name smoke -p 3000:3000 ${{ matrix.browser }}:test + for i in $(seq 1 30); do + docker logs smoke 2>&1 | grep -q WebSocketDebuggerUrl && break + sleep 1 + done + docker logs smoke + docker logs smoke 2>&1 | grep -q WebSocketDebuggerUrl + + - name: Detect browser version + run: | + mkdir -p /tmp/meta + docker run --rm --entrypoint sh ${{ matrix.browser }}:test -c '"$BROWSER_PATH" --version' \ + | grep -oE '[0-9]+(\.[0-9]+){3}' | head -1 > "/tmp/meta/version-${{ matrix.arch }}" + cat "/tmp/meta/version-${{ matrix.arch }}" + + - name: Push by digest + id: push + if: github.event_name != 'pull_request' + uses: docker/build-push-action@v6 + with: + context: docker + platforms: linux/${{ matrix.arch }} + build-args: | + BROWSER=${{ matrix.browser }} + VERSION=${{ github.sha }} + cache-from: type=gha,scope=${{ matrix.browser }}-${{ matrix.arch }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + if: github.event_name != 'pull_request' + run: | + mkdir -p /tmp/meta/digests + touch "/tmp/meta/digests/${DIGEST#sha256:}" + env: + DIGEST: ${{ steps.push.outputs.digest }} + + - name: Upload metadata + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: meta-${{ matrix.browser }}-${{ matrix.arch }} + path: /tmp/meta + retention-days: 1 + + publish: + name: Publish ${{ matrix.browser }} + if: github.event_name != 'pull_request' + needs: build + strategy: + matrix: + browser: [chrome, chromium] + runs-on: ubuntu-latest + permissions: + packages: write + env: + IMAGE: ghcr.io/${{ github.repository_owner }}/${{ matrix.browser }} + steps: + - name: Download metadata + uses: actions/download-artifact@v4 + with: + pattern: meta-${{ matrix.browser }}-* + path: /tmp/meta + merge-multiple: true + + - name: Setup Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create multi-arch manifest + working-directory: /tmp/meta + run: | + tags=(-t "$IMAGE:latest") + amd64=$(cat version-amd64) + arm64=$(cat version-arm64) + if [ -n "$amd64" ] && [ "$amd64" = "$arm64" ]; then + tags+=(-t "$IMAGE:$amd64" -t "$IMAGE:${amd64%%.*}") + else + echo "::warning::Browser versions differ (amd64: $amd64, arm64: $arm64), publishing only latest" + fi + docker buildx imagetools create "${tags[@]}" $(printf "$IMAGE@sha256:%s " $(ls digests)) + docker buildx imagetools inspect "$IMAGE:latest"