# 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`.