# Architecture > Audience: operators running IRDB, and engineers building replacement > frontends. Read this first if you're new to the codebase. ## System overview IRDB is an **IP reputation database** — it ingests abuse reports about specific IP addresses, applies a decaying weighted score per category, and distributes tailored block lists to firewalls and proxies. Reporters (web servers, IDS, fail2ban-style agents) push events; each **consumer** (firewall, proxy) pulls a block list shaped by a named **policy** that decides which categories and thresholds count. The system is shipped as a small Docker Compose stack. Reporters and consumers talk JSON to the `api` over an authenticated REST surface; human operators use a thin PHP UI that calls the same api with a service token plus an impersonation header. The split is deliberate: the UI is replaceable, the api is the contract. ## Container topology ```mermaid flowchart LR subgraph clients[Clients] rep[Reporters
web/IDS/fail2ban] con[Consumers
firewalls/proxies] adm[Admins
browser] end subgraph stack[Compose stack] ui[ui
PHP+Twig BFF
:8080] api[api
Slim+FrankenPHP
:8081] migrate[migrate
one-shot Phinx] sched[scheduler
busybox crond] mysql[(mysql
optional)] end db[(SQLite or MySQL)] rep -- POST /api/v1/report ----> api con -- GET /api/v1/blocklist --> api adm -- HTTPS --> ui ui -- service token + X-Acting-User-Id --> api sched -- POST /internal/jobs/tick --> api migrate --> db api --> db mysql -.-> db ``` Five services in compose; only two run continuously (`api` + `ui`). `migrate` is one-shot (`restart: "no"`); `scheduler` is opt-in via the `compose.scheduler.yml` overlay; `mysql` is opt-in via uncommented config. Single-host SQLite deployments run with just `migrate` + `api` + `ui`; the data volume is shared between `migrate` and `api`. ## Where state lives | Where | What | |----------------------------------|------------------------------------------------------------| | `irdb-data` Docker volume | SQLite database file `/data/irdb.sqlite`, GeoIP MMDBs at `/data/geoip/`. Owned exclusively by the `api` container; `migrate` mounts it briefly to apply schema changes. | | `mysql-data` Docker volume | Optional MySQL `/var/lib/mysql`. Replaces the SQLite path when `DB_DRIVER=mysql`. | | `ui` container's writable layer | PHP file-backed sessions (`/tmp` per the FrankenPHP base). Tied to a single replica; sticky sessions required if scaling UI horizontally. | | `.env` | All secrets (service token, internal job token, OIDC client secret, MySQL password, local-admin Argon2id hash). | | **Browser localStorage** | Light/dark theme preference only. Session is in a Secure cookie. | Notably absent: the `ui` container has zero persistent state. Replace its image and nothing of operational importance is lost. The api owns everything. ## Stable surfaces vs replaceable parts The contract for future frontends, integrators, and replacement UIs. **Anything in the "stable" column should not break across `v1` minor releases.** | Surface | Stability | Notes | |------------------------------------------------------|----------------|----------------------------------------------------------------------------------------| | `/api/v1/*` paths and request/response shapes | **stable** | Additive changes only within `v1`. Breaking changes ship as `v2`. OpenAPI is canonical. | | Token kinds (`reporter`, `consumer`, `admin`, `service`) | **stable** | Strings are persisted in `api_tokens.kind` and used in audit logs. | | RBAC roles (`viewer`, `operator`, `admin`) | **stable** | Persisted in `users.role` and `api_tokens.role`. | | `users` shape and `oidc_role_mappings` semantics | **stable** | Future SPAs/native UIs upsert via `/api/v1/auth/users/upsert-oidc` the same way the BFF does today. | | Service-token + `X-Acting-User-Id` impersonation | **stable** | The pattern any BFF replacement uses; the API doesn't trust the header without a service token. | | Error envelope `{error, details?}` | **stable** | Validation errors include `details`; auth failures don't. | | Audit `actor_kind` enum | **stable** | `user`, `admin-token`, `reporter`, `consumer`, `system`. Used by external SIEM exporters in future work. | | Twig template names under `ui/resources/views/` | replaceable | Tied to the current UI implementation; replacement UIs render however they like. | | UI route paths under `/app/*` | replaceable | Browser-only; not part of any contract. | | Internal class names, method signatures, file layout | replaceable | Refactoring is fair game. | | `/internal/jobs/*` | replaceable | Scheduler-only; no public guarantee. Not in OpenAPI. | | Twig globals, htmx attributes, Alpine components | replaceable | Implementation details of this UI. | ## Why this split **Backend-for-Frontend (BFF) pattern.** The `api` is a pure JSON service: no HTML, no sessions, no cookies. The `ui` is a thin browser-facing process that owns OIDC handshakes, browser sessions, CSRF, and Twig rendering — but no business logic, no scoring, no database. Every admin page on the UI fetches from the api with the service token plus the acting user's id. Why this matters: 1. **The UI is replaceable.** A team that wants Vue, Svelte, or a Tauri desktop app can replace the `ui` container without touching the api. The contract is the OpenAPI document and the impersonation pattern. See [`frontend-development.md`](./frontend-development.md). 2. **No HTML in the api.** Other clients (firewalls, fail2ban, monitoring) call the same admin endpoints the UI does. If the api started rendering Twig, those integrations would suddenly carry HTML they don't want. 3. **Auditing is honest.** The api records the impersonated user as the actor (`actor_kind=user`) when called via the BFF, never the service token. An admin token used directly records as `actor_kind=admin-token`. Future external authentication paths can plug in alongside without breaking the audit trail. 4. **The api can scale independently.** With MySQL, the api is stateless and can run as N replicas behind a load balancer; the `job_locks` table mediates between them. The UI is typically single-replica because of file-backed sessions. The trade-off: every UI request makes one or more outbound calls to the api, which costs latency and an HTTP hop. In practice the containers run on the same Docker network and the per-request cost is sub-millisecond. ## Disaster Recovery The api owns one stateful resource: the database (SQLite file on a Docker volume, or MySQL). Everything else — sessions, tokens, GeoIP caches — is recoverable without a backup. ### What carries irreplaceable state | Resource | Where | Recovery | |-----------------------------------|-----------------------------|-------------------------------------------| | Reports + scores | `reports`, `ip_scores` tables | DB backup | | Manual blocks + allowlist | `manual_blocks`, `allowlist` | DB backup | | Policies + thresholds | `policies`, `policy_category_thresholds` | DB backup | | Categories | `categories` | DB backup | | Reporters / consumers / users | `reporters`, `consumers`, `users` | DB backup | | API tokens (hashes only) | `api_tokens` | DB backup; **raw values gone — re-issue** | | Audit log | `audit_log` | DB backup | | Service token (`UI_SERVICE_TOKEN`)| `.env` file | Back up `.env` or regenerate | | OIDC client secret | `.env` file | Re-fetch from Entra app registration | | Local admin password hash | `.env` file | Regenerate via `password_hash` | | MaxMind/IPinfo credentials | `.env` file | Re-fetch from provider | | GeoIP MMDBs | `irdb-data` volume | `refresh-geoip` job re-downloads | | Browser sessions | UI container's writable layer | Discarded on restart — users re-login | ### Recovery checklist 1. **Provision** the host: install Docker; restore `.env` (or generate a fresh one — see [`README.md` → Quick start](../README.md#quick-start) and rotate the service token). 2. **Restore the database** per the relevant block in [`README.md` → Backups](../README.md#backups). For SQLite, drop the restored file into the `irdb-data` volume; for MySQL, pipe the dump through `mysql`. 3. **Bring up the stack**: `docker compose up -d`. The `migrate` container runs Phinx idempotently — safe to run on a restored DB that's already at HEAD; it'll exit immediately with "no migrations to run". 4. **Verify** by calling `/healthz` on both containers and signing in to the UI. Trigger `refresh-geoip` from Settings (or accept the first scheduled run) so GeoIP enrichment is populated again. 5. **Re-issue tokens** for any reporter or consumer that lost its raw token (the database has the SHA-256 hash, but the original string isn't recoverable). Consumers will keep failing 401 until they're given new tokens. ### What we DON'T support - **Encryption at rest** of the SQLite file. The volume's host-level disk encryption is the right layer; SPEC §15. - **Audit log signing / tamper-evidence**. The `audit_log` is append-only at the application layer but a sufficiently privileged attacker with DB access can rewrite history. Future work. - **Cross-region replication.** Docker volumes are local; high availability requires a managed MySQL with replication plus an `api` topology that can take advantage of it. ## Where the rest of the docs live - [`api-overview.md`](./api-overview.md) — the public API surface, with worked examples. - [`auth-flows.md`](./auth-flows.md) — every authentication flow (machine, BFF, OIDC, local admin), Entra setup, future user-token sketch. - [`frontend-development.md`](./frontend-development.md) — the headline doc for replacement UIs. - [`api-reference.md`](./api-reference.md) — short. Pointer to OpenAPI plus the things OpenAPI doesn't cleanly express (rate limits, ETag semantics, impersonation header, response conventions). The OpenAPI document itself is at `api/public/openapi.yaml` and served at `/api/v1/openapi.yaml` with a viewer at `/api/docs`.