Closes #46 once deployed. The headless browser leaves the API stack and becomes its own compose unit (`chrome/docker-compose.yml`) intended for the home machine, reached over the tailnet. No fallback sidecar is left on the VPS. The backend needs no code change — `BROWSER_WS_URL` was already the only coupling. Its default is now empty rather than a pinned Docker IP, so an unconfigured or unreachable browser degrades exactly as it always has: plain-TLS libraries unaffected, kagane/novelfull logged and skipped, stored covers still served. ### What shipped - `chrome/docker-compose.yml` + `chrome/.env.example` — the browser unit, with the CDP port bound to `${BROWSER_BIND_ADDR}` (no default) and the resource limits from the epic: 512 MiB / 1 GiB memory+swap, `oom_score_adj 800`, halved CPU weight, shm 1 GiB -> 128 MiB. - API stack drops the service, its `depends_on` and the `browser` network. - `bookmark-api` gains the `default` network. Dropping `browser` had left it on `db` alone, which is `internal: true` — no published port and, worse, no egress for the poller at all. Caught by actually bringing the stack up. - ADR-0006 for the topology; `DEPLOY.md` §7 for first-time setup of the browser machine; `REDEPLOY.md` §8 for its independent update cadence; architecture diagrams, config tables and troubleshooting rows across README/AGENTS/env. ### Verified locally - Browser unit builds and runs: Chrome 151, UA carries no `HeadlessChrome`, all limits applied as declared. - **Live smoke passes through the new unit**: `TestSmokeKaganeImage` fetched 56710 bytes of `image/webp`, `TestSmokeKaganeGet` got a 200 with a real chapter list. The challenge cleared under the reduced 128 MiB shm. - Bind isolation proven: refused on the host's non-loopback address, accepted on the configured one. - 321 MiB peak of the 512 MiB cap after a full solve; 0 restarts, no OOM kill. - API stack comes up clean, `/healthz` 200; egress confirmed present on `default` and absent on `db`. - `go test ./...`, `go vet`, `gofmt` clean. ### Left to the operator Provisioning the home machine, the Tailscale ACL, setting `BROWSER_WS_URL` in production, and observing acceptance criteria 5-7 (covers with the machine off, several days of zero OOM/restarts, VPS memory improvement). `DEPLOY.md` §7 now carries the before/after `free -m` reading those need. Reviewed-on: #52 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit is contained in:
+19
-21
@@ -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.
|
||||
|
||||
@@ -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://<host>:<port> go test -run TestSmokeKagane ./internal/latest`
|
||||
Live CDP proof (needs that browser and network, skipped otherwise):
|
||||
`SMOKE_BROWSER_WS_URL=ws://<ip>:<port> 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.
|
||||
|
||||
|
||||
@@ -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 <this repo> ~/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://<this machine's LAN IP>: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 <machine>`, 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`.
|
||||
|
||||
|
||||
@@ -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://<tailnet IP>: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
|
||||
|
||||
+59
-5
@@ -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://...<new uuid>"}
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
+7
-4
@@ -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 `<img>` on the web UI's
|
||||
|
||||
@@ -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/<uuid>, 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")
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
+10
-20
@@ -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:
|
||||
|
||||
+21
-58
@@ -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://<home machine tailnet IP>: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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user