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 was merged in pull request #52.
This commit is contained in:
@@ -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