fix(docker): correct the timezone claim — UTC is the tell, not a country mismatch

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).
This commit is contained in:
2026-08-08 23:16:39 +07:00
parent ec74559ca5
commit 1552dd15da
4 changed files with 29 additions and 13 deletions
+2 -1
View File
@@ -20,7 +20,8 @@ Userscript targets **Violentmonkey**, so `GM_*` APIs available, but stay GM-free
- Userscript run in **isolated world**, so embedded API token safe from site's JS.
- Cloudflare's block on manga sites **IP-reputation-based, not universal — and not reliably reproducible.** Verified 2026-07-26: plain `curl` from both CGNAT dev machine *and* deployed VPS got clean 200s with real HTML on both asurascans.com and demonicscans.org (homepage, series, chapter pages) — no interactive Turnstile challenge from either IP at test time. Contradicts earlier untested assumption CGNAT dev IP blocked; wasn't, at least this date. Treat "does curl work right now" as live, time-varying fact to re-check, not fixed property of machine — Cloudflare's bot scoring can flip previously-clean IP without notice. Backend fetcher still needs graceful-degrade path for when challenged, and adapters should be **verified against live pages** (Playwright MCP, on-device devtools, direct probe) before finalizing, not assumed from single earlier test.
- **kagane.to and novelfull.com are the exception to the above** — both sit behind a Cloudflare JavaScript challenge no TLS fingerprint clears, so the backend polls them over CDP (`BROWSER_WS_URL`) and skips them entirely when that's unset. The four other sites poll fine over plain TLS.
- **The CDP sidecar must look like a real browser, and stock headless images don't.** Measured 2026-08-08 against kagane.to, all from the same IP: `chromedp/headless-shell:stable` never cleared the challenge in 90s (`navigator.webdriver` true, empty plugin list, Chromium-branded client hints — suppressing `webdriver` alone changed nothing); `zenika/alpine-chrome` ships Chrome 124, refused outright; real Chrome with the default `--headless=new` UA never cleared, because the UA says `HeadlessChrome`; real Chrome with a stock UA **and** a clock zone matching the egress IP's country cleared in ~4s. Hence `chrome/` — a Debian image with `google-chrome-stable`, a version-derived UA, and `TZ`/`BROWSER_TZ`. Chrome reads the zone *name* through ICU from `/etc/localtime`'s symlink target, so mounting the host's `/etc/localtime` does **not** work; `/etc/timezone` is mounted instead.
- **The CDP sidecar must look like a real browser, and stock headless images don't.** Measured 2026-08-08 against kagane.to, all from the same IP: `chromedp/headless-shell:stable` never cleared the challenge in 90s (`navigator.webdriver` true, empty plugin list, Chromium-branded client hints — suppressing `webdriver` alone changed nothing); `zenika/alpine-chrome` ships Chrome 124, refused outright; real Chrome with the default `--headless=new` UA never cleared, because the UA says `HeadlessChrome`; real Chrome with a stock UA **and** a non-UTC clock zone cleared in ~4s. Hence `chrome/` — a Debian image with `google-chrome-stable`, a version-derived UA, and `TZ`/`BROWSER_TZ`. Chrome reads the zone *name* through ICU from `/etc/localtime`'s symlink target, ignoring the file's contents, so mounting the host's `/etc/localtime` does **not** work; `/etc/timezone` is mounted instead.
- **UTC is the tell, not a country mismatch.** A UTC clock is the datacenter default, so Cloudflare scores it as one; any real zone clears. Measured 2026-08-08, identical container, one Indonesian egress IP: UTC never cleared in 60s (twice), while `Asia/Jakarta` **and** `America/New_York` both cleared in 4s. An earlier note here claimed the zone had to match the egress IP's country — that was wrong, inferred from the host clock (`Asia/Bangkok`) rather than the measured egress. `BROWSER_TZ` therefore needs a plausible zone, not a geolocated one.
- **A challenged page needs the tab kept open.** The interstitial takes seconds to solve and only then writes clearance into the browser's shared cookie jar. Navigate-read-close never clears anything; `BrowserFetcher.run` holds one tab and re-reads until the payload arrives.
## Architecture