fix(docker): replace headless-shell with real Chrome so kagane's challenge clears

chromedp/headless-shell cannot clear kagane.to's managed challenge. It is
a stripped Chrome build, and the tells are structural rather than a
header: navigator.webdriver is true, the plugin list is empty, and the
client hints are Chromium- rather than Chrome-branded. Overriding
webdriver through CDP was tried on its own and changed nothing.

Everything below was measured on 2026-08-08 from a single IP, against the
same kagane cover, so the comparisons are like for like:

  chromedp/headless-shell:stable   never cleared (90s)
  zenika/alpine-chrome             never cleared - ships Chrome 124, old
                                   enough that Cloudflare refuses it and
                                   old enough to break chromedp's CDP structs
  google-chrome, default UA        never cleared (60s) - --headless=new
                                   advertises "HeadlessChrome"
  google-chrome, stock UA, UTC     never cleared (90s)
  google-chrome, stock UA, TZ set  cleared in ~4s

So both remaining tells are load-bearing, and each was tested in
isolation. chrome/ is a Debian image with google-chrome-stable, a UA
whose version is read back out of the binary at startup (a hardcoded one
would drift out of step with the Sec-CH-UA hints on the next Chrome
update and become a fresh tell), and no --enable-automation.

The timezone matters because Cloudflare scores a browser whose clock zone
disagrees with its egress IP's country as a proxy. Note that the usual
`-v /etc/localtime:/etc/localtime:ro` does not work here: Chrome resolves
the zone through ICU, which takes the name from that path's symlink
target and ignores the file's contents, so glibc reports the host zone
while Chrome still reports UTC. /etc/timezone carries the name and is
mounted instead; BROWSER_TZ overrides it for a host whose clock is UTC in
a country that is not.

Chrome also binds its DevTools port to loopback and silently ignores
--remote-debugging-address, which is why headless-shell fronted it with
socat. This image does the same, so it stays a drop-in: the compose
service keeps the headless-shell name and its pinned address, and
BROWSER_WS_URL is unchanged.

Deploying needs `docker compose build headless-shell`.
This commit is contained in:
2026-08-08 23:03:41 +07:00
parent 05e93a4869
commit 5713e3d04a
4 changed files with 126 additions and 13 deletions
+19 -12
View File
@@ -95,8 +95,24 @@ services:
- db
headless-shell:
image: chromedp/headless-shell:stable
# Real Google Chrome, not chromedp/headless-shell — see chrome/Dockerfile.
# The service name is kept so existing overrides and BROWSER_WS_URL stay put.
build: ./chrome
image: bookmarkmanager-chrome:latest
restart: unless-stopped
environment:
# Cloudflare scores a browser whose clock zone disagrees with its egress
# IP's country as a proxy, and kagane's challenge then never clears
# (measured 2026-08-08: identical container, UTC never cleared in 90s,
# Asia/Bangkok cleared in 4s from a Thai IP). Unset falls back to the
# host's /etc/timezone below, which is right whenever the host clock is
# set to local time; set BROWSER_TZ when the host runs UTC somewhere that
# isn't, since it is the IP's country that has to match, not the clock's.
TZ: ${BROWSER_TZ:-}
volumes:
# The zone *name*, which is what Chrome's ICU needs — see chrome/entrypoint.sh.
# Absent on a non-Debian host, which the entrypoint handles by falling back to UTC.
- /etc/timezone:/etc/timezone:ro
# Chrome allocates shared memory per tab and dies on Docker's 64MB default.
shm_size: '1gb'
# Reaps zombie renderer processes, which otherwise accumulate for the
@@ -104,17 +120,8 @@ services:
init: true
# Deliberately no `ports:` — an exposed CDP endpoint is remote code
# execution. Only bookmark-api, via the `browser` network below, may reach it.
# Don't pass --remote-debugging-address/--remote-debugging-port here: the
# image's own entrypoint (/headless-shell/run.sh) already starts Chrome on
# 127.0.0.1:9223 and fronts it with a socat proxy listening on 0.0.0.0:9222.
# Redeclaring the port flag here overrides Chrome's, so it binds 9222
# directly (IPv6 loopback only) instead of 9223 — collides with socat's own
# bind on 9222 and leaves nothing listening on 9223, so every external
# connection to headless-shell:9222 fails with EOF. Only pass flags the
# entrypoint doesn't already set.
command:
- --disable-gpu
- --no-sandbox
# No `command:` either: every flag this browser needs is in its entrypoint,
# and the UA override there is load-bearing for the challenge.
networks:
browser:
# Pinned so BROWSER_WS_URL can name an IP (required, see above) that