user-manual.md 44 KB

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; for API contracts see api-overview.md; for authentication wiring see 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).

1.2 Boot sequence (≈5 minutes)

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; 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:

    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.

    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:

    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.

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 chipsAll / 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.002.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.02.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:

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):

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

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

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):

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-evidenceaudit_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 — quickstart, generating secrets, scheduling.
  • architecture.md — container topology and stable surfaces.
  • auth-flows.md — Entra setup, every authentication path.
  • api-overview.md — public API surface with worked examples.
  • security.md — security posture, hardening notes, known limitations.
  • /api/docs (live, served by the api container) — interactive OpenAPI viewer.