# 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 ` 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 | `25` | 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": 25 } ``` --- ### `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 | `25` | 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": 25 } ``` --- ### `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 | `25` | 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": 25 } ``` --- ## 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 | `25` | 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": 25 } ``` --- ### `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 |