#!/bin/sh set -eu # A UTC clock is itself the bot signal — Cloudflare treats it as the datacenter # default — and kagane's challenge then never clears. Measured 2026-08-08 with # an identical container on one Indonesian egress IP: UTC never cleared in 60s # (twice), while Asia/Jakarta and America/New_York both cleared in 4s. Any real # zone will do; the zone does not have to match the IP's country, it just must # not be UTC. It does have to be right the way Chrome reads it. # # TZ must carry the zone *name*. Chrome resolves the zone through ICU, which # takes the name from /etc/localtime's symlink target and ignores the file's # contents; bind-mounting the host's /etc/localtime therefore lands on the # image's own symlink to Etc/UTC and leaves glibc reporting +07 while Chrome # still reports UTC. /etc/timezone, mounted by docker-compose.yml, is the name. [ -n "${TZ:-}" ] || TZ=$(cat /etc/timezone 2>/dev/null || echo UTC) export TZ state=/home/chrome/state profile=/home/chrome/profile lock_file=$state/lock pid_file=$state/chrome.pid connections_dir=$state/connections last_use_file=$state/last-use idle_seconds=300 mkdir -p "$state" "$profile" "$connections_dir" exec 9>>"$lock_file" # Chrome's own UA advertises "HeadlessChrome" under --headless=new, and that # one token is the difference between kagane.to's challenge clearing in ~4s and # never clearing at all. Read the installed major version so client hints and # the UA stay aligned after an image rebuild. major=$(google-chrome-stable --version | sed -E 's/[^0-9]*([0-9]+)\..*/\1/') ua="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36" lock() { flock 9 } unlock() { flock -u 9 } browser_alive() { [ -s "$pid_file" ] || return 1 pid=$(cat "$pid_file") [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null } has_connections() { for marker in "$connections_dir"/*; do [ -e "$marker" ] || continue pid=${marker##*/} if kill -0 "$pid" 2>/dev/null; then return 0 fi # A SIGKILLed helper cannot run its cleanup trap. Reconcile its marker # here so one dead client cannot pin Chrome forever. rm -f "$marker" done return 1 } start_browser() { # No --enable-automation: it sets navigator.webdriver, the first thing a # bot check reads. setsid gives Chrome a process group so the reaper can # terminate its renderer children with the browser. # --no-sandbox avoids granting SYS_ADMIN solely for Docker's unavailable # user namespaces; containment is the unprivileged user and private network. setsid google-chrome-stable \ --headless=new \ --no-sandbox \ --remote-debugging-port=9223 \ --user-agent="$ua" \ --user-data-dir="$profile" \ --no-first-run \ --no-default-browser-check \ --disable-gpu \ about:blank >/dev/null & printf '%s\n' "$!" >"$pid_file" } stop_browser() { pid=$(cat "$pid_file") kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true i=0 while kill -0 "$pid" 2>/dev/null && [ "$i" -lt 100 ]; do i=$((i + 1)) sleep 0.1 done if kill -0 "$pid" 2>/dev/null; then kill -KILL -- "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true fi rm -f "$pid_file" } wait_for_browser() { i=0 while [ "$i" -lt 300 ]; do if wget -qO /dev/null http://127.0.0.1:9223/json/version; then return 0 fi browser_alive || return 1 i=$((i + 1)) sleep 0.1 done return 1 } finish_connection() { lock rm -f "$connection_marker" date +%s >"$last_use_file" unlock } connection_signal() { trap - INT TERM HUP finish_connection exit 143 } connection() { connection_marker=$connections_dir/$$ lock : >"$connection_marker" if ! browser_alive; then rm -f "$pid_file" start_browser fi date +%s >"$last_use_file" unlock trap connection_signal INT TERM HUP if wait_for_browser; then if socat STDIO TCP:127.0.0.1:9223; then result=0 else result=$? fi else # The client only ever sees a bare connection reset here, so this is # the sole record that the browser, not the network, was the problem. echo "browser did not come up; dropping connection" >&2 result=1 fi finish_connection return "$result" } reaper() { while :; do sleep 10 lock if ! has_connections && browser_alive; then now=$(date +%s) last=$(cat "$last_use_file" 2>/dev/null || printf '%s' "$now") if [ $((now - last)) -ge "$idle_seconds" ]; then stop_browser fi fi unlock done } if [ "${1:-}" = connection ]; then connection exit $? fi # The files are process state, not the Chrome profile. The profile is a named # volume in Compose, so clearance survives both a reap and a container rebuild. for marker in "$connections_dir"/*; do [ -e "$marker" ] || continue rm -f "$marker" done rm -f "$pid_file" "$last_use_file" # Chrome's singleton lock names the hostname and pid that took it, and a # container rebuild changes both — so a Chrome killed uncleanly (OOM, docker # kill) leaves a lock the next container reads as "another computer holds this # profile" and refuses to start behind, permanently, with the only symptom a # bare connection reset at 9222. Clearing it here is safe precisely because # container_name pins this volume to one container: nothing can be holding the # profile at the moment this line runs. The lock is process state; the # clearance cookies it sits beside are not, and are left alone. rm -f "$profile"/Singleton* # Chrome binds DevTools to loopback and silently ignores # --remote-debugging-address. socat remains the network front-end, but each # accepted connection now starts a browser on demand and is tracked by a # per-helper marker. A connection held by Go's transport delays reap by its # idle timeout; the 300-second threshold starts once the last connection closes. reaper & reaper_pid=$! socat TCP-LISTEN:9222,reuseaddr,fork EXEC:'/entrypoint.sh connection',nofork & front_pid=$! stop_browser_gracefully() { lock if browser_alive; then # Chrome is a separate session, so stop its process group explicitly; # this gives its cookie batch time to flush before the container exits. stop_browser fi unlock } shutdown() { trap - INT TERM HUP stop_browser_gracefully kill "$front_pid" "$reaper_pid" 2>/dev/null || true exit 143 } trap shutdown INT TERM HUP if wait "$front_pid"; then status=0 else status=$? fi stop_browser_gracefully kill "$reaper_pid" 2>/dev/null || true exit "$status"