# BanGUI API Reference

Complete reference for the BanGUI REST API. All endpoints require authentication unless noted as public.

Base URL: `http://{host}:8000`

**Authentication** — All protected endpoints require a valid session cookie (`bangui_session`) or `Authorization: Bearer <token>` header.

---

## Public Endpoints

### `GET /api/v1/health`

Health check. No auth required.

**Response `200`**
```json
{
  "status": "ok",
  "fail2ban": "online",
  "database": "ok",
  "scheduler": "running",
  "cache": "initialised",
  "components": []
}
```

| Field | Description |
|---|---|
| `status` | `ok`, `degraded`, or `unavailable` |
| `fail2ban` | `online` or `offline` |
| `database` | `ok` or `error` |
| `scheduler` | `running`, `stopped`, or `unknown` |
| `cache` | `initialised` or `uninitialised` |
| `components` | List of unhealthy components (empty when `ok`) |

**Response `503**` — fail2ban offline.

---

### `GET /api/v1/setup`

Check whether initial setup has been completed.

**Response `200`**
```json
{ "completed": true }
```

---

### `POST /api/v1/setup`

Run the first-run setup wizard.

**Request**
```json
{
  "master_password": "Hallo123!",
  "database_path": "/var/lib/fail2ban/fail2ban.sqlite3",
  "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock",
  "timezone": "Europe/Berlin",
  "session_duration_minutes": 480
}
```

| Field | Type | Required | Description |
|---|---|---|---|
| `master_password` | string | Yes | Min 8 chars, uppercase + number + special (`!@#$%^&*()`) |
| `database_path` | string | No | Path to fail2ban DB (default: `/var/lib/fail2ban/fail2ban.sqlite3`) |
| `fail2ban_socket` | string | No | Path to fail2ban socket (default: `/var/run/fail2ban/fail2ban.sock`) |
| `timezone` | string | No | IANA timezone (default: `UTC`) |
| `session_duration_minutes` | int | No | Session TTL in minutes (default: `480`) |

**Response `201`** — Setup completed.

**Response `409`** — Setup already completed.

---

### `GET /api/v1/setup/timezone`

Returns the configured IANA timezone.

**Response `200`**
```json
{ "timezone": "Europe/Berlin" }
```

---

### `GET /metrics`

Prometheus metrics endpoint. No auth required. Returns OpenMetrics text format.

---

## Auth

### `POST /api/v1/auth/login`

Authenticate with the master password.

**Request**
```json
{ "password": "Hallo123!" }
```

> Note: The frontend SHA256-hashes the password before sending. The backend expects the already-hashed value.

**Response `200`** — Sets `bangui_session` cookie.
```json
{ "expires_at": "2024-12-25T10:00:00Z" }
```

**Response `401`** — Invalid password.

**Response `429`** — Too many login attempts (exponential backoff delay). Includes `Retry-After` header.

**Response `503`** — Setup not complete.

---

### `GET /api/v1/auth/session`

Validate the current session.

**Response `200`**
```json
{ "valid": true }
```

**Response `401`** — Session missing, expired, or invalid.

---

### `POST /api/v1/auth/logout`

Revoke the current session.

**Response `200`**
```json
{}
```

Session cookie is cleared. Idempotent — returns `200` even if no session present.

---

## Dashboard

### `GET /api/v1/dashboard/status`

Returns the cached fail2ban server health snapshot (refreshed every 30 seconds).

**Response `200`**
```json
{
  "status": {
    "version": "0.12.1",
    "online": true,
    "uptime": 86400,
    "jail_count": 3
  }
}
```

| Field | Type | Description |
|---|---|---|
| `version` | string | fail2ban server version |
| `online` | bool | Whether fail2ban daemon is reachable |
| `uptime` | int | Daemon uptime in seconds |
| `jail_count` | int | Number of configured jails |

**Response `401`** — Not authenticated.

**Response `502`** — fail2ban unreachable.

---

### `GET /api/v1/dashboard/bans`

