From e4a313e6268783abed38c699ef506d2e7061b93d Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sun, 9 Aug 2026 15:19:54 +0700 Subject: [PATCH 1/2] Move the browser off the VPS to its own unit (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headless browser leaves the API stack. It becomes its own compose unit (chrome/docker-compose.yml) deployed on the home machine and reached over the tailnet, returning 471 MiB of working set to a 1974 MiB VPS that has no swap. No fallback sidecar is left behind. The backend needs no code change: BROWSER_WS_URL was already the only coupling, so relocation is one environment variable. Its default is now empty rather than a pinned Docker IP — an unreachable or unconfigured browser degrades exactly as it always has, with plain-TLS libraries unaffected, kagane and novelfull logged and skipped, and stored covers still served. The browser unit publishes CDP on ${BROWSER_BIND_ADDR} with no default, because CDP authenticates nothing and the home machine has a real LAN: an unset value must fail the deploy rather than silently expose an endpoint that is remote code execution for anything that reaches it. Resource limits are sized against the measured 645 MiB untuned peak and the CI runner that already holds 1.2 GiB of that box. bookmark-api gains the default network. Dropping `browser` left it on `db` alone, which is internal: true — that meant no published port and, worse, no egress for the poller at all. Caught by bringing the stack up. Docs: ADR-0006 for the topology, DEPLOY.md §7 for first-time setup of the browser machine, REDEPLOY.md §8 for its independent update cadence, plus the architecture diagrams, config tables and troubleshooting rows. --- .env.example | 40 +++---- AGENTS.md | 15 ++- DEPLOY.md | 119 +++++++++++++++++-- README.md | 39 +++++- REDEPLOY.md | 64 +++++++++- backend/AGENTS.md | 11 +- backend/internal/latest/browser.go | 25 ++-- backend/internal/latest/smoke_image_test.go | 9 +- chrome/.env.example | 28 +++++ chrome/docker-compose.yml | 66 ++++++++++ docker-compose.prod.yml | 30 ++--- docker-compose.yml | 80 ++++--------- docs/adr/0005-on-demand-browser.md | 3 + docs/adr/0006-browser-on-the-home-machine.md | 74 ++++++++++++ 14 files changed, 466 insertions(+), 137 deletions(-) create mode 100644 chrome/.env.example create mode 100644 chrome/docker-compose.yml create mode 100644 docs/adr/0006-browser-on-the-home-machine.md 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..664d219 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,95 @@ 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. + +**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 +sed -i "s|^BROWSER_BIND_ADDR=.*|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. + +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. + +**Updating the browser** is independent of the API stack: + +```bash +cd ~/mangaBookmark/chrome && git pull && docker compose up -d --build +``` + +Rebuild is the Chrome upgrade path — the image installs `google-chrome-stable` +unpinned on purpose, because a stale browser is exactly what Cloudflare turns +away. The `chrome-profile` volume survives the rebuild, so the clearance cookies +are reused instead of re-solved. + +--- + ## Updating Pull new code, then rebuild: @@ -272,6 +363,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 +382,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..b2d4d8b 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 512 MiB cap did its job. Read `docker logs bookmark-browser` before raising it: the cap is sized against a measured 645 MiB untuned peak and exists so the kernel never takes the Gitea runner instead. | 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..af561f6 --- /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. +BROWSER_BIND_ADDR=100.x.y.z + +# Clock zone the 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. +# +# 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..7398c86 --- /dev/null +++ b/chrome/docker-compose.yml @@ -0,0 +1,66 @@ +# 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: + # 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 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..318bc35 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -17,23 +17,13 @@ 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`. + # `networks:` here replaces the base file's list entirely, so both must be + # named: `proxy` for Traefik routing, and `db` (defined in the base file) + # to keep reaching Postgres without putting it on `proxy`. `proxy` also + # carries the poller's outbound traffic — `db` is `internal: true`, so a + # container on it alone has no egress at all. networks: - proxy - - browser - db labels: - "traefik.enable=true" @@ -52,10 +42,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..cf86a1d 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,12 @@ 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. The removed `browser` + # network used to be what supplied both. networks: - - browser + - default - db postgres: @@ -102,59 +111,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. -- 2.52.0 From 15382eb603ef6b3a7c526b30ca5072462bf50752 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sun, 9 Aug 2026 15:24:22 +0700 Subject: [PATCH 2/2] Fix review findings from the browser relocation (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose merges `networks:` across override files rather than replacing them, so the prod override's claim that it must re-name every network was false — and the rationale built on it ("proxy carries the poller's egress") was false too. Verified against `docker compose config`: the API renders on db, default and proxy with only `proxy` named here. Egress comes from `default`, which is now the thing a maintainer must not tidy away. chrome/.env.example shipped BROWSER_BIND_ADDR=100.x.y.z as a live value, so `cp .env.example .env && docker compose up` failed with Docker rejecting an invalid IP instead of the guard message both troubleshooting tables promise. Commented out, so the promised message is what you actually get. DEPLOY §7 gains the two steps that were asserted but never instructed: a Tailscale ACL, without which "Tailscale identity is the access control" is aspirational and 9222 is open to every device on the tailnet; and a VPS `free -m` reading before and after, without which the memory this move reclaims cannot be shown. Also drops a change-narration comment and three restatements of measured facts that already have a canonical home. --- DEPLOY.md | 41 +++++++++++++++++++++++++++++---------- REDEPLOY.md | 2 +- chrome/.env.example | 14 ++++++------- chrome/docker-compose.yml | 12 ++++-------- docker-compose.prod.yml | 10 ++++------ docker-compose.yml | 3 +-- 6 files changed, 48 insertions(+), 34 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index 664d219..892f5b5 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -273,6 +273,17 @@ 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 @@ -280,7 +291,7 @@ git clone ~/mangaBookmark && cd ~/mangaBookmark/chrome tailscale ip -4 # -> 100.x.y.z, this machine's tailnet IP cp .env.example .env -sed -i "s|^BROWSER_BIND_ADDR=.*|BROWSER_BIND_ADDR=$(tailscale ip -4)|" .env +echo "BROWSER_BIND_ADDR=$(tailscale ip -4)" >> .env docker compose up -d --build ``` @@ -296,6 +307,21 @@ 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 @@ -337,16 +363,11 @@ 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. -**Updating the browser** is independent of the API stack: +Finally, take the VPS `free -m` reading again and compare it against the one +from the top of this section. -```bash -cd ~/mangaBookmark/chrome && git pull && docker compose up -d --build -``` - -Rebuild is the Chrome upgrade path — the image installs `google-chrome-stable` -unpinned on purpose, because a stale browser is exactly what Cloudflare turns -away. The `chrome-profile` volume survives the rebuild, so the clearance cookies -are reused instead of re-solved. +**Updating the browser** is independent of the API stack and has its own +runbook — `REDEPLOY.md` §8. --- diff --git a/REDEPLOY.md b/REDEPLOY.md index b2d4d8b..102fade 100644 --- a/REDEPLOY.md +++ b/REDEPLOY.md @@ -457,7 +457,7 @@ and skipped, stored covers still served. | 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 512 MiB cap did its job. Read `docker logs bookmark-browser` before raising it: the cap is sized against a measured 645 MiB untuned peak and exists so the kernel never takes the Gitea runner instead. | +| `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/chrome/.env.example b/chrome/.env.example index af561f6..f77bf50 100644 --- a/chrome/.env.example +++ b/chrome/.env.example @@ -13,14 +13,14 @@ # # 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. -BROWSER_BIND_ADDR=100.x.y.z +# 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. 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. +# 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 diff --git a/chrome/docker-compose.yml b/chrome/docker-compose.yml index 7398c86..59181a6 100644 --- a/chrome/docker-compose.yml +++ b/chrome/docker-compose.yml @@ -17,14 +17,10 @@ services: container_name: bookmark-browser 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. + # 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. diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 318bc35..7c8e628 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -17,14 +17,12 @@ services: bookmark-api: # Traffic arrives over the Traefik network, not a published port. ports: !reset [] - # `networks:` here replaces the base file's list entirely, so both must be - # named: `proxy` for Traefik routing, and `db` (defined in the base file) - # to keep reaching Postgres without putting it on `proxy`. `proxy` also - # carries the poller's outbound traffic — `db` is `internal: true`, so a - # container on it alone has no egress at all. + # 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 - - db labels: - "traefik.enable=true" - "traefik.docker.network=${PROXY_NETWORK:-proxy}" diff --git a/docker-compose.yml b/docker-compose.yml index cf86a1d..7265963 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,8 +86,7 @@ services: - "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. The removed `browser` - # network used to be what supplied both. + # — which would silently kill every poller fetch. networks: - default - db -- 2.52.0