Park self-host-overpass spec under docs/ideas

The interim proxy (#239/#240/#242/#243) covers the day-one needs for
Overpass — User-Agent compliance via server-side proxy, same-origin +
rate limit + cache with coalescing, bbox quantization, observability.
Further work on self-hosting the upstream is no longer urgent.

Move the full OpenSpec artifact set out of openspec/changes/ so it
doesn't clutter the active change list, and park it under
docs/ideas/self-host-overpass/ as a reference for when we revive it.
Adds a short README at the new location capturing:
- current interim solution
- triggers that would justify reviving (rate limits, >1 req/s, etc.)
- key decisions already made (Hetzner-to-Hetzner firewall model,
  DOCKER-USER chain, capacity ceiling at DACH on current box)
- how to switch when the time comes (flip OVERPASS_URL, no client
  changes)

The files were never committed in the first place (WIP in the working
tree through the proposal session), so no git history to preserve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-19 07:40:44 +02:00
parent b92367147f
commit c4874dc04c
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
9 changed files with 419 additions and 0 deletions

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-17

View file

@ -0,0 +1,52 @@
# self-host-overpass (parked)
Full OpenSpec artifact set (`proposal.md`, `design.md`, `specs/`, `tasks.md`)
for hosting our own Overpass API. Moved here from `openspec/changes/` so it
does not clutter the active change list; revive by moving the directory back
under `openspec/changes/` when ready to implement.
## Status
**Parked.** The interim proxy work (`/api/overpass``overpass.private.coffee`,
see `apps/planner/app/routes/api.overpass.ts`) covers the day-one needs:
User-Agent compliance, same-origin check, rate limiting, server-side cache
with coalescing, bbox quantization, Grafana observability. That buys us time
before we need to own the upstream.
## When to revive
Revisit once **any** of these is true:
- private.coffee rate-limits our traffic or changes policy
- Our query volume makes continued use of a free public instance feel like
abuse (rule of thumb: >1 req/s sustained)
- We want POI coverage outside regions private.coffee happens to import
- Privacy posture requires full control of the upstream
## Key decisions already made
- **Topology**: Overpass runs on the maintainer's second Hetzner box
(FSN1, dedicated, i7-2600, 32 GB, 2×3 TB HDD, 1.8 TB free). Both hosts
share Hetzner's internal backbone with ~1 ms RTT.
- **Access control**: host-level firewall via nftables `DOCKER-USER` chain
(Docker bypasses `INPUT` when publishing ports, which is the classic
gotcha). Only the Planner host's egress IP is allowed on the Overpass
port. Planner host IP lives in `/etc/overpass/planner-ip.env` on the
Overpass box, **not** in the repo. Tailscale / WireGuard / vSwitch kept
as future hardening options.
- **Capacity ceiling on current hardware**: DACH extract fits comfortably
in the 23 GB of RAM available on the Overpass box. Europe+ would need
different hardware (AX52 ≈ €54/mo for a dedicated 64 GB NVMe box that
handles planet at low user counts; see design.md).
- **Switch path**: no client changes needed to cut over — the Planner
proxy already reads `OVERPASS_URL` from env and defaults to
private.coffee. Flipping the env var points at our own instance.
## What's in the folder
- `proposal.md` — why / what / impact
- `design.md` — topology, firewall pattern, capacity analysis, risks
- `specs/` — delta specs (would land against `overpass-hosting`,
`osm-poi-overlays`, `infrastructure`, `rate-limiting`)
- `tasks.md` — 10 groups of implementation tasks
- `.openspec.yaml` — OpenSpec scaffolding; keep for when it comes back

View file

@ -0,0 +1,131 @@
## Context
The Planner's POI overlays issue Overpass QL queries from the browser to `overpass.kumi.systems` (primary) and `overpass-api.de` (fallback) via `apps/planner/app/lib/overpass.ts`. These are community-run services with shared rate limits; during the alpha we will quickly exceed what is socially acceptable for a free public resource, and every browser tab that opens the Planner leaks its viewport coordinates to those third-party hosts.
BRouter is already self-hosted and already demonstrates the access-restriction pattern we want to reuse: the browser never calls BRouter directly — it POSTs to the Planner server's `/api/route`, which rate-limits by session and then calls `http://brouter:17777` on the compose-internal Docker network. BRouter has no `ports:` mapping and no Caddy route, so it simply isn't reachable from the public internet. No token, no CORS — topology is the boundary.
Two constraints shape the Overpass design differently from BRouter:
- The existing Hetzner compose host has 40 GB of SSD; the BRouter segment files fit comfortably but a Germany Overpass DB is already ~4060 GB and Europe is ~200350 GB. Running Overpass next to BRouter is not viable.
- The maintainer has a second Hetzner server in the same datacenter (FSN1) with 1.8 TB free. Running Overpass there is cheap and generous, but it puts the backend on a *different host* — so the compose-internal network that protects BRouter doesn't exist naturally, and we need an equivalent private network between the two Hetzner boxes.
## Goals / Non-Goals
**Goals:**
- Run our own Overpass endpoint on the dedicated server
- Make the Planner use that endpoint exclusively for POI queries
- Keep the endpoint off the public internet — same security posture as BRouter today
- Keep POI latency comparable to or better than current public endpoints
- Keep the instance up-to-date via daily diff replication
**Non-Goals:**
- Serve Overpass publicly to other apps outside trails.cool
- Host Overpass on the Hetzner compose host (won't fit)
- Replace the Overpass stack with a custom engine — we want query-level compatibility
- Add per-user quotas (Planner is anonymous; per-session is enough)
## Decisions
### Topology: host-level firewall allowlist between two Hetzner boxes
**Decision:** Keep both hosts on their existing Hetzner public IPs (no overlay, no vSwitch). On the Overpass host, configure nftables so the Overpass port only accepts connections from the Planner host's egress IP; drop everything else. The Planner server proxy calls the Overpass host directly over Hetzner's internal backbone, which routes the traffic at ~1 ms RTT and never leaves Hetzner's network.
**Alternatives considered:**
- **Tailscale overlay** — stronger (encrypted transport, resilient to IP changes, MagicDNS hostnames) but adds a daemon on both hosts and a third-party control plane. Useful as a future hardening step, not needed for v1.
- **Self-hosted WireGuard** — same as Tailscale minus the third party, more key-management work.
- **Hetzner vSwitch** — native Hetzner VLAN-tagged private network between dedicated and Cloud. Free, no daemons, but requires Robot + Cloud configuration and a VLAN sub-interface on the dedicated server.
- **Public Caddy + mTLS** — exposes an Overpass URL publicly behind client certs; more moving parts than we need.
- **Bearer token + public endpoint** — tokens in env vars are operationally awkward and don't add anything over a firewall allowlist for the server-to-server case.
**Why firewall allowlist:** It is the smallest change that reproduces BRouter's "topology is the boundary" property. Traffic already routes inside Hetzner's network (sub-ms RTT confirmed); the only additional thing we need is for the Overpass host's kernel to reject packets that don't originate from the Planner host. No daemon, no control plane, no cert rotation.
**What we give up:** transport is not encrypted (Hetzner backbone, but still), and if the Planner host's egress IP ever changes we have to update the allowlist. Both are acceptable trade-offs for v1; Tailscale is the obvious upgrade path if either bites.
### Making the firewall actually work with Docker
**Decision:** Use the `DOCKER-USER` nftables/iptables chain for the allowlist. Docker runs this chain *before* its own FORWARD rules and will never touch it, so user-defined rules are not clobbered on container restart or daemon reload.
**Why this matters:** Docker publishes ports by inserting rules into `PREROUTING` (DNAT) and `FORWARD` that run *before* the host's `INPUT` chain. A naïve `-A INPUT -p tcp --dport 8080 -j DROP` silently does nothing — the packet is already NATed into the container's network namespace before `INPUT` is consulted. This is a classic gotcha and has bitten us before.
**Rule shape (parameterised by the Planner host IP, which is *not* checked into the repo):**
1. `iptables -I DOCKER-USER -i <wan-iface> -s <planner-host-ip> -p tcp --dport <overpass-port> -j ACCEPT`
2. `iptables -I DOCKER-USER -i <wan-iface> -p tcp --dport <overpass-port> -j DROP`
3. Persist via `iptables-save` + systemd unit or `nftables.conf`.
The Planner host IP lives in an env file on the Overpass host (e.g. `/etc/overpass/planner-ip.env`), loaded by the rule-apply script. Rotating it is a single variable edit and a script rerun.
**Alternatives:**
- **`network_mode: host` on the Overpass container** — skips Docker's NAT entirely; the container listens directly on the host network, so regular `INPUT` rules work. Simpler rule logic, but the container has to bind a specific host port and port conflicts become the operator's problem. Keep as a fallback if `DOCKER-USER` ever surprises us.
- **`iptables=false` in Docker daemon config** — disables Docker's automatic rule management entirely, but then we own every NAT rule manually. Too invasive.
- **Bind the published port to `127.0.0.1` only** — doesn't work here because we need trails.cool to reach it over the public interface.
### Which Overpass image to run
**Decision:** Use the community `wiktorn/overpass-api` Docker image, pinned, wrapped in `infrastructure/overpass-host/`.
**Why:** Most widely deployed, actively maintained, handles PBF import + diff replication out of the box, matches the query surface the browser already uses. Wrapping it in our own directory gives us a single place for configuration.
### Extract size
**Decision:** Start with a Germany (or DACH) extract. The `OVERPASS_PBF_URL` is an env var so switching extracts is a data-load step, not a code change.
**Why:** Disk on the dedicated host is plentiful (~2 TB free) but RAM is the binding constraint. With ~23 GB available on the Overpass host after existing services, Germany (816 GB working set) or DACH (1018 GB) fit comfortably and leave headroom for the page cache. Europe (32+ GB) would evict the host's other services and hit swap — not acceptable on a shared box. Planet (~128 GB) is out of scope for this hardware. Treat DACH as the practical ceiling for this deployment; scaling to Europe+ is a deliberate hardware decision (more RAM, or a different host), not a silent incident.
### Access restriction model
**Decision:** Same two-boundary model as BRouter, adapted for the two-host topology:
1. **Browser → Planner server** (`/api/overpass`):
- Requires Planner session cookie (same cookie the rest of the Planner uses)
- Same-origin / Origin-header check as defense-in-depth
- Per-session rate limit via the existing `packages/rate-limiting` capability
2. **Planner server → Overpass** (Tailscale):
- Overpass binds only to the Tailscale interface
- Planner resolves `overpass.tailnet` over MagicDNS and connects on the private network
- No token required because the network itself is the auth boundary — same logic as BRouter
**What CORS does *not* do here:** CORS only restricts browsers from *reading* cross-origin responses; it doesn't stop `curl` or any non-browser client from sending requests. An `Origin` check at the proxy is worth having, but only as defense-in-depth — the real protection is that Overpass isn't on the public internet.
### Rate limiting
**Decision:** Extend `packages/rate-limiting` with a per-session bucket for `/api/overpass`: ~20 queries/min with a burst of 5. Exceeded requests return 429; the existing POI UI already handles 429.
**Why:** Matches the client's debounced pan/zoom traffic; a scripted misuse hits the ceiling immediately. Mirrors how `/api/route` protects BRouter.
### Data refresh
**Decision:** Use `wiktorn/overpass-api`'s built-in replication pointing at Geofabrik's daily diffs. Nothing to schedule externally.
**Why:** Zero operational overhead; daily staleness is fine for POIs.
### Where the initial load runs
**Decision:** The one-shot PBF import runs on the Overpass host via `docker compose run --rm overpass-loader`, operator-invoked, documented in `infrastructure/overpass-host/README.md`. Replication takes over afterwards.
**Why:** Initial import takes hours and can't block a normal deploy loop; it's a one-time operation.
## Risks / Trade-offs
- **Tailscale dependency** → Tailscale's control plane is a third party. Data plane is peer-to-peer and keeps working during control-plane outages; new connections may fail briefly. Mitigation: monitor via the existing observability stack; consider self-hosted Headscale or WireGuard if we outgrow the Tailscale free tier or want to remove the dependency.
- **Two-host operational burden** → The dedicated server adds a host to keep patched and monitored. Mitigation: minimal compose (Overpass only), same backup/monitoring approach as Hetzner.
- **Shared-host coupling** → The Overpass host is a general-purpose server already running unrelated services. trails.cool now depends on that host's uptime, and any RAM pressure from other services can degrade Overpass query latency (and vice versa). Mitigation: cap the Overpass working set at DACH-scale so it never competes for the last few GB of RAM; monitor available memory on that host alongside Overpass health.
- **Initial import eats hours of maintainer time** → Scripted in `infrastructure/overpass-host/scripts/initial-load.sh`, documented, run once per extract change.
- **Replication drift** → Expose replication lag as a Prometheus metric via the existing observability stack (Prometheus scrapes over Tailscale). Alert at >48 h.
- **Removing public fallback removes resilience** → If our Overpass is down, POI overlays are broken until it's back. Trade-off vs. privacy and reliability; accept it and treat Overpass like BRouter.
- **Blast radius on proxy bugs** → A bug in `/api/overpass` skipping the rate limiter could let a malicious tab exhaust the backend. Mitigation: limiter enforced by middleware, not per-route code; integration tests cover the 429 path.
## Migration Plan
1. Tailscale onboarding: create a tailnet, install Tailscale on the Hetzner host, install on the dedicated server, verify ping over the mesh.
2. On the dedicated server: check out `infrastructure/overpass-host/`, run the one-shot PBF import, start the service, confirm it responds on the Tailscale interface only.
3. Merge the Planner code changes behind an env var (`OVERPASS_URL`) that defaults to the public endpoint; set the env var to the Tailscale URL on Hetzner.
4. Flip the client code to use `/api/overpass`. Monitor error rate and latency.
5. Once stable, remove the public-endpoint fallback and the env-var override, locking in the self-hosted path.
**Rollback:** Change `OVERPASS_URL` back to a public endpoint URL. The dedicated server stays up but unused. No data migration.
## Open Questions
- First-cut extract: Germany only, or DACH? Planet is overkill for alpha even with 1.8 TB of disk.
- Do we add a Grafana panel for Overpass query volume + replication lag in the same PR, or ship the service first and add observability as a follow-up?
- Should the proxy cache Overpass responses (Redis / in-memory) on top of the existing client-side cache, given many Planner sessions pan over the same cities?
- Prometheus scraping over Tailscale: scrape from the Hetzner Prometheus across the tailnet, or run a local exporter on the Overpass host and push?

View file

@ -0,0 +1,34 @@
## Why
The Planner's POI overlays depend on public Overpass API instances (`overpass.kumi.systems`, `overpass-api.de`) called directly from the browser. These instances are community-run, rate-limit aggressively, have no SLA, and every Planner user contends for the same pool — on launch we would be abusing someone else's free service and relying on it to stay up. Hosting our own Overpass keeps the Planner working under load, lets us tune rate limits for our traffic, and lets us stop leaking our users' viewport coordinates to a third party. The endpoint must be locked to Planner traffic only so we are not inadvertently running a free public Overpass mirror for the rest of the internet.
## What Changes
- Overpass runs on a **separate Hetzner server** the maintainer already owns in FSN1 (1.8 TB free disk), not on the existing Hetzner compose host, since the latter is storage-constrained (40 GB SSD) while Germany alone is ~4060 GB and Europe is ~200350 GB
- The Overpass host runs a **host-level firewall (nftables) that only accepts traffic from the Planner host's egress IP** on the Overpass port. Both hosts sit in Hetzner FSN1 and measured RTT is ~1 ms over Hetzner's internal backbone, so no overlay network is needed for latency. The firewall allowlist gives us the same "only trails.cool can reach it" property as a VPN, with zero extra daemons. No public Caddy route, no public DNS record — the port exists but all packets from any other source are dropped at the kernel.
- Tailscale/WireGuard remains on the table as a future hardening step (transport encryption, IP-rotation resilience) and is explicitly documented as a migration path.
- On the Overpass host: a small Docker Compose file running a community Overpass image with a configurable OSM extract URL and built-in diff replication for daily updates
- Planner server gains an `/api/overpass` proxy route that forwards queries to `http://<overpass-host>:8080` on the private network — mirrors how `/api/route` already proxies to the internal BRouter
- Planner browser code switches from public Overpass endpoints to the Planner's own `/api/overpass` — the two public endpoints are removed from the fallback list
- Access restriction is **exactly the BRouter model**, just extended across two hosts: browser → Planner (same-origin + session + rate limit) → private network → backend. CORS/origin checks on the proxy are defense-in-depth, not the primary boundary.
- Rate limiting: per-session budget on the proxy route (e.g. 20 queries/min) layered on top of the existing `packages/rate-limiting` capability
- **BREAKING**: `apps/planner/app/lib/overpass.ts` stops talking to public Overpass hosts
## Capabilities
### New Capabilities
- `overpass-hosting`: Operate a self-hosted Overpass API instance fed from a regional OSM extract with nightly diff updates, fronted only by an authenticated planner proxy route. Covers the container, the data import and refresh lifecycle, the proxy route contract, and the access-restriction model.
### Modified Capabilities
- `osm-poi-overlays`: POI queries now route through the Planner's `/api/overpass` proxy instead of public Overpass endpoints. The public-endpoint fallback requirement is removed.
- `infrastructure`: Adds a new long-running service (Overpass) to the compose stack, with a volume for the OSM database, a separate CI/CD workflow for the initial data load, and monitoring hooks for replication lag.
- `rate-limiting`: Adds a per-session bucket for `/api/overpass` to protect the underlying service.
## Impact
- **Code**: `apps/planner/app/lib/overpass.ts` (endpoint change), new route `apps/planner/app/routes/api.overpass.ts`, `packages/rate-limiting` integration for the new route
- **Infrastructure (existing Hetzner host)**: Add `OVERPASS_URL` env var to the Planner container pointing at the Overpass host. No new compose service on this host.
- **Infrastructure (Overpass Hetzner host)**: New `infrastructure/overpass-host/` directory with its own compose file, Dockerfile wrapper, nftables ruleset (parameterised by the Planner host IP — not checked in), and one-shot data-load script. Deployed independently of the existing CD pipelines.
- **Data**: With 1.8 TB of headroom we can run an extract as large as Europe (~200350 GB) or even planet without redesign. Start with the extract that matches current user base and scale later — the choice is a data-load decision, not a schema decision.
- **Privacy**: Removes third-party leakage of viewport coordinates to public Overpass hosts; aligns with the Planner's privacy-first principle.
- **Rollback**: If the self-hosted instance is unhealthy, we can point `/api/overpass` back at a public endpoint via env var — but the long-term contract is that public endpoints are no longer used.

View file

@ -0,0 +1,34 @@
## ADDED Requirements
### Requirement: Separate Overpass host
The trails.cool infrastructure SHALL include a second Hetzner host, distinct from the existing compose host, dedicated to running the Overpass service and its OSM data volume.
#### Scenario: Host isolation
- **WHEN** the operator provisions the Overpass host
- **THEN** it runs only the Overpass stack (plus its firewall/tooling) and shares no filesystem, database, or container with the existing trails.cool compose host
#### Scenario: Host-specific configuration lives in its own directory
- **WHEN** a change is made to the Overpass host configuration
- **THEN** the change is contained to a dedicated `infrastructure/overpass-host/` directory (compose file, Dockerfile wrapper, firewall rule template, load scripts) and does not touch the existing compose stack
### Requirement: Planner-side configuration for the Overpass proxy
The Planner container SHALL read the Overpass endpoint from an environment variable so the target host can be swapped without code changes.
#### Scenario: Endpoint configurable
- **WHEN** the operator sets `OVERPASS_URL` on the Planner container to any reachable Overpass endpoint
- **THEN** the Planner proxy route forwards queries to that endpoint without a rebuild or code change
#### Scenario: Missing configuration handled gracefully
- **WHEN** `OVERPASS_URL` is unset or empty
- **THEN** the Planner proxy responds with a service-unavailable status and a log message, rather than contacting an unintended default
### Requirement: Overpass observability
The infrastructure SHALL surface basic Overpass health signals via the existing Prometheus / Grafana stack.
#### Scenario: Service up metric
- **WHEN** Prometheus scrapes the overpass service (directly or via a sidecar exporter / blackbox probe)
- **THEN** an `overpass_up` gauge reflects whether the service is responding to health checks
#### Scenario: Replication lag metric
- **WHEN** Prometheus scrapes the overpass replication state
- **THEN** a metric reports the age of the most recently applied OSM diff, so alerts can fire when lag exceeds 48 hours

View file

@ -0,0 +1,16 @@
## MODIFIED Requirements
### Requirement: Overpass rate limit handling
The Planner SHALL handle Overpass API rate limits gracefully. POI queries SHALL be sent to the Planner's own `/api/overpass` proxy route, not to any public Overpass endpoint.
#### Scenario: Rate limited response
- **WHEN** the `/api/overpass` proxy returns a 429 status (either from the proxy's own session rate limiter or propagated from the upstream Overpass service)
- **THEN** the Planner shows a temporary "POI data unavailable — try again shortly" message and retries with exponential backoff
#### Scenario: Overpass unavailable
- **WHEN** the `/api/overpass` proxy is unreachable or returns a 5xx status
- **THEN** the Planner shows a message and tile overlays continue to function normally
#### Scenario: No fallback to public endpoints
- **WHEN** the `/api/overpass` proxy returns any error
- **THEN** the Planner does NOT fall back to a public Overpass endpoint; the error surfaces to the user via the existing POI error UI

View file

@ -0,0 +1,76 @@
## ADDED Requirements
### Requirement: Self-hosted Overpass service
The trails.cool infrastructure SHALL run an Overpass API instance as a Docker service on a dedicated host, populated from a configurable regional OpenStreetMap extract, and reachable only from the Planner host.
#### Scenario: Service reachable from the Planner host
- **WHEN** the Planner server sends an Overpass query from its configured egress address to the Overpass host on the configured port
- **THEN** the request is accepted and served
#### Scenario: Service not reachable from any other source
- **WHEN** any other host on the public internet attempts to connect to the Overpass host's Overpass port
- **THEN** the connection is dropped by the host firewall and the Overpass service never sees the packets
#### Scenario: Regional extract is configurable
- **WHEN** the operator sets a different `OVERPASS_PBF_URL` and runs the initial-load procedure
- **THEN** the service comes up populated from that extract without code changes
### Requirement: Firewall compatible with Docker
The Overpass host firewall SHALL enforce the allowlist in a way that survives Docker daemon restarts, container restarts, and port-publication rule changes — i.e. user rules MUST be placed on the chain Docker reserves for user-managed filtering rather than relying on chains that Docker bypasses when publishing container ports.
#### Scenario: Rule survives container restart
- **WHEN** the Overpass container is stopped and started
- **THEN** the firewall allowlist is still in effect and unauthorised sources are still dropped without operator intervention
#### Scenario: Rule survives Docker daemon restart
- **WHEN** the Docker daemon on the Overpass host is restarted
- **THEN** the firewall allowlist is still in effect and unauthorised sources are still dropped without operator intervention
#### Scenario: Planner host address change
- **WHEN** the Planner host's egress address changes and the operator updates the configured allowlist address
- **THEN** applying the updated ruleset restores Planner connectivity without requiring Docker or Overpass to restart
### Requirement: Overpass data refresh
The Overpass service SHALL keep its OSM database current by applying upstream diffs on a recurring schedule without manual intervention after the initial import.
#### Scenario: Daily replication
- **WHEN** 24 hours have passed since the last diff application
- **THEN** the container has fetched and applied the next diff from the upstream provider, and the replication timestamp advances
#### Scenario: Recoverable replication failure
- **WHEN** diff replication fails once (network blip, upstream 5xx)
- **THEN** the container retries on its next scheduled interval without requiring an operator to restart it
#### Scenario: Replication lag observable
- **WHEN** replication has been failing for more than 48 hours
- **THEN** a monitoring signal indicates the service is stale so the operator can investigate
### Requirement: Planner Overpass proxy route
The Planner server SHALL expose an authenticated, rate-limited proxy route that forwards Overpass QL queries to the Overpass host. This SHALL be the only path through which Overpass is reachable from outside the Overpass host.
#### Scenario: Forward valid query
- **WHEN** an authenticated Planner browser session POSTs a valid Overpass QL query to `/api/overpass`
- **THEN** the proxy forwards the query to the Overpass service and returns the upstream response body and status
#### Scenario: Reject unauthenticated request
- **WHEN** a request arrives at `/api/overpass` without a valid Planner session cookie
- **THEN** the proxy responds with HTTP 401 and does not contact the Overpass service
#### Scenario: Reject cross-origin request
- **WHEN** a request arrives at `/api/overpass` with an Origin header not matching the Planner's own origin
- **THEN** the proxy responds with HTTP 403 and does not contact the Overpass service
#### Scenario: Rate limit exceeded
- **WHEN** a session sends more Overpass queries than the configured per-session limit allows within the rate-limit window
- **THEN** the proxy responds with HTTP 429 and does not contact the Overpass service
### Requirement: Initial data load is out-of-band
The initial import of the regional OSM extract into the Overpass database SHALL NOT run as part of a normal deploy and SHALL NOT block routine container restarts once the data volume is populated.
#### Scenario: First-time setup
- **WHEN** the operator provisions a new Overpass host with an empty data volume
- **THEN** a documented one-shot procedure (e.g. a compose-run command) performs the initial PBF download and import, and exits cleanly
#### Scenario: Routine restart
- **WHEN** the `overpass` service is restarted with an already-populated data volume
- **THEN** the service comes up and is query-ready without re-importing data

View file

@ -0,0 +1,12 @@
## ADDED Requirements
### Requirement: Overpass proxy rate limit
The Planner SHALL limit Overpass queries on the `/api/overpass` proxy route to 20 per session per minute, with a burst allowance of 5.
#### Scenario: Overpass rate limit exceeded
- **WHEN** a session sends more than 20 Overpass queries within 60 seconds (beyond the burst allowance)
- **THEN** the server responds with 429 and the request is NOT forwarded to the upstream Overpass service
#### Scenario: Normal browsing within limit
- **WHEN** a session pans and zooms the map at a realistic pace (well under 20 queries/minute)
- **THEN** all queries are forwarded to the upstream Overpass service without rate-limit rejections

View file

@ -0,0 +1,62 @@
## 1. Overpass host Docker image
- [ ] 1.1 Create `infrastructure/overpass-host/` with a `Dockerfile` wrapping a pinned `wiktorn/overpass-api` release
- [ ] 1.2 Add `infrastructure/overpass-host/scripts/initial-load.sh` that downloads the PBF from `OVERPASS_PBF_URL` and runs the one-shot import
- [ ] 1.3 Document the first-time setup (initial import command, expected duration, disk footprint) in `infrastructure/overpass-host/README.md`
## 2. Overpass host compose
- [ ] 2.1 Add `infrastructure/overpass-host/docker-compose.yml` with the `overpass` service: image, named volume for the OSM DB, env vars (`OVERPASS_PBF_URL`, `OVERPASS_DIFF_URL`, `OVERPASS_META` as needed), healthcheck, restart policy, explicit published port binding on the public interface
- [ ] 2.2 Add the named volume for the OSM database and document the expected disk footprint for the chosen extract
## 3. Firewall (Docker-aware)
- [ ] 3.1 Write an nftables / iptables rule template using the `DOCKER-USER` chain: ACCEPT from `<PLANNER_HOST_IP>` to the overpass port on the public interface, DROP everything else on that port
- [ ] 3.2 Load the Planner host IP from a local env file on the Overpass host (e.g. `/etc/overpass/planner-ip.env`) — do NOT check the IP into the repo
- [ ] 3.3 Add a `scripts/apply-firewall.sh` that renders the rule template, applies it, and persists across reboots (systemd unit or `nftables.conf`)
- [ ] 3.4 Verify rules survive `systemctl restart docker` and `docker compose restart` without clobbering
- [ ] 3.5 Verify an outside host (e.g. laptop home IP) cannot connect to the overpass port; verify the Planner host can
## 4. Planner proxy route
- [ ] 4.1 Create `apps/planner/app/routes/api.overpass.ts` as a React Router action that accepts POST with an Overpass QL body
- [ ] 4.2 Read the upstream URL from `OVERPASS_URL` env var; return 503 with a clear log message when unset or empty
- [ ] 4.3 Enforce session + same-origin: reuse the Planner's existing session cookie check; reject cross-origin with 403
- [ ] 4.4 Stream the upstream response body and status back to the caller; pass through 429s as-is
- [ ] 4.5 Add unit tests covering 401 (no session), 403 (cross-origin), 503 (unset `OVERPASS_URL`), and happy-path forwarding (mock upstream)
## 5. Planner compose wiring
- [ ] 5.1 Add `OVERPASS_URL` to the planner service env in `infrastructure/docker-compose.yml`, pointing at the Overpass host's URL (value held in the SOPS-encrypted env file, not hard-coded)
- [ ] 5.2 Update `cd-infra.yml` SCP sources list if any new files under `infrastructure/` are added
## 6. Rate limiting
- [ ] 6.1 Add an Overpass proxy limiter to `packages/rate-limiting` (or the Planner equivalent) at 20 queries/session/min with a burst of 5
- [ ] 6.2 Return 429 when exceeded; never contact upstream Overpass on rejected requests
- [ ] 6.3 Add a test that rapid-fire requests from one session hit 429 and no upstream call is made
## 7. Planner client switch
- [ ] 7.1 Change `apps/planner/app/lib/overpass.ts` to POST to `/api/overpass` instead of iterating over `OVERPASS_ENDPOINTS`
- [ ] 7.2 Remove the `OVERPASS_ENDPOINTS` constant and the public-endpoint fallback loop
- [ ] 7.3 Update `apps/planner/app/lib/overpass.test.ts` to reflect the new single-endpoint path
- [ ] 7.4 Verify the existing POI error UI (rate-limit banner, unavailable message) still fires on 429 / 5xx from the proxy
## 8. Observability
- [ ] 8.1 Expose an `overpass_up` probe (HTTP healthcheck wrapped as a Prometheus metric — either a blackbox probe or a small sidecar)
- [ ] 8.2 Expose a replication-lag metric derived from the Overpass `replicate_id` / timestamp
- [ ] 8.3 Add a Grafana panel (or extend an existing dashboard) showing Overpass up/down and replication lag
## 9. Documentation
- [ ] 9.1 Update `docs/architecture.md` to reflect that POI queries go via Planner → Overpass host over a firewall-restricted public route
- [ ] 9.2 Update the Planner README (or equivalent) to note the new `OVERPASS_URL` dependency and the one-time initial-load step
- [ ] 9.3 Update the Journal privacy manifest to remove references to third-party Overpass hosts
## 10. Cutover
- [ ] 10.1 Initial import on the Overpass host via the documented one-shot procedure; confirm query works end-to-end from the Planner UI
- [ ] 10.2 Monitor Sentry, Grafana Overpass panel, and error rate in the Planner POI UI for 24 h
- [ ] 10.3 Remove any transitional feature flag; public Overpass endpoints are no longer referenced anywhere in the repo