Paginated list of recent bans with geo enrichment.

**Query Parameters**

| Param | Type | Default | Description |
|---|---|---|---|
| `range` | `TimeRange` | `24h` | Time window: `24h`, `7d`, `30d`, `365d` |
| `source` | string | `fail2ban` | Data source: `fail2ban` or `archive` |
| `page` | int | `1` | 1-based page number |
| `page_size` | int | `100` | Items per page (max 500) |
| `origin` | string | null | Filter: `blocklist` or `selfblock` |

**Response `200`**
```json
{
  "items": [
    {
      "ip": "1.2.3.4",
      "jail": "sshd",
      "banned_at": "2024-12-25T08:00:00Z",
      "expires_at": "2024-12-26T08:00:00Z",
      "country": "US",
      "asn": "AS15169",
      "org": "Google LLC"
    }
  ],
  "total": 150,
  "page": 1,
  "page_size": 100
}
```

---

### `GET /api/v1/dashboard/bans/by-country`

Ban counts aggregated by country.

**Query Parameters** — Same as `/bans` plus optional `country_code` filter.

**Response `200`**
```json
{
  "countries": { "US": 45, "CN": 32, "BR": 18 },
  "total": 150,
  "items": [...]
}
```

---

### `GET /api/v1/dashboard/bans/trend`

Ban counts grouped into time buckets for charts.

**Bucket sizes:**
- `24h` → 1-hour buckets (24 total)
- `7d` → 6-hour buckets (28 total)
- `30d` → 1-day buckets (30 total)
- `365d` → 7-day buckets (~53 total)

**Query Parameters** — Same as `/bans`.

**Response `200`**
```json
{
  "buckets": [
    { "ts": "2024-12-24T00:00:00Z", "count": 12 },
    { "ts": "2024-12-24T01:00:00Z", "count": 8 }
  ],
  "bucket_size": "1h",
  "total": 150
}
```

---

### `GET /api/v1/dashboard/bans/by-jail`

Ban counts grouped by jail.

**Query Parameters** — Same as `/bans`.

**Response `200`**
```json
{
  "jails": [
    { "jail": "sshd", "count": 120 },
    { "jail": "nginx-http-auth", "count": 30 }
  ],
  "total": 150
}
```

---

## Bans

### `GET /api/v1/bans/active`

List all currently banned IPs across all jails.

**Response `200`**
```json
{
  "items": [
    {
      "ip": "1.2.3.4",
      "jail": "sshd",
      "banned_at": "2024-12-25T08:00:00Z",
      "expires_at": "2024-12-26T08:00:00Z",
      "country": "US"
    }
  ],
  "total": 42
}
```

**Response `401`** — Not authenticated.

**Response `502`** — fail2ban unreachable.

---

### `POST /api/v1/bans`

Ban an IP address in a specific jail.

**Request**
```json
{ "jail": "sshd", "ip": "5.6.7.8" }
```

**Response `201`**
```json
{ "message": "IP '5.6.7.8' banned in jail 'sshd'.", "jail": "sshd" }
```

**Response `400`** — Invalid IP address.

**Response `404`** — Jail not found.

**Response `409`** — Ban command failed in fail2ban.

**Response `429`** — Rate limit exceeded (10 ban requests/minute per IP).

**Response `502`** — fail2ban unreachable.

---

### `DELETE /api/v1/bans`

Unban an IP from one or all jails.

**Request**
```json
{ "ip": "5.6.7.8", "jail": "sshd", "unban_all": false }
```

| Field | Type | Required | Description |
|---|---|---|---|
| `ip` | string | Yes | IP address to unban |
| `jail` | string | No | Specific jail to unban from |
| `unban_all` | bool | No | `true` = unban from all jails (default: `false` if `jail` omitted) |

**Response `200`**
```json
{ "message": "IP '5.6.7.8' unbanned from jail 'sshd'.", "jail": "sshd" }
```

**Response `404`** — Jail not found.

**Response `429`** — Rate limit exceeded (10 unban requests/minute per IP).

