# 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 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 | |---------------------------|--------------------------------------------------------------------------------------------------| | `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://: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://: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('', '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 `` with `( 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 | `` + 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`. `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/` 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.