diff --git a/.env.example b/.env.example index 2a78424..613c239 100644 --- a/.env.example +++ b/.env.example @@ -85,29 +85,27 @@ LATEST_CHAPTER_POLL_STAGGER=20s # delay between fetches in a batch # defaults. Beyond that the cadence stretches uniformly rather than breaking; # raise BATCH or lower INTERVAL. Keep BATCH x STAGGER under INTERVAL. -# Headless-shell CDP endpoint for sites behind a JavaScript challenge (kagane). -# Unset disables browser polling; those sites then rely on the userscript alone. -# Leave commented — the compose files' own default (ws://172.28.0.10:9222) is -# correct. Do NOT set this to the "headless-shell" DNS name: Chrome's DevTools -# HTTP handler 500s any /json/version request whose Host header isn't an IP or -# "localhost", which silently breaks every kagane poll. -# BROWSER_WS_URL=ws://172.28.0.10:9222 - -# Clock zone the headless browser reports. 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, identical container, one -# Indonesian egress IP: UTC never cleared in 60s (twice); Asia/Jakarta and -# America/New_York both cleared in 4s. So any real zone works; it does not -# have to match the IP's country, it just must not be UTC. +# CDP endpoint of the browser, used for the two sites behind a Cloudflare +# JavaScript challenge (kagane, novelfull) and by the web UI's kagane cover +# proxy. Unset disables browser polling and serves 404 for covers not already +# stored; those sites then rely on the userscript alone. That is also exactly +# how an unreachable browser degrades, so a home machine that is off costs +# chapter freshness and nothing else. # -# Unset falls back to the host's /etc/timezone, which is a real zone whenever -# the host clock is set to local time. Set this when the host runs UTC — a UTC -# server is exactly the case that fails. Only the browser sidecar reads it — -# the backend's own zone is API_TZ below, and is cosmetic. -# BROWSER_TZ=Asia/Jakarta +# The browser does NOT run in this stack. It is its own compose unit on the +# home machine (chrome/docker-compose.yml, chrome/.env.example) and is reached +# over the tailnet, so set this to that machine's tailnet address: +# +# BROWSER_WS_URL=ws://100.x.y.z:9222 +# +# It must be the tailnet **IP**, never a MagicDNS hostname and never the old +# Docker service name: Chrome's DevTools HTTP handler 500s any /json/version +# request whose Host header isn't an IP or "localhost", which silently breaks +# every kagane poll. Left unset here on purpose — a wrong default would poll a +# stranger's address, and "no browser" is a safe, self-announcing state. +# BROWSER_WS_URL=ws://100.x.y.z:9222 -# Zone the backend stamps its log lines in. Cosmetic only — it exists so the -# API's logs read on the same clock as the browser sidecar's. Nothing else in +# Zone the backend stamps its log lines in. Cosmetic only. Nothing else in # the service has a zone: bookmark timestamps are unix ms, and the two real # time columns are timestamptz. Defaults to Asia/Jakarta; set to UTC for the # conventional server default. diff --git a/AGENTS.md b/AGENTS.md index 98d94bc..6ab3941 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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. +- **The CDP browser 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. +- **The browser is not in the API stack and must not be put back.** It's its own compose unit (`chrome/docker-compose.yml`) on a second machine, reached over the tailnet — it held 471 MiB on a 1974 MiB swapless VPS, and a residential egress scores better with Cloudflare anyway (ADR-0006). Consequences that constrain code: `BROWSER_WS_URL` must be a tailnet **IP** (a MagicDNS name 500s at `/json/version`, same trap as the old Docker service name); the CDP port binds to the tailnet address only, since CDP authenticates nothing and that host has a real LAN; and the browser is on-demand (ADR-0005), so an unreachable or asleep one must degrade exactly as an unset `BROWSER_WS_URL` — plain-TLS libraries unaffected, kagane/novelfull logged and skipped, stored covers still served. Never add `chromedp.NoModifyURL`: discovery per fetch is what makes a restarted Chrome invisible. - **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. @@ -29,9 +30,13 @@ Userscript targets **Violentmonkey**, so `GM_*` APIs available, but stay GM-free ``` Two Violentmonkey userscripts (isolated world, per-site adapters, localStorage cache) -- fetch() HTTPS --> reverse proxy (TLS + CORS) --> Go net/http --> Postgres (volume) + | + | CDP over tailnet + v + on-demand Chrome, separate machine (chrome/) ``` -Backend-specific architecture (packages, endpoints, poller, config env vars) lives in `backend/AGENTS.md`. Userscript-specific structure (adapters, retry queue, UI, live URL shapes) lives in `userscript/AGENTS.md`. +Two deployable units on two machines: the API stack (`docker-compose.yml` + `docker-compose.prod.yml`, on the VPS) and the browser (`chrome/docker-compose.yml`, on the home machine). They share nothing but `BROWSER_WS_URL` and update independently. Backend-specific architecture (packages, endpoints, poller, config env vars) lives in `backend/AGENTS.md`. Userscript-specific structure (adapters, retry queue, UI, live URL shapes) lives in `userscript/AGENTS.md`. Deploy order `DEPLOY.md` (§7 for the browser), redeploy `REDEPLOY.md` (§8 for the browser). ## Commands @@ -40,10 +45,10 @@ Backend (`cd backend`): - Single test: `go test -run TestName ./...` - Build static binary: `CGO_ENABLED=0 go build` -Local stack: `docker compose up` (bookmark-api + postgres + headless-shell; `postgres-data` named volume, `restart: unless-stopped`). The `headless-shell` service keeps its name but now builds `chrome/` — real Google Chrome, for the reason in the hard constraints above. +Local stack: `docker compose up` (bookmark-api + postgres only; `postgres-data` named volume, `restart: unless-stopped`). No browser — without `BROWSER_WS_URL` the poller logs and skips kagane and novelfull. To run one: `cd chrome && BROWSER_BIND_ADDR=172.17.0.1 docker compose up -d --build`, then `BROWSER_WS_URL=ws://172.17.0.1:9222` in the root `.env` (bridge gateway, so the API container can name it by IP). -Live CDP proof (needs a sidecar and network, skipped otherwise): -`SMOKE_BROWSER_WS_URL=ws://: go test -run TestSmokeKagane ./internal/latest` +Live CDP proof (needs that browser and network, skipped otherwise): +`SMOKE_BROWSER_WS_URL=ws://: go test -run TestSmokeKagane ./internal/latest` — fetches a real kagane cover and chapter list. A red run means the challenge is not clearing from this IP, which is a live fact to re-check, not necessarily a defect. diff --git a/DEPLOY.md b/DEPLOY.md index f87b10b..892f5b5 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -157,15 +157,17 @@ This merges the base file (build/image/env/volume) with the prod override (no host port, Traefik network + router labels). Always pass **both** `-f` flags — the prod file is not standalone. -Three services come up: `bookmark-api` (the backend), `postgres` (its database, -`postgres:17-alpine`), and `headless-shell`, a CDP sidecar the poller uses to -fetch kagane (behind a Cloudflare JS challenge). Neither of the latter two -publishes a port: `postgres` sits alone with `bookmark-api` on an -`internal: true` network, and `headless-shell` is reachable only over -`BROWSER_WS_URL`. A missing headless-shell just makes the poller skip kagane and -log it. A missing Postgres stops everything — `bookmark-api` waits for -`pg_isready` to pass, then applies its embedded migrations, and only then -listens. The schema is created that way; there is nothing to import by hand. +Two services come up: `bookmark-api` (the backend) and `postgres` (its +database, `postgres:17-alpine`). Postgres publishes no port — it sits alone +with `bookmark-api` on an `internal: true` network — and stops everything if it +is missing: `bookmark-api` waits for `pg_isready` to pass, then applies its +embedded migrations, and only then listens. The schema is created that way; +there is nothing to import by hand. + +There is deliberately no browser here. Kagane and novelfull need one, and it +runs on a **separate machine** over the tailnet — §7. Until you do that step, +`BROWSER_WS_URL` is unset, the poller logs and skips those two sites, and +everything else works normally. Check it's up and healthy: @@ -259,6 +261,116 @@ copy immediately — reinstall on all devices, or they silently stop syncing. --- +## 7. The browser, on the home machine + +Kagane and novelfull sit behind a Cloudflare JavaScript challenge no TLS +fingerprint clears, so the poller reaches them through a real Chrome over CDP. +That browser does **not** run on the VPS: it held 471 MiB of a 1974 MiB box +with no swap, and it scores better from a residential IP anyway (ADR-0006). It +is its own compose unit, deployed and updated independently of everything +above. + +Do this after §2, on the second machine. Both machines must already be on the +same tailnet. + +First, on the VPS, record what you are reclaiming — this is the whole point of +the move and there is no way to measure it afterwards: + +```bash +free -m | awk '/^Mem:/ {print "available before:", $NF, "MiB"}' +``` + +Take it again after §7 is finished and the old sidecar is gone. Expect roughly +the sidecar's former footprint back (measured at 471 MiB working set, 595 MiB +cgroup). + +**On the home machine:** + +```bash +git clone ~/mangaBookmark && cd ~/mangaBookmark/chrome + +tailscale ip -4 # -> 100.x.y.z, this machine's tailnet IP +cp .env.example .env +echo "BROWSER_BIND_ADDR=$(tailscale ip -4)" >> .env + +docker compose up -d --build +``` + +The clone is only for `chrome/`; nothing else on this machine reads the rest of +the repo. The unit is its own compose project (`bookmark-browser`), so it shares +no volume, network or lifecycle with an API stack that happens to sit beside it. + +`BROWSER_BIND_ADDR` has no default on purpose. CDP authenticates nothing — +whatever reaches port 9222 drives the browser and, through it, this host — so +the bind address *is* the access control, backed by Tailscale device identity. +On the VPS that job was done by Docker network membership; this machine has a +real LAN, so `0.0.0.0` would be a hole punched into your home network. Compose +refuses to start rather than guess. + +**Narrow it to the one device that needs it.** The bind address keeps CDP off +your LAN; it still leaves port 9222 open to every device on the tailnet, and +CDP has no login. Add a rule in the Tailscale admin console's access controls +so only the VPS can reach it — tag the two machines, then: + +```jsonc +// tailnet policy file +"acls": [ + { "action": "accept", "src": ["tag:bookmark-api"], "dst": ["tag:bookmark-browser:9222"] }, +] +``` + +Without a rule the tailnet default is allow-all, so this step is what makes +"Tailscale identity is the access control" true rather than aspirational. + +Prove the bind is tight, from the home machine itself: + +```bash +curl -s -m 3 http://$(tailscale ip -4):9222/json/version # -> JSON +curl -s -m 3 http://:9222/json/version +# -> curl: (7) Failed to connect ... Connection refused +``` + +The first call is also what wakes Chrome: it is not running until something +connects, and it is reaped again after five idle minutes. A cold first response +takes a few seconds; that is the browser starting, not a fault. + +**On the VPS:** + +```bash +cd ~/mangaBookmark +echo 'BROWSER_WS_URL=ws://100.x.y.z:9222' >> .env # the home machine's tailnet IP +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +``` + +It must be the tailnet **IP**. A MagicDNS hostname fails: Chrome's DevTools HTTP +handler answers `/json/version` with a 500 for any `Host` header that is not an +IP or `localhost`, and the failure looks like a broken site rather than a broken +hostname. + +**Prove it end to end.** This is the only check that says the challenge actually +clears from that machine's egress — it fetches a real kagane cover and a real +chapter list: + +```bash +cd backend +SMOKE_BROWSER_WS_URL=ws://100.x.y.z:9222 go test -run TestSmokeKagane ./internal/latest +``` + +A red run means "not clearing from this address right now", which is a live +fact to re-check before it is a defect — Cloudflare's scoring moves. Then, from +the web UI, open a bookmarked kagane series and confirm the cover renders. Once +a cover is stored it is served from Postgres forever after, so the browser being +asleep, unreachable, or mid-power-outage costs chapter freshness and nothing +visible. + +Finally, take the VPS `free -m` reading again and compare it against the one +from the top of this section. + +**Updating the browser** is independent of the API stack and has its own +runbook — `REDEPLOY.md` §8. + +--- + ## Updating Pull new code, then rebuild: @@ -272,6 +384,10 @@ server predates the Postgres migration, the old SQLite volume `bookmarks-data` is still on disk and deliberately undeclared in compose so `down -v` cannot take it; see `REDEPLOY.md` §1 for when to remove it.) +The browser is a separate unit on a separate machine with its own update +command — §7. Nothing above touches it, and it needs no coordination: the API +picks up a restarted Chrome's new debugger UUID by itself. + --- ## Troubleshooting @@ -287,6 +403,12 @@ it; see `REDEPLOY.md` §1 for when to remove it.) | `compose ... config` errors about `TOKEN_KEY`, `OWNER_DISCORD_ID` or `POSTGRES_PASSWORD` | Run compose from the dir with `.env`, or export the vars. All three are required and none has a fallback. | | `bookmark-api` restarts in a loop, `password authentication failed for user "bookmarks"` | `POSTGRES_PASSWORD` was changed after first boot; Postgres only applies it to an empty `postgres-data`. Restore the old value, or reset the role (`REDEPLOY.md` troubleshooting). | | `bookmark-api` never logs `listening on :8080` | It is blocked on `postgres` passing `pg_isready`, or a migration failed. `docker compose -f docker-compose.yml -f docker-compose.prod.yml logs postgres`. | +| kagane rows never get a `latest_chapter`; log says `browser fetcher disabled` or nothing at all | `BROWSER_WS_URL` unset. Expected before §7 is done. | +| kagane polls all fail; log shows a 500 from `/json/version` | `BROWSER_WS_URL` names a MagicDNS hostname (or any name). Chrome's DevTools handler only accepts an IP or `localhost` — use the tailnet IP. | +| kagane polls fail with a connection error | Home machine off, off the tailnet, or the unit is down. `tailscale ping `, then `docker compose ps` in its `chrome/`. Costs freshness only; stored covers keep serving. | +| kagane cover is a placeholder for a newly bookmarked series | Its cover has never been fetched and the browser is unreachable. It fills in on the next successful poll of that series (up to `LATEST_CHAPTER_POLL_BROWSER_COOLDOWN`, default 6h). | +| `compose` in `chrome/` errors `set BROWSER_BIND_ADDR to this machine's tailnet IP` | No `chrome/.env`, or the variable is empty. Deliberate — it has no default so an unset value cannot publish CDP to the LAN. | +| browser container restarts, or is OOM-killed | `docker inspect bookmark-browser --format '{{.RestartCount}} {{.State.OOMKilled}}'`. The 512 MiB cap is sized against a measured 645 MiB untuned peak; a real breach is a Chrome regression worth reading `docker logs` for, not a number to raise reflexively. | Backend config reference and endpoint list: see `README.md`. diff --git a/README.md b/README.md index c1ca8a7..69006bf 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,21 @@ Two parts: ``` Bromite userscript (isolated world, Shadow DOM UI, localStorage cache) -- fetch() HTTPS --> reverse proxy (TLS + CORS) --> Go net/http --> Postgres (volume) + | + | CDP over the tailnet + v + headless Chrome, on-demand, + on a separate machine + (chrome/, ADR-0006) ``` +Kagane and novelfull sit behind a Cloudflare JavaScript challenge no TLS +fingerprint clears, so the poller reaches those two through a real Chrome over +CDP. That browser is **not** part of the API stack: it is its own compose unit +on a second machine, spawned on the first connection and reaped when idle. The +API needs it only to discover new chapters and to fetch a kagane cover once — +covers are stored, so the library renders in full with the browser switched off. + --- ## 1. Backend @@ -30,7 +43,7 @@ Bromite userscript (isolated world, Shadow DOM UI, localStorage cache) | `ALLOWED_ORIGINS` | Asura + Demonic + Comix + Kagane origins | Comma-separated CORS allowlist. | | `DATABASE_URL` | *(required)* | Postgres connection URL, e.g. `postgres://bookmarks:…@postgres:5432/bookmarks?sslmode=disable`. Compose builds it from `POSTGRES_PASSWORD`. | | `PORT` | `8080` | Plain HTTP; TLS terminated by the proxy. | -| `BROWSER_WS_URL` | `ws://172.28.0.10:9222` | Headless-shell CDP endpoint used to poll Kagane past its JS challenge. Must be an IP or `localhost` — Chrome's DevTools handler 500s any other Host header. | +| `BROWSER_WS_URL` | empty | CDP endpoint of the remote browser (`ws://:9222`), used to poll Kagane/Novelfull past their JS challenge and to fetch uncached Kagane covers. Must be an IP or `localhost` — Chrome's DevTools handler 500s any other Host header, MagicDNS names included. Unset disables both; stored covers still serve. | | `DISCORD_CLIENT_ID` | *(required)* | Discord application credentials for the browser sign-in (ADR-0002). | | `DISCORD_CLIENT_SECRET` | *(required)* | As above. Never logged, never echoed in an error. | | `DISCORD_GUILD_ID` | *(required)* | The one guild whose membership gates sign-in, checked at login only. Membership *is* registration: any member becomes a Reader on first login. | @@ -50,8 +63,11 @@ Compose reads a few more from the same `.env` that the backend never sees: `POSTGRES_PASSWORD` (required — `DATABASE_URL` is built from it, and Postgres only applies it while `postgres-data` is empty), `BOOKMARK_API_HOST` and `BOOKMARK_WEB_HOST` (required by the prod override), and the optional -`PROXY_NETWORK` / `TRAEFIK_ENTRYPOINT` / `TRAEFIK_CERTRESOLVER`. Full commentary -is in `.env.example`; deployment order is `DEPLOY.md`. +`PROXY_NETWORK` / `TRAEFIK_ENTRYPOINT` / `TRAEFIK_CERTRESOLVER`. The browser +unit has its own `chrome/.env` on its own machine — `BROWSER_BIND_ADDR` +(required, the tailnet IP the CDP port is published on) and the optional +`BROWSER_TZ`. Full commentary is in `.env.example` and `chrome/.env.example`; +deployment order is `DEPLOY.md`. ### Endpoints @@ -97,6 +113,23 @@ cp .env.example .env docker compose up -d --build # binds 127.0.0.1:8080 ``` +That brings up two services — the API and Postgres. The browser is deliberately +not one of them; without `BROWSER_WS_URL` the poller logs and skips kagane and +novelfull, and everything else works. To run one locally, publish it on the +Docker bridge gateway so the API container can name it by IP: + +```bash +cd chrome +echo 'BROWSER_BIND_ADDR=172.17.0.1' > .env +docker compose up -d --build +# then in the repo's own .env: BROWSER_WS_URL=ws://172.17.0.1:9222 +``` + +Bind it to `127.0.0.1` instead if you only want to drive it from the host, e.g. +`SMOKE_BROWSER_WS_URL=ws://127.0.0.1:9222 go test -run TestSmokeKagane ./internal/latest`. +In production that address is the home machine's tailnet IP and nothing else — +see `DEPLOY.md` §7 and ADR-0006. + Smoke test: ```bash diff --git a/REDEPLOY.md b/REDEPLOY.md index 86e7ebf..102fade 100644 --- a/REDEPLOY.md +++ b/REDEPLOY.md @@ -59,7 +59,7 @@ network can reach it — so every command below goes in through the container: ```bash $COMPOSE exec -T postgres psql -U bookmarks -d bookmarks -c '\dt' -# -> bookmarks, readers, schema_migrations, series, sessions +# -> bookmarks, covers, readers, schema_migrations, series, sessions ``` Inside the container that connects over the local socket as the `bookmarks` @@ -99,10 +99,11 @@ you will act as though you have one: docker run --rm -v "$BACKUP_DIR":/backup postgres:17-alpine \ pg_restore --list "/backup/bookmarks-$STAMP.dump" | grep 'TABLE DATA' # -> 1234; 0 0 TABLE DATA public bookmarks bookmarks -# -> 1235; 0 0 TABLE DATA public readers bookmarks -# -> 1236; 0 0 TABLE DATA public schema_migrations bookmarks -# -> 1237; 0 0 TABLE DATA public series bookmarks -# -> 1238; 0 0 TABLE DATA public sessions bookmarks +# -> 1235; 0 0 TABLE DATA public covers bookmarks +# -> 1236; 0 0 TABLE DATA public readers bookmarks +# -> 1237; 0 0 TABLE DATA public schema_migrations bookmarks +# -> 1238; 0 0 TABLE DATA public series bookmarks +# -> 1239; 0 0 TABLE DATA public sessions bookmarks # 2. Sanity-check the live row count you just captured. $COMPOSE exec -T postgres psql -U bookmarks -d bookmarks \ @@ -387,6 +388,55 @@ panel works on the phone. --- +## 8. The browser unit (separate machine, separate cadence) + +Everything above is the API stack on the VPS. The headless browser is its own +compose unit on the home machine (ADR-0006, `DEPLOY.md` §7) and is redeployed +on its own schedule — it holds no data you can lose, so there is nothing to +back up and no ordering constraint against the API. + +```bash +cd ~/mangaBookmark/chrome +git pull --ff-only +docker compose up -d --build +``` + +Then confirm it answers, and that a stopped-and-restarted Chrome is invisible +to the API: + +```bash +curl -s -m 15 http://$(tailscale ip -4):9222/json/version | head -c 120 +# -> {"Browser":"Chrome/1xx...","webSocketDebuggerUrl":"ws://..."} +``` + +The first call takes a few seconds: Chrome is not running until something +connects, and it is reaped again after five idle minutes. The debugger UUID +changes on every start and the API does not care — chromedp re-runs +`/json/version` discovery per fetch, which is exactly why `chromedp.NoModifyURL` +must never be added to `browser.go`. + +**Rebuild is the Chrome upgrade path.** The image installs +`google-chrome-stable` unpinned on purpose: a stale browser is what Cloudflare +turns away, and the pinned Chrome 124 in `zenika/alpine-chrome` is the worked +example. The `chrome-profile` volume survives `--build`, so clearance cookies +are reused rather than re-solved. + +Two things worth a glance after several days, both from the acceptance criteria +of the move: + +```bash +docker inspect bookmark-browser --format '{{.RestartCount}} {{.State.OOMKilled}}' +# -> 0 false +free -m # the Gitea runner should still have its headroom +``` + +Nothing here needs doing during an API redeploy. The API stack does not +`depends_on` the browser, and an unreachable one degrades exactly as an unset +`BROWSER_WS_URL`: plain-TLS libraries unaffected, kagane and novelfull logged +and skipped, stored covers still served. + +--- + ## Troubleshooting | Symptom | Cause / fix | @@ -404,6 +454,10 @@ panel works on the phone. | `pg_restore`: `cannot drop … other objects depend on it` / `being accessed by other users` | Live connections block `--clean`. `$COMPOSE stop bookmark-api` first (§6). If they persist: `$COMPOSE exec -T postgres psql -U bookmarks -d postgres -c "select pg_terminate_backend(pid) from pg_stat_activity where datname='bookmarks' and pid <> pg_backend_pid()"`. | | Dump is 0 bytes, or `pg_restore`: `did not find magic string in file header` | You ran `exec` without `-T`. The allocated TTY rewrites newlines in the binary stream and corrupts the archive in flight (§1). | | `git pull`: `could not read Username for 'https://…'` | The checkout's remote is the HTTPS clone URL and the server has no credential helper, so the pull prompts into a closed stdin. Switch it to SSH once — `git remote set-url origin ssh://git@gitea.violetcrown.my.id:2222/sulthan/mangaBookmark.git`. Gitea's SSH listens on **2222**, not 22; port 22 is the host's own sshd and answers `Permission denied (publickey)` no matter which key is registered. | +| kagane rows stopped updating after a redeploy | Check `BROWSER_WS_URL` survived the `.env` edit and still names the home machine's tailnet **IP**. A hostname 500s at `/json/version`; an empty value disables the browser silently. Plain-TLS sites keep working either way, which is why this is easy to miss. | +| kagane covers went blank in the web UI | They should not — covers are rows in `covers`, not an in-process cache. `$COMPOSE exec -T postgres psql -U bookmarks -d bookmarks -c 'select count(*) from covers'`. Zero after a restore means the dump predates the covers table; they refill on the next poll of each series. | +| Browser unit will not start: `set BROWSER_BIND_ADDR to this machine's tailnet IP` | `chrome/.env` is missing or the variable is empty. It has no default on purpose — an unset value must fail the deploy rather than publish an unauthenticated CDP port to the LAN. | +| `bookmark-browser` shows `OOMKilled true` | The cap did its job. Read `docker logs bookmark-browser` before raising it — the sizing and what the cap protects are in ADR-0006. | Full first-time setup: `DEPLOY.md`. The one-off SQLite→Postgres move: `CUTOVER.md`. Config reference and endpoints: `README.md`. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 06ecf7f..dd79ea3 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -128,10 +128,13 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN `/userscript/novel-bookmark.user.js`, both supplied by bindmount; the `__API_TOKEN__` placeholder inside them is substituted with the requesting Reader's credential at serve time). - `BROWSER_WS_URL` (CDP endpoint of the `chrome/` sidecar, used by the poller - for kagane and novelfull *and* by the web UI's kagane cover proxy; unset - disables browser polling and serves 404 from the proxy, leaving those sites - to the userscript alone). + `BROWSER_WS_URL` (CDP endpoint of the browser, which runs on a **separate + machine** and is reached over the tailnet — ADR-0006, `chrome/docker-compose.yml`. + Used by the poller for kagane and novelfull *and* by the web UI's kagane + cover proxy; unset — the default — disables browser polling and serves 404 + for covers not already stored, leaving those sites to the userscript alone. + Must be a tailnet IP, never a hostname: Chrome's DevTools handler 500s + `/json/version` for any Host that isn't an IP or `localhost`). - **kagane covers are proxied, not hot-linked:** kagane serves cover images behind the same challenge as its pages and with `cross-origin-resource-policy: same-origin`, so no `` on the web UI's diff --git a/backend/internal/latest/browser.go b/backend/internal/latest/browser.go index 621ee28..b19d773 100644 --- a/backend/internal/latest/browser.go +++ b/backend/internal/latest/browser.go @@ -53,19 +53,22 @@ var kaganeImageIDRe = regexp.MustCompile(`^[0-9a-f-]{36}$`) type BrowserFetcher struct { allocCtx context.Context cancel context.CancelFunc - // One page at a time: caps the sidecar's memory and keeps series from - // sharing page state. + // One page at a time: caps the browser's memory — it runs under a hard + // cgroup cap on a shared machine — and keeps series from sharing page state. mu sync.Mutex } var _ Fetcher = (*BrowserFetcher)(nil) -// NewBrowserFetcher connects to a headless-shell over CDP. wsURL must name the -// sidecar by IP, e.g. ws://172.28.0.10:9222 — not by Docker DNS name. Chrome's -// DevTools HTTP handler 500s any /json/version request whose Host header -// isn't an IP or "localhost" (confirmed 2026-08-03 against -// chromedp/headless-shell:stable), so the compose network pins the sidecar's -// address for this to resolve at all. +// NewBrowserFetcher connects to a Chrome over CDP. The browser is not a +// sidecar: it runs on a separate machine and is reached over the tailnet +// (ADR-0006), so wsURL is that machine's tailnet address, e.g. +// ws://100.64.0.5:9222. +// +// It must be an IP, never a hostname — not MagicDNS, not a Docker service +// name. Chrome's DevTools HTTP handler 500s any /json/version request whose +// Host header isn't an IP or "localhost" (confirmed 2026-08-03), so a name +// fails at discovery and surfaces as a dead site rather than a bad URL. // // Do not add chromedp.NoModifyURL here: that option skips the /json/version // discovery request entirely and dials wsURL as if it were already the full @@ -73,8 +76,10 @@ var _ Fetcher = (*BrowserFetcher)(nil) // /devtools/browser/, a path chosen fresh at every Chrome start — dialing // the bare host:port 404s. The default (discovery) path works precisely // because Chrome's /json/version response echoes back the Host header of the -// discovery request in webSocketDebuggerUrl, so as long as wsURL is a -// container-reachable IP, the URL chromedp gets back already points at it. +// discovery request in webSocketDebuggerUrl, so as long as wsURL is an IP this +// process can reach, the URL chromedp gets back already points at it. That is +// also why a Chrome restarted behind a stable endpoint needs no reconnect +// here: the fresh UUID arrives with the next discovery. func NewBrowserFetcher(wsURL string) (*BrowserFetcher, error) { if wsURL == "" { return nil, fmt.Errorf("empty browser websocket url") diff --git a/backend/internal/latest/smoke_image_test.go b/backend/internal/latest/smoke_image_test.go index cb88b8c..42d2e43 100644 --- a/backend/internal/latest/smoke_image_test.go +++ b/backend/internal/latest/smoke_image_test.go @@ -9,11 +9,14 @@ import ( ) // TestSmokeKaganeImage is the live proof that the cover proxy's fetch actually -// clears Cloudflare and returns image bytes. It needs a real headless Chrome +// clears Cloudflare and returns image bytes. It needs the real browser unit // with outbound network, so it runs only when SMOKE_BROWSER_WS_URL is set: // -// docker run --rm --shm-size=1gb -p 19222:9222 chromedp/headless-shell:stable -// SMOKE_BROWSER_WS_URL=ws://127.0.0.1:19222 go test -run TestSmokeKaganeImage ./internal/latest +// cd chrome && BROWSER_BIND_ADDR=127.0.0.1 docker compose up -d --build +// SMOKE_BROWSER_WS_URL=ws://127.0.0.1:9222 go test -run TestSmokeKaganeImage ./internal/latest +// +// Not chromedp/headless-shell: its challenge never clears (see chrome/Dockerfile), +// so a red run there proves nothing about kagane. func TestSmokeKaganeImage(t *testing.T) { ws := os.Getenv("SMOKE_BROWSER_WS_URL") if ws == "" { diff --git a/chrome/.env.example b/chrome/.env.example new file mode 100644 index 0000000..f77bf50 --- /dev/null +++ b/chrome/.env.example @@ -0,0 +1,28 @@ +# Copy to chrome/.env on the home machine. Never commit the real .env. +# +# This file configures the browser unit only. It is separate from the API +# stack's ../.env on purpose: the two run on different machines. + +# The address the CDP port is published on — required, no default. +# +# Use this machine's **tailnet IP**, e.g. 100.x.y.z (`tailscale ip -4`). Not +# 0.0.0.0, not the LAN address: CDP has no authentication of its own, so +# anything that can reach this port has full control of the browser and a +# foothold on this host. Tailscale device identity plus an ACL is the access +# control; the bind address is what enforces it. +# +# For a throwaway local test, 127.0.0.1 is fine — but then only this machine +# can reach it, so the API must run here too. +# Left commented so `cp .env.example .env && docker compose up` fails with the +# variable's own message telling you what to set, rather than Docker rejecting +# "100.x.y.z" as an invalid IP. +# BROWSER_BIND_ADDR=100.x.y.z + +# Clock zone the browser reports. Any real zone works and it need not match +# the egress IP's country — but it must not be UTC, which is itself the bot +# signal that stops the challenge clearing. The measurement is in entrypoint.sh. +# +# Unset falls back to the host's /etc/timezone, which is a real zone whenever +# the host clock is set to local time. Set this when the host runs UTC — a UTC +# server is exactly the case that fails. +# BROWSER_TZ=Asia/Jakarta diff --git a/chrome/docker-compose.yml b/chrome/docker-compose.yml new file mode 100644 index 0000000..59181a6 --- /dev/null +++ b/chrome/docker-compose.yml @@ -0,0 +1,62 @@ +# The browser, as its own deployable unit. +# +# This does NOT run beside the API. It runs on the home machine, reached from +# the VPS over the tailnet, and is updated without touching the API stack: +# +# cd chrome && docker compose up -d --build +# +# Set BROWSER_BIND_ADDR in chrome/.env to this machine's tailnet IP. See +# ../DEPLOY.md §7 for the full first-time procedure and ../docs/adr/ +# 0006-browser-on-the-home-machine.md for why the browser lives here at all. +name: bookmark-browser + +services: + browser: + build: . + image: bookmarkmanager-chrome:latest + container_name: bookmark-browser + restart: unless-stopped + environment: + # Any real zone works, but a UTC clock is itself the bot signal and the + # challenge then never clears — measurement in entrypoint.sh. Unset falls + # back to the host's /etc/timezone below, which is a real zone whenever + # the host clock is local; set BROWSER_TZ when the host runs UTC. + TZ: ${BROWSER_TZ:-} + volumes: + # The zone *name*, which is what Chrome's ICU needs — see entrypoint.sh. + # Absent on a non-Debian host, which the entrypoint handles by falling back to UTC. + - /etc/timezone:/etc/timezone:ro + # Cloudflare clearance must survive Chrome reaping and image recreation. + - chrome-profile:/home/chrome/profile + # Bound to the tailnet address only, never 0.0.0.0. CDP authenticates + # nothing: whatever reaches this port drives the browser and, through it, + # this host. On the VPS the safety was Docker network membership; here the + # machine has a real LAN, so the bind address *is* the access control, + # backed by Tailscale device identity. No default — an unset variable must + # fail the deploy rather than silently publish CDP to the LAN. + ports: + - "${BROWSER_BIND_ADDR:?set BROWSER_BIND_ADDR to this machine's tailnet IP}:9222:9222" + # Reaps zombie renderer processes, which otherwise accumulate for the + # container's lifetime. + init: true + # Chrome allocates shared memory per tab and dies on Docker's 64MB default. + # 128MB against a measured 19MB peak: the old 1GB reservation was sized by + # superstition, and this box has 1.8GB total. + shm_size: '128mb' + # The browser is the newcomer on a machine where a Gitea runner already + # holds ~1.2GiB of 1.8GiB. Load-bearing, not decorative: untuned Chrome + # peaked at 645MiB cgroup, which is more than is free here. + # + # memswap_limit is memory+swap combined, so this allows 512MiB of swap — + # Chrome reclaims its own cold pages onto this box's 5.9GiB of SATA swap + # instead of taking resident memory from the runner. + mem_limit: 512m + memswap_limit: 1g + # If the box does run out, the kernel takes the browser and never CI. + oom_score_adj: 800 + # A challenge solve yields to a running build. Cold start degrades to ~3s + # at half a CPU, immaterial against a 45-second challenge budget. + cpu_shares: 512 + +volumes: + chrome-profile: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 031e574..7c8e628 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -17,24 +17,12 @@ services: bookmark-api: # Traffic arrives over the Traefik network, not a published port. ports: !reset [] - environment: - # Must be an IP, not the DNS name — see the base file's comment on this - # same key: Chrome's DevTools HTTP handler 500s any Host header that - # isn't an IP or "localhost". - BROWSER_WS_URL: ${BROWSER_WS_URL:-ws://172.28.0.10:9222} - depends_on: - headless-shell: - condition: service_started - postgres: - condition: service_healthy - # `networks:` here replaces the base file's list entirely, so all three must - # be named: `proxy` for Traefik routing, and `browser` / `db` (defined in - # the base file) to keep reaching headless-shell and Postgres without - # putting either on `proxy`. + # Compose *merges* this list with the base file's, so the service ends up on + # `default`, `db` and `proxy` — only the addition is named here. Do not + # "tidy" the base file down to `db` on the strength of `proxy` being present: + # `db` is `internal: true`, and egress comes from `default`. networks: - proxy - - browser - - db labels: - "traefik.enable=true" - "traefik.docker.network=${PROXY_NETWORK:-proxy}" @@ -52,10 +40,12 @@ services: - "traefik.http.routers.bmweb.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}" - "traefik.http.routers.bmweb.service=bmapi" - # headless-shell is untouched here: it keeps its `browser` network membership - # from the base file and must never join `proxy` — that network is shared - # with whatever else sits behind Traefik on this host, and an exposed - # CDP endpoint on it would be remote code execution for any of them. + # No browser service here. It runs on the home machine as its own unit + # (chrome/docker-compose.yml) and is reached over the tailnet — see + # docs/adr/0006-browser-on-the-home-machine.md. It must never be given a + # service on this host: `proxy` is shared with whatever else sits behind + # Traefik, and an unauthenticated CDP endpoint on it is remote code + # execution for any of them. networks: proxy: diff --git a/docker-compose.yml b/docker-compose.yml index 71b64a8..7265963 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,9 @@ # If your proxy runs in Docker on its own network, use the prod override which # attaches to that network instead of publishing a port: # docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +# +# The browser is not here. It is its own unit on the home machine — +# chrome/docker-compose.yml — reached over the tailnet via BROWSER_WS_URL. services: bookmark-api: @@ -27,7 +30,7 @@ services: # Log timestamps only. Go's `log` stamps lines in local time, and this # service has no other use for a zone: bookmark timestamps are unix ms # and the two real time columns are timestamptz, both absolute instants. - # Purely so these lines read on the same clock as the sidecar's. Named + # Purely so these lines read on the same clock as the browser's. Named # API_TZ rather than TZ so an operator's exported shell TZ cannot leak # in; distroless already carries tzdata, so the name just resolves. TZ: ${API_TZ:-Asia/Jakarta} @@ -53,18 +56,20 @@ services: LATEST_CHAPTER_POLL_INTERVAL: ${LATEST_CHAPTER_POLL_INTERVAL:-10m} LATEST_CHAPTER_POLL_BATCH: ${LATEST_CHAPTER_POLL_BATCH:-14} LATEST_CHAPTER_POLL_STAGGER: ${LATEST_CHAPTER_POLL_STAGGER:-20s} - # CDP endpoint for sites behind a JavaScript challenge (kagane). Unset - # disables browser polling for those sites; the userscript still covers them. - # Must be an IP, not the "headless-shell" DNS name: Chrome's DevTools HTTP - # handler rejects the discovery request (GET /json/version) with a 500 - # unless the Host header is an IP address or "localhost" — confirmed - # 2026-08-03 against chromedp/headless-shell:stable, independent of - # chromedp's own dial logic. The sidecar's static address below exists so - # this URL survives container recreation. - BROWSER_WS_URL: ${BROWSER_WS_URL:-ws://172.28.0.10:9222} + # CDP endpoint for sites behind a JavaScript challenge (kagane, + # novelfull). The browser is not part of this stack — it runs on the home + # machine as its own unit (chrome/docker-compose.yml) and is reached over + # the tailnet. Unset disables browser polling for those sites and serves + # 404 from the cover proxy for covers not already stored; the userscript + # still covers them. Set it in .env to ws://:9222. + # + # Must be an IP, not a MagicDNS hostname: Chrome's DevTools HTTP handler + # rejects the discovery request (GET /json/version) with a 500 unless the + # Host header is an IP address or "localhost" — confirmed 2026-08-03, + # independent of chromedp's own dial logic. The same trap that used to + # force a pinned Docker IP now forbids the tailnet name. + BROWSER_WS_URL: ${BROWSER_WS_URL:-} depends_on: - headless-shell: - condition: service_started # The migration runner is the first thing the binary does, so a Postgres # that is still initialising means a crash-loop until it is not. postgres: @@ -79,8 +84,11 @@ services: # the public internet does not. ports: - "127.0.0.1:8080:8080" + # `default` is not decoration: `db` is `internal: true`, and a container on + # nothing but an internal network gets neither a published port nor egress + # — which would silently kill every poller fetch. networks: - - browser + - default - db postgres: @@ -102,59 +110,14 @@ services: networks: - db - headless-shell: - # 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: - # 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, identical container, one Indonesian egress IP: UTC never - # cleared in 60s (twice); Asia/Jakarta and America/New_York both cleared - # in 4s. So any real zone works and it need not match the IP's country — - # only UTC fails. Unset falls back to the host's /etc/timezone below, - # which is a real zone whenever the host clock is set to local time; set - # BROWSER_TZ when the host runs UTC. - 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 - # Cloudflare clearance must survive Chrome reaping and image recreation. - - chrome-profile:/home/chrome/profile - # 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 - # container's lifetime. - init: true - # Deliberately no `ports:` — an exposed CDP endpoint is remote code - # execution. Only bookmark-api, via the `browser` network below, may reach it. - # 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 - # survives `docker compose up` recreating this container. - ipv4_address: 172.28.0.10 - volumes: postgres-data: # The pre-Postgres SQLite volume (bookmarks-data) is deliberately no longer # declared here: undeclared means `docker compose down -v` cannot take it # with the rest, so the old database survives the cutover until someone # removes it by hand. - chrome-profile: networks: - # Not `internal: true`: headless Chrome still needs outbound access to reach - # kagane.to. Isolation here comes from membership (only bookmark-api and - # headless-shell join it), not from cutting egress. - browser: - ipam: - config: - - subnet: 172.28.0.0/24 # Postgres needs no egress and nothing outside bookmark-api needs to reach # it, so this one really can be cut off from the outside world. db: diff --git a/docs/adr/0005-on-demand-browser.md b/docs/adr/0005-on-demand-browser.md index c3ddba6..87eadcc 100644 --- a/docs/adr/0005-on-demand-browser.md +++ b/docs/adr/0005-on-demand-browser.md @@ -3,6 +3,9 @@ Date: 2026-08-09 Status: accepted +Superseded in part by ADR-0006: the lifecycle below is unchanged, but the +service no longer lives in the API stack and the name `headless-shell` is gone. + ## Decision Keep the `headless-shell` service and its CDP port alive, but start Google Chrome diff --git a/docs/adr/0006-browser-on-the-home-machine.md b/docs/adr/0006-browser-on-the-home-machine.md new file mode 100644 index 0000000..756ac59 --- /dev/null +++ b/docs/adr/0006-browser-on-the-home-machine.md @@ -0,0 +1,74 @@ +# ADR-0006: The browser runs on the home machine, over the tailnet + +Date: 2026-08-09 +Status: accepted + +## Decision + +The headless browser is no longer part of the API stack. It is its own compose +unit (`chrome/docker-compose.yml`), deployed on the home machine, and the API on +the VPS reaches it over the existing tailnet through `BROWSER_WS_URL`. No +fallback sidecar remains on the VPS. + +The backend needs no code change for this. The CDP endpoint was already a +configuration seam and the fetcher only ever holds the endpoint URL, so +relocation — and reversal — is one environment variable. + +## Why + +The sidecar held 471 MiB working set (645 MiB peak) on a 1974 MiB VPS with no +swap, which also hosts Traefik, Gitea and its Postgres. That is 24% of the host +and 86% of this project's memory, for a service that at the time answered zero +requests: the poller's due query joins bookmarks, production held four kagane +series and no bookmarks on any of them, and with no kagane bookmark the web UI +never rendered a kagane cover either. + +The home machine has 5.9 GiB of swap and a residential egress, which Cloudflare +scores better than a datacenter IP. Both machines were already on the tailnet. + +This move is only safe because covers are persisted (ADR-0005's sibling work, +issue #43/#45) and the browser is on-demand (ADR-0005). Without stored covers a +sleeping home machine would blank the library; without on-demand start the CI +runner that already holds ~1.2 GiB of that box's 1.8 GiB would be squeezed +around the clock. + +## Constraints + +**`BROWSER_WS_URL` must be the tailnet IP, never a MagicDNS hostname.** Chrome's +DevTools HTTP handler answers `/json/version` with a 500 for any `Host` header +that is not an IP or `localhost`. This is the same trap that previously forced a +pinned Docker IP; the pinned subnet is gone, the constraint is not. + +**The CDP port binds to the tailnet address only, never `0.0.0.0`.** CDP +authenticates nothing: whatever reaches the port drives the browser and, through +it, the host. On the VPS the safety came from Docker network membership; the +home machine has a real LAN, so a `0.0.0.0` bind is a hole punched into it. The +bind address is the enforcement and Tailscale device identity plus a per-device +ACL is the policy. `BROWSER_BIND_ADDR` deliberately has no default, so an unset +value fails the deploy instead of publishing CDP to the LAN. + +No bearer-token proxy is added in front of CDP. It would only defend against a +device already inside the tailnet, and it would be one more thing between the +poller and a browser that is already hard enough to keep clearing challenges. + +**Resource limits are load-bearing, not decorative.** The browser is the +newcomer on that box, not the incumbent. A hard 512 MiB cap with 1 GiB +memory+swap makes Chrome reclaim its own cold pages onto the machine's SATA swap +instead of taking resident memory from the runner; untuned Chrome peaked at +645 MiB cgroup, which is more than is free there. `oom_score_adj` biases the +kernel to kill the browser first and never CI. Reduced CPU weight makes a +challenge solve yield to a running build — cold start degrades to about 3 s at +half a CPU, immaterial against a 45-second challenge budget. The shared-memory +reservation drops from 1 GiB to 128 MiB against a measured 19 MiB peak. + +## Consequences + +An unreachable browser degrades exactly as an unset `BROWSER_WS_URL` already +does: plain-TLS libraries are unaffected, kagane and novelfull log and skip, the +series waits out its cooldown, and stored covers keep serving. A power outage at +home costs chapter freshness on two sites, never the appearance of the library. + +The two units are deployed and updated independently. `REDEPLOY.md` §8 covers +the browser; everything before it covers the API stack. A local `docker compose +up` now brings up two services, not three, and polls kagane only if +`BROWSER_WS_URL` is pointed somewhere.