api-overview.md 9.0 KB

API overview

Audience: integrators (firewalls, fail2ban-style agents, monitoring) and frontend authors. The OpenAPI document at /api/v1/openapi.yaml is the source of truth for endpoint shapes; this file covers the surrounding conventions OpenAPI doesn't express.

Base URL & versioning

Default compose deployment: http://localhost:8081. Production is typically behind a reverse proxy at https://reputation-api.example.com.

Single major version v1. The contract is additive-only within v1:

  • new endpoints may be added at any time
  • new optional query parameters may be added
  • new optional fields may appear in responses
  • existing fields will not change shape, type, or semantics
  • breaking changes ship as a new major (v2) at a different path prefix

Don't pin to undocumented behaviour (e.g. response field ordering); pin to the documented schema in OpenAPI.

Authentication

Every endpoint takes Authorization: Bearer <token>. There are four token kinds, distinguishable by their prefix:

Token format Kind What it can do
irdb_rep_<32 base32> reporter POST /api/v1/report only. Bound to a reporter row.
irdb_con_<32 base32> consumer GET /api/v1/blocklist only. Bound to a consumer row.
irdb_adm_<32 base32> admin All /api/v1/admin/* as itself, with a configured role. For automation.
irdb_svc_<32 base32> service UI BFF only. Combined with X-Acting-User-Id, executes as that user.

Tokens are persisted as their SHA-256 hash; the raw value is shown once on creation and never echoed again. Revoke + re-issue if it leaks.

Failure modes return a uniform 401 unauthorized envelope — bad token, revoked token, expired token, wrong-kind token all look identical to the caller. Authorization failures (authenticated but RBAC-denied) return 403.

See auth-flows.md for the full picture of how each flow plays out.

Endpoint groups

Group Path prefix Audience Documented in OpenAPI
Public /api/v1/report, /api/v1/blocklist machine clients yes
Admin /api/v1/admin/* UI BFF + admin-kind tokens yes
Auth /api/v1/auth/* UI BFF only yes (x-internal: true)
Internal jobs /internal/jobs/* scheduler no — private contract

The internal jobs surface is bound to RFC1918 + loopback in the api's Caddyfile and authenticated with a dedicated INTERNAL_JOB_TOKEN. External callers get 404. The admin proxy at POST /api/v1/admin/jobs/trigger/{name} is the public path for authorized human operators to invoke the same logic.

Common conventions

JSON shape

Bodies are JSON unless explicitly noted (the blocklist endpoint returns text/plain by default). Successful responses use the shape the OpenAPI document describes; errors always look like:

{ "error": "validation_failed", "details": { "ip": "must be a valid IPv4 or IPv6 address" } }

details is present on 400 validation_failed only. Other errors (401, 403, 404, 409, 412, 429, 500) carry just error and sometimes a sibling diagnostic field (provider and missing for 412 no_credential, consumers for 409 policy_in_use, etc.).

Pagination

Paginated lists take page (1-indexed, default 1) and page_size (default 50, max 200). The response carries {items, page, page_size, total}. Don't compute total pages from total / page_size on the client; do ceil. The api returns items: [] past the last page rather than 404.

ETags

GET /api/v1/blocklist returns an ETag (SHA-256 over the rendered body, excluding generated_at). Re-fetching with If-None-Match: "<etag>" returns 304 Not Modified and an empty body. The ETag value is content-type-sensitive — text/plain and application/json produce different ETags for the same logical state.

Rate limiting

Public endpoints are rate-limited at 60 req/s per token (configurable via API_RATE_LIMIT_PER_SECOND). Token-bucket; refill rate equals the configured per-second value, bucket size is 2x the refill rate. On exhaustion: 429 Too Many Requests with Retry-After: 1 (seconds).

Admin endpoints aren't rate-limited. Real abuse on admin endpoints is rare — the audience is humans plus the BFF, both authenticated with tokens that can be revoked. Add a limit if you measure a problem. The internal /internal/jobs/* API is also unrated; it sits behind a network gate (RFC1918 only) plus a bearer token.

Login throttling (separate concern, lives in the UI): the local-admin sign-in is gated by an in-process LoginThrottle. Failures are bucketed by (username, source_ip) so an attacker spraying one IP can't lock out a legitimate admin from another. Progression: 5 fails → 60 s lockout, 10 → 300 s, 15+ → 1800 s. A successful login resets the bucket. Restart the ui container to clear all locks.

See security.md for the full rate-limit and brute-force-protection posture.

IP normalization

Every IP in a request body or path segment is normalized:

  • IPv4 like 203.0.113.42 is canonicalised and stored as a 16-byte IPv4-mapped-in-IPv6 binary (::ffff:203.0.113.42). v4 input is echoed back as v4 in responses.
  • IPv6 is canonicalised (2001:DB8::12001:db8::1).
  • Embedded zone identifiers (fe80::1%eth0) are rejected.
  • "203.0.113.42 " (whitespace) is rejected — strip on the client.

CIDRs follow the same rules; the network address is canonicalised so 203.0.113.55/24 is silently normalized to 203.0.113.0/24 and the response includes normalized_from: "203.0.113.55/24" so the caller can see the change.

Timestamps

All timestamps are UTC, ISO 8601, with Z suffix: 2026-04-29T10:00:00Z. Don't try to send local-time strings.

Worked examples

Posting an abuse report

curl -X POST http://localhost:8081/api/v1/report \
  -H "Authorization: Bearer $IRDB_REP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "ip": "203.0.113.42",
    "category": "brute_force",
    "metadata": {"url": "/wp-login.php", "ua": "Mozilla/5.0 (...)"}
  }'

# 202 Accepted
# {"report_id": 17, "ip": "203.0.113.42", "received_at": "2026-04-29T10:00:00Z"}

The score for (203.0.113.42, brute_force) is updated synchronously in ip_scores. Bulk decay is reapplied periodically by the recompute-scores job.

Pulling a blocklist

# Plain text — one IP or CIDR per line.
curl http://localhost:8081/api/v1/blocklist \
  -H "Authorization: Bearer $IRDB_CON_TOKEN"
# 203.0.113.42
# 198.51.100.0/24

# JSON — richer per-entry data.
curl 'http://localhost:8081/api/v1/blocklist?format=json' \
  -H "Authorization: Bearer $IRDB_CON_TOKEN"
# {"count":2,"generated_at":"2026-04-29T10:00:00Z","policy":"moderate","entries":[...]}

# ETag round-trip — second request returns 304 if nothing changed.
ETAG=$(curl -sI -H "Authorization: Bearer $IRDB_CON_TOKEN" \
  http://localhost:8081/api/v1/blocklist | awk '/^etag:/i {print $2}' | tr -d '\r')
curl -i -H "Authorization: Bearer $IRDB_CON_TOKEN" \
  -H "If-None-Match: $ETAG" \
  http://localhost:8081/api/v1/blocklist
# HTTP/1.1 304 Not Modified

Drop-in shell wrappers for iptables, nginx, and HAProxy are in examples/consumers/.

Admin: search IPs (via service-token impersonation)

This is what the UI BFF does on every admin page. You'd only do this yourself if you're building a replacement BFF.

curl 'http://localhost:8081/api/v1/admin/ips?q=203.0.113&page=1&page_size=25' \
  -H "Authorization: Bearer $UI_SERVICE_TOKEN" \
  -H "X-Acting-User-Id: 7"
# {"items": [...], "page": 1, "page_size": 25, "total": 42}

The api validates the service token, looks up user 7, applies that user's role to the request, and writes any audit row as actor_kind=user, actor_id=7 (never the service token).

Admin: search IPs (via admin-kind token)

Direct path for automation that doesn't go through a BFF.

curl 'http://localhost:8081/api/v1/admin/ips?q=203.0.113' \
  -H "Authorization: Bearer $IRDB_ADM_TOKEN"
# Same response shape; no impersonation header.

The audit row records actor_kind=admin-token, actor_id=<token row id>.

OpenAPI

Canonical reference for endpoint shapes:

  • Viewer: http://localhost:8081/api/docs (RapiDoc)
  • YAML: http://localhost:8081/api/v1/openapi.yaml

If anything in this document conflicts with the OpenAPI spec, the OpenAPI spec wins — file an issue against this doc. Discrepancies between docs and code are a hard CI failure (scripts/check-doc-endpoints.sh).