|
|
@@ -0,0 +1,798 @@
|
|
|
+# IRDB User Manual
|
|
|
+
|
|
|
+> Audience: operators using the IRDB web UI day-to-day. Covers
|
|
|
+> first-time setup, every screen and field, how the decay engine works,
|
|
|
+> installation, and backup / disaster recovery.
|
|
|
+
|
|
|
+This document is the operational guide. For architecture see
|
|
|
+[`architecture.md`](./architecture.md); for API contracts see
|
|
|
+[`api-overview.md`](./api-overview.md); for authentication wiring see
|
|
|
+[`auth-flows.md`](./auth-flows.md).
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 1. Installation and first run
|
|
|
+
|
|
|
+### 1.1 Prerequisites
|
|
|
+
|
|
|
+- A Linux host with Docker 24+ and the `docker compose` plugin.
|
|
|
+- Outbound HTTPS to download container images and (optionally) GeoIP
|
|
|
+ MMDBs from your chosen provider.
|
|
|
+- A DNS name and TLS certificate if you intend to expose the UI to
|
|
|
+ non-loopback clients (use a reverse proxy — see
|
|
|
+ [`README.md` § Reverse proxy](../README.md#reverse-proxy-in-production)).
|
|
|
+
|
|
|
+### 1.2 Boot sequence (≈5 minutes)
|
|
|
+
|
|
|
+```bash
|
|
|
+git clone <repo-url> irdb && cd irdb
|
|
|
+cp .env.example .env
|
|
|
+$EDITOR .env # fill in the secrets — see § 1.3
|
|
|
+docker compose -f docker-compose.yml -f compose.scheduler.yml up -d
|
|
|
+```
|
|
|
+
|
|
|
+That brings up:
|
|
|
+
|
|
|
+| Container | Port | Role |
|
|
|
+|-------------|------|-------------------------------------------------------------------|
|
|
|
+| `migrate` | — | Runs Phinx migrations + seeds, exits 0. |
|
|
|
+| `api` | 8081 | JSON backend (reporters, consumers, admin endpoints, jobs). |
|
|
|
+| `ui` | 8080 | Browser-facing PHP+Twig BFF. |
|
|
|
+| `scheduler` | — | (Overlay) busybox `crond` posting `/internal/jobs/tick` once/min. |
|
|
|
+| `mysql` | 3306 | (Optional) only if you uncomment the block and set `DB_DRIVER=mysql`. |
|
|
|
+
|
|
|
+Health checks:
|
|
|
+
|
|
|
+- `curl http://localhost:8080/healthz` → UI status (`api_reachable`).
|
|
|
+- `curl http://localhost:8081/healthz` → API status with DB + jobs
|
|
|
+ freshness.
|
|
|
+
|
|
|
+The api validates required env vars on boot, so misconfiguration
|
|
|
+crashes `docker compose up` rather than the first user click.
|
|
|
+
|
|
|
+### 1.3 Required secrets
|
|
|
+
|
|
|
+Every value in `.env` marked as a secret must be generated. The full
|
|
|
+recipe lives in [`README.md` § Generating secrets](../README.md#generating-secrets);
|
|
|
+the key ones are:
|
|
|
+
|
|
|
+| Variable | How to generate |
|
|
|
+|---------------------------|--------------------------------------------------------------------------------------------------|
|
|
|
+| `UI_SECRET` | `openssl rand -hex 32` — signs UI session cookies. |
|
|
|
+| `APP_SECRET` | `openssl rand -hex 32` — signs api-side ETags and similar. |
|
|
|
+| `INTERNAL_JOB_TOKEN` | `openssl rand -hex 32` — Bearer for `/internal/jobs/*`. |
|
|
|
+| `UI_SERVICE_TOKEN` | `irdb_svc_…` — bridge between UI and API (see `.env.example` for the one-liner). |
|
|
|
+| `LOCAL_ADMIN_PASSWORD_HASH` | `php -r "echo password_hash('your-pw', PASSWORD_ARGON2ID);"` — **double every `$` to `$$`** in the .env file so docker-compose doesn't eat it. |
|
|
|
+
|
|
|
+### 1.4 First-time configuration walkthrough
|
|
|
+
|
|
|
+After `docker compose up -d`, sign in to the UI at
|
|
|
+`http://localhost:8080/` with the local admin credentials you set in
|
|
|
+`.env`. The first session lands on the dashboard.
|
|
|
+
|
|
|
+Do these in order — each step depends on the previous one:
|
|
|
+
|
|
|
+1. **Verify defaults.** Open `Categories`. Five seeded categories
|
|
|
+ (`brute_force`, `spam`, `scanner`, `malware_c2`, `web_attack`)
|
|
|
+ should be present, each with `exponential` decay and a 14-day
|
|
|
+ half-life. Open `Policies`; three seeded policies (`strict`,
|
|
|
+ `moderate`, `paranoid`) should be present.
|
|
|
+2. **Create the categories you actually need.** If the seeded set
|
|
|
+ doesn't fit (e.g. you want `phishing`), use `Categories → New`.
|
|
|
+ You can deactivate seeded categories you won't use by opening their
|
|
|
+ detail page and unchecking "active".
|
|
|
+3. **Adjust policy thresholds.** Open `Policies → moderate → Edit`.
|
|
|
+ The threshold matrix has one row per category. Empty value = "this
|
|
|
+ category does not contribute to this policy". The default seed sets
|
|
|
+ every category to `1.0` for `moderate`, `2.5` for `strict`, `0.3`
|
|
|
+ for `paranoid`. Tune to taste; the live preview at the bottom of
|
|
|
+ the edit page shows the resulting blocklist size.
|
|
|
+4. **Create your first reporter.** `Reporters → New`. Pick a name
|
|
|
+ (e.g. `web-prod-01`) and leave `Trust weight` at `1.0`.
|
|
|
+5. **Issue a reporter token.** `Tokens → Issue`, kind = `reporter`,
|
|
|
+ pick the reporter you just made. **A modal shows the raw
|
|
|
+ `irdb_rep_…` token once.** Copy it into your reporter's config
|
|
|
+ immediately — refreshing the page discards it permanently.
|
|
|
+6. **Smoke-test ingestion.** From any host that can reach the api:
|
|
|
+ ```bash
|
|
|
+ curl -X POST http://<api-host>:8081/api/v1/report \
|
|
|
+ -H "Authorization: Bearer irdb_rep_..." \
|
|
|
+ -H "Content-Type: application/json" \
|
|
|
+ -d '{"ip":"203.0.113.42","category":"brute_force"}'
|
|
|
+ # → 202 with {"report_id": 1, ...}
|
|
|
+ ```
|
|
|
+ Refresh the UI dashboard; counters should go up.
|
|
|
+7. **Create your first consumer.** `Consumers → New`. Pick a policy
|
|
|
+ (`moderate` is a good default).
|
|
|
+8. **Issue a consumer token.** `Tokens → Issue`, kind = `consumer`,
|
|
|
+ pick the consumer.
|
|
|
+9. **Smoke-test distribution.**
|
|
|
+ ```bash
|
|
|
+ curl http://<api-host>:8081/api/v1/blocklist \
|
|
|
+ -H "Authorization: Bearer irdb_con_..."
|
|
|
+ # → text/plain, one IP/CIDR per line
|
|
|
+ ```
|
|
|
+10. **Set up scheduling.** If you used `compose.scheduler.yml`, the
|
|
|
+ sidecar is already firing `/internal/jobs/tick` once a minute.
|
|
|
+ Otherwise install the host crontab from `examples/scheduler/`.
|
|
|
+ Without a tick driver, scores never decay.
|
|
|
+11. **Configure GeoIP** (optional). Default provider is **DB-IP**
|
|
|
+ (no credential needed). Switch to MaxMind by setting
|
|
|
+ `GEOIP_PROVIDER=maxmind` + `MAXMIND_LICENSE_KEY`, or to IPinfo
|
|
|
+ with `GEOIP_PROVIDER=ipinfo` + `IPINFO_TOKEN`. Restart the api
|
|
|
+ and trigger `refresh-geoip` from `Settings`.
|
|
|
+12. **Map OIDC groups to roles** (if using Entra). Until the
|
|
|
+ dedicated admin UI ships, populate `oidc_role_mappings` directly:
|
|
|
+ ```bash
|
|
|
+ docker compose exec -T api sqlite3 /data/irdb.sqlite \
|
|
|
+ "INSERT INTO oidc_role_mappings(group_id, role) VALUES('<group-id>', 'admin');"
|
|
|
+ ```
|
|
|
+
|
|
|
+### 1.5 Reverse-proxy and TLS
|
|
|
+
|
|
|
+The default compose deployment serves plain HTTP. Production should
|
|
|
+sit behind a reverse proxy that terminates TLS and routes by
|
|
|
+hostname. A working Caddy config is in
|
|
|
+[`examples/reverse-proxy/Caddyfile`](../examples/reverse-proxy/Caddyfile).
|
|
|
+
|
|
|
+When deploying behind a proxy, set `APP_ENV=production` so HSTS,
|
|
|
+Secure-cookie, and CSRF behave correctly.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 2. Roles and permissions
|
|
|
+
|
|
|
+Three roles are persisted on the user record (`viewer`, `operator`,
|
|
|
+`admin`). They are the source of truth — the UI hides buttons the
|
|
|
+current user can't use, but security is enforced server-side. If a
|
|
|
+viewer crafts a direct URL to a write endpoint, the api returns
|
|
|
+`403`.
|
|
|
+
|
|
|
+| Action | viewer | operator | admin |
|
|
|
+|-------------------------------------------|:------:|:--------:|:-----:|
|
|
|
+| Browse IPs / scores / history | ✓ | ✓ | ✓ |
|
|
|
+| Browse audit log | ✓ | ✓ | ✓ |
|
|
|
+| Add / remove manual blocks | | ✓ | ✓ |
|
|
|
+| Manage allowlist | | ✓ | ✓ |
|
|
|
+| Manage policies / categories | | | ✓ |
|
|
|
+| Manage reporters / consumers | | | ✓ |
|
|
|
+| Manage tokens | | | ✓ |
|
|
|
+| Trigger jobs from Settings | | | ✓ |
|
|
|
+| View Settings (config + jobs) | | | ✓ |
|
|
|
+
|
|
|
+The local admin always has `admin` role. OIDC users get their role
|
|
|
+from `oidc_role_mappings`; the default for unmapped users is
|
|
|
+`OIDC_DEFAULT_ROLE` (`viewer` by default; set to `none` to deny
|
|
|
+login outright).
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 3. UI screens — field-by-field reference
|
|
|
+
|
|
|
+The UI is laid out as a sticky top bar plus a left sidebar. The top
|
|
|
+bar carries the IRDB logo (link to `/app/me`), a theme toggle, and
|
|
|
+the user menu (`My identity` + `Sign out`). The sidebar lists every
|
|
|
+section; the active section is highlighted.
|
|
|
+
|
|
|
+All forms are CSRF-protected. Destructive actions (delete, revoke)
|
|
|
+open a confirmation modal before submitting.
|
|
|
+
|
|
|
+### 3.1 `/login` — Sign-in screen
|
|
|
+
|
|
|
+Shown when no session is active.
|
|
|
+
|
|
|
+| Element | Purpose |
|
|
|
+|----------------------------|-----------------------------------------------------------------------------------|
|
|
|
+| **Sign in with Microsoft** | Initiates the OIDC authorization-code-with-PKCE flow against Entra. Visible when `OIDC_ENABLED=true`. |
|
|
|
+| **Use local sign-in** | Toggle that reveals the username/password form. Visible when `LOCAL_ADMIN_ENABLED=true`. Hidden entirely otherwise. |
|
|
|
+| **Username** field | Plaintext username; must match `LOCAL_ADMIN_USERNAME`. |
|
|
|
+| **Password** field | Validated against `LOCAL_ADMIN_PASSWORD_HASH` (Argon2id) on the UI side. |
|
|
|
+| **Sign in** button | Submits the form. After 5 failures the form is locked for 30 s (per-process; restarting the UI clears the lockout — that is the documented "unlock the admin" path). |
|
|
|
+
|
|
|
+If neither method is enabled, the page shows an error pointing you
|
|
|
+at the env vars to set.
|
|
|
+
|
|
|
+### 3.2 `/no-access`
|
|
|
+
|
|
|
+Shown when an OIDC sign-in succeeded but the user's groups didn't
|
|
|
+match any row in `oidc_role_mappings` and `OIDC_DEFAULT_ROLE=none`.
|
|
|
+Has one button — **Back to sign-in** — and a hint to ask an admin
|
|
|
+to add a mapping.
|
|
|
+
|
|
|
+### 3.3 `/app/dashboard` — Dashboard
|
|
|
+
|
|
|
+Default landing page after login. Refreshes every 30 s on the api
|
|
|
+side (cached); reload the page to fetch fresh numbers.
|
|
|
+
|
|
|
+**Header** — title and a hint reading "Last 24 hours, refreshed every
|
|
|
+30 s. reference policy: moderate". The reference policy comes from
|
|
|
+the api and is currently fixed to `moderate`; it appears in the
|
|
|
+"Active blocks" caveat.
|
|
|
+
|
|
|
+**Counter cards** (four):
|
|
|
+
|
|
|
+| Card | Source |
|
|
|
+|------------------|----------------------------------------------------------------------------------|
|
|
|
+| Active blocks | Distinct IPs in `ip_scores` with score > 0 plus single-IP `manual_blocks`. Approximation under the `moderate` reference policy. |
|
|
|
+| Manual blocks | Total rows in `manual_blocks` across IPs and subnets. |
|
|
|
+| Allowlist entries| Total rows in `allowlist`. |
|
|
|
+| Reports (24h) | Rows in `reports` with `received_at` in the last 24 h, all categories. |
|
|
|
+
|
|
|
+**Reports per hour** chart — Chart.js bar chart, 24 buckets keyed by
|
|
|
+hour (UTC). Hover to read each bucket.
|
|
|
+
|
|
|
+**Top reporters (24h)** and **Top categories (24h)** — leader-board
|
|
|
+tables sorted by report count. Empty if no reports ingested in the
|
|
|
+last day.
|
|
|
+
|
|
|
+**Jobs status** — one row per registered job: name, last status
|
|
|
+pill (`success` / `failure` / `skipped_locked` / `running`), an
|
|
|
+amber **overdue** pill if the job hasn't completed within its
|
|
|
+declared interval, plus the `last_finished_at` timestamp. Read-only
|
|
|
+here; trigger buttons live on the Settings page.
|
|
|
+
|
|
|
+If the api is unreachable, a yellow banner appears at the top and
|
|
|
+the cards/charts are skipped — the UI degrades gracefully.
|
|
|
+
|
|
|
+### 3.4 `/app/ips` — IP search
|
|
|
+
|
|
|
+Paginated table of every IP with a row in `ip_scores`.
|
|
|
+
|
|
|
+**Filter form** (collapsed into a card):
|
|
|
+
|
|
|
+| Field | Purpose |
|
|
|
+|---------------|------------------------------------------------------------------------------------------|
|
|
|
+| **IP / prefix** | Free text (`q`). Matches via `LIKE 'prefix%'` on `ip_text`; `203.0.113.` lists everything in that /24. Substring match (`%foo%`) works but is slower. |
|
|
|
+| **Category** | Limits to IPs scored in the picked category (slug). |
|
|
|
+| **Min score** | Floor on the IP's max score across categories. Decimal. |
|
|
|
+| **Max score** | Ceiling on the IP's max score. |
|
|
|
+| **Country** | Two-letter code. If the api has any enrichment data, becomes a dropdown of seen codes with counts; otherwise a free-text input. |
|
|
|
+| **ASN** | Integer ASN. Filters on the enrichment table. |
|
|
|
+| **Status** | Dropdown — `clean`, `scored`, `manually_blocked`, `allowlisted`. Combines score row presence with the manual-block / allowlist tables. |
|
|
|
+| **Reset / Filter** | Reset clears the form; Filter submits. |
|
|
|
+
|
|
|
+**Results table** — IP (clickable, links to detail), Country flag,
|
|
|
+ASN, top category, max score, last report timestamp, status pill.
|
|
|
+Pagination underneath when total > page size.
|
|
|
+
|
|
|
+**Notes / known limitations**
|
|
|
+
|
|
|
+- IPs that are *only* manually blocked or allowlisted (never reported
|
|
|
+ yet) won't appear in this list — they aren't in `ip_scores`. Use
|
|
|
+ the dedicated `Manual blocks` / `Allowlist` pages.
|
|
|
+- Manual subnets and allowlist subnets aren't expanded for search;
|
|
|
+ only single-IP entries from those tables intersect via the
|
|
|
+ `Status` filter.
|
|
|
+- Country / ASN columns are blank for IPs the enrichment job hasn't
|
|
|
+ processed yet; trigger `enrich-pending` from Settings to backfill.
|
|
|
+
|
|
|
+### 3.5 `/app/ips/{ip}` — IP detail
|
|
|
+
|
|
|
+Reached by clicking an IP from the list, or by direct URL (e.g.
|
|
|
+`/app/ips/203.0.113.42`). Both IPv4 and IPv6 work; Slim's segment
|
|
|
+matcher allows colons via the `{ip:.+}` route.
|
|
|
+
|
|
|
+**Header** — IP in monospace + status pill + back link.
|
|
|
+
|
|
|
+**Action buttons** (operator/admin only):
|
|
|
+
|
|
|
+| Button | Effect |
|
|
|
+|-----------------------------|---------------------------------------------------------------------------------------|
|
|
|
+| **Add to allowlist** / **Remove from allowlist** | Toggles whether this single IP is on the allowlist. Allowlist always wins over scoring and manual blocks. |
|
|
|
+| **Manually block** / **Remove manual block** | Toggles a single-IP `manual_blocks` row. The "Manually block" modal accepts an optional reason and an optional `expires_at` (datetime-local). |
|
|
|
+
|
|
|
+**Enrichment card** — country flag + code, ASN (linked to bgp.he.net
|
|
|
+in a new tab), AS organisation, `enriched_at` timestamp. If no row
|
|
|
+exists yet, says "not yet enriched". Footer carries the data-licence
|
|
|
+attribution required by DB-IP and IPinfo (MaxMind requires no
|
|
|
+attribution).
|
|
|
+
|
|
|
+**Override status card** — shows whether the IP is currently
|
|
|
+allowlisted or manually blocked, with the reason text and creation
|
|
|
+time. Empty otherwise.
|
|
|
+
|
|
|
+**Score per category** — one bar per category the IP has a non-zero
|
|
|
+score in. Bar width = score / max-score-on-this-IP. Suffix shows
|
|
|
+`<score>` with `(<n> in 30d)`.
|
|
|
+
|
|
|
+**History** — most recent 200 events (reports + manual-block adds +
|
|
|
+allowlist adds), newest first. Reports show category, reporter,
|
|
|
+weight, and any metadata (pretty-printed JSON). A footer note
|
|
|
+indicates if older events exist beyond the 200-row cap.
|
|
|
+
|
|
|
+### 3.6 `/app/manual-blocks` — Manual blocks
|
|
|
+
|
|
|
+Lists every row in `manual_blocks`. The sidebar entry "Subnets" goes
|
|
|
+to the same page with `?kind=subnet`.
|
|
|
+
|
|
|
+**Kind filter chips** — `All` / `IPs` / `Subnets`. Each filters the
|
|
|
+listing.
|
|
|
+
|
|
|
+**Add manual block** form (operator/admin only):
|
|
|
+
|
|
|
+| Field | Values |
|
|
|
+|---------------|----------------------------------------------------------------------------------|
|
|
|
+| **Kind** | `Single IP` or `Subnet (CIDR)`. Toggling swaps the visible target field. |
|
|
|
+| **IP** | Visible when Kind = Single IP. Plain v4 or v6 address. |
|
|
|
+| **CIDR** | Visible when Kind = Subnet. e.g. `192.0.2.0/24` or `2001:db8::/48`. Non-canonical input is silently normalised; the response echoes a `normalized_from` note when the input changed. |
|
|
|
+| **Reason** | Free text. Stored verbatim in `manual_blocks.reason` and shown in audit + UI. |
|
|
|
+| **Expires (optional)** | datetime-local. Empty = never expires. Past expiry, the read-time filter excludes the block from blocklists; the daily `cleanup-expired-manual-blocks` job hard-deletes it (one audit row per prune). |
|
|
|
+
|
|
|
+**Listing table** — Kind, target (IP or CIDR), reason, expiry,
|
|
|
+created-at. Operator/admin sees a **Remove** action that opens the
|
|
|
+confirmation modal.
|
|
|
+
|
|
|
+### 3.7 `/app/allowlist` — Allowlist
|
|
|
+
|
|
|
+Same shape as Manual blocks but green-themed. Allowlist entries
|
|
|
+suppress an IP from any blocklist regardless of score and regardless
|
|
|
+of manual-block presence. The `CidrEvaluator` logs a WARNING when
|
|
|
+allowlist and manual-blocks overlap; allowlist wins.
|
|
|
+
|
|
|
+**Add allowlist entry** form (operator/admin only):
|
|
|
+
|
|
|
+| Field | Values |
|
|
|
+|----------|--------------------------------------------------------------------------------------|
|
|
|
+| **Kind** | `Single IP` or `Subnet (CIDR)`. |
|
|
|
+| **IP** / **CIDR** | As per Manual blocks. |
|
|
|
+| **Reason** | Free text. There is no expiry on allowlist entries — remove them manually when no longer needed. |
|
|
|
+
|
|
|
+### 3.8 `/app/policies` — Policies
|
|
|
+
|
|
|
+A policy is a named set of (category, threshold) pairs plus a flag
|
|
|
+controlling whether manual blocks are merged in. The seeded
|
|
|
+`strict` / `moderate` / `paranoid` policies cover the common
|
|
|
+spectrum.
|
|
|
+
|
|
|
+**Listing** — policy name (clickable), description, count of
|
|
|
+configured thresholds, "manual blocks: yes/no". Admin sees Edit and
|
|
|
+Delete buttons. Delete is refused with `409` if any consumer
|
|
|
+references the policy.
|
|
|
+
|
|
|
+**New policy** form (admin only):
|
|
|
+
|
|
|
+| Field | Values |
|
|
|
+|-----------------------------|----------------------------------------------------------------------------------|
|
|
|
+| **Name** | Unique. Freeform, but kebab-ish identifiers work best (used in audit logs). |
|
|
|
+| **Description** | Optional, free text. |
|
|
|
+| **include manual blocks** | Checkbox; checked by default. When checked, every entry in `manual_blocks` (subject to allowlist) appears in this policy's blocklist. |
|
|
|
+
|
|
|
+Thresholds are configured on the edit page after creation.
|
|
|
+
|
|
|
+### 3.9 `/app/policies/{id}` — Policy editor
|
|
|
+
|
|
|
+**Metadata card** — Name, Description, "include manual blocks". The
|
|
|
+form is read-only for viewers.
|
|
|
+
|
|
|
+**Threshold matrix** — every active category with three columns:
|
|
|
+
|
|
|
+| Column | Meaning |
|
|
|
+|---------------|----------------------------------------------------------------------------------|
|
|
|
+| Category | `<slug>` + the human name. |
|
|
|
+| Decay | The category's decay function and parameter, for context. |
|
|
|
+| Threshold | The minimum score for an IP to land in this policy's blocklist for that category. **Empty value = category not in this policy.** Saving with empty drops the row from `policy_category_thresholds`; saving with a number replaces the existing row. |
|
|
|
+
|
|
|
+The PATCH replaces the entire threshold set in one transaction;
|
|
|
+unchecked rows simply don't appear in the body.
|
|
|
+
|
|
|
+**Preview card** — calls `/app/policies/{id}/preview-proxy` (a UI
|
|
|
+proxy that adds the service token and forwards to
|
|
|
+`/api/v1/admin/policies/{id}/preview`). Renders the resulting count
|
|
|
+of entries plus a sample of the first 50. **Refresh** re-runs the
|
|
|
+query without saving the form. Useful to gauge the impact of a
|
|
|
+threshold change before saving.
|
|
|
+
|
|
|
+### 3.10 `/app/reporters` — Reporters
|
|
|
+
|
|
|
+Reporters are the upstream sources of abuse signal — one row per
|
|
|
+host or service that posts to `POST /api/v1/report`.
|
|
|
+
|
|
|
+**New reporter** form (admin only):
|
|
|
+
|
|
|
+| Field | Values |
|
|
|
+|---------------|----------------------------------------------------------------------------------------------------------------------------|
|
|
|
+| **Name** | Required. Unique. Used in audit logs and dashboard top-list. |
|
|
|
+| **Trust weight** | Decimal `0.00`–`2.00`, default `1.0`. Multiplies every report's contribution to score. `0.0` mutes the source; `2.0` doubles its influence. The value is snapshotted into `reports.weight_at_report` at ingest time, so changing trust later doesn't retroactively rewrite history. |
|
|
|
+| **Description** | Optional free text. |
|
|
|
+
|
|
|
+**Listing** — name (clickable, edit page), trust weight, description,
|
|
|
+status pill (`active` / `inactive`). Admin sees Edit and (for active
|
|
|
+rows) **Deactivate**, which is a soft-delete. Deactivated reporters
|
|
|
+stop accepting new reports (their tokens get `401`); existing
|
|
|
+`reports` rows are preserved for the audit trail.
|
|
|
+
|
|
|
+**Edit page** — same fields plus an `active` checkbox.
|
|
|
+
|
|
|
+### 3.11 `/app/consumers` — Consumers
|
|
|
+
|
|
|
+Consumers are the downstream pull clients — one row per firewall /
|
|
|
+proxy / iptables instance that fetches `GET /api/v1/blocklist`.
|
|
|
+
|
|
|
+**New consumer** form (admin only):
|
|
|
+
|
|
|
+| Field | Values |
|
|
|
+|---------------|----------------------------------------------------------------------------------|
|
|
|
+| **Name** | Required, unique. Used in audit + audit attribution. |
|
|
|
+| **Policy** | Required. Pick from the existing policies; this is what shapes the consumer's blocklist. |
|
|
|
+| **Description** | Optional. |
|
|
|
+
|
|
|
+**Listing** — name, policy, description, status. Admin sees Edit /
|
|
|
+Deactivate; Deactivate is a soft-delete (returns 401 on subsequent
|
|
|
+pulls).
|
|
|
+
|
|
|
+### 3.12 `/app/tokens` — API tokens
|
|
|
+
|
|
|
+Bearer tokens for every machine client and admin automation. Service
|
|
|
+tokens are deliberately not listed (the UI's own service token is
|
|
|
+held only in `.env` and `api_tokens`, never exposed in management
|
|
|
+views).
|
|
|
+
|
|
|
+**Issue token** form (admin only):
|
|
|
+
|
|
|
+| Field | Values |
|
|
|
+|---------------|------------------------------------------------------------------------------------------------------------------|
|
|
|
+| **Kind** | `admin`, `reporter`, or `consumer`. Toggling swaps the visible secondary field. |
|
|
|
+| **Role** | (Visible when Kind = admin) `viewer`, `operator`, or `admin`. Persisted in `api_tokens.role`. Determines what `/api/v1/admin/*` endpoints the token may call. |
|
|
|
+| **Reporter** | (Visible when Kind = reporter) Pick the reporter the token belongs to. |
|
|
|
+| **Consumer** | (Visible when Kind = consumer) Pick the consumer the token belongs to. |
|
|
|
+
|
|
|
+**Token-just-created modal** — appears once after submission. Shows:
|
|
|
+
|
|
|
+- Kind + 8-char prefix (this is what's stored permanently).
|
|
|
+- The full raw token string in a read-only input + **Copy** button.
|
|
|
+- "I have stored it safely" closes the modal. **Refreshing this page
|
|
|
+ or navigating away discards the raw token permanently.** If lost,
|
|
|
+ revoke and re-issue.
|
|
|
+
|
|
|
+**Listing** — Kind, prefix, role-or-target column, last-used
|
|
|
+timestamp, status (`active` / `revoked`). Admin sees a **Revoke**
|
|
|
+button per active row; revocation is immediate and irreversible
|
|
|
+(the row stays for audit; clients start getting `401`).
|
|
|
+
|
|
|
+### 3.13 `/app/categories` — Abuse categories
|
|
|
+
|
|
|
+Each category is a column in the (IP × category) score matrix. The
|
|
|
+five seeded categories cover most use cases; add more for niche
|
|
|
+signal sources.
|
|
|
+
|
|
|
+**New category** form (admin only):
|
|
|
+
|
|
|
+| Field | Values |
|
|
|
+|-------------------|------------------------------------------------------------------------------------------|
|
|
|
+| **Slug** | Required, unique. Pattern `^[a-z][a-z0-9_]*$`. Used in API requests, blocklist filters, and audit logs. |
|
|
|
+| **Name** | Required, human-readable. |
|
|
|
+| **Decay function**| `exponential` (half-life days) or `linear` (days to zero). See § 4 below. |
|
|
|
+| **Decay param** | Decimal ≥ 0.1, default 14 (interpreted as half-life when exponential, days-to-zero when linear). |
|
|
|
+| **Description** | Optional. |
|
|
|
+
|
|
|
+**Listing** — slug, name, decay function + parameter, status pill.
|
|
|
+Admin sees Edit and Delete. **Delete returns 409** if any policy or
|
|
|
+report references the category — soft-delete by editing the
|
|
|
+category and unchecking `active` instead.
|
|
|
+
|
|
|
+**Edit page** — same fields + an `active` checkbox + a live
|
|
|
+**Decay preview** SVG plot (x-axis: 0–60 days, y-axis: 1.0 → 0.0).
|
|
|
+Toggling the function or changing the parameter redraws the curve
|
|
|
+without saving.
|
|
|
+
|
|
|
+### 3.14 `/app/audit` — Audit log
|
|
|
+
|
|
|
+Append-only record of every successful state-changing call through
|
|
|
+the admin and auth APIs. Visible to all signed-in roles.
|
|
|
+
|
|
|
+**Filter form**:
|
|
|
+
|
|
|
+| Field | Values |
|
|
|
+|-------------------|--------------------------------------------------------------------------------------------------------|
|
|
|
+| **Actor kind** | `user`, `admin-token`, `reporter`, `consumer`, `system`. Service-token + impersonation always records as `user` with the impersonated `user_id`. |
|
|
|
+| **Actor id** | Integer FK into the relevant table for that actor kind. |
|
|
|
+| **Action** | Dropdown of known action strings (`reporter.created`, `policy.updated`, `manual_block.deleted`, `job.triggered`, etc.). |
|
|
|
+| **Entity type** | Free text matching `audit_log.target_type` (the API/UI surface them as `entity_type`). |
|
|
|
+| **Entity id** | Free text — entity primary key as a string. |
|
|
|
+| **From / To (ISO)** | ISO-8601 timestamps to bound the time window (`2026-04-01T00:00:00Z`). |
|
|
|
+
|
|
|
+**Result table** — When (timestamp), Actor (kind pill + id), Action
|
|
|
+(coloured pill grouped by domain), Entity (type + id), Source IP,
|
|
|
+and a **View** button that expands a JSON pretty-print of the
|
|
|
+event's `details` payload below the row.
|
|
|
+
|
|
|
+Failed (4xx) calls are deliberately not audited — only successful
|
|
|
+state changes leave a trace. Token-creation events store the prefix,
|
|
|
+never the raw token. Pagination at the bottom for total > page size.
|
|
|
+
|
|
|
+### 3.15 `/app/settings` — Settings (admin only)
|
|
|
+
|
|
|
+Three sections:
|
|
|
+
|
|
|
+#### Configuration
|
|
|
+
|
|
|
+The api's effective config, grouped into sections (database, jobs,
|
|
|
+GeoIP, etc.). Secrets are masked: `***` for `INTERNAL_JOB_TOKEN`,
|
|
|
+`MAXMIND_LICENSE_KEY`, `IPINFO_TOKEN`, `DB_MYSQL_PASSWORD`,
|
|
|
+`APP_SECRET`. `UI_SERVICE_TOKEN` is shown as the first 8 characters
|
|
|
++ `…`. Empty values are rendered as `(empty)` so misconfiguration is
|
|
|
+visible.
|
|
|
+
|
|
|
+#### Jobs
|
|
|
+
|
|
|
+One row per registered job with its last run status, last
|
|
|
+finished-at, items-processed count, and an amber **overdue** pill
|
|
|
+when the job hasn't completed within its `default_interval_seconds`.
|
|
|
+
|
|
|
+| Job | Default interval | Purpose |
|
|
|
+|------------------------------------|------------------|------------------------------------------------------------------------------------------|
|
|
|
+| `recompute-scores` | 5 min | Re-applies decay to every (IP, category) pair touched recently or stale beyond freshness window. |
|
|
|
+| `cleanup-audit` | 24 h | Prunes `audit_log` rows older than `JOB_AUDIT_RETENTION_DAYS` (default 180). |
|
|
|
+| `enrich-pending` | 5 min | Looks up GeoIP/ASN for IPs without an `ip_enrichment` row. |
|
|
|
+| `cleanup-expired-manual-blocks` | 24 h | Hard-deletes manual-block rows past their `expires_at`. Emits one audit row per prune. |
|
|
|
+| `refresh-geoip` | 7 days (`JOB_GEOIP_REFRESH_INTERVAL_DAYS`) | Downloads fresh MMDB files from the configured provider; atomic-replaces them on disk. |
|
|
|
+| `tick` | external | The dispatcher invoked by host cron / sidecar; iterates the registry and runs whatever is overdue. |
|
|
|
+
|
|
|
+**Run now** button — admin-only. Posts to
|
|
|
+`POST /app/settings/jobs/trigger/<name>` which proxies to the api's
|
|
|
+admin endpoint. Runs synchronously — wait for the response. Does
|
|
|
+**not** appear next to `tick` (drive that externally). The
|
|
|
+`refresh-geoip` button returns `412` if the configured provider's
|
|
|
+credential is unset (DB-IP needs none; MaxMind needs
|
|
|
+`MAXMIND_LICENSE_KEY`; IPinfo needs `IPINFO_TOKEN`).
|
|
|
+
|
|
|
+#### GeoIP
|
|
|
+
|
|
|
+Read-only summary of the GeoIP configuration:
|
|
|
+
|
|
|
+| Field | Source |
|
|
|
+|----------------------|--------------------------------------------------------------------------------------|
|
|
|
+| Provider | `GEOIP_PROVIDER` (`dbip` / `maxmind` / `ipinfo`). |
|
|
|
+| Country DB / ASN DB | On-disk paths (`/data/geoip/country.mmdb` and `/data/geoip/asn.mmdb`). |
|
|
|
+| MaxMind key | Status only: shows `(unset)` when empty. |
|
|
|
+| IPinfo token | Status only. |
|
|
|
+
|
|
|
+To rotate provider or credentials, edit `.env` and restart the api
|
|
|
+container.
|
|
|
+
|
|
|
+### 3.16 `/app/me` — My identity
|
|
|
+
|
|
|
+Shows what `GET /api/v1/admin/me` returns for the current request:
|
|
|
+User ID, Display name, Email, Role pill, Source (`oidc` / `local` /
|
|
|
+`admin-token`). A **Sign out** button at the bottom posts to
|
|
|
+`/logout` (CSRF-protected). The page also surfaces an amber banner
|
|
|
+if the api was unreachable at render time.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 4. The decay engine — how scores age out
|
|
|
+
|
|
|
+Every report contributes to a single `(IP, category)` score. The
|
|
|
+score is recomputed as the sum of every report's `weight_at_report`
|
|
|
+multiplied by a category-specific decay function of the report's
|
|
|
+age:
|
|
|
+
|
|
|
+```
|
|
|
+score(IP, category) = Σ over reports r:
|
|
|
+ r.weight_at_report × decay(now − r.received_at, category)
|
|
|
+```
|
|
|
+
|
|
|
+Reports older than `SCORE_REPORT_HARD_CUTOFF_DAYS` (default 365) are
|
|
|
+excluded outright — the cutoff bounds memory and query time as the
|
|
|
+table grows.
|
|
|
+
|
|
|
+### 4.1 Two decay shapes
|
|
|
+
|
|
|
+Each category picks one of two functions, configured on the
|
|
|
+`Categories` page:
|
|
|
+
|
|
|
+| Function | Formula | `decay_param` interpretation | When to pick |
|
|
|
+|-------------------------|----------------------------------------------------|------------------------------------------|----------------------------------------------------------------------------------------------------|
|
|
|
+| **Linear** | `max(0, 1 − age_days / decay_param)` | Days until contribution reaches zero. | Hard cutoff: a report stops counting after exactly N days. Used when "contribution = 1 if recent, 0 if old" is the right model. |
|
|
|
+| **Exponential** | `0.5 ^ (age_days / decay_param)` (half-life) | Days for contribution to halve. | Smooth decay: a report's contribution halves every N days, never reaches zero (until the hard cutoff). The default for all five seeded categories. |
|
|
|
+
|
|
|
+A few worked numbers, with `decay_param = 14`:
|
|
|
+
|
|
|
+| Age (days) | Linear | Exponential |
|
|
|
+|-----------:|-------:|------------:|
|
|
|
+| 0 | 1.000 | 1.000 |
|
|
|
+| 7 | 0.500 | 0.707 |
|
|
|
+| 14 | 0.000 | 0.500 |
|
|
|
+| 28 | 0.000 | 0.250 |
|
|
|
+| 90 | 0.000 | 0.012 |
|
|
|
+
|
|
|
+The `Categories` edit page renders the same curve live as you
|
|
|
+change the parameter.
|
|
|
+
|
|
|
+### 4.2 What you can tune
|
|
|
+
|
|
|
+| Knob | Where | Effect |
|
|
|
+|---------------------------------------------|------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------|
|
|
|
+| **Per-category `decay_function`** | `Categories → edit` | Switches between linear and exponential. Curve shape changes immediately on save; existing scores recompute on next tick. |
|
|
|
+| **Per-category `decay_param`** | `Categories → edit` | Faster (smaller param) decay = scores fall off quickly = blocklist drops IPs sooner. Slower (larger) = sticky scores. |
|
|
|
+| **Per-reporter `trust_weight`** | `Reporters → edit` | Multiplies the contribution of every new report from this source. Snapshotted into `reports.weight_at_report` at ingest, so changes don't rewrite history. Range `0.0`–`2.0`. |
|
|
|
+| **Hard cutoff `SCORE_REPORT_HARD_CUTOFF_DAYS`** | `.env` (`api`) | Floor on memory: reports older than this are excluded entirely. Default 365. |
|
|
|
+| **Recompute interval `SCORE_RECOMPUTE_INTERVAL_SECONDS`** | `.env` (`api`) | How often the bulk recompute job runs; lower = scores reflect decay sooner; higher = less load. Default 300 s. |
|
|
|
+| **Recompute batch size `JOB_RECOMPUTE_MAX_ROWS_PER_TICK`** | `.env` (`api`) | Cap on rows refreshed per tick; tune if recompute starts overrunning its window. Default 5000. |
|
|
|
+| **Recompute timeout `JOB_RECOMPUTE_MAX_RUNTIME_SECONDS`** | `.env` (`api`) | Hard ceiling. The lock auto-expires after this; concurrent calls return `skipped_locked`. Default 240 s. |
|
|
|
+| **Per-policy thresholds** | `Policies → edit` | The score floor for inclusion in a policy's blocklist. Higher = stricter inclusion = smaller list. Lower = looser. |
|
|
|
+
|
|
|
+### 4.3 How recompute actually works
|
|
|
+
|
|
|
+The job runs every `SCORE_RECOMPUTE_INTERVAL_SECONDS`:
|
|
|
+
|
|
|
+1. Acquires the `recompute-scores` lock with a max runtime of
|
|
|
+ `JOB_RECOMPUTE_MAX_RUNTIME_SECONDS`. Concurrent calls get `409
|
|
|
+ skipped_locked` immediately.
|
|
|
+2. Selects every `(ip, category)` pair touched by reports in the
|
|
|
+ recent interval, **plus** every row whose `recomputed_at` is
|
|
|
+ stale (older than the freshness window) — capped at
|
|
|
+ `JOB_RECOMPUTE_MAX_ROWS_PER_TICK`.
|
|
|
+3. Recomputes the score by summing decayed contributions over all
|
|
|
+ in-cutoff reports for that pair.
|
|
|
+4. Upserts into `ip_scores`.
|
|
|
+5. Drops rows where `score < 0.01 AND last_report_at < now − 90
|
|
|
+ days` so the table doesn't grow unbounded.
|
|
|
+6. Writes a `job_runs` row and releases the lock.
|
|
|
+
|
|
|
+Single-pair updates also happen **synchronously** at report time
|
|
|
+(via `PairScorer`), so the dashboard and detail page reflect a new
|
|
|
+report immediately. The bulk recompute is what *ages* every score
|
|
|
+between reports.
|
|
|
+
|
|
|
+A full-table recompute is exposed as an admin-triggered job
|
|
|
+(`Settings → recompute-scores → Run now` with `full=true` body, or
|
|
|
+the CLI `php api/bin/console scores:rebuild`). Use after large
|
|
|
+policy / decay changes that should reflect everywhere immediately.
|
|
|
+
|
|
|
+### 4.4 Manual override semantics
|
|
|
+
|
|
|
+`manual_blocks` and `allowlist` are **not** folded into scores. They
|
|
|
+are evaluated at distribution time:
|
|
|
+
|
|
|
+- **Allowlist always wins.** An IP on the allowlist is excluded from
|
|
|
+ every blocklist regardless of score and regardless of any manual
|
|
|
+ block. Overlap is logged as a WARNING.
|
|
|
+- **Manual blocks** appear in a policy's blocklist if and only if
|
|
|
+ the policy's `include_manual_blocks` flag is set. Subnet manual
|
|
|
+ blocks emit as a single CIDR line; covered single-IP scored entries
|
|
|
+ are deduplicated.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 5. Backup and disaster recovery
|
|
|
+
|
|
|
+### 5.1 What carries irreplaceable state
|
|
|
+
|
|
|
+| Resource | Where | Recovery |
|
|
|
+|-----------------------------------|-----------------------------|-------------------------------------------|
|
|
|
+| Reports + scores | `reports`, `ip_scores` | 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 | `api_tokens` (hashes only) | DB backup; **raw values gone — re-issue** |
|
|
|
+| Audit log | `audit_log` | DB backup |
|
|
|
+| OIDC role mappings | `oidc_role_mappings` | DB backup |
|
|
|
+| Service token | `.env` (`UI_SERVICE_TOKEN`) | Back up `.env` or regenerate |
|
|
|
+| Internal job token | `.env` (`INTERNAL_JOB_TOKEN`) | Back up `.env` or regenerate |
|
|
|
+| OIDC client secret | `.env` | Re-fetch from Entra app registration |
|
|
|
+| Local admin password hash | `.env` | Regenerate via `password_hash()` |
|
|
|
+| MaxMind / IPinfo credentials | `.env` | 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 |
|
|
|
+
|
|
|
+### 5.2 SQLite backup (default)
|
|
|
+
|
|
|
+The SQLite backup API quiesces WAL and produces a consistent
|
|
|
+snapshot online — no downtime:
|
|
|
+
|
|
|
+```bash
|
|
|
+docker compose exec api sh -c \
|
|
|
+ 'sqlite3 /data/irdb.sqlite ".backup /data/irdb-backup.sqlite"'
|
|
|
+docker compose cp api:/data/irdb-backup.sqlite ./irdb-backup-$(date +%F).sqlite
|
|
|
+```
|
|
|
+
|
|
|
+Schedule this from host cron alongside your existing backup
|
|
|
+infrastructure. The file is small — under 20 tables; even a
|
|
|
+year-old install is typically tens of MB.
|
|
|
+
|
|
|
+**Whole-volume tarball** (alternative, requires the api stopped):
|
|
|
+
|
|
|
+```bash
|
|
|
+docker compose stop api
|
|
|
+docker run --rm -v irdb-data:/data -v "$(pwd):/backup" alpine \
|
|
|
+ tar czf /backup/irdb-backup.tar.gz -C /data .
|
|
|
+docker compose start api
|
|
|
+```
|
|
|
+
|
|
|
+### 5.3 SQLite restore
|
|
|
+
|
|
|
+```bash
|
|
|
+docker compose down
|
|
|
+docker run --rm -v irdb-data:/data -v "$(pwd):/backup" alpine \
|
|
|
+ sh -c 'rm -rf /data/* && tar xzf /backup/irdb-backup.tar.gz -C /data'
|
|
|
+docker compose up -d
|
|
|
+```
|
|
|
+
|
|
|
+The `migrate` container reruns Phinx idempotently — safe to run on a
|
|
|
+restored DB that's already at HEAD; it exits immediately with "no
|
|
|
+migrations to run" if nothing's pending.
|
|
|
+
|
|
|
+### 5.4 MySQL backup and restore
|
|
|
+
|
|
|
+```bash
|
|
|
+docker compose exec mysql sh -c \
|
|
|
+ 'mysqldump --single-transaction --routines --quick \
|
|
|
+ -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' \
|
|
|
+ > irdb-mysql-$(date +%F).sql
|
|
|
+```
|
|
|
+
|
|
|
+Restore (api must be stopped to avoid observing a half-loaded
|
|
|
+schema):
|
|
|
+
|
|
|
+```bash
|
|
|
+docker compose stop api migrate
|
|
|
+docker compose exec -T mysql sh -c \
|
|
|
+ 'mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' \
|
|
|
+ < irdb-mysql-2026-04-29.sql
|
|
|
+docker compose up -d migrate api
|
|
|
+```
|
|
|
+
|
|
|
+### 5.5 Disaster-recovery checklist
|
|
|
+
|
|
|
+Use this when restoring on a fresh host, e.g. after a hardware
|
|
|
+failure or migration:
|
|
|
+
|
|
|
+1. **Provision** the host: install Docker, clone the repo, restore
|
|
|
+ `.env` from your secret store (or generate fresh — the service
|
|
|
+ token can be rotated, the local-admin hash regenerated).
|
|
|
+2. **Restore the database** via the relevant block above. SQLite:
|
|
|
+ drop the file into the `irdb-data` volume. MySQL: pipe the dump
|
|
|
+ through `mysql`.
|
|
|
+3. **Bring up the stack**: `docker compose up -d`. The `migrate`
|
|
|
+ container is idempotent.
|
|
|
+4. **Verify health.** `/healthz` on both containers; log in to the
|
|
|
+ UI; visit `/app/dashboard` and `/app/settings`.
|
|
|
+5. **Trigger `refresh-geoip`** from Settings (or wait for the
|
|
|
+ scheduled run) so enrichment is repopulated.
|
|
|
+6. **Re-issue tokens** for any reporter or consumer whose raw token
|
|
|
+ was lost (the database has the SHA-256 hash, but the original
|
|
|
+ string isn't recoverable). Until re-issued, those clients will
|
|
|
+ see `401`.
|
|
|
+7. **Notify users** that browser sessions are gone — they must
|
|
|
+ sign in again.
|
|
|
+
|
|
|
+### 5.6 What's explicitly NOT supported
|
|
|
+
|
|
|
+- **Encryption at rest of the SQLite file** — host-level disk
|
|
|
+ encryption is the right layer; no application-level encryption.
|
|
|
+- **Audit-log signing or tamper-evidence** — `audit_log` is
|
|
|
+ append-only at the application layer, but a sufficiently
|
|
|
+ privileged attacker with database access can rewrite history.
|
|
|
+- **Cross-region replication** — Docker volumes are local. High
|
|
|
+ availability requires managed MySQL with replication plus an
|
|
|
+ `api` topology that exploits the stateless mode.
|
|
|
+- **Backup of secrets** (service token, OIDC client secret, etc.)
|
|
|
+ is out of scope for the database backup. Back up `.env`
|
|
|
+ separately or regenerate on restore.
|
|
|
+
|
|
|
+### 5.7 Operational sanity checks
|
|
|
+
|
|
|
+Things that often go wrong, and where to look:
|
|
|
+
|
|
|
+| Symptom | Likely cause |
|
|
|
+|-----------------------------------------------------------|----------------------------------------------------------------------------------------------|
|
|
|
+| Scores never decay; `recompute-scores` shows "never run" | Scheduler isn't running. Check `compose.scheduler.yml` is included or host cron is firing. |
|
|
|
+| Dashboard "API unreachable" banner | The `api` container is down or the UI can't resolve `API_BASE_URL`. |
|
|
|
+| All reporter ingest returns `401` | Token revoked, expired, or wrong kind. Check `Tokens` and `Audit`. |
|
|
|
+| `refresh-geoip` returns 412 | Selected provider's credential is unset. Switch provider or set the key. |
|
|
|
+| Local-admin login is locked out | Five failed attempts in succession. Restart the `ui` container to clear the throttle. |
|
|
|
+| `migrate` keeps restarting | Database connection failed. Check `DB_DRIVER`, `DB_MYSQL_*`, and the volume mount. |
|
|
|
+| Disk filling up | `audit_log` growing without bounds. Check `cleanup-audit` is running and `JOB_AUDIT_RETENTION_DAYS` is set. |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 6. Where to go next
|
|
|
+
|
|
|
+- [`README.md`](../README.md) — quickstart, generating secrets, scheduling.
|
|
|
+- [`architecture.md`](./architecture.md) — container topology and stable surfaces.
|
|
|
+- [`auth-flows.md`](./auth-flows.md) — Entra setup, every authentication path.
|
|
|
+- [`api-overview.md`](./api-overview.md) — public API surface with worked examples.
|
|
|
+- [`security.md`](./security.md) — security posture, hardening notes, known limitations.
|
|
|
+- `/api/docs` (live, served by the api container) — interactive OpenAPI viewer.
|