---

### `DELETE /api/v1/bans/all`

Unban every currently banned IP across all jails.

**Response `200`**
```json
{ "message": "All bans cleared. 42 IP addresses unbanned.", "count": 42 }
```

---

## History

### `GET /api/v1/history`

Paginated historical ban records.

**Query Parameters**

| Param | Type | Default | Description |
|---|---|---|---|
| `range` | `TimeRange` | null | Time filter: `24h`, `7d`, `30d`, `365d` (null = all-time) |
| `jail` | string | null | Filter by jail name (exact match) |
| `ip` | string | null | Filter by IP prefix |
| `origin` | string | null | Filter: `blocklist` or `selfblock` |
| `source` | string | `fail2ban` | `fail2ban` or `archive` |
| `page` | int | `1` | 1-based page number |
| `page_size` | int | `100` | Items per page (max 500) |

**Response `200`**
```json
{
  "items": [
    {
      "ip": "1.2.3.4",
      "jail": "sshd",
      "banned_at": "2024-12-25T08:00:00Z",
      "unbanned_at": "2024-12-26T08:00:00Z",
      "origin": "selfblock",
      "country": "US"
    }
  ],
  "total": 500,
  "page": 1,
  "page_size": 100
}
```

---

### `GET /api/v1/history/archive`

Same as `/history` but reads from the archive database.

**Query Parameters** — Same as `/history` (no `origin` filter).

---

### `GET /api/v1/history/{ip}`

Complete ban timeline for a single IP.

**Response `200`**
```json
{
  "ip": "1.2.3.4",
  "country": "US",
  "total_bans": 5,
  "timeline": [
    {
      "jail": "sshd",
      "banned_at": "2024-12-25T08:00:00Z",
      "unbanned_at": "2024-12-26T08:00:00Z",
      "origin": "selfblock"
    }
  ]
}
```

**Response `404`** — No history found for this IP.

---

## Jails

### `GET /api/v1/jails`

List all active fail2ban jails.

**Response `200`**
```json
{
  "items": [
    {
      "name": "sshd",
      "enabled": true,
      "currently_banned": 12,
      "total_bans": 150,
      "failed_attempts": 320,
      "find_time": 600,
      "ban_time": 86400,
      "max_retries": 5,
      "backend": "polling",
      "idle": false
    }
  ],
  "total": 3
}
```

---

### `GET /api/v1/jails/{name}`

Full detail for a single jail.

**Response `200`**
```json
{
  "name": "sshd",
  "enabled": true,
  "log_paths": ["/var/log/auth.log"],
  "fail_regex": ["^%(__prefix_line)sFailed publickey forInvalid user"],
  "ignore_regex": [],
  "date_pattern": null,
  "log_encoding": "UTF-8",
  "actions": ["iptables"],
  "find_time": 600,
  "ban_time": 86400,
  "max_retries": 5,
  "ignore_list": ["192.168.1.1"],
  "ignore_self": true,
  "currently_banned": 12,
  "total_bans": 150,
  "failed_attempts": 320
}
```

**Response `404`** — Jail not found.

---

### `POST /api/v1/jails/{name}/start`

Start a stopped jail.

**Response `200`**
```json
{ "message": "Jail 'sshd' started.", "jail": "sshd" }
```

---

### `POST /api/v1/jails/{name}/stop`

Stop a running jail.

**Response `200`**
```json
{ "message": "Jail 'sshd' stopped.", "jail": "sshd" }
```

---

### `POST /api/v1/jails/{name}/idle`

Toggle jail idle mode.

**Request body**
```json
{ "on": true }
```

**Response `200`**
```json
{ "message": "Jail 'sshd' idle mode turned on.", "jail": "sshd" }
```

---

### `POST /api/v1/jails/{name}/reload`

Reload a single jail.

**Response `200`**
```json
{ "message": "Jail 'sshd' reloaded.", "jail": "sshd" }
```

---

### `POST /api/v1/jails/reload-all`

Reload all fail2ban jails.

