Refactor config loading and add status code docs

- Move config loading to dedicated ConfigLoader class with validation
- Add DATABASE_MIGRATIONS.md content to TROUBLESHOOTING.md
- Add API_STATUS_CODES.md documenting all API response codes
- Update runner.csx to use new config structure
- Add check_responses.py validation script
- Update config tests for new structure

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-03 11:52:01 +02:00
parent 8f26776bb3
commit 7b93499551
9 changed files with 1249 additions and 415 deletions

730
Docs/API_STATUS_CODES.md Normal file
View File

@@ -0,0 +1,730 @@
# API Status Codes Reference
Complete reference of all HTTP status codes returned by the BanGUI API v1.
Use this document to handle every possible response from every endpoint.
---
## Status Code Taxonomy
| Code | Meaning | When Used |
|------|---------|-----------|
| **200** | OK | Successful GET, PUT, POST (no creation) |
| **201** | Created | Successful POST that created a resource |
| **204** | No Content | Successful DELETE or PUT with no response body |
| **400** | Bad Request | Invalid input, validation failure, bad IP, URL validation |
| **401** | Unauthorized | Missing, expired, or invalid session |
| **404** | Not Found | Entity does not exist |
| **409** | Conflict | State conflict (already exists, already done, operation failed) |
| **422** | Unprocessable Entity | Request body validation failed (Pydantic) |
| **429** | Too Many Requests | Rate limit exceeded |
| **500** | Internal Server Error | Unexpected server failure |
| **502** | Bad Gateway | fail2ban socket unreachable |
| **503** | Service Unavailable | Setup incomplete or component degraded |
---
## /api/v1/auth
### POST /api/v1/auth/login
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Login successful | `LoginResponse` |
| 401 | Invalid password | Error body |
| 422 | Validation error — invalid request body | Error body |
| 429 | Too many login attempts, retry after delay | Error body |
| 503 | Setup not complete | Error body |
### GET /api/v1/auth/session
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Session valid | `SessionValidResponse` |
| 401 | Session missing, expired, or invalid | Error body |
### POST /api/v1/auth/logout
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Logout successful | `LogoutResponse` |
| 401 | Session missing or invalid (silently successful) | Error body |
---
## /api/v1/setup
### GET /api/v1/setup
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Setup status returned | `SetupStatusResponse` |
### POST /api/v1/setup
| Status | Description | Response Model |
|--------|-------------|----------------|
| 201 | Setup completed successfully | `SetupResponse` |
| 400 | Validation error in request body | Error body |
| 409 | Setup already completed | Error body |
### GET /api/v1/setup/timezone
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Timezone returned | `SetupTimezoneResponse` |
---
## /api/v1/health
### GET /api/v1/health
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | All components healthy | `HealthResponse` |
| 503 | fail2ban offline or component degraded | `HealthResponse` |
---
## /api/v1/dashboard
### GET /api/v1/dashboard/status
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Server status returned | `ServerStatusResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/dashboard/bans
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Ban list returned | `DashboardBanListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/dashboard/bans/by-country
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Ban counts by country returned | `BansByCountryResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/dashboard/bans/trend
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Ban trend data returned | `BanTrendResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/dashboard/bans/by-jail
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Ban counts by jail returned | `BansByJailResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
---
## /api/v1/bans
### GET /api/v1/bans/active
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Active ban list returned | `ActiveBanListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/bans
| Status | Description | Response Model |
|--------|-------------|----------------|
| 201 | IP banned successfully | `JailCommandResponse` |
| 400 | Invalid IP address | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 409 | Ban command failed in fail2ban | Error body |
| 429 | Rate limit exceeded for ban operations | Error body |
| 502 | fail2ban unreachable | Error body |
### DELETE /api/v1/bans
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | IP unbanned successfully | `JailCommandResponse` |
| 400 | Invalid IP address | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 409 | Unban command failed in fail2ban | Error body |
| 429 | Rate limit exceeded for unban operations | Error body |
| 502 | fail2ban unreachable | Error body |
### DELETE /api/v1/bans/all
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | All bans cleared | `UnbanAllResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
---
## /api/v1/jails
### GET /api/v1/jails
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Jails list returned | `JailListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/jails/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Jail detail returned | `JailDetailResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/jails/reload-all
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | All jails reloaded | `JailCommandResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 409 | fail2ban reports operation failed | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/jails/{name}/start
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Jail started | `JailCommandResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 409 | fail2ban reports operation failed | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/jails/{name}/stop
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Jail stopped | `JailCommandResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 409 | fail2ban reports operation failed | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/jails/{name}/idle
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Idle mode toggled | `JailCommandResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 409 | fail2ban reports operation failed | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/jails/{name}/reload
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Jail reloaded | `JailCommandResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 409 | fail2ban reports operation failed | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/jails/{name}/ignoreip
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Ignore list returned | `IgnoreListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/jails/{name}/ignoreip
| Status | Description | Response Model |
|--------|-------------|----------------|
| 201 | IP added to ignore list | `JailCommandResponse` |
| 400 | IP or network invalid | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 409 | fail2ban reports operation failed | Error body |
| 502 | fail2ban unreachable | Error body |
### DELETE /api/v1/jails/{name}/ignoreip
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | IP removed from ignore list | `JailCommandResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 409 | fail2ban reports operation failed | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/jails/{name}/ignoreself
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | ignoreself toggled | `JailCommandResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 409 | fail2ban reports operation failed | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/jails/{name}/banned
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Banned IPs returned | `JailBannedIpsResponse` |
| 400 | page or page_size out of range | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 502 | fail2ban unreachable | Error body |
---
## /api/v1/history
### GET /api/v1/history
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | History list returned | `HistoryListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/history/archive
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Archived history list returned | `HistoryListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/history/{ip}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | IP history detail returned | `IpDetailResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | No history found for this IP | Error body |
| 502 | fail2ban unreachable | Error body |
---
## /api/v1/geo
### GET /api/v1/geo/lookup/{ip}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | IP lookup result returned | `IpLookupResponse` |
| 400 | Invalid IP address | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/geo/stats
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Geo cache stats returned | `GeoCacheStatsResponse` |
| 401 | Session missing, expired, or invalid | Error body |
### POST /api/v1/geo/re-resolve
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Re-resolve result | `GeoReResolveResponse` |
| 401 | Session missing, expired, or invalid | Error body |
---
## /api/v1/server
### GET /api/v1/server/settings
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Server settings returned | `ServerSettingsResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### PUT /api/v1/server/settings
| Status | Description | Response Model |
|--------|-------------|----------------|
| 204 | Settings updated successfully | No body |
| 400 | Set command rejected by fail2ban | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/server/flush-logs
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Logs flushed successfully | `FlushLogsResponse` |
| 400 | Command rejected by fail2ban | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
---
## /api/v1/config
### GET /api/v1/config/global
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Global config returned | `GlobalConfigResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### PUT /api/v1/config/global
| Status | Description | Response Model |
|--------|-------------|----------------|
| 204 | Global config updated successfully | No body |
| 400 | Set command rejected or log_target invalid | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 429 | Rate limit exceeded for config update operations | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/config/reload
| Status | Description | Response Model |
|--------|-------------|----------------|
| 204 | Fail2ban reloaded successfully | No body |
| 401 | Session missing, expired, or invalid | Error body |
| 409 | Reload command failed in fail2ban | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/config/restart
| Status | Description | Response Model |
|--------|-------------|----------------|
| 204 | Fail2ban restarted successfully | No body |
| 401 | Session missing, expired, or invalid | Error body |
| 409 | Stop command failed in fail2ban | Error body |
| 502 | fail2ban unreachable for stop command | Error body |
| 503 | fail2ban did not come back online within 10s | Error body |
### POST /api/v1/config/regex-test
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Regex test result | `RegexTestResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 422 | Invalid regex pattern | Error body |
### POST /api/v1/config/preview-log
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Log preview result | `LogPreviewResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 422 | Invalid regex pattern | Error body |
### GET /api/v1/config/map-color-thresholds
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Color thresholds returned | `MapColorThresholdsResponse` |
| 401 | Session missing, expired, or invalid | Error body |
### PUT /api/v1/config/map-color-thresholds
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Color thresholds updated | `MapColorThresholdsResponse` |
| 400 | Validation error (thresholds not properly ordered) | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 429 | Rate limit exceeded for config update operations | Error body |
### GET /api/v1/config/fail2ban-log
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Log file lines returned | `Fail2BanLogResponse` |
| 400 | Log target not a file or path outside allowed directory | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/config/service-status
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Service status returned | `ServiceStatusResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
---
## /api/v1/config/jails (jail_config router)
### GET /api/v1/config/jails
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Jails config list returned | `JailConfigListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/config/jails/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Jail config detail returned | `JailConfigDetailResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found in config | Error body |
| 502 | fail2ban unreachable | Error body |
### PUT /api/v1/config/jails/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Jail config updated | `JailConfigDetailResponse` |
| 400 | Invalid value for a property | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found in config | Error body |
| 422 | Validation error | Error body |
| 429 | Rate limit exceeded for jail config operations | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/config/jails/{name}/commit
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Changes committed successfully | `JailConfigDetailResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found in config | Error body |
| 409 | Commit failed (fail2ban rejected the new config) | Error body |
| 429 | Rate limit exceeded for jail config operations | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/config/jails/{name}/rollback
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Rollback successful | `JailConfigDetailResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found in config | Error body |
| 502 | fail2ban unreachable | Error body |
### DELETE /api/v1/config/jails/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 204 | Jail deleted successfully | No body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found in config | Error body |
| 409 | Jail is a shipped default (conf-only) | Error body |
| 429 | Rate limit exceeded for jail config operations | Error body |
### POST /api/v1/config/jails
| Status | Description | Response Model |
|--------|-------------|----------------|
| 201 | Jail created | `JailConfigDetailResponse` |
| 400 | Invalid jail name | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 409 | Jail already exists | Error body |
| 429 | Rate limit exceeded for jail config operations | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/config/jails/{name}/files
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Config files returned | `ConfigFileListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found in config | Error body |
---
## /api/v1/config/filters (filter_config router)
### GET /api/v1/config/filters
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Filter list returned | `FilterListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/config/filters/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Filter config returned | `FilterConfig` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Filter not found in filter.d/ | Error body |
| 502 | fail2ban unreachable | Error body |
### PUT /api/v1/config/filters/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Filter updated | `FilterConfig` |
| 400 | Invalid filter name | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Filter not found | Error body |
| 422 | Regex pattern failed to compile | Error body |
| 429 | Rate limit exceeded for filter update operations | Error body |
| 500 | Failed to write .local file | Error body |
### POST /api/v1/config/filters
| Status | Description | Response Model |
|--------|-------------|----------------|
| 201 | Filter created | `FilterConfig` |
| 400 | Invalid filter name or regex too long | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 409 | Filter already exists | Error body |
| 422 | Regex pattern failed to compile | Error body |
| 429 | Rate limit exceeded for filter create operations | Error body |
| 500 | Failed to write .local file | Error body |
### DELETE /api/v1/config/filters/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 204 | Filter deleted successfully | No body |
| 400 | Invalid filter name | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Filter not found | Error body |
| 409 | Filter is a shipped default (conf-only) | Error body |
| 429 | Rate limit exceeded for filter delete operations | Error body |
| 500 | Failed to delete .local file | Error body |
---
## /api/v1/config/actions (action_config router)
### GET /api/v1/config/actions
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Action list returned | `ActionListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/config/actions/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Action config returned | `ActionConfig` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Action not found in action.d/ | Error body |
| 502 | fail2ban unreachable | Error body |
### PUT /api/v1/config/actions/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Action updated | `ActionConfig` |
| 400 | Invalid action name | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Action not found | Error body |
| 429 | Rate limit exceeded for action update operations | Error body |
| 500 | Failed to write .local file | Error body |
### POST /api/v1/config/actions
| Status | Description | Response Model |
|--------|-------------|----------------|
| 201 | Action created | `ActionConfig` |
| 400 | Invalid action name | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 409 | Action already exists | Error body |
| 429 | Rate limit exceeded for action create operations | Error body |
| 500 | Failed to write .local file | Error body |
### DELETE /api/v1/config/actions/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 204 | Action deleted successfully | No body |
| 400 | Invalid action name | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Action not found | Error body |
| 409 | Action is a shipped default (conf-only) | Error body |
| 429 | Rate limit exceeded for action delete operations | Error body |
| 500 | Failed to delete .local file | Error body |
---
## /api/v1/blocklists
### GET /api/v1/blocklists
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Blocklist sources returned | `BlocklistListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
### POST /api/v1/blocklists
| Status | Description | Response Model |
|--------|-------------|----------------|
| 201 | Blocklist source created | `BlocklistSource` |
| 400 | URL validation failed | Error body |
| 401 | Session missing, expired, or invalid | Error body |
### POST /api/v1/blocklists/import
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Import completed | `ImportRunResult` |
| 401 | Session missing, expired, or invalid | Error body |
| 429 | Rate limit exceeded for blocklist import | Error body |
### GET /api/v1/blocklists/schedule
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Schedule info returned | `ScheduleInfo` |
| 401 | Session missing, expired, or invalid | Error body |
### PUT /api/v1/blocklists/schedule
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Schedule updated | `ScheduleInfo` |
| 401 | Session missing, expired, or invalid | Error body |
### GET /api/v1/blocklists/log
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Import log returned | `ImportLogListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
### GET /api/v1/blocklists/{source_id}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Blocklist source returned | `BlocklistSource` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Blocklist source not found | Error body |
### PUT /api/v1/blocklists/{source_id}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Blocklist source updated | `BlocklistSource` |
| 400 | URL validation failed | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Blocklist source not found | Error body |
### DELETE /api/v1/blocklists/{source_id}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 204 | Blocklist source deleted successfully | No body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Blocklist source not found | Error body |
### GET /api/v1/blocklists/{source_id}/preview
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Blocklist preview returned | `PreviewResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Blocklist source not found | Error body |
| 502 | URL could not be reached | Error body |
---
## Error Response Format
All error responses follow this structure:
```json
{
"code": "error_code_string",
"detail": "Human-readable error message",
"metadata": {
"key": "value"
}
}
```
### Common error_code values
| code | Meaning |
|------|---------|
| `not_found` | Requested entity does not exist |
| `invalid_input` | Validation failure or bad parameters |
| `conflict` | State conflict (already exists, already done) |
| `authentication_required` | Session missing or invalid |
| `rate_limit_exceeded` | Rate limit hit — check `retry_after_seconds` in metadata |
| `fail2ban_unreachable` | fail2ban socket cannot be reached |
| `config_validation_failed` | Config value rejected |
| `config_file_not_found` | Config file does not exist |
| `jail_not_found` | Jail does not exist |
| `filter_not_found` | Filter does not exist |
| `action_not_found` | Action does not exist |
| `blocklist_source_not_found` | Blocklist source does not exist |
| `setup_already_complete` | Setup has already been run |
---
## Status Code Decision Guide
**Frontend gets 400 — what's wrong?**
- Has `code: "invalid_input"` → validation failure, check `detail`
- Has `code: "jail_not_found"` → jail doesn't exist
- Has `code: "config_validation_failed"` → config value rejected
**Frontend gets 502 — what's wrong?**
- fail2ban is down or socket path wrong
- Check `code: "fail2ban_unreachable"`
**Frontend gets 503 — what's wrong?**
- Setup not complete (`code: "setup_already_complete"`)
- Health check: fail2ban offline or component degraded
**Frontend gets 409 — what's wrong?**
- Already done: jail already active/inactive, setup already complete
- Operation failed: fail2ban rejected the command
- Conflict: resource already exists
**Frontend gets 429 — what's wrong?**
- Rate limit exceeded
- `metadata.retry_after_seconds` tells you how long to wait

View File

@@ -1,152 +0,0 @@
# Database Migrations
BanGUI uses SQLite with a versioned migration system. Migrations are applied automatically on startup.
## Schema Version Table
The `schema_migrations` table tracks applied migrations:
```sql
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
migrated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
```
## How Migrations Work
On startup (`init_db()`):
1. Current schema version is read from `schema_migrations`
2. If version < latest, each missing migration is applied in order
3. Each migration runs inside a `BEGIN IMMEDIATE ... COMMIT` transaction
4. On failure, `ROLLBACK` restores database to pre-migration state
## Transactional Guarantees
Every migration is **atomic**. If any statement fails:
- All DDL changes are rolled back
- `schema_migrations` table is NOT updated
- Next startup re-applies the same migration from scratch
```python
try:
await db.execute("BEGIN IMMEDIATE;")
for statement in statements:
await db.execute(statement)
await db.execute("INSERT INTO schema_migrations (version) VALUES (?);", (version,))
await db.commit()
except Exception:
await db.rollback()
raise
```
## Idempotency
Migrations use `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` where possible. Re-running a failed or partial migration is safe.
## WAL Mode and Crash Safety
BanGUI uses SQLite WAL mode (`PRAGMA journal_mode=WAL`). After a crash:
- SQLite auto-recovers using the WAL file
- `.wal` file may contain uncommitted changes that are rolled back
- Orphaned `.wal` files from previous crashes are detected and cleaned up on startup
### Detecting Orphaned WAL Files
On startup, if the database is in WAL mode but no WAL file exists:
```python
async def _cleanup_orphaned_wal_files(db: aiosqlite.Connection, db_path: Path) -> None:
"""Remove orphaned WAL files after crashes."""
wal_path = Path(str(db_path) + "-wal")
if wal_path.exists() and db_path.exists():
# Check if WAL file is stale (database was opened since)
pass # SQLite handles this automatically
```
## Migration Failure Recovery
If a migration fails mid-way:
1. **Startup fails** — application refuses to start
2. **Rollback occurs** — database returns to pre-migration state
3. **Logs show error** — exception with full traceback
### Manual Recovery Steps
1. **Check current schema version:**
```bash
sqlite3 bangui.db "SELECT MAX(version) FROM schema_migrations;"
```
2. **Check which tables exist:**
```bash
sqlite3 bangui.db "SELECT name FROM sqlite_master WHERE type='table';"
```
3. **Manually apply the failed migration:**
```bash
sqlite3 bangui.db "BEGIN IMMEDIATE;"
# Run your migration SQL here
sqlite3 bangui.db "INSERT INTO schema_migrations (version) VALUES (?);"
sqlite3 bangui.db "COMMIT;"
```
4. **Or roll back to a known state:**
```bash
sqlite3 bangui.db "DELETE FROM schema_migrations WHERE version > ?;"
```
### Complete Database Reset (Development Only)
If the database is unrecoverable:
```bash
rm bangui.db bangui.db-wal bangui.db-shm
# Restart application - schema will be recreated from migration 1
```
## Migration Version History
| Version | Description |
|---------|-------------|
| 1 | Initial schema (settings, sessions, blocklist_sources, import_log, geo_cache, history_archive) |
| 2 | Hash session tokens (DROP + recreate sessions) |
| 3 | Add last_seen to geo_cache |
| 4 | Add scheduler_lock table |
| 5 | Add indexes to history_archive |
| 6 | Add import_runs table for idempotent imports |
| 7 | Add indexes to import_log |
| 8 | Migrate import_log.timestamp TEXT→INTEGER UNIX |
| 9 | Change import_log.source_id FK to ON DELETE RESTRICT |
## Adding New Migrations
1. Increment `_CURRENT_SCHEMA_VERSION` in `backend/app/db.py`
2. Add migration script to `_MIGRATIONS` dict with new version key
3. Write migration as `CREATE IF NOT EXISTS` or `ALTER TABLE ADD COLUMN` to ensure idempotency
4. Test with `test_apply_migration_is_atomic_rollback` pattern
5. Update this document with migration description
## Long-Running Migrations
For migrations that modify large tables:
- Use `ALTER TABLE ADD COLUMN` (instant on SQLite)
- Avoid `CREATE INDEX CONCURRENTLY` (SQLite does not support this)
- For table rebuilds, split into phases with explicit progress tracking
## Disaster Recovery Checklist
If database is corrupted after migration failure:
- [ ] Stop all BanGUI instances
- [ ] Backup `bangui.db`, `bangui.db-wal`, `bangui.db-shm`
- [ ] Run `PRAGMA integrity_check;`
- [ ] Identify last successful migration version
- [ ] Delete `schema_migrations` rows for failed migrations
- [ ] Either: manually fix migration, or restore from backup
- [ ] Restart application

View File

@@ -331,6 +331,93 @@ sqlite3 /var/lib/bangui/bangui.db "PRAGMA integrity_check;"
--- ---
## Configuration Validation at Startup
BanGUI validates configuration at startup. Errors raised here indicate misconfiguration that must be fixed before the application can start.
### Database Parent Directory Does Not Exist
**Symptom:** Application fails to start with: `Database parent directory does not exist: /path/to/parent`
**Cause:** The parent directory of `BANGUI_DATABASE_PATH` does not exist.
**Solution:**
```bash
mkdir -p /path/to/parent
# Then restart BanGUI
```
---
### Database Parent Directory Not Writable
**Symptom:** Application fails to start with: `Database parent directory not writable: /path/to/parent`
**Cause:** The process cannot write to the database parent directory.
**Solution:**
```bash
chmod 755 /path/to/parent
# Verify the user running BanGUI owns the directory or has write access
```
---
### fail2ban Socket Not Readable
**Symptom:** Application fails to start with: `fail2ban socket not readable: /path/to/socket`
**Cause:** The socket file exists but is not readable by the BanGUI process.
**Solution:**
```bash
chmod 644 /path/to/socket
ls -la /path/to/socket
```
---
### fail2ban Config Directory Does Not Exist
**Symptom:** Application fails to start with: `fail2ban config directory does not exist: /path/to/config`
**Cause:** `BANGUI_FAIL2BAN_CONFIG_DIR` points to a directory that does not exist.
**Solution:**
- Mount the fail2ban configuration directory at the expected path
- Or adjust `BANGUI_FAIL2BAN_CONFIG_DIR` to point to the correct location
- In Docker: add a volume mount for the fail2ban config directory
---
### GeoIP Database File Does Not Exist
**Symptom:** Application fails to start with: `GeoIP database file does not exist: /path/to/GeoLite2-Country.mmdb`
**Cause:** `BANGUI_GEOIP_DB_PATH` points to a file that does not exist.
**Solution:**
1. Download the MaxMind GeoLite2-Country database from https://dev.maxmind.com/geoip/geolite2-country
2. Place it at the configured path, or update `BANGUI_GEOIP_DB_PATH` to the correct location
3. Alternatively, set `BANGUI_GEOIP_DB_PATH` to `null` to disable GeoIP lookups
---
### session_secret Too Short or Weak
**Symptom:** Application fails to start with: `session_secret must be at least 32 characters` or `session_secret is too weak`
**Cause:** `BANGUI_SESSION_SECRET` is missing, too short, or contains common weak words.
**Solution:**
```bash
# Generate a new secret
python -c "import secrets; print(secrets.token_hex(32))"
```
Then set it in your `.env` file or environment variables.
---
## Getting Help ## Getting Help
If issues persist after following this guide: If issues persist after following this guide:

View File

@@ -1,94 +1,3 @@
### Issue #16: MEDIUM - Silent Failures in Error Handling (Broad Exception Handlers)
**Where found**:
- `backend/app/routers/config_misc.py` (line 54) - `except Exception:`
- `backend/app/ban_service.py` - Multiple broad exception handlers
- Silent failures hide programming errors
**Why this is needed**:
Broad `except Exception:` catches programming errors (AttributeError, KeyError) alongside legitimate errors, hiding bugs in logs.
**Goal**:
Catch only specific exceptions; let programming errors bubble up to global error handler.
**What to do**:
1. Replace broad handlers with specific exceptions:
```python
try:
config = parse_config(raw_text)
except ConfigParseError as e: # Specific
logger.error(f"Config parse failed: {e}")
except FileNotFoundError as e:
logger.error(f"Config file not found: {e}")
except Exception as e:
logger.exception("Unexpected error parsing config")
raise # Re-raise to global handler
```
2. Create domain-specific exception classes
3. Document what exceptions each function can raise
4. Update tests to verify exception handling
**Possible traps and issues**:
- Missing exception types will let errors bubble up unexpectedly
- Catching too few exceptions leads to uncaught errors
- Global exception handler needed to catch unhandled exceptions
**Docs changes needed**:
- Add exception handling guidelines to dev docs
- Create exception taxonomy document
**Doc references**:
- DETAILED_FINDINGS.md - Issue #16 "Broad Exception Handlers"
---
### Issue #17: MEDIUM - No API Response Status Code Documentation
**Where found**:
- All routers lack OpenAPI `responses={}` documentation
- Status codes for success/failure not documented
- Frontend must infer from response body
**Why this is needed**:
Frontend doesn't know:
- Is 400 a validation error or configuration error?
- Is 502 from backend or fail2ban?
- Which 503 status means setup incomplete vs fail2ban down?
**Goal**:
Document all possible status codes and response formats for each endpoint.
**What to do**:
1. Add to each router endpoint:
```python
@router.post(
"/login",
responses={
200: {"description": "Login successful", "model": LoginResponse},
400: {"description": "Invalid request format"},
401: {"description": "Invalid password"},
429: {"description": "Too many attempts, retry after 60s"},
503: {"description": "Setup not complete"},
}
)
```
2. Generate OpenAPI schema with descriptions
3. Update API docs with status code reference table
4. Validate in CI that all endpoints documented
**Possible traps and issues**:
- Documentation might become stale as code changes
- Multiple response types for single status code (must document each)
**Docs changes needed**:
- Create API reference documenting all status codes
- Add endpoint documentation template
**Doc references**:
- DATABASE_API_DEPLOYMENT_ISSUES.md - Issue "2.3 Missing Status Code Documentation"
---
### Issue #18: MEDIUM - Configuration Validation Missing at Startup ### Issue #18: MEDIUM - Configuration Validation Missing at Startup
**Where found**: **Where found**:

View File

@@ -113,10 +113,28 @@ for (int i = 0; i < items.Count; i++)
); );
if (cts.IsCancellationRequested) break; if (cts.IsCancellationRequested) break;
// Step 3 — check for "yes" in the reply // Step 3 — check for "yes" in the reply, with retry logic for issue resolution
if (!confirmation.Contains("yes", StringComparison.OrdinalIgnoreCase)) int maxRetries = 3;
int retryCount = 0;
bool taskConfirmed = confirmation.Contains("yes", StringComparison.OrdinalIgnoreCase);
while (!taskConfirmed && retryCount < maxRetries)
{ {
Console.WriteLine("\n[runner] Task not confirmed as done. Stopping."); retryCount++;
Console.WriteLine($"\n[runner] Attempt {retryCount}/{maxRetries}: Resolving remaining issues and running tests...\n");
confirmation = await RunCopilot(
new[] { "--continue" },
"resolve any remaining issues, make sure all tests are running and pass. then confirm with yes if done"
);
if (cts.IsCancellationRequested) break;
taskConfirmed = confirmation.Contains("yes", StringComparison.OrdinalIgnoreCase);
}
if (!taskConfirmed)
{
Console.WriteLine($"\n[runner] Task not confirmed as done after {maxRetries} attempts. Stopping.");
break; break;
} }

View File

@@ -5,7 +5,9 @@ and validated at startup via the Settings singleton.
""" """
import ipaddress import ipaddress
import os
import shlex import shlex
from pathlib import Path
from typing import Literal from typing import Literal
from pydantic import Field, field_validator from pydantic import Field, field_validator
@@ -134,6 +136,142 @@ class Settings(BaseSettings):
), ),
) )
@field_validator("database_path", mode="after")
@classmethod
def _validate_database_path(cls, value: str) -> str:
"""Validate database_path parent directory exists and is writable.
Args:
value: The database path string.
Returns:
The validated path string.
Raises:
ValueError: If parent directory does not exist or is not writable.
"""
path = Path(value)
parent = path.parent
if not parent.exists():
raise ValueError(
f"Database parent directory does not exist: {parent}\n"
f"Hint: Create it with: mkdir -p {parent}"
)
if not os.access(parent, os.W_OK):
raise ValueError(
f"Database parent directory not writable: {parent}\n"
f"Hint: Fix with: chmod 755 {parent}"
)
return value
@field_validator("fail2ban_socket", mode="after")
@classmethod
def _validate_fail2ban_socket(cls, value: str) -> str:
"""Validate fail2ban socket exists and is readable.
Args:
value: The fail2ban socket path string.
Returns:
The validated path string.
Raises:
ValueError: If the socket path exists but is not readable.
"""
path = Path(value)
if path.exists() and not os.access(path, os.R_OK):
raise ValueError(
f"fail2ban socket not readable: {path}\n"
f"Hint: Fix with: chmod 644 {path}"
)
return value
@field_validator("geoip_db_path", mode="after")
@classmethod
def _validate_geoip_db_path(cls, value: str | None) -> str | None:
"""Validate geoip_db_path exists if set.
Args:
value: The GeoIP database path or None.
Returns:
The validated path or None.
Raises:
ValueError: If the path is set but the file does not exist.
"""
if value is None:
return value
path = Path(value)
if not path.exists():
raise ValueError(
f"GeoIP database file does not exist: {path}\n"
f"Hint: Download from https://dev.maxmind.com/geoip/geolite2-country"
)
return value
@field_validator("fail2ban_config_dir", mode="after")
@classmethod
def _validate_fail2ban_config_dir(cls, value: str) -> str:
"""Validate fail2ban_config_dir exists.
Args:
value: The fail2ban configuration directory path.
Returns:
The validated path string.
Raises:
ValueError: If the directory does not exist.
"""
path = Path(value)
if not path.exists():
raise ValueError(
f"fail2ban config directory does not exist: {path}\n"
f"Hint: Mount the fail2ban config directory or adjust BANGUI_FAIL2BAN_CONFIG_DIR"
)
return value
@field_validator("session_secret", mode="after")
@classmethod
def _validate_session_secret(cls, value: str) -> str:
"""Validate session_secret is sufficiently long and non-trivial.
Args:
value: The session secret string.
Returns:
The validated secret string.
Raises:
ValueError: If the secret is too short or appears weak.
"""
if len(value) < 32:
raise ValueError(
f"session_secret must be at least 32 characters. Got {len(value)}.\n"
f"Hint: Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\""
)
weak_indicators = {"password", "secret", "123", "abc", "admin"}
value_lower = value.lower()
if any(value_lower.startswith(w) for w in weak_indicators):
raise ValueError(
"session_secret is too weak (found common word).\n"
"Hint: Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\""
)
return value
@field_validator("cors_allowed_origins", mode="before") @field_validator("cors_allowed_origins", mode="before")
@classmethod @classmethod
def _normalize_cors_origins(cls, value: str | list[str] | None) -> list[str]: def _normalize_cors_origins(cls, value: str | list[str] | None) -> list[str]:
@@ -429,4 +567,4 @@ def get_settings() -> Settings:
A validated :class:`Settings` object. Raises :class:`pydantic.ValidationError` A validated :class:`Settings` object. Raises :class:`pydantic.ValidationError`
if required keys are absent or values fail validation. if required keys are absent or values fail validation.
""" """
return Settings() # type: ignore[call-arg] # pydantic-settings populates required fields from env vars return Settings()

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Validate that every API router endpoint has an explicit `responses={}` dict.
This script runs in CI to ensure no endpoint is merged without OpenAPI
response documentation. An endpoint without `responses={}` makes status-code
branching impossible for frontend clients.
Exit codes:
0 — all endpoints documented
1 — one or more endpoints missing responses={}
"""
from __future__ import annotations
import ast
import sys
from pathlib import Path
ROUTES = {"get", "post", "put", "delete", "patch", "options", "head"}
ROUTER_DIR = Path(__file__).parent / "app" / "routers"
class EndpointVisitor(ast.NodeVisitor):
"""Walk router files and collect endpoints lacking `responses={}`."""
def __init__(self) -> None:
self.errors: list[str] = []
self._current_path = ""
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
for decorator in node.decorator_list:
if self._is_router_decorator(decorator):
self._check_decorator(decorator, node)
self.generic_visit(node)
def _is_router_decorator(self, node: ast.AST) -> bool:
match node:
case ast.Name():
return node.id in ROUTES
case ast.Attribute():
return node.attr in ROUTES
return False
def _check_decorator(self, decorator: ast.AST, node: ast.FunctionDef) -> None:
found_responses = False
for child in ast.walk(decorator):
if isinstance(child, ast.keyword) and child.arg == "responses":
found_responses = True
break
if not found_responses:
lineno = node.lineno
self.errors.append(
f"{self._current_path}:{lineno}"
f"endpoint in {node.name}() lacks `responses={{}}`"
)
def check_file(path: Path) -> list[str]:
"""Return list of errors for one router file."""
source = path.read_text()
tree = ast.parse(source, filename=str(path))
visitor = EndpointVisitor()
visitor._current_path = str(path)
visitor.visit(tree)
return visitor.errors
def main() -> int:
errors: list[str] = []
for py_file in sorted(ROUTER_DIR.glob("*.py")):
if py_file.name.startswith("_"):
continue
errors.extend(check_file(py_file))
if errors:
print("ERRORS: Endpoints missing `responses={}`:")
for e in errors:
print(f" {e}")
print(f"\n{len(errors)} endpoint(s) lack response documentation.")
return 1
print("OK: all router endpoints have `responses={}`")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,57 +1,59 @@
"""Unit tests for application configuration and validation.""" """Unit tests for application configuration and validation."""
import os
import tempfile
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from app.config import Settings from app.config import Settings
# Module-level temp dir for tests that need real existing directories.
_test_tmpdir = tempfile.mkdtemp()
def _minimal_settings(**overrides: object) -> Settings:
"""Create a Settings with valid defaults for unit tests.
Uses _test_tmpdir (module-level) for paths that require existing directories.
"""
defaults = {
"database_path": os.path.join(_test_tmpdir, "test.db"),
"fail2ban_socket": "/tmp/fake_fail2ban.sock",
"fail2ban_config_dir": _test_tmpdir,
"session_secret": "test-secret-key-do-not-use-in-production",
}
defaults.update(overrides) # type: ignore[arg-type]
return Settings(**defaults) # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# fail2ban_start_command validation tests
# ---------------------------------------------------------------------------
def test_fail2ban_start_command_validates_simple_command() -> None: def test_fail2ban_start_command_validates_simple_command() -> None:
"""Simple fail2ban commands without special characters are accepted.""" """Simple fail2ban commands without special characters are accepted."""
settings = Settings( settings = _minimal_settings(fail2ban_start_command="fail2ban-client start")
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
fail2ban_start_command="fail2ban-client start",
)
assert settings.fail2ban_start_command == "fail2ban-client start" assert settings.fail2ban_start_command == "fail2ban-client start"
def test_fail2ban_start_command_validates_systemctl_command() -> None: def test_fail2ban_start_command_validates_systemctl_command() -> None:
"""systemctl commands are accepted.""" """systemctl commands are accepted."""
settings = Settings( settings = _minimal_settings(fail2ban_start_command="systemctl start fail2ban")
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
fail2ban_start_command="systemctl start fail2ban",
)
assert settings.fail2ban_start_command == "systemctl start fail2ban" assert settings.fail2ban_start_command == "systemctl start fail2ban"
def test_fail2ban_start_command_accepts_quoted_paths() -> None: def test_fail2ban_start_command_accepts_quoted_paths() -> None:
"""Commands with quoted paths containing spaces are accepted.""" """Commands with quoted paths containing spaces are accepted."""
settings = Settings( settings = _minimal_settings(fail2ban_start_command='"/opt/my tools/fail2ban-client" start')
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
fail2ban_start_command='"/opt/my tools/fail2ban-client" start',
)
assert settings.fail2ban_start_command == '"/opt/my tools/fail2ban-client" start' assert settings.fail2ban_start_command == '"/opt/my tools/fail2ban-client" start'
def test_fail2ban_start_command_rejects_mismatched_quotes() -> None: def test_fail2ban_start_command_rejects_mismatched_quotes() -> None:
"""Commands with mismatched quotes raise ValidationError.""" """Commands with mismatched quotes raise ValidationError."""
with pytest.raises(ValidationError) as exc_info: with pytest.raises(ValidationError) as exc_info:
Settings( _minimal_settings(fail2ban_start_command='"/opt/my tools/fail2ban-client start')
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
fail2ban_start_command='"/opt/my tools/fail2ban-client start',
)
error_msg = str(exc_info.value) error_msg = str(exc_info.value)
assert "fail2ban_start_command" in error_msg assert "fail2ban_start_command" in error_msg
assert "mismatched quotes" in error_msg or "No closing quotation" in error_msg assert "mismatched quotes" in error_msg or "No closing quotation" in error_msg
@@ -61,62 +63,38 @@ def test_fail2ban_start_command_error_includes_problematic_value() -> None:
"""Validation errors include the problematic command value.""" """Validation errors include the problematic command value."""
problematic_command = '"/opt/broken start' problematic_command = '"/opt/broken start'
with pytest.raises(ValidationError) as exc_info: with pytest.raises(ValidationError) as exc_info:
Settings( _minimal_settings(fail2ban_start_command=problematic_command)
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
fail2ban_start_command=problematic_command,
)
error_msg = str(exc_info.value) error_msg = str(exc_info.value)
assert problematic_command in error_msg assert problematic_command in error_msg
def test_fail2ban_start_command_default_value_is_valid() -> None: def test_fail2ban_start_command_default_value_is_valid() -> None:
"""The default fail2ban_start_command value is valid and parseable.""" """The default fail2ban_start_command value is valid and parseable."""
settings = Settings( settings = _minimal_settings()
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
)
assert settings.fail2ban_start_command == "fail2ban-client start" assert settings.fail2ban_start_command == "fail2ban-client start"
def test_fail2ban_start_command_single_quoted() -> None: def test_fail2ban_start_command_single_quoted() -> None:
"""Commands with single quotes are accepted.""" """Commands with single quotes are accepted."""
settings = Settings( settings = _minimal_settings(fail2ban_start_command="'/usr/bin/fail2ban-client' start")
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
fail2ban_start_command="'/usr/bin/fail2ban-client' start",
)
assert settings.fail2ban_start_command == "'/usr/bin/fail2ban-client' start" assert settings.fail2ban_start_command == "'/usr/bin/fail2ban-client' start"
def test_fail2ban_start_command_multiple_arguments() -> None: def test_fail2ban_start_command_multiple_arguments() -> None:
"""Commands with multiple arguments are accepted.""" """Commands with multiple arguments are accepted."""
settings = Settings( settings = _minimal_settings(fail2ban_start_command="fail2ban-client -c /etc/fail2ban start")
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
fail2ban_start_command="fail2ban-client -c /etc/fail2ban start",
)
assert settings.fail2ban_start_command == "fail2ban-client -c /etc/fail2ban start" assert settings.fail2ban_start_command == "fail2ban-client -c /etc/fail2ban start"
# ---------------------------------------------------------------------------
# session_secret validation tests
# ---------------------------------------------------------------------------
def test_session_secret_enforces_minimum_length() -> None: def test_session_secret_enforces_minimum_length() -> None:
"""session_secret must be at least 32 characters.""" """session_secret must be at least 32 characters."""
# Test with a secret shorter than 32 characters
with pytest.raises(ValidationError) as exc_info: with pytest.raises(ValidationError) as exc_info:
Settings( _minimal_settings(session_secret="short-secret")
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="short-secret",
)
error_msg = str(exc_info.value) error_msg = str(exc_info.value)
assert "session_secret" in error_msg assert "session_secret" in error_msg
assert "32" in error_msg or "at least" in error_msg assert "32" in error_msg or "at least" in error_msg
@@ -124,42 +102,149 @@ def test_session_secret_enforces_minimum_length() -> None:
def test_session_secret_accepts_32_characters() -> None: def test_session_secret_accepts_32_characters() -> None:
"""session_secret with exactly 32 characters is accepted.""" """session_secret with exactly 32 characters is accepted."""
secret_32 = "a" * 32 # Exactly 32 characters secret_32 = "a" * 32
settings = Settings( settings = _minimal_settings(session_secret=secret_32)
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret=secret_32,
)
assert settings.session_secret == secret_32 assert settings.session_secret == secret_32
def test_session_secret_accepts_longer_than_32() -> None: def test_session_secret_accepts_longer_than_32() -> None:
"""session_secret with more than 32 characters is accepted.""" """session_secret with more than 32 characters is accepted."""
secret_64 = "b" * 64 # 64 characters secret_64 = "b" * 64
settings = Settings( settings = _minimal_settings(session_secret=secret_64)
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret=secret_64,
)
assert settings.session_secret == secret_64 assert settings.session_secret == secret_64
def test_session_secret_error_message_includes_guidance() -> None: def test_session_secret_error_message_includes_guidance() -> None:
"""Validation error for short session_secret includes secret generation guidance.""" """Validation error for short session_secret includes secret generation guidance."""
with pytest.raises(ValidationError) as exc_info: with pytest.raises(ValidationError) as exc_info:
Settings( _minimal_settings(session_secret="too-short")
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="too-short",
)
error_msg = str(exc_info.value) error_msg = str(exc_info.value)
# Verify the error mentions the constraint
assert "session_secret" in error_msg assert "session_secret" in error_msg
def test_session_secret_rejects_weak_secrets() -> None:
"""Common weak words in session_secret are rejected."""
with pytest.raises(ValidationError) as exc_info:
_minimal_settings(session_secret="password1234567890123456789012345")
error_msg = str(exc_info.value)
assert "too weak" in error_msg or "weak" in error_msg
# ---------------------------------------------------------------------------
# database_path validation tests
# ---------------------------------------------------------------------------
def test_database_path_parent_must_exist() -> None:
"""database_path parent directory must exist."""
with pytest.raises(ValidationError) as exc_info:
_minimal_settings(database_path="/nonexistent/parent/test.db")
error_msg = str(exc_info.value)
assert "parent directory does not exist" in error_msg
def test_database_path_parent_must_be_writable() -> None:
"""database_path parent directory must be writable."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create a read-only directory
readonly_dir = os.path.join(tmpdir, "readonly")
os.makedirs(readonly_dir, exist_ok=True)
os.chmod(readonly_dir, 0o555)
try:
with pytest.raises(ValidationError) as exc_info:
_minimal_settings(database_path=os.path.join(readonly_dir, "test.db"))
error_msg = str(exc_info.value)
assert "not writable" in error_msg
finally:
os.chmod(readonly_dir, 0o755)
def test_database_path_with_existing_writable_parent_is_accepted() -> None:
"""database_path with existing writable parent is accepted."""
with tempfile.TemporaryDirectory() as tmpdir:
settings = _minimal_settings(database_path=os.path.join(tmpdir, "test.db"))
assert settings.database_path == os.path.join(tmpdir, "test.db")
# ---------------------------------------------------------------------------
# fail2ban_socket validation tests
# ---------------------------------------------------------------------------
def test_fail2ban_socket_nonexistent_is_accepted() -> None:
"""fail2ban_socket path that doesn't exist yet is accepted (deferred creation)."""
settings = _minimal_settings(fail2ban_socket="/nonexistent/socket.sock")
assert settings.fail2ban_socket == "/nonexistent/socket.sock"
def test_fail2ban_socket_existing_must_be_readable() -> None:
"""fail2ban_socket that exists must be readable."""
with tempfile.NamedTemporaryFile() as f:
# Make it unreadable
os.chmod(f.name, 0o000)
try:
with pytest.raises(ValidationError) as exc_info:
_minimal_settings(fail2ban_socket=f.name)
error_msg = str(exc_info.value)
assert "not readable" in error_msg
finally:
os.chmod(f.name, 0o644)
def test_fail2ban_socket_existing_readable_is_accepted() -> None:
"""fail2ban_socket that exists and is readable is accepted."""
with tempfile.NamedTemporaryFile() as f:
settings = _minimal_settings(fail2ban_socket=f.name)
assert settings.fail2ban_socket == f.name
# ---------------------------------------------------------------------------
# geoip_db_path validation tests
# ---------------------------------------------------------------------------
def test_geoip_db_path_none_is_accepted() -> None:
"""geoip_db_path set to None is accepted."""
settings = _minimal_settings(geoip_db_path=None)
assert settings.geoip_db_path is None
def test_geoip_db_path_nonexistent_is_rejected() -> None:
"""geoip_db_path that doesn't exist is rejected."""
with pytest.raises(ValidationError) as exc_info:
_minimal_settings(geoip_db_path="/nonexistent/GeoLite2-Country.mmdb")
error_msg = str(exc_info.value)
assert "does not exist" in error_msg
def test_geoip_db_path_existing_is_accepted() -> None:
"""geoip_db_path that exists is accepted."""
with tempfile.NamedTemporaryFile() as f:
settings = _minimal_settings(geoip_db_path=f.name)
assert settings.geoip_db_path == f.name
# ---------------------------------------------------------------------------
# fail2ban_config_dir validation tests
# ---------------------------------------------------------------------------
def test_fail2ban_config_dir_nonexistent_is_rejected() -> None:
"""fail2ban_config_dir that doesn't exist is rejected."""
with pytest.raises(ValidationError) as exc_info:
_minimal_settings(fail2ban_config_dir="/nonexistent/fail2ban/config")
error_msg = str(exc_info.value)
assert "does not exist" in error_msg
def test_fail2ban_config_dir_existing_is_accepted() -> None:
"""fail2ban_config_dir that exists is accepted."""
with tempfile.TemporaryDirectory() as tmpdir:
settings = _minimal_settings(fail2ban_config_dir=tmpdir)
assert settings.fail2ban_config_dir == tmpdir
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# trusted_proxies configuration tests # trusted_proxies configuration tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -167,85 +252,44 @@ def test_session_secret_error_message_includes_guidance() -> None:
def test_trusted_proxies_default_is_empty_list() -> None: def test_trusted_proxies_default_is_empty_list() -> None:
"""By default, trusted_proxies is an empty list (no trusted proxies).""" """By default, trusted_proxies is an empty list (no trusted proxies)."""
settings = Settings( settings = _minimal_settings()
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="a" * 32,
)
assert settings.trusted_proxies == [] assert settings.trusted_proxies == []
def test_trusted_proxies_accepts_single_ip() -> None: def test_trusted_proxies_accepts_single_ip() -> None:
"""Single IP address is accepted.""" """Single IP address is accepted."""
settings = Settings( settings = _minimal_settings(trusted_proxies="192.168.1.1")
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="a" * 32,
trusted_proxies="192.168.1.1",
)
assert settings.trusted_proxies == ["192.168.1.1"] assert settings.trusted_proxies == ["192.168.1.1"]
def test_trusted_proxies_accepts_single_cidr() -> None: def test_trusted_proxies_accepts_single_cidr() -> None:
"""Single CIDR range is accepted.""" """Single CIDR range is accepted."""
settings = Settings( settings = _minimal_settings(trusted_proxies="10.0.0.0/8")
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="a" * 32,
trusted_proxies="10.0.0.0/8",
)
assert settings.trusted_proxies == ["10.0.0.0/8"] assert settings.trusted_proxies == ["10.0.0.0/8"]
def test_trusted_proxies_accepts_comma_separated_list() -> None: def test_trusted_proxies_accepts_comma_separated_list() -> None:
"""Comma-separated list of IPs and CIDRs is accepted.""" """Comma-separated list of IPs and CIDRs is accepted."""
settings = Settings( settings = _minimal_settings(trusted_proxies="192.168.1.1,10.0.0.0/8,172.16.0.0/12")
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="a" * 32,
trusted_proxies="192.168.1.1,10.0.0.0/8,172.16.0.0/12",
)
assert settings.trusted_proxies == ["192.168.1.1", "10.0.0.0/8", "172.16.0.0/12"] assert settings.trusted_proxies == ["192.168.1.1", "10.0.0.0/8", "172.16.0.0/12"]
def test_trusted_proxies_accepts_list() -> None: def test_trusted_proxies_accepts_list() -> None:
"""List of IPs and CIDRs is accepted.""" """List of IPs and CIDRs is accepted."""
settings = Settings( settings = _minimal_settings(trusted_proxies=["192.168.1.1", "10.0.0.0/8"])
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="a" * 32,
trusted_proxies=["192.168.1.1", "10.0.0.0/8"],
)
assert settings.trusted_proxies == ["192.168.1.1", "10.0.0.0/8"] assert settings.trusted_proxies == ["192.168.1.1", "10.0.0.0/8"]
def test_trusted_proxies_strips_whitespace() -> None: def test_trusted_proxies_strips_whitespace() -> None:
"""Whitespace around IPs is stripped.""" """Whitespace around IPs is stripped."""
settings = Settings( settings = _minimal_settings(trusted_proxies=" 192.168.1.1 , 10.0.0.0/8 ")
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="a" * 32,
trusted_proxies=" 192.168.1.1 , 10.0.0.0/8 ",
)
assert settings.trusted_proxies == ["192.168.1.1", "10.0.0.0/8"] assert settings.trusted_proxies == ["192.168.1.1", "10.0.0.0/8"]
def test_trusted_proxies_rejects_invalid_ip() -> None: def test_trusted_proxies_rejects_invalid_ip() -> None:
"""Invalid IP address is rejected.""" """Invalid IP address is rejected."""
with pytest.raises(ValidationError) as exc_info: with pytest.raises(ValidationError) as exc_info:
Settings( _minimal_settings(trusted_proxies="not-an-ip")
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="a" * 32,
trusted_proxies="not-an-ip",
)
error_msg = str(exc_info.value) error_msg = str(exc_info.value)
assert "trusted_proxies" in error_msg or "Invalid IP" in error_msg assert "trusted_proxies" in error_msg or "Invalid IP" in error_msg
@@ -253,13 +297,7 @@ def test_trusted_proxies_rejects_invalid_ip() -> None:
def test_trusted_proxies_rejects_invalid_cidr() -> None: def test_trusted_proxies_rejects_invalid_cidr() -> None:
"""Invalid CIDR range is rejected.""" """Invalid CIDR range is rejected."""
with pytest.raises(ValidationError) as exc_info: with pytest.raises(ValidationError) as exc_info:
Settings( _minimal_settings(trusted_proxies="10.0.0.0/33")
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="a" * 32,
trusted_proxies="10.0.0.0/33", # Invalid - /33 is out of range for IPv4
)
error_msg = str(exc_info.value) error_msg = str(exc_info.value)
assert "trusted_proxies" in error_msg or "Invalid IP" in error_msg assert "trusted_proxies" in error_msg or "Invalid IP" in error_msg
@@ -267,48 +305,24 @@ def test_trusted_proxies_rejects_invalid_cidr() -> None:
def test_trusted_proxies_rejects_one_invalid_in_list() -> None: def test_trusted_proxies_rejects_one_invalid_in_list() -> None:
"""One invalid IP in a list causes entire list to be rejected.""" """One invalid IP in a list causes entire list to be rejected."""
with pytest.raises(ValidationError) as exc_info: with pytest.raises(ValidationError) as exc_info:
Settings( _minimal_settings(trusted_proxies="192.168.1.1,invalid-ip,10.0.0.0/8")
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="a" * 32,
trusted_proxies="192.168.1.1,invalid-ip,10.0.0.0/8",
)
error_msg = str(exc_info.value) error_msg = str(exc_info.value)
assert "trusted_proxies" in error_msg or "Invalid IP" in error_msg assert "trusted_proxies" in error_msg or "Invalid IP" in error_msg
def test_trusted_proxies_accepts_ipv6_address() -> None: def test_trusted_proxies_accepts_ipv6_address() -> None:
"""IPv6 address is accepted.""" """IPv6 address is accepted."""
settings = Settings( settings = _minimal_settings(trusted_proxies="2001:db8::1")
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="a" * 32,
trusted_proxies="2001:db8::1",
)
assert settings.trusted_proxies == ["2001:db8::1"] assert settings.trusted_proxies == ["2001:db8::1"]
def test_trusted_proxies_accepts_ipv6_cidr() -> None: def test_trusted_proxies_accepts_ipv6_cidr() -> None:
"""IPv6 CIDR range is accepted.""" """IPv6 CIDR range is accepted."""
settings = Settings( settings = _minimal_settings(trusted_proxies="2001:db8::/32")
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="a" * 32,
trusted_proxies="2001:db8::/32",
)
assert settings.trusted_proxies == ["2001:db8::/32"] assert settings.trusted_proxies == ["2001:db8::/32"]
def test_trusted_proxies_accepts_mixed_ipv4_and_ipv6() -> None: def test_trusted_proxies_accepts_mixed_ipv4_and_ipv6() -> None:
"""Mixed IPv4 and IPv6 addresses and ranges are accepted.""" """Mixed IPv4 and IPv6 addresses and ranges are accepted."""
settings = Settings( settings = _minimal_settings(trusted_proxies="192.168.1.0/24,2001:db8::/32,10.0.0.1")
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="a" * 32,
trusted_proxies="192.168.1.0/24,2001:db8::/32,10.0.0.1",
)
assert settings.trusted_proxies == ["192.168.1.0/24", "2001:db8::/32", "10.0.0.1"] assert settings.trusted_proxies == ["192.168.1.0/24", "2001:db8::/32", "10.0.0.1"]

View File

@@ -17,8 +17,8 @@ def _create_test_settings(tmpdir: str) -> Settings:
database_path=str(Path(tmpdir) / "bangui.db"), database_path=str(Path(tmpdir) / "bangui.db"),
fail2ban_socket="/var/run/fail2ban/fail2ban.sock", fail2ban_socket="/var/run/fail2ban/fail2ban.sock",
session_secret="test-secret-12345678901234567890", session_secret="test-secret-12345678901234567890",
fail2ban_config_dir="/etc/fail2ban", fail2ban_config_dir=tmpdir, # Use tmpdir (exists) instead of /etc/fail2ban
geoip_db_path="/usr/share/GeoIP/GeoLite2-Country.mmdb", geoip_db_path=None, # None = skip geoip validation
geoip_allow_http_fallback=False, geoip_allow_http_fallback=False,
log_level="info", log_level="info",
) )