The previous commit documented the clock-zone requirement as "the zone
must match the egress IP's country". Re-measuring against the deployment
case shows that is wrong.
The original inference came from reading the host's /etc/timezone
(Asia/Bangkok) and assuming the egress IP was Thai. It is not: this host
egresses from an Indonesian IP. Asia/Bangkok cleared the challenge not
because it matched a country but because it is simply not UTC, and the
two share +07, which hid the distinction.
Measured 2026-08-08, identical container, one Indonesian egress IP:
TZ=UTC never cleared (60s, twice)
TZ=Asia/Jakarta cleared in 4s
TZ=America/New_York cleared in 4s
America/New_York matches neither the country nor the offset nor the
hemisphere, and clears just as fast. So a UTC clock is itself the bot
signal - Cloudflare scores it as the datacenter default - and any real
zone satisfies the check.
This makes the knob considerably less fragile than documented: BROWSER_TZ
needs a plausible zone, not a geolocated one, and a deployment that moves
region does not have to keep it in sync. Comments in chrome/entrypoint.sh
and docker-compose.yml, the hard constraint in AGENTS.md, and the PR
description are corrected accordingly.
BROWSER_TZ is also documented in .env.example for the first time, which
is the file an operator actually copies - the setting decides whether
kagane works at all, and a UTC server (the common case) is exactly the
one that fails with it unset.
Re-verified against the shipped image with TZ=Asia/Jakarta:
TestSmokeKaganeImage PASS (5.00s, 56710 bytes of image/webp),
TestSmokeKaganeGet PASS (1.24s, status 200).
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`.