**Response `200`**
```json
{ "message": "All jails reloaded successfully.", "jail": "*" }
```

---

### `GET /api/v1/jails/{name}/ignoreip`

Get the ignore (whitelist) list for a jail.

**Response `200`**
```json
{ "items": ["192.168.1.0/24", "10.0.0.1"], "total": 2 }
```

---

### `POST /api/v1/jails/{name}/ignoreip`

Add an IP or CIDR to the ignore list.

**Request**
```json
{ "ip": "192.168.1.100" }
```

**Response `201`**
```json
{ "message": "IP '192.168.1.100' added to ignore list of jail 'sshd'.", "jail": "sshd" }
```

**Response `400`** — Invalid IP or network.

---

### `DELETE /api/v1/jails/{name}/ignoreip`

Remove an IP or CIDR from the ignore list.

**Request**
```json
{ "ip": "192.168.1.100" }
```

**Response `200`**
```json
{ "message": "IP '192.168.1.100' removed from ignore list of jail 'sshd'.", "jail": "sshd" }
```

---

### `POST /api/v1/jails/{name}/ignoreself`

Toggle the `ignoreself` option (ban server's own IP).

**Request**
```json
{ "on": true }
```

**Response `200`**
```json
{ "message": "ignoreself enabled for jail 'sshd'.", "jail": "sshd" }
```

---

### `GET /api/v1/jails/{name}/banned`

Paginated currently-banned IPs for a specific jail.

**Query Parameters**

| Param | Type | Default | Description |
|---|---|---|---|
| `page` | int | `1` | 1-based page number |
| `page_size` | int | `100` | Items per page (max 100) |
| `search` | string | null | Case-insensitive substring filter on IP |

**Response `200`**
```json
{
  "items": [
    {
      "ip": "1.2.3.4",
      "banned_at": "2024-12-25T08:00:00Z",
      "expires_at": "2024-12-26T08:00:00Z",
      "country": "US",
      "asn": "AS15169",
      "org": "Google LLC"
    }
  ],
  "total": 12,
  "page": 1,
  "page_size": 100
}
```

---

## Config

### `GET /api/v1/config/global`

Get global fail2ban settings.

**Response `200`**
```json
{
  "loglevel": "INFO",
  "logtarget": "/var/log/fail2ban.log",
  "syslog_socket": "auto",
  "db_file": "/var/lib/fail2ban/fail2ban.sqlite3",
  "db_purge_age": 86400
}
```

---

### `PUT /api/v1/config/global`

Update global fail2ban settings.

**Request** — All fields optional (only non-null fields written):
```json
{
  "loglevel": "DEBUG",
  "logtarget": "/var/log/fail2ban.log",
  "db_purge_age": 604800
}
```

| Field | Type | Description |
|---|---|---|
| `loglevel` | string | `CRITICAL`, `ERROR`, `WARNING`, `NOTICE`, `INFO`, `DEBUG` |
| `logtarget` | string | `STDOUT`, `STDERR`, `SYSLOG`, or a file path |
| `db_purge_age` | int | Seconds before old ban records are purged |

**Response `204`** — Updated.

**Response `400`** — `logtarget` invalid (not in allowed directories).

**Response `429`** — Rate limit exceeded (10 updates/minute per IP).

---

### `POST /api/v1/config/reload`

Trigger a full fail2ban reload.

**Response `204`**

---

### `POST /api/v1/config/restart`

Restart the fail2ban service (stop + start).

**Response `204`**

**Response `503`** — fail2ban did not come back online within 10 seconds.

---

### `POST /api/v1/config/regex-test`

Test a fail regex pattern against a sample log line (stateless, no fail2ban call).

**Request**
```json
{ "pattern": "^%(__prefix_line)sFailed publickey", "sample": "Dec 25 08:00:01 server sshd[123]: Failed publickey for user admin from 1.2.3.4" }
```

**Response `200`**
```json
{ "matched": true, "groups": ["Dec 25 08:00:01", "server", "123", "admin", "1.2.3.4"] }
```

---

### `POST /api/v1/config/preview-log`

Read a log file and test a regex against each line.

**Request**
```json
{
  "path": "/var/log/auth.log",
  "pattern": "^Failed publickey",
  "lines": 50
}
```

**Response `200`**
```json
{
  "lines": [
    { "line": "Dec 25 08:00:01 server sshd[123]: Failed publickey...", "matched": true, "groups": [...] },
    { "line": "Dec 25 08:00:02 server sshd[456]: Accepted publickey...", "matched": false }
  ]
}
```

---

### `GET /api/v1/config/map-color-thresholds`

Get map color threshold configuration.

**Response `200`**
```json
{
  "thresholds": [
    { "count": 0, "color": "#4ade80" },
    { "count": 10, "color": "#facc15" },
    { "count": 50, "color": "#f97316" },
    { "count": 200, "color": "#ef4444" }
  ]
}
```

---

### `PUT /api/v1/config/map-color-thresholds`

Update map color thresholds.

**Request**
```json
{
  "thresholds": [
    { "count": 0, "color": "#4ade80" },
    { "count": 100, "color": "#facc15" }
  ]
}
```

> Thresholds must be strictly ascending by `count`.

**Response `200`** — Updated thresholds.

**Response `400`** — Thresholds not properly ordered.

---

### `GET /api/v1/config/fail2ban-log`

Read the tail of the fail2ban daemon log file.

**Query Parameters**

| Param | Type | Default | Description |
|---|---|---|---|
| `lines` | int | `200` | Number of tail lines (1–2000) |
| `filter` | string | null | Plain-text substring filter |

**Response `200`**
```json
{
  "lines": ["2024-12-25 08:00:01,000 INFO ...", "2024-12-25 08:00:02,000 WARNING ..."],
  "count": 2
}
```

---

### `GET /api/v1/config/service-status`

Fail2ban service health with log configuration.

**Response `200`**
```json
{
  "online": true,
  "version": "0.12.1",
  "loglevel": "INFO",
  "logtarget": "/var/log/fail2ban.log"
}
```

---

## Filters

### `GET /api/v1/config/filters`

List all available filters with active/inactive status.

**Response `200`**
```json
{
  "items": [
    {
      "name": "sshd",
      "active": true,
      "used_by_jails": ["sshd"],
      "source_file": "/etc/fail2ban/filter.d/sshd.conf",
      "has_local_override": false,
      "failregex": ["^%(__prefix_line)sFailed publickey"],
      "ignoreregex": [],
      "date_pattern": null,
      "journalmatch": null
    }
  ],
  "total": 12
}
```

Active filters (used by running jails) are listed first, sorted alphabetically. Inactive filters follow.

---

### `GET /api/v1/config/filters/{name}`

Full detail for a single filter.

**Response `200`** — FilterConfig object (same shape as list item).

**Response `404`** — Filter not found in `filter.d/`.

---

### `POST /api/v1/config/filters`

Create a new user-defined filter.

**Request**
```json
{
  "name": "nginx-404",
  "failregex": ["^\\s*\\S+ \\S+ \\S+ GET /nonexistent"],
  "ignoreregex": null,
  "date_pattern": null,
  "journalmatch": null
}
```

**Response `201`** — Created FilterConfig object.

**Response `409`** — Filter with this name already exists.

**Response `422`** — Regex failed to compile.

**Response `429`** — Rate limit exceeded (5 creates/minute per IP).

---

### `PUT /api/v1/config/filters/{name}`

Update a filter's `.local` override. Only non-null fields are written.

**Request**
```json
{
  "failregex": ["^new pattern here"],
  "ignoreregex": null
}
```

**Query Parameter** — `reload` (bool, default `false`) — trigger fail2ban reload after writing.

**Response `200`** — Updated FilterConfig object.

**Response `422`** — Regex failed to compile.

**Response `429`** — Rate limit exceeded (10 updates/minute per IP).

---

### `DELETE /api/v1/config/filters/{name}`

Delete a user-created filter's `.local` file. Shipped `.conf`-only filters cannot be deleted.

**Response `204`**

**Response `409`** — Filter is a shipped default (conf-only).

---

## Actions

### `GET /api/v1/config/actions`

List all available actions with active/inactive status.

**Response `200`**
```json
{
  "actions": [
    {
      "name": "iptables",
      "active": true,
      "used_by_jails": ["sshd", "nginx-http-auth"],
      "source_file": "/etc/fail2ban/action.d/iptables.conf",
      "has_local_override": false,
      "start_command": "iptables -N f2b-sshd...",
      "stop_command": "iptables -X f2b-sshd...",
      "check_command": "iptables -L f2b-sshd -n",
      "ban_action": "iptables -I f2b-sshd...",
      "unban_action": "iptables -D f2b-sshd..."
    }
  ],
  "total": 8
}
```

---

### `GET /api/v1/config/actions/{name}`

Full detail for a single action.

**Response `200`** — ActionConfig object (same shape as list item).

---

### `POST /api/v1/config/actions`

Create a new user-defined action.

**Request**
```json
{
  "name": "my-custom-action",
  "start_command": "echo 'starting'",
  "stop_command": "echo 'stopping'",
  "check_command": "echo 'checking'",
  "ban_action": "echo 'banning'",
  "unban_action": "echo 'unbanning'"
}
```

**Response `201`** — Created ActionConfig object.

**Response `409`** — Action with this name already exists.

---

### `PUT /api/v1/config/actions/{name}`

Update an action's `.local` override.

**Request** — All fields optional:
```json
{ "ban_action": "new ban command here" }
```

**Query Parameter** — `reload` (bool, default `false`).

**Response `200`** — Updated ActionConfig object.

---

### `DELETE /api/v1/config/actions/{name}`

Delete a user-created action's `.local` file.

**Response `204`**

**Response `409`** — Action is a shipped default (conf-only).

---

## Geo

### `GET /api/v1/geo/lookup/{ip}`

Ban status and geo info for an IP.

**Response `200`**
```json
{
  "ip": "1.2.3.4",
  "banned": true,
  "jails": ["sshd", "nginx-http-auth"],
  "country": "US",
  "country_name": "United States",
  "region": "North America",
  "city": "Mountain View",
  "isp": "Google LLC",
  "asn": "AS15169",
  "org": "Google LLC",
  "last_ban": "2024-12-25T08:00:00Z",
  "total_bans": 3
}
```

**Response `400`** — Invalid IP address.

---

### `GET /api/v1/geo/stats`

Geo cache diagnostic counters.

**Response `200`**
```json
{
  "total": 1500,
  "resolved": 1480,
  "failed": 20,
  "cache_size": 1480
}
```

---

### `POST /api/v1/geo/re-resolve`

Re-resolve all IPs with failed geo lookups.

**Response `200`**
```json
{
  "total": 20,
  "resolved": 18,
  "failed": 2
}
```

---

## Blocklists

### `GET /api/v1/blocklists`

List all blocklist sources.

**Response `200`**
```json
{
  "sources": [
    {
      "id": 1,
      "name": "Country Block List",
      "url": "https://example.com/blocklist.txt",
      "enabled": true,
      "last_import_at": "2024-12-25T08:00:00Z",
      "last_import_succeeded": true,
      "last_import_ban_count": 45
    }
  ],
  "total": 1
}
```

---

### `POST /api/v1/blocklists`

Add a new blocklist source.

**Request**
```json
{ "name": "Spamhaus DROP", "url": "https://www.spamhaus.org/drop/drop.txt", "enabled": true }
```

**Response `201`** — Created BlocklistSource object.

**Response `400`** — URL validation failed.

---

### `GET /api/v1/blocklists/{source_id}`

Get a single blocklist source.

---

### `PUT /api/v1/blocklists/{source_id}`

Update a blocklist source.

**Request** — All fields optional:
```json
{ "name": "New Name", "enabled": false }
```

---

### `DELETE /api/v1/blocklists/{source_id}`

Delete a blocklist source.

**Response `204`**

---

### `POST /api/v1/blocklists/import`

Trigger an immediate import of all enabled blocklist sources.

**Response `200`**
```json
{
  "started_at": "2024-12-25T10:00:00Z",
  "sources": [
    {
      "id": 1,
      "name": "Spamhaus DROP",
      "url": "https://www.spamhaus.org/drop/drop.txt",
      "imported": 45,
      "skipped": 3,
      "failed": false,
      "error": null
    }
  ],
  "total_imported": 45,
  "total_skipped": 3,
  "total_failed": 0
}
```

**Response `429`** — Rate limit exceeded (1 import/hour per IP).

---

### `GET /api/v1/blocklists/schedule`

Get the current import schedule.

**Response `200`**
```json
{
  "enabled": true,
  "interval_hours": 24,
  "next_run_at": "2024-12-26T08:00:00Z"
}
```

---

### `PUT /api/v1/blocklists/schedule`

Update the import schedule.

**Request**
```json
{ "enabled": true, "interval_hours": 12 }
```

**Response `200`** — Updated ScheduleInfo.

---

### `GET /api/v1/blocklists/log`

Paginated import log.

**Query Parameters**

| Param | Type | Default | Description |
|---|---|---|---|
| `source_id` | int | null | Filter by source |
| `page` | int | `1` | 1-based page |
| `page_size` | int | `100` | Items per page (max 500) |

**Response `200`**
```json
{
  "items": [
    {
      "id": 1,
      "source_id": 1,
      "source_name": "Spamhaus DROP",
      "started_at": "2024-12-25T08:00:00Z",
      "completed_at": "2024-12-25T08:01:23Z",
      "imported": 45,
      "skipped": 3,
      "failed": false,
      "error": null
    }
  ],
  "total": 50,
  "page": 1,
  "page_size": 100
}
```

---

### `GET /api/v1/blocklists/{source_id}/preview`

Preview the contents of a blocklist source (downloads and samples first ~20 lines).

**Response `200`**
```json
{
  "url": "https://example.com/blocklist.txt",
  "validated_lines": ["1.2.3.4", "5.6.7.8"],
  "invalid_lines": ["not-an-ip"],
  "total_valid": 2,
  "total_invalid": 1
}
```

**Response `502`** — URL could not be reached.

---

## Server

### `GET /api/v1/server/settings`

Get fail2ban server-level settings.

**Response `200`**
```json
{
  "loglevel": "INFO",
  "logtarget": "/var/log/fail2ban.log",
  "syslog_socket": "auto",
  "db_file": "/var/lib/fail2ban/fail2ban.sqlite3",
  "db_purge_age": 86400,
  "max_matches": 100
}
```

---

### `PUT /api/v1/server/settings`

Update fail2ban server-level settings.

**Request** — All fields optional:
```json
{ "loglevel": "DEBUG", "max_matches": 200 }
```

**Response `204`**

**Response `400`** — fail2ban rejected a setting.

---

### `POST /api/v1/server/flush-logs`

Flush and re-open fail2ban log files (after log rotation).

**Response `200`**
```json
{ "message": "Success: 1 log(s) flushed" }
```

---

## Common Types

### `TimeRange`

```
"24h" | "7d" | "30d" | "365d"
```

### `BanOrigin`

```
"blocklist" | "selfblock"
```

### `Source`

```
"fail2ban" | "archive"
```

---

## Status Codes

| Code | Meaning |
|---|---|
| `200` | OK |
| `201` | Created |
| `204` | No Content |
| `400` | Bad Request — invalid input |
| `401` | Unauthorized — session missing, expired, or invalid |
| `404` | Not Found |
| `409` | Conflict |
| `422` | Unprocessable Entity — validation failed |
| `429` | Too Many Requests — rate limit exceeded |
| `502` | Bad Gateway — fail2ban unreachable |
| `503` | Service Unavailable |
