Make browser sidecar on-demand (#44)
This commit is contained in:
+190
-39
@@ -16,46 +16,197 @@ set -eu
|
||||
[ -n "${TZ:-}" ] || TZ=$(cat /etc/timezone 2>/dev/null || echo UTC)
|
||||
export TZ
|
||||
|
||||
# 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 (measured 2026-08-08, same host, same Chrome, only the
|
||||
# UA changed). Overriding it does not touch the Sec-CH-UA client hints, which
|
||||
# report the real version, so the version is read back out of the binary rather
|
||||
# than hardcoded: a hardcoded one would drift out of step with the hints on the
|
||||
# next Chrome update and become a fresh tell.
|
||||
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"
|
||||
|
||||
# Chrome binds its DevTools port to loopback and silently ignores
|
||||
# --remote-debugging-address (verified 2026-08-08: Chrome 151 with
|
||||
# --remote-debugging-address=0.0.0.0 still listened on 127.0.0.1 only), so the
|
||||
# caller — another container — cannot reach it directly. socat fronting the
|
||||
# loopback port is how chromedp/headless-shell solved the same problem and is
|
||||
# why this image is a drop-in for it.
|
||||
#
|
||||
# Nothing publishes 9222; reachability is the `browser` network in
|
||||
# docker-compose.yml, and an exposed CDP endpoint is remote code execution.
|
||||
socat TCP-LISTEN:9222,fork,reuseaddr TCP:127.0.0.1:9223 &
|
||||
lock() {
|
||||
flock 9
|
||||
}
|
||||
|
||||
# Chrome stays in the foreground so that its death takes the container down and
|
||||
# compose's restart policy applies; a backgrounded browser behind a live socat
|
||||
# would leave the sidecar looking healthy while answering nothing.
|
||||
#
|
||||
# No --enable-automation: it sets navigator.webdriver, the first thing a bot
|
||||
# check reads.
|
||||
#
|
||||
# --no-sandbox because Chrome's zygote wants user namespaces, which Docker's
|
||||
# default profile does not hand out; the alternative is --cap-add=SYS_ADMIN,
|
||||
# which gives the container strictly more than it takes away. Containment here
|
||||
# is the unprivileged user, the isolated network, and the fact that this
|
||||
# browser only ever navigates to kagane.to and novelfull.com.
|
||||
exec google-chrome-stable \
|
||||
--headless=new \
|
||||
--no-sandbox \
|
||||
--remote-debugging-port=9223 \
|
||||
--user-agent="$ua" \
|
||||
--user-data-dir=/home/chrome/profile \
|
||||
--no-first-run \
|
||||
--no-default-browser-check \
|
||||
--disable-gpu \
|
||||
about:blank
|
||||
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 2>&1 &
|
||||
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
|
||||
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 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"
|
||||
|
||||
Reference in New Issue
Block a user