# hrafn Browser-fetch service — a genuine Chromium kept behind a REST API so consumers never have to embed a browser themselves. It exists because bot-gated sites (Reddit first) 403 every non-browser client; hrafn is the one place in the fleet that keeps a real browser warm. **Deploys to:** - **ana-docker** (10.250.50.70) — internal-only, no published host port. Consumers reach it at `http://hrafn:8080` on `traefik-net`. Built and handed to infra-ops for uptime ownership by nevermore-claude, 2026-08-22. Upstream source: `vh/hrafn` (private gitea repo). ## Reaching it There is **no host port** — this is deliberate (contract § Deployment). Only containers on `traefik-net` can talk to it: ```bash # from another container on traefik-net curl -H "Authorization: Bearer $HRAFN_TOKEN" \ -X POST http://hrafn:8080/v1/fetch -d '{"url":"https://example.com"}' # from the host, for diagnostics only ssh infra-ops@10.250.50.70 \ 'sudo docker run --rm --network traefik-net curlimages/curl:latest \ -s -o /dev/null -w "%{http_code}\n" http://hrafn:8080/readyz' ``` `/readyz` and the other health endpoints are unauthenticated; `POST /v1/fetch` requires the bearer token. ## Credentials `HRAFN_TOKEN` lives in `/opt/docker/compose/hrafn/.env` on the host and is vaulted at `ana-docker/hrafn/bearer-token` (secrets-broker). The `.env` is **0600** — it was 0644 on handoff, which exposed a live bearer token to every local account on the box; tightened 2026-08-22. Keep it 0600. ## Operational notes - **`shm_size: 1gb` is load-bearing.** Chromium crashes on some pages with Docker's 64 MB default. Don't trim it to save memory. - **Playwright and the base image are version-coupled.** Base is `mcr.microsoft.com/playwright/python:v1.62.0-noble` with playwright pinned to `1.62.0`. Bump both together or the browser binary and the client library disagree. - **SSRF is denied by default** — private and reserved targets are refused unless listed in `HRAFN_PRIVATE_ALLOWLIST`, which is empty. Leave it empty unless there is a specific internal target and a reason. - **`restart: unless-stopped` does not act on the healthcheck.** Docker restarts on *exit*, not on *unhealthy*. A wedged-but-running Chromium will sit there marked unhealthy indefinitely. Beszel on this host reports container status, so that is the alerting path. ## Deploy **No CI deploy is wired.** As of the handoff the image builds from a working tree rsync'd into the compose directory, so `/opt/docker/compose/hrafn/` contains the full source (`src/`, `tests/`, `pyproject.toml`, …) rather than just a compose file. That is a deviation from the house convention and means the running image has no commit provenance. Current update path: ```bash ssh infra-ops@10.250.50.70 cd /opt/docker/compose/hrafn # rsync the updated working tree in, then: sudo docker compose build && sudo docker compose up -d sudo docker compose ps # expect (healthy) ``` ## Known gaps | gap | impact | status | |---|---|---| | No CI deploy | manual rsync + build; no provenance for what is running | offered by nevermore-claude, not yet built | | Source tree in the compose dir | deviates from `/opt/docker/compose//compose.yaml` convention | needs a host-clone or registry-image deploy shape | | No homepage entry | internal-only service, so arguably correct | intentional for now |