Compare commits
443 Commits
v0.9.13
...
v0.9.19-rc
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a12d1c22f | |||
| aebe0d0236 | |||
| 99e1b74405 | |||
| 9fe52755a5 | |||
| 9d2d6fadf3 | |||
| 2e5ac092bf | |||
| dcee222a41 | |||
| 12fe70d768 | |||
| 83b2cb67b1 | |||
| 7308ff88d6 | |||
| 77df5d5d65 | |||
| 96ce516ecf | |||
| 7ec80fdeec | |||
| 7790736918 | |||
| 79df1aa493 | |||
| cc9d3220c9 | |||
| 8fc1989cc4 | |||
| aa717a28f8 | |||
| e4c3ae718c | |||
| d4bab89cf3 | |||
| 48ef85bec5 | |||
| 17ba07b592 | |||
| 481f32bb85 | |||
| d25b56e7e1 | |||
| 48d57c31e1 | |||
| e41831447f | |||
| 23c3a0d9e6 | |||
| 5fa67d3288 | |||
| 744275d17f | |||
| 58173bd6a9 | |||
| 69e1726045 | |||
| 0a3f9c6c16 | |||
| 42e177e6ea | |||
| eb339efcfd | |||
| 65fe747cba | |||
| c8b48b5b65 | |||
| fc57c83f79 | |||
| b2747381ec | |||
| edebf1a339 | |||
| a2afec2d1e | |||
| 52a70c3eea | |||
| 3376009903 | |||
| 7fcfc14199 | |||
| dafe8d61e2 | |||
| cee3daffc1 | |||
| 1c3dff31e8 | |||
| c3cd1574dc | |||
| ae9313568e | |||
| e1a6491ac2 | |||
| 4d09d2538d | |||
| 624f869f5b | |||
| 497d7cab41 | |||
| c96b87ee8b | |||
| 96525573fa | |||
| 85d05ee582 | |||
| 5f0ab40816 | |||
| 2f9fc8076d | |||
| 2df029f7e8 | |||
| 5058a50143 | |||
| 896751ada9 | |||
|
|
22db607875 | ||
| 0133489920 | |||
| 7b93499551 | |||
| 8f26776bb3 | |||
| 7ad885d276 | |||
| 881cfbdd71 | |||
| bd6170722a | |||
| b587c6e850 | |||
| 0817a4cb47 | |||
| e436727942 | |||
| 1285bc8571 | |||
| b631c1c546 | |||
| f6c3c02183 | |||
| cc6dbcf3f0 | |||
| 0d5882b32f | |||
| 1830da496d | |||
| 3b3728c58d | |||
| e46062d4cd | |||
| 1af67eb0ce | |||
| 37078b742b | |||
| 60d9c5b340 | |||
| 445c2c5418 | |||
| 8138857ee1 | |||
| 67b26a3ef7 | |||
| be974b9b0d | |||
| 96a21ffb70 | |||
| c988b4b8b6 | |||
| 4f7316c484 | |||
| 0221e423f2 | |||
| 73021429f7 | |||
| 05c3b564ae | |||
| f9e283541b | |||
| 94d6352d1d | |||
| 52f237d5d4 | |||
| 400ab1a3f1 | |||
| 3bd9848a08 | |||
| d1316ca66e | |||
| 90f4c6239c | |||
| fc5f44ebe4 | |||
| e24b1241fb | |||
| 59c92f9a83 | |||
| c4ede71fa6 | |||
| f074882f2d | |||
| 3bd2a71367 | |||
| 69d32bfbe9 | |||
| ac53a56ae7 | |||
| 9afdbe2852 | |||
| 7f68d6b7d7 | |||
| 3d5acb756f | |||
| 277f2a467c | |||
| 2db635ae19 | |||
| 9b4aee7f37 | |||
| 100fd47c4b | |||
| 3d1a6f5538 | |||
| 9a43123b3a | |||
| b6631b86e4 | |||
| 187cd8250d | |||
| 336242ad06 | |||
| 0a350b3acc | |||
| bc4ba703f0 | |||
| 6bc440dce4 | |||
| dd14ed7e7e | |||
| c2dd9f5f55 | |||
| 18036d53bf | |||
| 1302ac821f | |||
| cc4370c50d | |||
| 9072117db3 | |||
| 1e2576af2a | |||
| a2129bb9bd | |||
| ad21590f60 | |||
| b27765928a | |||
| 1c673d600c | |||
| 7ba1cf7ca2 | |||
| e0a4d36fc3 | |||
| 252204ed97 | |||
| 72c4a0ed04 | |||
| ca23858946 | |||
| 2fea513c9c | |||
| d10145e5d6 | |||
| 5166789b68 | |||
| 6c8e2b3423 | |||
| f169bbd39a | |||
| ae34d98859 | |||
| da6433b2cf | |||
| 42beb9cf3b | |||
| 69a5f0ceb1 | |||
| ace8930482 | |||
| e86ab6dad1 | |||
| a273b96563 | |||
| 52a4d04d92 | |||
| 3888c5eb3f | |||
| 507f153ab9 | |||
| 813cf09bed | |||
| afc1e44e99 | |||
| 2e221f6852 | |||
| 79112c0430 | |||
| e08a16c7dd | |||
| 3bbf413c55 | |||
| bc315b936b | |||
| 93021500c3 | |||
| e2560f5db0 | |||
| 32aad186c3 | |||
| 1d91e24a88 | |||
| b9289a3b0e | |||
| 5d24780c63 | |||
| 46fa7c78bc | |||
| 57eacf39ba | |||
| df841c21e4 | |||
| a768a2d303 | |||
| c2348d7075 | |||
| a44f1ef35b | |||
| 81f009e323 | |||
| 5709785942 | |||
| ec253d9b7a | |||
| d476e9d611 | |||
| d9022b9d06 | |||
| 4ceb11a4e3 | |||
| b6e8e3f5ff | |||
| 667ab674ca | |||
| 94bdabe622 | |||
| d66493f135 | |||
| b9e046bd66 | |||
| 308cf680a7 | |||
| 2331567bd7 | |||
| 5d9cef7760 | |||
| 3095fa3313 | |||
| 5b24a9c142 | |||
| 8698b89f6a | |||
| 4ab767e3d4 | |||
| a5b55d1248 | |||
| ea4c7c2f85 | |||
| 9725714aa2 | |||
| f55c317f87 | |||
| 29daaa9906 | |||
| d982fe3efc | |||
| 825a67f13a | |||
| def412797a | |||
| 045c8048fe | |||
| c1135150c3 | |||
| 69a0296c47 | |||
| 3b527244aa | |||
| 6490e9d3df | |||
| f84aeef249 | |||
| 6a062a72a7 | |||
| 8bd5713d38 | |||
| 8d30a81346 | |||
| b44b72053a | |||
| 4b8af1d43a | |||
| 1a3401f418 | |||
| ac2028e1c2 | |||
| 420ea18fd9 | |||
| 83452ffc23 | |||
| c3410bd554 | |||
| 24f9fdd358 | |||
| e57d19fd76 | |||
| d467190eb1 | |||
| 654dbdb000 | |||
| fdfd24508f | |||
| fd685e8211 | |||
| 5480dce221 | |||
| b634ce876a | |||
| 6d21a53620 | |||
| eaff272aae | |||
| ac44bab8e6 | |||
| 9c5757eeb0 | |||
| 8904e180d1 | |||
| 6d5be523ab | |||
| 9375430e02 | |||
| a87d892584 | |||
| f3d6574160 | |||
| 941502b710 | |||
| 814000fe68 | |||
| 10c534d090 | |||
| 1bcc336c9b | |||
| 3fba69970c | |||
| 584588e363 | |||
| 4e3f2005f9 | |||
| 57ee5a2892 | |||
| 0223cb12a4 | |||
| 5a6cb640d8 | |||
| 1c5b2d36d9 | |||
| f0caa24d91 | |||
| 1510dfc851 | |||
| 97d47fae81 | |||
| 3024a4ef07 | |||
| 1d50bc1a73 | |||
| 3c310e1d79 | |||
| 649ebf2dc7 | |||
| dfd1b9006b | |||
| d1674add90 | |||
| 0bfa975222 | |||
| 0f261e31c2 | |||
| 3e3578f4d8 | |||
| 0481810226 | |||
| a286ede49c | |||
| 1bf0645c04 | |||
| 1d41822a36 | |||
| b7fbad0328 | |||
| b6d9c649ca | |||
| 1ba82d56e7 | |||
| 260ce7e875 | |||
| 4c313af1c5 | |||
| fef8f60ee2 | |||
| 4f91e8fdd3 | |||
| b3eb5dc6ec | |||
| 094fb4fece | |||
| 4da2703966 | |||
| 86a7336ac0 | |||
| e244a85291 | |||
| e683108965 | |||
| cf5a000bf5 | |||
| 51e340fa33 | |||
| 69d5cffabd | |||
| 8b4a2f0b71 | |||
| 1694ac17f8 | |||
| 1d6564aa32 | |||
| 27369b43d6 | |||
| 20412dd94b | |||
| e593498de5 | |||
| cc8c71906f | |||
| d0991e0d40 | |||
| c58eb240b1 | |||
| 082dcc7ee1 | |||
| 76c9f388a8 | |||
| 5446f6c3e1 | |||
| 9e7f881a8a | |||
| 7fb0cc727f | |||
| b6303cff72 | |||
| e7582c4bae | |||
| d44a667592 | |||
| e6ee525e0f | |||
| 09a1d3c7b7 | |||
| d99d6bd119 | |||
| 91269448d0 | |||
| 47f9c602d4 | |||
| 38b9d35255 | |||
| 6c053cdaee | |||
| 2105f8b435 | |||
| 3f197b1ad7 | |||
| fba7675eb8 | |||
| d9550ae4aa | |||
| 01f2e07921 | |||
| c1f188643c | |||
| be1d66988f | |||
| 52e08e17a4 | |||
| 99731a9919 | |||
| db5b4cb77e | |||
| 7055971163 | |||
| 5e5d7c34b2 | |||
| 16687b0520 | |||
| 4754f1407e | |||
| 7a1cb0c46c | |||
| 1e2850a34e | |||
| 04b2e2f700 | |||
| 900d111a5d | |||
| 487f252a4d | |||
| 8c6950afc1 | |||
| 6e1e3c4546 | |||
| 7d16391c6c | |||
| 74ff4cb4b8 | |||
| e70d98809b | |||
| 58112fb191 | |||
| 33643880ed | |||
| c21cf82e9e | |||
| 13b3fde274 | |||
| 73cc212e28 | |||
| a5e95e2061 | |||
| 56f03f39c7 | |||
| cdb0c3681e | |||
| 0e22d1c425 | |||
| 328f3575e2 | |||
| a79f5339bc | |||
| 6dc53a80b5 | |||
| 56c511d905 | |||
| a8f2d2d7b9 | |||
| 2451ec77b2 | |||
| b70dc6fa7a | |||
| 58bb769a35 | |||
| 86fa271c40 | |||
| 41f8c1f6cb | |||
| 2a7766d206 | |||
| 6b436dc354 | |||
| 09c764cebc | |||
| b1fba79a2e | |||
| 53cdd63b6a | |||
| ec91c1c8b2 | |||
| fdede3e7dd | |||
| 5379cca238 | |||
| 0e5e08374f | |||
| 5e4d3fcf12 | |||
| 0e84f1f60c | |||
| cee5372690 | |||
| 41a67d52ab | |||
| 56ade7fb08 | |||
| 88715ab07f | |||
| 21eabb1f0f | |||
| a564830abb | |||
| 5a9d226cca | |||
| b4959133dd | |||
| 37646e57f7 | |||
| a5674f9e4c | |||
| 4b2e86edbb | |||
| 5957d851b5 | |||
| 8e43ef9ad2 | |||
| e6df045e5e | |||
| ee880e6296 | |||
| 72488b14b2 | |||
| e221cd414f | |||
| e271207795 | |||
| 21b38365c4 | |||
| ffe7ada469 | |||
| cd69550053 | |||
| ae81a8f5be | |||
| 9cba5a9fcb | |||
| 952469e667 | |||
| 91e5792caf | |||
| f61d497e4e | |||
| 3371ff8324 | |||
| 3b6e39ddad | |||
| 9b4cd17e3b | |||
| 1dfc17f4f5 | |||
| 2157502670 | |||
| ff92733f90 | |||
| 6b177f1881 | |||
| 148756fb79 | |||
| e1d741956e | |||
| 4043cdfa3c | |||
| 208f98dc97 | |||
| 6eab47f7ba | |||
| be46547114 | |||
| 1fc04ed978 | |||
| effcc65e1b | |||
| 3cc495dfce | |||
| 1e39e5a1d6 | |||
| ed3aa61c35 | |||
| 30e0dd71c9 | |||
| 59a56f2e4f | |||
| e21f153946 | |||
| 0a70e40d8b | |||
| ca4b0ed324 | |||
| 95f72018f7 | |||
| c2982116a8 | |||
| 1a7096b276 | |||
| 89ab41cc9e | |||
| 3ccfc20c64 | |||
| 594f55d157 | |||
| f0ee466603 | |||
| 5107ff10d7 | |||
| 3b58179845 | |||
| 42c030c706 | |||
| fde4c480fa | |||
| 96f75db75f | |||
| 554c75247f | |||
| 6e2abe9d97 | |||
| 15d53a8e96 | |||
| acdb0e1f03 | |||
| f1e3d4c4c9 | |||
| c51858ec71 | |||
| c03a5c1cbc | |||
| eb983799cd | |||
| d3f564d66f | |||
| bbd57c808b | |||
| ffaa14f864 | |||
| 7d09b78437 | |||
| 8e2bb5d3fb | |||
| bfe0daf754 | |||
| 13823b1182 | |||
| 7967191ccd | |||
| 470c29443c | |||
| 6f15e1fa24 | |||
| 487cb171f2 | |||
| 7789353690 | |||
| ccfcbc82c5 | |||
| 7626c9cb60 | |||
| ac4fd967aa | |||
| 9f05da2d4d | |||
| 876af46955 | |||
| 0d4a2a3311 | |||
| f555b1b0a2 | |||
| a30b92471a | |||
| 9e43282bbc | |||
| 2ea4a8304f | |||
| e99920e616 |
33
.editorconfig
Normal file
33
.editorconfig
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.py]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{js,ts,tsx,jsx}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[Dockerfile]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.yml]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.yaml]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
60
.env.example
Normal file
60
.env.example
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# BanGUI — Environment Variables Template
|
||||||
|
# Copy this file to .env and fill in the values below
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Session Secret (REQUIRED)
|
||||||
|
# Generate a secure random secret for session tokens.
|
||||||
|
# WARNING: Do not use the same secret across different environments.
|
||||||
|
# Generate with: python -c 'import secrets; print(secrets.token_hex(32))'
|
||||||
|
# Example value: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
|
||||||
|
BANGUI_SESSION_SECRET=
|
||||||
|
|
||||||
|
# Previous Session Secret (optional)
|
||||||
|
# Used during secret rotation to accept tokens signed with the old secret.
|
||||||
|
# Set this to the previous secret when rotating secrets, then unset it once
|
||||||
|
# all old tokens have expired. This enables gradual rotation without forcing logout.
|
||||||
|
# Leave empty unless performing a rotation.
|
||||||
|
BANGUI_SESSION_SECRET_PREVIOUS=
|
||||||
|
|
||||||
|
# Timezone (optional, defaults to UTC)
|
||||||
|
# Use standard timezone names from the IANA Time Zone Database
|
||||||
|
# Examples: America/New_York, Europe/London, Asia/Tokyo, UTC
|
||||||
|
BANGUI_TIMEZONE=UTC
|
||||||
|
|
||||||
|
# Backend port (optional, defaults to 8000)
|
||||||
|
# When using docker-compose, this is the port on your host machine
|
||||||
|
BANGUI_BACKEND_PORT=8000
|
||||||
|
|
||||||
|
# Frontend port (optional, defaults to 5173)
|
||||||
|
# When using docker-compose, this is the port on your host machine
|
||||||
|
BANGUI_FRONTEND_PORT=5173
|
||||||
|
|
||||||
|
# Public port (optional, defaults to 8080)
|
||||||
|
# When using production compose, this is the public-facing port
|
||||||
|
BANGUI_PORT=8080
|
||||||
|
|
||||||
|
# IP Geolocation (optional)
|
||||||
|
# Path to MaxMind GeoLite2-Country MMDB database file (primary resolver).
|
||||||
|
# Download from: https://www.maxmind.com/en/geolite2/signup
|
||||||
|
# If not set, geolocation is disabled (or falls back to HTTP if enabled below).
|
||||||
|
# Example: /data/GeoLite2-Country.mmdb
|
||||||
|
BANGUI_GEOIP_DB_PATH=
|
||||||
|
|
||||||
|
# IP Geolocation HTTP Fallback (optional, defaults to false)
|
||||||
|
# ⚠️ SECURITY WARNING: Only enable if you cannot mount the MaxMind database.
|
||||||
|
# When enabled, unresolved IP addresses are sent unencrypted to ip-api.com.
|
||||||
|
# This is a privacy and GDPR/CCPA concern. Do NOT enable in production unless necessary.
|
||||||
|
# Set to "true" to enable (default is "false" for security).
|
||||||
|
BANGUI_GEOIP_ALLOW_HTTP_FALLBACK=false
|
||||||
|
|
||||||
|
# CORS Configuration (optional)
|
||||||
|
# Comma-separated list of allowed origins for cross-origin requests.
|
||||||
|
# Defaults to common localhost development origins (http://localhost:5173, http://127.0.0.1:5173, etc).
|
||||||
|
# Set this in production to your frontend domain(s).
|
||||||
|
# Examples:
|
||||||
|
# BANGUI_CORS_ALLOWED_ORIGINS=https://example.com,https://app.example.com
|
||||||
|
# BANGUI_CORS_ALLOWED_ORIGINS= (empty to disable CORS)
|
||||||
|
# WARNING: Do NOT use wildcard "*" — it defeats CORS security when credentials are enabled.
|
||||||
|
BANGUI_CORS_ALLOWED_ORIGINS=
|
||||||
|
|
||||||
174
.github/workflows/ci.yml
vendored
Normal file
174
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
name: Backend Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: pytest --cov=app --cov-report=term-missing --cov-fail-under=80
|
||||||
|
|
||||||
|
- name: Upload coverage report
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: coverage-report
|
||||||
|
path: backend/htmlcov/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
ruff:
|
||||||
|
name: Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install ruff
|
||||||
|
|
||||||
|
- name: Run ruff
|
||||||
|
run: ruff check .
|
||||||
|
|
||||||
|
mypy:
|
||||||
|
name: Type Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -e ".[dev]"
|
||||||
|
|
||||||
|
- name: Run mypy
|
||||||
|
run: mypy app
|
||||||
|
|
||||||
|
import-linter:
|
||||||
|
name: Import Boundary
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -e ".[dev]"
|
||||||
|
|
||||||
|
- name: Run import-linter
|
||||||
|
run: linter
|
||||||
|
|
||||||
|
openapi-breaking-changes:
|
||||||
|
name: OpenAPI Breaking Changes
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
|
# Only run on PRs — main branch push is covered by the baseline-commit step.
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -e ".[dev]"
|
||||||
|
|
||||||
|
- name: Generate current OpenAPI spec
|
||||||
|
run: python scripts/generate_openapi.py current-openapi.json
|
||||||
|
|
||||||
|
- name: Fetch baseline spec from main
|
||||||
|
run: |
|
||||||
|
git fetch origin main:main
|
||||||
|
git show main:backend/openapi.json > baseline-openapi.json 2>/dev/null || \
|
||||||
|
echo "{}" > baseline-openapi.json
|
||||||
|
|
||||||
|
- name: Install openapi-diff
|
||||||
|
run: npm install -g openapi-diff
|
||||||
|
|
||||||
|
- name: Check for breaking changes
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
openapi-diff baseline-openapi.json current-openapi.json --format stylish 2>&1
|
||||||
|
EXIT_CODE=$?
|
||||||
|
if [ $EXIT_CODE -ne 0 ]; then
|
||||||
|
echo "BREAKING CHANGE DETECTED — see output above"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "No breaking changes found."
|
||||||
|
|
||||||
|
openapi-baseline-commit:
|
||||||
|
name: OpenAPI Baseline Commit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Only run on push to main (not PRs).
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -e ".[dev]"
|
||||||
|
|
||||||
|
- name: Generate and commit OpenAPI baseline
|
||||||
|
run: |
|
||||||
|
python scripts/generate_openapi.py backend/openapi.json
|
||||||
|
|
||||||
|
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git config --local user.name "github-actions[bot]"
|
||||||
|
|
||||||
|
git add backend/openapi.json
|
||||||
|
git diff --cached --quiet && echo "No changes to openapi.json" || \
|
||||||
|
git commit -m "chore: update OpenAPI baseline spec [skip ci]
|
||||||
|
|
||||||
|
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"
|
||||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -95,20 +95,16 @@ Thumbs.db
|
|||||||
# ── Docker dev config ─────────────────────────
|
# ── Docker dev config ─────────────────────────
|
||||||
# Ignore auto-generated linuxserver/fail2ban config files,
|
# Ignore auto-generated linuxserver/fail2ban config files,
|
||||||
# but track our custom filter, jail, and documentation.
|
# but track our custom filter, jail, and documentation.
|
||||||
Docker/fail2ban-dev-config/**
|
data/*
|
||||||
!Docker/fail2ban-dev-config/README.md
|
|
||||||
!Docker/fail2ban-dev-config/fail2ban/
|
|
||||||
!Docker/fail2ban-dev-config/fail2ban/filter.d/
|
|
||||||
!Docker/fail2ban-dev-config/fail2ban/filter.d/bangui-sim.conf
|
|
||||||
!Docker/fail2ban-dev-config/fail2ban/filter.d/bangui-access.conf
|
|
||||||
!Docker/fail2ban-dev-config/fail2ban/jail.d/
|
|
||||||
!Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-sim.conf
|
|
||||||
!Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-access.conf
|
|
||||||
!Docker/fail2ban-dev-config/fail2ban/jail.d/blocklist-import.conf
|
|
||||||
!Docker/fail2ban-dev-config/fail2ban/jail.local
|
|
||||||
|
|
||||||
# ── Misc ──────────────────────────────────────
|
# ── Misc ──────────────────────────────────────
|
||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
*.orig
|
*.orig
|
||||||
|
|
||||||
|
# ── E2E test results ───────────────────────────
|
||||||
|
e2e/results/
|
||||||
|
e2e/Instructions.md
|
||||||
|
|
||||||
|
playwright-log.txt
|
||||||
|
|||||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
cd frontend && npm run validate:types
|
||||||
23
.pre-commit-config.yaml
Normal file
23
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
hooks:
|
||||||
|
- id: check-yaml
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: check-merge-conflict
|
||||||
|
- id: check-added-large-files
|
||||||
|
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix]
|
||||||
|
- id: ruff-format
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
|
hooks:
|
||||||
|
- id: prettier
|
||||||
|
args: [--check]
|
||||||
|
name: prettier (frontend)
|
||||||
|
files: ^frontend/
|
||||||
|
entry: prettier --check
|
||||||
|
language: system
|
||||||
157
CONTRIBUTING.md
Normal file
157
CONTRIBUTING.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# Contributing to BanGUI
|
||||||
|
|
||||||
|
Welcome! This guide covers everything you need to know to set up your dev environment, understand the codebase, and submit changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dev Setup
|
||||||
|
|
||||||
|
### 1 — Clone and init
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url>
|
||||||
|
cd BanGUI
|
||||||
|
cp .env.example .env
|
||||||
|
python -c 'import secrets; print(secrets.token_hex(32))'
|
||||||
|
# paste output as BANGUI_SESSION_SECRET in .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2 — Start the stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make up
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend: http://127.0.0.1:8000 · Frontend (Vite proxy): http://127.0.0.1:5173
|
||||||
|
|
||||||
|
### 3 — Editor Setup
|
||||||
|
|
||||||
|
Install **EditorConfig** plugin for your IDE. Ensures consistent formatting (indent style, line endings) across all editors.
|
||||||
|
|
||||||
|
| IDE | Plugin |
|
||||||
|
|-----|--------|
|
||||||
|
| VS Code | EditorConfig (ms-vscode.editorconfig) |
|
||||||
|
| PyCharm / IntelliJ | Built-in (enable in Settings → Editor → Code Style) |
|
||||||
|
| Vim / Neovim | editorconfig-vim |
|
||||||
|
| Sublime Text | EditorConfig |
|
||||||
|
|
||||||
|
### 4 — Pre-commit hooks
|
||||||
|
|
||||||
|
**Backend** (pre-commit, all languages):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pre-commit
|
||||||
|
pre-commit install
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend** (husky, TypeScript validation):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm install
|
||||||
|
npx husky install
|
||||||
|
```
|
||||||
|
|
||||||
|
Hooks run automatically on every `git commit`. To run manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pre-commit run --all-files # backend hooks
|
||||||
|
cd frontend && npm run validate:types # frontend type check
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
BanGUI/
|
||||||
|
├── backend/ Python FastAPI app
|
||||||
|
│ └── app/
|
||||||
|
│ ├── routers/ HTTP endpoint handlers
|
||||||
|
│ ├── services/ Business logic
|
||||||
|
│ ├── repos/ Data access
|
||||||
|
│ ├── models/ Pydantic request/response/domain models
|
||||||
|
│ └── utils/ Shared helpers
|
||||||
|
├── frontend/ React + TypeScript + Fluent UI v9
|
||||||
|
│ └── src/
|
||||||
|
│ ├── pages/ Route-level page components
|
||||||
|
│ ├── components/ Reusable UI components
|
||||||
|
│ ├── hooks/ Custom React hooks
|
||||||
|
│ └── types/ Shared TypeScript types
|
||||||
|
├── Docs/ Architecture, design, and feature documentation
|
||||||
|
└── Docker/ Container compose files
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
| Tool | Scope | Command |
|
||||||
|
|---|---|---|
|
||||||
|
| `ruff` | Backend linting | `cd backend && ruff check .` |
|
||||||
|
| `ruff-format` | Backend formatting | `cd backend && ruff format .` |
|
||||||
|
| `mypy --strict` | Backend type checking | `cd backend && mypy --strict app` |
|
||||||
|
| `tsc --noEmit` | Frontend type checking | `cd frontend && tsc --noEmit` |
|
||||||
|
| `eslint` | Frontend linting | `cd frontend && eslint src` |
|
||||||
|
| `prettier --check` | Frontend formatting | `cd frontend && prettier --check src` |
|
||||||
|
| `import-linter` | Layer boundary enforcement | `cd backend && linter` |
|
||||||
|
|
||||||
|
**All checks must pass before committing.** CI runs the same suite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend && pytest --cov=app --cov-report=term-missing
|
||||||
|
|
||||||
|
# Coverage threshold: 80%. Build fails if coverage drops below.
|
||||||
|
```
|
||||||
|
|
||||||
|
The CI pipeline enforces the same 80% minimum coverage threshold.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Rules
|
||||||
|
|
||||||
|
### Never echo raw user input in error messages
|
||||||
|
|
||||||
|
User-supplied values (jail names, filter names, action names, IPs, filenames, etc.)
|
||||||
|
MUST be sanitized before interpolation into any string that may be rendered in an
|
||||||
|
HTML context (error messages, admin UI, email notifications).
|
||||||
|
|
||||||
|
Use the `sanitize_for_display()` helper from `app.utils.display_sanitizer`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.utils.display_sanitizer import sanitize_for_display
|
||||||
|
|
||||||
|
# Good: sanitized before display
|
||||||
|
super().__init__(f"Jail not found: {sanitize_for_display(name)!r}")
|
||||||
|
|
||||||
|
# Bad: raw user input echoed — XSS vector if rendered as HTML
|
||||||
|
super().__init__(f"Jail not found: {name!r}")
|
||||||
|
```
|
||||||
|
|
||||||
|
This rule applies even when the value has been validated: validation checks the
|
||||||
|
format, not the rendering context. JSON API responses do NOT need sanitization
|
||||||
|
(JSON is not HTML); apply it only at HTML render boundaries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Stack |
|
||||||
|
|---|---|
|
||||||
|
| Backend | Python 3.12+, FastAPI, Pydantic v2, aiosqlite, structlog |
|
||||||
|
| Frontend | TypeScript, React, Fluent UI v9, Vite |
|
||||||
|
| Container | Docker Compose (development + production) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Docs
|
||||||
|
|
||||||
|
- [Instructions.md](Docs/Instructions.md) — Agent operating rules
|
||||||
|
- [Backend-Development.md](Docs/Backend-Development.md) — Backend conventions
|
||||||
|
- [Web-Development.md](Docs/Web-Development.md) — Frontend conventions
|
||||||
|
- [Features.md](Docs/Features.md) — Complete feature list
|
||||||
|
- [Architekture.md](Docs/Architekture.md) — System architecture
|
||||||
@@ -7,6 +7,11 @@
|
|||||||
# Usage:
|
# Usage:
|
||||||
# docker build -t bangui-backend -f Docker/Dockerfile.backend .
|
# docker build -t bangui-backend -f Docker/Dockerfile.backend .
|
||||||
# podman build -t bangui-backend -f Docker/Dockerfile.backend .
|
# podman build -t bangui-backend -f Docker/Dockerfile.backend .
|
||||||
|
#
|
||||||
|
# Signal handling:
|
||||||
|
# - STOPSIGNAL defaults to SIGTERM (handled by uvicorn → lifespan shutdown)
|
||||||
|
# - stop_grace_period in docker-compose.yml controls Docker's kill timeout
|
||||||
|
# - Python code allows 25s for in-flight tasks to drain before hard kill
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# ── Stage 1: build dependencies ──────────────────────────────
|
# ── Stage 1: build dependencies ──────────────────────────────
|
||||||
@@ -33,6 +38,11 @@ FROM docker.io/library/python:3.12-slim AS runtime
|
|||||||
LABEL maintainer="BanGUI" \
|
LABEL maintainer="BanGUI" \
|
||||||
description="BanGUI backend — fail2ban web management API"
|
description="BanGUI backend — fail2ban web management API"
|
||||||
|
|
||||||
|
# Install curl for healthcheck (used by Docker HEALTHCHECK and Compose healthcheck)
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Non-root user for security
|
# Non-root user for security
|
||||||
RUN groupadd --gid 1000 bangui \
|
RUN groupadd --gid 1000 bangui \
|
||||||
&& useradd --uid 1000 --gid bangui --shell /bin/bash --create-home bangui
|
&& useradd --uid 1000 --gid bangui --shell /bin/bash --create-home bangui
|
||||||
@@ -56,14 +66,32 @@ VOLUME ["/data"]
|
|||||||
# Default environment values (override at runtime)
|
# Default environment values (override at runtime)
|
||||||
ENV BANGUI_DATABASE_PATH="/data/bangui.db" \
|
ENV BANGUI_DATABASE_PATH="/data/bangui.db" \
|
||||||
BANGUI_FAIL2BAN_SOCKET="/var/run/fail2ban/fail2ban.sock" \
|
BANGUI_FAIL2BAN_SOCKET="/var/run/fail2ban/fail2ban.sock" \
|
||||||
BANGUI_LOG_LEVEL="info"
|
BANGUI_LOG_LEVEL="info" \
|
||||||
|
BANGUI_WORKERS="1"
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
USER bangui
|
USER bangui
|
||||||
|
|
||||||
# Health-check using the built-in health endpoint
|
# Health-check using the built-in health endpoint
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
# Returns exit 0 (success) for HTTP 200 (fail2ban online)
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1
|
# Returns exit 1 (failure) for HTTP 503 (fail2ban offline)
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/api/health || exit 1
|
||||||
|
|
||||||
|
# ⚠️ IMPORTANT: Single-Worker Requirement
|
||||||
|
# BanGUI must always run as a single worker process:
|
||||||
|
# - Do NOT pass --workers or --worker-class to uvicorn
|
||||||
|
# - Do NOT use gunicorn with -w 4 or similar
|
||||||
|
# - Do NOT override BANGUI_WORKERS to > 1
|
||||||
|
#
|
||||||
|
# Why? The session cache is process-local. Multiple workers would cause:
|
||||||
|
# - Random user logouts (sessions not shared between workers)
|
||||||
|
# - Duplicate background jobs (each worker runs the scheduler)
|
||||||
|
# - SQLite lock contention and timeouts
|
||||||
|
#
|
||||||
|
# For high availability, use container orchestration (Kubernetes, Docker Swarm)
|
||||||
|
# to run multiple instances, not multiple workers in a single process.
|
||||||
|
#
|
||||||
|
# See Docs/Architekture.md § Deployment Constraints for details.
|
||||||
CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ WORKDIR /build
|
|||||||
COPY frontend/package.json frontend/package-lock.json* /build/
|
COPY frontend/package.json frontend/package-lock.json* /build/
|
||||||
RUN npm ci --ignore-scripts
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
# Copy source and build
|
# Copy source + local OpenAPI spec (avoids needing a running backend during build)
|
||||||
COPY frontend/ /build/
|
COPY frontend/ /build/
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
v0.9.13
|
v0.9.19-rc.5
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ services:
|
|||||||
PUID: 0
|
PUID: 0
|
||||||
PGID: 0
|
PGID: 0
|
||||||
volumes:
|
volumes:
|
||||||
- ./fail2ban-dev-config:/config
|
- ../data/fail2ban-dev-config:/config
|
||||||
- fail2ban-dev-run:/var/run/fail2ban
|
- fail2ban-dev-run:/var/run/fail2ban
|
||||||
- /var/log:/var/log:ro
|
- /var/log:/var/log:ro
|
||||||
- ./logs:/remotelogs/bangui
|
- ../data/log:/remotelogs/bangui
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "fail2ban-client", "ping"]
|
test: ["CMD", "fail2ban-client", "ping"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
@@ -58,17 +58,22 @@ services:
|
|||||||
BANGUI_DATABASE_PATH: "/data/bangui.db"
|
BANGUI_DATABASE_PATH: "/data/bangui.db"
|
||||||
BANGUI_FAIL2BAN_SOCKET: "/var/run/fail2ban/fail2ban.sock"
|
BANGUI_FAIL2BAN_SOCKET: "/var/run/fail2ban/fail2ban.sock"
|
||||||
BANGUI_FAIL2BAN_CONFIG_DIR: "/config/fail2ban"
|
BANGUI_FAIL2BAN_CONFIG_DIR: "/config/fail2ban"
|
||||||
|
BANGUI_LOG_FILE: "/data/log/bangui.log"
|
||||||
BANGUI_LOG_LEVEL: "debug"
|
BANGUI_LOG_LEVEL: "debug"
|
||||||
BANGUI_SESSION_SECRET: "${BANGUI_SESSION_SECRET:-dev-secret-do-not-use-in-production}"
|
BANGUI_ENABLE_DOCS: "true"
|
||||||
|
BANGUI_SESSION_SECRET: "${BANGUI_SESSION_SECRET:?BANGUI_SESSION_SECRET must be set — generate with: python -c 'import secrets; print(secrets.token_hex(32))'}"
|
||||||
BANGUI_TIMEZONE: "${BANGUI_TIMEZONE:-UTC}"
|
BANGUI_TIMEZONE: "${BANGUI_TIMEZONE:-UTC}"
|
||||||
|
# Secure=false is intentional for local HTTP development.
|
||||||
|
# In production, Secure=true prevents session cookies over unencrypted HTTP.
|
||||||
|
BANGUI_SESSION_COOKIE_SECURE: "false"
|
||||||
|
# BANGUI_WORKERS should not be set (defaults to 1).
|
||||||
|
# Never set it to > 1; the session cache is process-local.
|
||||||
volumes:
|
volumes:
|
||||||
- ../backend/app:/app/app:z
|
- ../backend/app:/app/app:z
|
||||||
- ../fail2ban-master:/app/fail2ban-master:ro,z
|
- ../fail2ban-master:/app/fail2ban-master:ro,z
|
||||||
- bangui-dev-data:/data
|
- ../data:/data
|
||||||
- fail2ban-dev-run:/var/run/fail2ban:ro
|
- fail2ban-dev-run:/var/run/fail2ban:ro
|
||||||
- ./fail2ban-dev-config:/config:rw
|
- ../data/fail2ban-dev-config:/config:rw
|
||||||
ports:
|
|
||||||
- "${BANGUI_BACKEND_PORT:-8000}:8000"
|
|
||||||
command:
|
command:
|
||||||
[
|
[
|
||||||
"uvicorn", "app.main:create_app", "--factory",
|
"uvicorn", "app.main:create_app", "--factory",
|
||||||
@@ -76,13 +81,12 @@ services:
|
|||||||
"--reload", "--reload-dir", "/app/app"
|
"--reload", "--reload-dir", "/app/app"
|
||||||
]
|
]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:8000/api/health\", timeout=4)'"]
|
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:8000/api/v1/health/live\", timeout=4)'"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
start_period: 45s
|
start_period: 45s
|
||||||
retries: 5
|
retries: 5
|
||||||
networks:
|
network_mode: host
|
||||||
- bangui-dev-net
|
|
||||||
|
|
||||||
# ── Frontend (Vite dev server with HMR) ─────────────────────
|
# ── Frontend (Vite dev server with HMR) ─────────────────────
|
||||||
frontend:
|
frontend:
|
||||||
@@ -92,23 +96,15 @@ services:
|
|||||||
working_dir: /app
|
working_dir: /app
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
|
VITE_BACKEND_URL: "http://localhost:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ../frontend:/app:z
|
- ../frontend:/app:z
|
||||||
- frontend-node-modules:/app/node_modules
|
- frontend-node-modules:/app/node_modules
|
||||||
ports:
|
|
||||||
- "${BANGUI_FRONTEND_PORT:-5173}:5173"
|
|
||||||
command: ["sh", "-c", "npm install && npm run dev -- --host 0.0.0.0"]
|
command: ["sh", "-c", "npm install && npm run dev -- --host 0.0.0.0"]
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
network_mode: host
|
||||||
test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost:5173/"]
|
|
||||||
interval: 15s
|
|
||||||
timeout: 5s
|
|
||||||
start_period: 30s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- bangui-dev-net
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
bangui-dev-data:
|
bangui-dev-data:
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
# BanGUI — Production Compose
|
# BanGUI — Production Compose
|
||||||
#
|
#
|
||||||
# Compatible with:
|
# Usage:
|
||||||
# docker compose -f Docker/compose.prod.yml up -d
|
# docker compose -f Docker/compose.prod.yml up -d
|
||||||
# podman compose -f Docker/compose.prod.yml up -d
|
# podman compose -f Docker/compose.prod.yml up -d
|
||||||
# podman-compose -f Docker/compose.prod.yml up -d
|
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Features:
|
||||||
# Create a .env file at the project root (or pass --env-file):
|
# - Multi-stage built images (no volume-mounted source code)
|
||||||
# BANGUI_SESSION_SECRET=<random-secret>
|
# - Frontend served by nginx with API reverse proxy
|
||||||
|
# - Backend running uvicorn without --reload
|
||||||
|
# - Only port 8080 exposed to host
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
name: bangui
|
name: bangui
|
||||||
@@ -28,26 +29,23 @@ services:
|
|||||||
PUID: 0
|
PUID: 0
|
||||||
PGID: 0
|
PGID: 0
|
||||||
volumes:
|
volumes:
|
||||||
- fail2ban-config:/config
|
- ../data/fail2ban-dev-config:/config
|
||||||
- fail2ban-run:/var/run/fail2ban
|
- fail2ban-run:/var/run/fail2ban
|
||||||
- /var/log:/var/log:ro
|
- /var/log:/var/log:ro
|
||||||
|
- ../data/log:/remotelogs/bangui
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "fail2ban-client", "ping"]
|
test: ["CMD", "fail2ban-client", "ping"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
start_period: 15s
|
start_period: 15s
|
||||||
retries: 3
|
retries: 3
|
||||||
# NOTE: The fail2ban-config volume must be pre-populated with the following files:
|
|
||||||
# • fail2ban/jail.conf (or jail.d/*.conf) with the DEFAULT section containing:
|
|
||||||
# banaction = iptables-allports[lockingopt="-w 5"]
|
|
||||||
# This prevents xtables lock contention errors when multiple jails start in parallel.
|
|
||||||
# See https://fail2ban.readthedocs.io/en/latest/development/environment.html
|
|
||||||
|
|
||||||
# ── Backend (FastAPI + uvicorn) ─────────────────────────────
|
# ── Backend (FastAPI + uvicorn) ─────────────────────────────
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: Docker/Dockerfile.backend
|
dockerfile: Docker/Dockerfile.backend
|
||||||
|
target: runtime
|
||||||
container_name: bangui-backend
|
container_name: bangui-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -57,50 +55,48 @@ services:
|
|||||||
BANGUI_DATABASE_PATH: "/data/bangui.db"
|
BANGUI_DATABASE_PATH: "/data/bangui.db"
|
||||||
BANGUI_FAIL2BAN_SOCKET: "/var/run/fail2ban/fail2ban.sock"
|
BANGUI_FAIL2BAN_SOCKET: "/var/run/fail2ban/fail2ban.sock"
|
||||||
BANGUI_FAIL2BAN_CONFIG_DIR: "/config/fail2ban"
|
BANGUI_FAIL2BAN_CONFIG_DIR: "/config/fail2ban"
|
||||||
BANGUI_LOG_LEVEL: "info"
|
BANGUI_LOG_FILE: "/data/log/bangui.log"
|
||||||
BANGUI_SESSION_SECRET: "${BANGUI_SESSION_SECRET:?Set BANGUI_SESSION_SECRET}"
|
BANGUI_LOG_LEVEL: "${BANGUI_LOG_LEVEL:-info}"
|
||||||
|
BANGUI_SESSION_SECRET: "${BANGUI_SESSION_SECRET:?BANGUI_SESSION_SECRET must be set — generate with: python -c 'import secrets; print(secrets.token_hex(32))'}"
|
||||||
BANGUI_TIMEZONE: "${BANGUI_TIMEZONE:-UTC}"
|
BANGUI_TIMEZONE: "${BANGUI_TIMEZONE:-UTC}"
|
||||||
|
BANGUI_SESSION_COOKIE_SECURE: "${BANGUI_SESSION_COOKIE_SECURE:-true}"
|
||||||
|
BANGUI_CORS_ALLOWED_ORIGINS: "${BANGUI_CORS_ALLOWED_ORIGINS:-}"
|
||||||
volumes:
|
volumes:
|
||||||
- bangui-data:/data
|
- ../data:/data
|
||||||
|
- ../fail2ban-master:/app/fail2ban-master:ro
|
||||||
- fail2ban-run:/var/run/fail2ban:ro
|
- fail2ban-run:/var/run/fail2ban:ro
|
||||||
- fail2ban-config:/config:rw
|
- ../data/fail2ban-dev-config:/config:rw
|
||||||
expose:
|
|
||||||
- "8000"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
start_period: 10s
|
|
||||||
retries: 3
|
|
||||||
networks:
|
networks:
|
||||||
- bangui-net
|
- bangui-net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -f http://localhost:8000/api/v1/health/live || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
start_period: 40s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
# ── Frontend (nginx serving built SPA + API proxy) ──────────
|
# ── Frontend (nginx serving built SPA) ──────────────────────
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: Docker/Dockerfile.frontend
|
dockerfile: Docker/Dockerfile.frontend
|
||||||
container_name: bangui-frontend
|
container_name: bangui-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- "${BANGUI_PORT:-8080}:80"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "${BANGUI_PORT:-8080}:80"
|
||||||
|
networks:
|
||||||
|
- bangui-net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost:80/"]
|
test: ["CMD-SHELL", "wget -qO /dev/null http://localhost:80/ || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
start_period: 5s
|
start_period: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
networks:
|
|
||||||
- bangui-net
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
bangui-data:
|
|
||||||
driver: local
|
|
||||||
fail2ban-config:
|
|
||||||
driver: local
|
|
||||||
fail2ban-run:
|
fail2ban-run:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
services:
|
|
||||||
fail2ban:
|
|
||||||
image: lscr.io/linuxserver/fail2ban:latest
|
|
||||||
container_name: fail2ban
|
|
||||||
cap_add:
|
|
||||||
- NET_ADMIN
|
|
||||||
- NET_RAW
|
|
||||||
network_mode: host
|
|
||||||
environment:
|
|
||||||
- PUID=1011
|
|
||||||
- PGID=1001
|
|
||||||
- TZ=Etc/UTC
|
|
||||||
- VERBOSITY=-vv #optional
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- /server/server_fail2ban/config:/config
|
|
||||||
- /server/server_fail2ban/fail2ban-run:/var/run/fail2ban
|
|
||||||
- /var/log:/var/log
|
|
||||||
- /server/server_nextcloud/config/nextcloud.log:/remotelogs/nextcloud/nextcloud.log:ro #optional
|
|
||||||
- /server/server_nginx/data/logs:/remotelogs/nginx:ro #optional
|
|
||||||
- /server/server_gitea/log/gitea.log:/remotelogs/gitea/gitea.log:ro #optional
|
|
||||||
|
|
||||||
|
|
||||||
#- /path/to/homeassistant/log:/remotelogs/homeassistant:ro #optional
|
|
||||||
#- /path/to/unificontroller/log:/remotelogs/unificontroller:ro #optional
|
|
||||||
#- /path/to/vaultwarden/log:/remotelogs/vaultwarden:ro #optional
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
backend:
|
|
||||||
image: git.lpl-mind.de/lukas.pupkalipinski/bangui/backend:latest
|
|
||||||
container_name: bangui-backend
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
fail2ban:
|
|
||||||
condition: service_started
|
|
||||||
environment:
|
|
||||||
- PUID=1011
|
|
||||||
- PGID=1001
|
|
||||||
- BANGUI_DATABASE_PATH=/data/bangui.db
|
|
||||||
- BANGUI_FAIL2BAN_SOCKET=/var/run/fail2ban/fail2ban.sock
|
|
||||||
- BANGUI_FAIL2BAN_CONFIG_DIR=/config/fail2ban
|
|
||||||
- BANGUI_LOG_LEVEL=info
|
|
||||||
- BANGUI_SESSION_SECRET=${BANGUI_SESSION_SECRET:?Set BANGUI_SESSION_SECRET}
|
|
||||||
- BANGUI_TIMEZONE=${BANGUI_TIMEZONE:-UTC}
|
|
||||||
volumes:
|
|
||||||
- /server/server_fail2ban/bangui-data:/data
|
|
||||||
- /server/server_fail2ban/fail2ban-run:/var/run/fail2ban:ro
|
|
||||||
- /server/server_fail2ban/config:/config:rw
|
|
||||||
expose:
|
|
||||||
- "8000"
|
|
||||||
networks:
|
|
||||||
- bangui-net
|
|
||||||
|
|
||||||
# ── Frontend (nginx serving built SPA + API proxy) ──────────
|
|
||||||
frontend:
|
|
||||||
image: git.lpl-mind.de/lukas.pupkalipinski/bangui/frontend:latest
|
|
||||||
container_name: bangui-frontend
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- PUID=1011
|
|
||||||
- PGID=1001
|
|
||||||
ports:
|
|
||||||
- "${BANGUI_PORT:-8080}:80"
|
|
||||||
depends_on:
|
|
||||||
backend:
|
|
||||||
condition: service_started
|
|
||||||
networks:
|
|
||||||
- bangui-net
|
|
||||||
|
|
||||||
networks:
|
|
||||||
bangui-net:
|
|
||||||
name: bangui-net
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
# BanGUI — Fail2ban Dev Test Environment
|
|
||||||
|
|
||||||
This directory contains the fail2ban configuration and supporting scripts for a
|
|
||||||
self-contained development test environment. A simulation script writes fake
|
|
||||||
authentication-failure log lines, fail2ban detects them via the `manual-Jail`
|
|
||||||
jail, and bans the offending IP — giving a fully reproducible ban/unban cycle
|
|
||||||
without a real service.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Docker or Podman installed and running.
|
|
||||||
- `docker compose` (v2) or `podman-compose` available on the `PATH`.
|
|
||||||
- The repo checked out; all commands run from the **repo root**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### 1 — Start the fail2ban container
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f Docker/compose.debug.yml up -d fail2ban
|
|
||||||
# or: make up (starts the full dev stack)
|
|
||||||
```
|
|
||||||
|
|
||||||
Wait ~15 s for the health-check to pass (`docker ps` shows `healthy`).
|
|
||||||
|
|
||||||
### 2 — Run the login-failure simulation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash Docker/simulate_failed_logins.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Default: writes **5** failure lines for IP `192.168.100.99` to
|
|
||||||
`Docker/logs/auth.log`.
|
|
||||||
Optional overrides:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash Docker/simulate_failed_logins.sh <COUNT> <SOURCE_IP> <LOG_FILE>
|
|
||||||
# e.g. bash Docker/simulate_failed_logins.sh 10 203.0.113.42
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3 — Verify the IP was banned
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash Docker/check_ban_status.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The output shows the current jail counters and the list of banned IPs with their
|
|
||||||
ban expiry timestamps.
|
|
||||||
|
|
||||||
### 4 — Unban and re-test
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash Docker/check_ban_status.sh --unban 192.168.100.99
|
|
||||||
```
|
|
||||||
|
|
||||||
### One-command smoke test (Makefile shortcut)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make dev-ban-test
|
|
||||||
```
|
|
||||||
|
|
||||||
Chains steps 1–3 automatically with appropriate sleep intervals.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration Reference
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `fail2ban/filter.d/manual-Jail.conf` | Defines the `failregex` that matches simulation log lines |
|
|
||||||
| `fail2ban/jail.d/manual-Jail.conf` | Jail settings: `maxretry=3`, `bantime=60s`, `findtime=120s` |
|
|
||||||
| `Docker/logs/auth.log` | Log file written by the simulation script (host path) |
|
|
||||||
|
|
||||||
Inside the container the log file is mounted at `/remotelogs/bangui/auth.log`
|
|
||||||
(see `fail2ban/paths-lsio.conf` — `remote_logs_path = /remotelogs`).
|
|
||||||
|
|
||||||
To change sensitivity, edit `fail2ban/jail.d/manual-Jail.conf`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
maxretry = 3 # failures before a ban
|
|
||||||
findtime = 120 # look-back window in seconds
|
|
||||||
bantime = 60 # ban duration in seconds
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Log file not detected
|
|
||||||
|
|
||||||
The jail uses `backend = polling` for reliability inside Docker containers.
|
|
||||||
If fail2ban still does not pick up new lines, verify the volume mount in
|
|
||||||
`Docker/compose.debug.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- ./logs:/remotelogs/bangui
|
|
||||||
```
|
|
||||||
|
|
||||||
and confirm `Docker/logs/auth.log` exists after running the simulation script.
|
|
||||||
|
|
||||||
### Filter regex mismatch
|
|
||||||
|
|
||||||
Test the regex manually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec bangui-fail2ban-dev \
|
|
||||||
fail2ban-regex /remotelogs/bangui/auth.log manual-Jail
|
|
||||||
```
|
|
||||||
|
|
||||||
The output should show matched lines. If nothing matches, check that the log
|
|
||||||
lines match the corresponding `failregex` pattern:
|
|
||||||
|
|
||||||
```
|
|
||||||
# manual-Jail (auth log):
|
|
||||||
YYYY-MM-DD HH:MM:SS bangui-auth: authentication failure from <IP>
|
|
||||||
```
|
|
||||||
|
|
||||||
### iptables / permission errors
|
|
||||||
|
|
||||||
The fail2ban container requires `NET_ADMIN` and `NET_RAW` capabilities and
|
|
||||||
`network_mode: host`. Both are already set in `Docker/compose.debug.yml`. If
|
|
||||||
you see iptables errors, check that the host kernel has iptables loaded:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo modprobe ip_tables
|
|
||||||
```
|
|
||||||
|
|
||||||
### IP not banned despite enough failures
|
|
||||||
|
|
||||||
Check whether the source IP falls inside the `ignoreip` range defined in
|
|
||||||
`fail2ban/jail.d/manual-Jail.conf`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
|
||||||
```
|
|
||||||
|
|
||||||
The default simulation IP `192.168.100.99` is outside these ranges and will be
|
|
||||||
banned normally.
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# BanGUI — Simulated authentication failure filter
|
|
||||||
#
|
|
||||||
# Matches lines written by Docker/simulate_failed_logins.sh
|
|
||||||
# Format: <timestamp> bangui-auth: authentication failure from <HOST>
|
|
||||||
# Jail: manual-Jail
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[Definition]
|
|
||||||
|
|
||||||
failregex = ^.* bangui-auth: authentication failure from <HOST>\s*$
|
|
||||||
|
|
||||||
ignoreregex =
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# BanGUI — Blocklist-import jail
|
|
||||||
#
|
|
||||||
# Dedicated jail for IPs banned via the BanGUI blocklist import
|
|
||||||
# feature. This is a manual-ban jail: it does not watch any log
|
|
||||||
# file. All bans are injected programmatically via
|
|
||||||
# fail2ban-client set blocklist-import banip <ip>
|
|
||||||
# which the BanGUI backend uses through its fail2ban socket
|
|
||||||
# client.
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[blocklist-import]
|
|
||||||
|
|
||||||
enabled = true
|
|
||||||
# No log-based detection — only manual banip commands are used.
|
|
||||||
filter =
|
|
||||||
logpath = /dev/null
|
|
||||||
backend = auto
|
|
||||||
maxretry = 1
|
|
||||||
findtime = 1d
|
|
||||||
# Block imported IPs for 24 hours.
|
|
||||||
bantime = 86400
|
|
||||||
|
|
||||||
# Never ban the Docker bridge network or localhost.
|
|
||||||
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# BanGUI — Simulated authentication failure jail
|
|
||||||
#
|
|
||||||
# Watches Docker/logs/auth.log (mounted at /remotelogs/bangui)
|
|
||||||
# for lines produced by Docker/simulate_failed_logins.sh.
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[manual-Jail]
|
|
||||||
|
|
||||||
enabled = true
|
|
||||||
filter = manual-Jail
|
|
||||||
logpath = /remotelogs/bangui/auth.log
|
|
||||||
backend = polling
|
|
||||||
maxretry = 3
|
|
||||||
findtime = 120
|
|
||||||
bantime = 60
|
|
||||||
|
|
||||||
# Never ban localhost, the Docker bridge network, or the host machine.
|
|
||||||
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# Local overrides — not overwritten by the container init script.
|
|
||||||
# Provides banaction so all jails can resolve %(action_)s interpolation.
|
|
||||||
|
|
||||||
[DEFAULT]
|
|
||||||
banaction = iptables-multiport
|
|
||||||
banaction_allports = iptables-allports
|
|
||||||
@@ -10,6 +10,15 @@ server {
|
|||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||||
gzip_min_length 256;
|
gzip_min_length 256;
|
||||||
|
|
||||||
|
# ── Security headers ─────────────────────────────────────
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||||
|
# Uncomment when HTTPS is fully configured:
|
||||||
|
# add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||||
|
|
||||||
# ── API reverse proxy → backend container ─────────────────
|
# ── API reverse proxy → backend container ─────────────────
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
# ./release.sh
|
# ./release.sh
|
||||||
#
|
#
|
||||||
# The current version is stored in VERSION (next to this script).
|
# The current version is stored in VERSION (next to this script).
|
||||||
# You will be asked whether to bump major, minor, or patch.
|
# You will be asked whether to bump major, minor, patch, or release candidate (rc).
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -24,24 +24,60 @@ CURRENT="$(cat "${VERSION_FILE}")"
|
|||||||
# Strip leading 'v' for arithmetic
|
# Strip leading 'v' for arithmetic
|
||||||
VERSION="${CURRENT#v}"
|
VERSION="${CURRENT#v}"
|
||||||
|
|
||||||
IFS='.' read -r MAJOR MINOR PATCH <<< "${VERSION}"
|
# Parse version: X.Y.Z or X.Y.Z-rc.N
|
||||||
|
if [[ "${VERSION}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-rc\.([0-9]+))?$ ]]; then
|
||||||
|
MAJOR="${BASH_REMATCH[1]}"
|
||||||
|
MINOR="${BASH_REMATCH[2]}"
|
||||||
|
PATCH="${BASH_REMATCH[3]}"
|
||||||
|
RC_SUFFIX="${BASH_REMATCH[4]:-}"
|
||||||
|
RC_NUM="${BASH_REMATCH[5]:-0}"
|
||||||
|
else
|
||||||
|
echo "Error: version '${VERSION}' does not match expected format X.Y.Z or X.Y.Z-rc.N" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo " BanGUI — Release"
|
echo " BanGUI — Release"
|
||||||
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}"
|
if [[ -n "${RC_SUFFIX}" ]]; then
|
||||||
|
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM}"
|
||||||
|
else
|
||||||
|
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}"
|
||||||
|
fi
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo ""
|
echo ""
|
||||||
echo "How would you like to bump the version?"
|
echo "How would you like to bump the version?"
|
||||||
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
|
if [[ -n "${RC_SUFFIX}" ]]; then
|
||||||
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
|
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v${MAJOR}.${MINOR}.${PATCH})"
|
||||||
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
|
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v${MAJOR}.$((MINOR + 1)).0)"
|
||||||
|
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v$((MAJOR + 1)).0.0)"
|
||||||
|
echo " 4) rc (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v${MAJOR}.${MINOR}.${PATCH}-rc.$((RC_NUM + 1)))"
|
||||||
|
else
|
||||||
|
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
|
||||||
|
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
|
||||||
|
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
|
||||||
|
echo " 4) rc (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.${PATCH}-rc.1)"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
read -rp "Enter choice [1/2/3]: " CHOICE
|
read -rp "Enter choice [1/2/3/4]: " CHOICE
|
||||||
|
|
||||||
case "${CHOICE}" in
|
case "${CHOICE}" in
|
||||||
1) NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
|
1)
|
||||||
|
if [[ -n "${RC_SUFFIX}" ]]; then
|
||||||
|
# Release the RC: strip RC suffix
|
||||||
|
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}"
|
||||||
|
else
|
||||||
|
NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
2) NEW_TAG="v${MAJOR}.$((MINOR + 1)).0" ;;
|
2) NEW_TAG="v${MAJOR}.$((MINOR + 1)).0" ;;
|
||||||
3) NEW_TAG="v$((MAJOR + 1)).0.0" ;;
|
3) NEW_TAG="v$((MAJOR + 1)).0.0" ;;
|
||||||
|
4)
|
||||||
|
if [[ "${RC_NUM}" -gt 0 ]]; then
|
||||||
|
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}-rc.$((RC_NUM + 1))"
|
||||||
|
else
|
||||||
|
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}-rc.1"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Invalid choice. Aborting." >&2
|
echo "Invalid choice. Aborting." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -68,11 +104,26 @@ FRONT_PKG="${SCRIPT_DIR}/../frontend/package.json"
|
|||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}"
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}"
|
||||||
echo "frontend/package.json version updated → ${FRONT_VERSION}"
|
echo "frontend/package.json version updated → ${FRONT_VERSION}"
|
||||||
|
|
||||||
|
# Keep backend/pyproject.toml in sync so app.__version__ matches Docker/VERSION in the runtime container.
|
||||||
|
BACKEND_PYPROJECT="${SCRIPT_DIR}/../backend/pyproject.toml"
|
||||||
|
if [[ -f "${BACKEND_PYPROJECT}" ]]; then
|
||||||
|
sed -i "s/^version = \".*\"/version = \"${FRONT_VERSION}\"/" "${BACKEND_PYPROJECT}"
|
||||||
|
echo "backend/pyproject.toml version updated → ${FRONT_VERSION}"
|
||||||
|
else
|
||||||
|
echo "Warning: backend/pyproject.toml not found, skipping backend version sync" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Push containers
|
# Push containers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
|
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
|
||||||
bash "${SCRIPT_DIR}/push.sh"
|
|
||||||
|
# Push to "latest" or "latestRC" depending on whether this is a release candidate
|
||||||
|
if [[ "${NEW_TAG}" == *-rc* ]]; then
|
||||||
|
bash "${SCRIPT_DIR}/push.sh" "latestRC"
|
||||||
|
else
|
||||||
|
bash "${SCRIPT_DIR}/push.sh" "latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
# Defaults:
|
# Defaults:
|
||||||
# COUNT : 5
|
# COUNT : 5
|
||||||
# SOURCE_IP: 192.168.100.99
|
# SOURCE_IP: 192.168.100.99
|
||||||
# LOG_FILE : Docker/logs/auth.log (relative to repo root)
|
# LOG_FILE : data/log/auth.log (relative to repo root)
|
||||||
#
|
#
|
||||||
# Log line format (must match manual-Jail failregex exactly):
|
# Log line format (must match manual-Jail failregex exactly):
|
||||||
# YYYY-MM-DD HH:MM:SS bangui-auth: authentication failure from <IP>
|
# YYYY-MM-DD HH:MM:SS bangui-auth: authentication failure from <IP>
|
||||||
@@ -25,7 +25,7 @@ readonly DEFAULT_IP="192.168.100.99"
|
|||||||
|
|
||||||
# Resolve script location so defaults work regardless of cwd.
|
# Resolve script location so defaults work regardless of cwd.
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
readonly DEFAULT_LOG_FILE="${SCRIPT_DIR}/logs/auth.log"
|
readonly DEFAULT_LOG_FILE="${SCRIPT_DIR}/../data/log/auth.log"
|
||||||
|
|
||||||
# ── Arguments ─────────────────────────────────────────────────
|
# ── Arguments ─────────────────────────────────────────────────
|
||||||
COUNT="${1:-${DEFAULT_COUNT}}"
|
COUNT="${1:-${DEFAULT_COUNT}}"
|
||||||
|
|||||||
1338
Docs/API-Reference.md
Normal file
1338
Docs/API-Reference.md
Normal file
File diff suppressed because it is too large
Load Diff
730
Docs/API_STATUS_CODES.md
Normal file
730
Docs/API_STATUS_CODES.md
Normal 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
|
||||||
166
Docs/API_VERSIONING.md
Normal file
166
Docs/API_VERSIONING.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# API Versioning Strategy
|
||||||
|
|
||||||
|
**Status:** Active — Current version: **v1**
|
||||||
|
|
||||||
|
All BanGUI API endpoints are versioned using URI path versioning (e.g., `/api/v1/`).
|
||||||
|
This document explains when and how to version endpoints, how deprecation works, and what guarantees consumers can rely on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Version Lifecycle
|
||||||
|
|
||||||
|
| Stage | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| **Current** | Active, receiving new features and bug fixes. |
|
||||||
|
| **Deprecated** | Still functional but marked for removal. Clients receive `Deprecation: true` and `Sunset: <date>` response headers. |
|
||||||
|
| **Removed** | Endpoint no longer exists. Clients must migrate to a newer version. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. URL Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/v{major}/<resource>/<path>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **v1** — current version (released 2026-05-02)
|
||||||
|
- **v2** — reserved; skeleton router deployed at `/api/v2/jails` but **not yet active** for production traffic
|
||||||
|
- **PATCH** versions (v1.1, v1.2) are **not** used; only **major** version bumps indicate breaking changes
|
||||||
|
- The OpenAPI schema is always available at `/api/openapi.json` regardless of version
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. What Triggers a Version Bump
|
||||||
|
|
||||||
|
A new major version is required when a **breaking change** must be introduced, including:
|
||||||
|
|
||||||
|
- Removing or renaming a field in a response model
|
||||||
|
- Changing the type of a request or response field
|
||||||
|
- Removing an endpoint entirely
|
||||||
|
- Changing authentication/authorization semantics
|
||||||
|
- Modifying the semantics of an existing operation
|
||||||
|
|
||||||
|
**Non-breaking changes** (backward-compatible):
|
||||||
|
|
||||||
|
- Adding new optional request fields
|
||||||
|
- Adding new response fields
|
||||||
|
- Adding new endpoints
|
||||||
|
- Fixing bugs that caused incorrect behavior
|
||||||
|
|
||||||
|
These do **not** require a version bump.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Deprecation Policy
|
||||||
|
|
||||||
|
When an endpoint is deprecated:
|
||||||
|
|
||||||
|
1. The endpoint **remains functional** for a minimum of **6 months** from the `Sunset` date
|
||||||
|
2. Response headers are added to every 2xx response:
|
||||||
|
```
|
||||||
|
Deprecation: true
|
||||||
|
Sunset: <RFC-5322 date>
|
||||||
|
Link: <https://bangui.example.com/api/v2/...>; rel="successor-version"
|
||||||
|
```
|
||||||
|
3. The endpoint is registered in the deprecation middleware (``app/middleware/deprecation.py``)
|
||||||
|
4. The OpenAPI schema marks the endpoint with `deprecated: true`
|
||||||
|
5. Documentation is updated to show the endpoint as deprecated
|
||||||
|
|
||||||
|
### Implementing Deprecation Headers
|
||||||
|
|
||||||
|
The ``DeprecationHeaderMiddleware`` (``app/middleware/deprecation.py``) automatically injects
|
||||||
|
the correct headers for any registered deprecated endpoint. To schedule an endpoint for removal:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from app.middleware.deprecation import register_deprecated_endpoint
|
||||||
|
|
||||||
|
# Example: deprecate /api/v1/jails on 2026-11-03 (6 months from v2 release)
|
||||||
|
register_deprecated_endpoint(
|
||||||
|
path_prefix="/api/v1/jails",
|
||||||
|
sunset_date=datetime(2026, 11, 3, tzinfo=timezone.utc),
|
||||||
|
successor_url="/api/v2/jails",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The middleware runs on every response; if the request path matches a registered deprecated prefix,
|
||||||
|
the appropriate headers are appended before the response is returned.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Backend Development: Adding Versioned Endpoints
|
||||||
|
|
||||||
|
### New endpoints
|
||||||
|
|
||||||
|
All new endpoints are added to the **current** version (`/api/v1/`). Prefix your router:
|
||||||
|
|
||||||
|
```python
|
||||||
|
router = APIRouter(prefix="/api/v1/my-resource", tags=["My Resource"])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Breaking changes requiring v2
|
||||||
|
|
||||||
|
1. Create a new router file (e.g., `routers/my_resource_v2.py`) with the v2 prefix:
|
||||||
|
```python
|
||||||
|
router = APIRouter(prefix="/api/v2/my-resource", tags=["My Resource (v2)"])
|
||||||
|
```
|
||||||
|
2. Copy or adapt the v1 handler logic as needed. Extract shared business logic into
|
||||||
|
a **service layer function** so both routers call the same underlying code.
|
||||||
|
3. Register the new router in `app/main.py`:
|
||||||
|
```python
|
||||||
|
app.include_router(my_resource_v2.router)
|
||||||
|
```
|
||||||
|
4. Register the v1 endpoint for deprecation headers (see §4 above)
|
||||||
|
5. Update this document to reflect the new version lifecycle
|
||||||
|
|
||||||
|
### Keeping routers DRY
|
||||||
|
|
||||||
|
Routers should only contain HTTP concerns (parameters, responses, status codes). Business logic
|
||||||
|
belongs in the service layer. Both v1 and v2 handlers can call the same service function.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Frontend Development
|
||||||
|
|
||||||
|
The frontend always uses the current version's base URL:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const BASE_URL: string = import.meta.env.VITE_API_URL ?? "/api/v1";
|
||||||
|
```
|
||||||
|
|
||||||
|
All endpoint paths in `frontend/src/api/endpoints.ts` are defined as relative paths (e.g., `/bans`, `/jails`) and are appended to `BASE_URL` at runtime.
|
||||||
|
|
||||||
|
When v2 is released, update ``VITE_API_URL`` in the environment configuration to point to `/api/v2`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. OpenAPI / Documentation
|
||||||
|
|
||||||
|
- Swagger UI: `/api/docs`
|
||||||
|
- ReDoc: `/api/redoc`
|
||||||
|
- OpenAPI schema: `/api/openapi.json`
|
||||||
|
- Docs are **not** versioned; they always reflect the **current** (latest) API version
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. CI Breaking-Change Checks
|
||||||
|
|
||||||
|
A GitHub Actions job runs on every pull request to detect breaking OpenAPI changes:
|
||||||
|
|
||||||
|
- ``openapi-breaking-changes`` job (PR only): generates the current OpenAPI spec and
|
||||||
|
compares it against the baseline committed on the last push to `main`. If any breaking
|
||||||
|
changes are found, the job fails and the PR cannot be merged.
|
||||||
|
- ``openapi-baseline-commit`` job (main push only): generates and commits the current
|
||||||
|
OpenAPI spec as the new baseline for future PR comparisons.
|
||||||
|
|
||||||
|
To trigger the baseline update, push to main after merging a version bump or any change
|
||||||
|
that legitimately alters the OpenAPI surface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Version History
|
||||||
|
|
||||||
|
| Version | Status | Released | Sunset Date | Notes |
|
||||||
|
|---------|--------|---------|-------------|-------|
|
||||||
|
| v1 | **Current** | 2026-05-02 | — | Initial versioning; all endpoints moved from `/api/` to `/api/v1/` |
|
||||||
|
| v2 | **Reserved — skeleton active, endpoints not yet available** | — | — | Router skeleton at `app/routers/jails_v2.py`; real endpoints will be added before activation |
|
||||||
1021
Docs/Architekture.md
1021
Docs/Architekture.md
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
194
Docs/CONFIGURATION.md
Normal file
194
Docs/CONFIGURATION.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# Configuration Reference
|
||||||
|
|
||||||
|
All runtime settings are environment variables prefixed with `BANGUI_`. Values are validated at startup — missing required fields or invalid values cause the application to refuse to start.
|
||||||
|
|
||||||
|
For setup instructions, see [Instructions.md](./Instructions.md). For deployment, see [Deployment.md](./Deployment.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
| Variable | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `BANGUI_DATABASE_PATH` | string | `bangui.db` | Filesystem path to the SQLite application database. Parent directory must exist and be writable at startup. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session & Security
|
||||||
|
|
||||||
|
| Variable | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `BANGUI_SESSION_SECRET` | string | **(required)** | Secret key for signing session tokens. Must be ≥ 32 characters. Generate with `python -c "import secrets; print(secrets.token_hex(32))"`. Never reuse across environments. |
|
||||||
|
| `BANGUI_SESSION_SECRET_PREVIOUS` | string | `null` | Previous session secret used during rotation. Set to the old secret while rotating; unset once all old tokens expire. |
|
||||||
|
| `BANGUI_SESSION_DURATION_MINUTES` | int | `60` | Session lifetime in minutes. Must be ≥ 1. |
|
||||||
|
| `BANGUI_SESSION_CACHE_ENABLED` | bool | `false` | Enable in-memory session validation cache. Disable in multi-worker deployments to avoid stale revoked sessions. |
|
||||||
|
| `BANGUI_SESSION_CACHE_TTL_SECONDS` | float | `10.0` | TTL for cached session entries. Ignored when `BANGUI_SESSION_CACHE_ENABLED` is `false`. Must be ≥ 0. |
|
||||||
|
| `BANGUI_SESSION_COOKIE_HTTPONLY` | bool | `true` | Mark the session cookie as `HttpOnly` (JavaScript cannot access it). |
|
||||||
|
| `BANGUI_SESSION_COOKIE_SAMESITE` | string | `lax` | SameSite policy for the session cookie. Valid values: `lax`, `strict`, `none`. |
|
||||||
|
| `BANGUI_SESSION_COOKIE_SECURE` | bool | `true` | Set the `Secure` flag on the session cookie. `true` required for HTTPS. Set to `false` only for local HTTP development. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## fail2ban Integration
|
||||||
|
|
||||||
|
| Variable | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `BANGUI_FAIL2BAN_SOCKET` | string | `/var/run/fail2ban/fail2ban.sock` | Path to the fail2ban Unix domain socket. Socket must exist and be readable at startup (warning issued if not). |
|
||||||
|
| `BANGUI_FAIL2BAN_CONFIG_DIR` | string | `/config/fail2ban` | Path to the fail2ban configuration directory. Must contain `jail.d/`, `filter.d/`, and `action.d/`. |
|
||||||
|
| `BANGUI_FAIL2BAN_START_COMMAND` | string | `fail2ban-client start` | Shell command to start the fail2ban daemon (no shell interpretation). Used during recovery rollback. Must be parseable by `shlex.split`. |
|
||||||
|
| `BANGUI_ALLOWED_LOG_DIRS` | list | `/var/log,/config/log` | Allowed directory prefixes for jail log paths. Any log path must resolve within one of these directories. |
|
||||||
|
| `BANGUI_TRUSTED_PROXIES` | list | `[]` | Trusted reverse proxy IP addresses or CIDR ranges (e.g., `192.168.1.1,10.0.0.0/8`). Only these sources can set `X-Forwarded-For` and `X-Real-IP`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTP Client
|
||||||
|
|
||||||
|
These settings control outbound HTTP requests made by the backend (geolocation fallback, blocklist downloads).
|
||||||
|
|
||||||
|
| Variable | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `BANGUI_HTTP_REQUEST_TIMEOUT_SECONDS` | float | `20.0` | Maximum total time for an outbound HTTP request. Must be ≥ 0. |
|
||||||
|
| `BANGUI_HTTP_CONNECT_TIMEOUT_SECONDS` | float | `5.0` | Maximum time to establish a TCP connection. Must be ≥ 0. |
|
||||||
|
| `BANGUI_HTTP_MAX_CONNECTIONS` | int | `10` | Maximum concurrent outbound HTTP connections. Must be ≥ 1. |
|
||||||
|
| `BANGUI_HTTP_KEEPALIVE_TIMEOUT_SECONDS` | float | `15.0` | How long idle keepalive connections are retained. Must be ≥ 0. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Geolocation
|
||||||
|
|
||||||
|
| Variable | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `BANGUI_GEOIP_DB_PATH` | string | `null` | Path to a MaxMind GeoLite2-Country `.mmdb` file. Primary resolver for IP geolocation when set. Download from https://dev.maxmind.com/geoip/geolite2-country. |
|
||||||
|
| `BANGUI_GEOIP_ALLOW_HTTP_FALLBACK` | bool | `false` | Allow HTTP fallback to `ip-api.com` when the MMDB is unavailable. **Warning**: sends IP addresses unencrypted. Only enable when MMDB cannot be mounted. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-Origin (CORS)
|
||||||
|
|
||||||
|
| Variable | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `BANGUI_CORS_ALLOWED_ORIGINS` | list | `http://localhost:5173,http://127.0.0.1:5173,https://localhost:5173,https://127.0.0.1:5173` | Allowed CORS origins. Comma-separated string or YAML list. Empty list disables CORS. **Never use `"*"` in production** when credentials are enabled. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Display
|
||||||
|
|
||||||
|
| Variable | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `BANGUI_TIMEZONE` | string | `UTC` | IANA timezone name used when displaying timestamps in the UI (e.g., `America/New_York`, `Europe/London`). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## External Logging
|
||||||
|
|
||||||
|
Enable with `BANGUI_EXTERNAL_LOGGING_ENABLED=true`, then set the provider and provider-specific variables.
|
||||||
|
|
||||||
|
| Variable | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `BANGUI_EXTERNAL_LOGGING_ENABLED` | bool | `false` | Send logs to a centralized logging platform instead of stdout only. |
|
||||||
|
| `BANGUI_EXTERNAL_LOGGING_PROVIDER` | string | `null` | Logging provider: `datadog`, `papertrail`, or `elasticsearch`. Required when external logging is enabled. |
|
||||||
|
| `BANGUI_EXTERNAL_LOGGING_BUFFER_SIZE` | int | `1000` | Max log records buffered in memory before dropping oldest. Must be ≥ 10. |
|
||||||
|
| `BANGUI_EXTERNAL_LOGGING_FLUSH_INTERVAL_SECONDS` | float | `5.0` | Max seconds before flushing a log batch. Must be > 0. |
|
||||||
|
|
||||||
|
### Datadog
|
||||||
|
|
||||||
|
| Variable | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `BANGUI_DATADOG_API_KEY` | string | `null` | Datadog API key. Required when provider is `datadog`. |
|
||||||
|
| `BANGUI_DATADOG_SITE` | string | `datadoghq.com` | Datadog site: `datadoghq.com` (US) or `datadoghq.eu` (EU). |
|
||||||
|
| `BANGUI_DATADOG_BATCH_SIZE` | int | `10` | Number of log records per batch. Must be ≥ 1. |
|
||||||
|
|
||||||
|
### Papertrail
|
||||||
|
|
||||||
|
| Variable | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `BANGUI_PAPERTRAIL_HOST` | string | `null` | Papertrail host address (e.g., `logs1.papertrailapp.com`). Required when provider is `papertrail`. |
|
||||||
|
| `BANGUI_PAPERTRAIL_PORT` | int | `null` | Papertrail port. Required when provider is `papertrail`. Range: 1–65535. |
|
||||||
|
| `BANGUI_PAPERTRAIL_PROGRAM_NAME` | string | `bangui` | Program name in Syslog messages sent to Papertrail. |
|
||||||
|
|
||||||
|
### Elasticsearch
|
||||||
|
|
||||||
|
| Variable | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `BANGUI_ELASTICSEARCH_HOSTS` | list | `[]` | Elasticsearch host URLs (e.g., `http://elasticsearch:9200`). Required when provider is `elasticsearch`. |
|
||||||
|
| `BANGUI_ELASTICSEARCH_INDEX_PREFIX` | string | `bangui` | Prefix for Elasticsearch indices. |
|
||||||
|
| `BANGUI_ELASTICSEARCH_BATCH_SIZE` | int | `10` | Number of log documents per batch. Must be ≥ 1. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
Per-IP rate limits applied to API endpoints.
|
||||||
|
|
||||||
|
| Variable | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `BANGUI_RATE_LIMIT_BANS_PER_MINUTE` | int | `100` | Max ban/unban requests per IP per minute. |
|
||||||
|
| `BANGUI_RATE_LIMIT_BLOCKLIST_IMPORT_PER_HOUR` | int | `100` | Max blocklist import requests per IP per hour. |
|
||||||
|
| `BANGUI_RATE_LIMIT_CONFIG_UPDATE_PER_MINUTE` | int | `50` | Max config update requests per IP per minute. |
|
||||||
|
|
||||||
|
**Rate limit reset mechanism:** Each limit is applied per-client IP. To bypass the blocklist import rate limit in automated tests (E2E-4), send a unique `X-Forwarded-For` header with each import request — e.g., `X-Forwarded-For: 10.0.0.99`. The header is only honoured when the client IP falls within `BANGUI_TRUSTED_PROXIES`; otherwise the real client IP is used.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pagination & Display Limits
|
||||||
|
|
||||||
|
Configurable limits that affect API response sizes and data retention.
|
||||||
|
|
||||||
|
| Variable | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `BANGUI_MAX_PAGE_SIZE` | int | `500` | Maximum records returned per paginated API response. Individual endpoints may further limit this. Must be 1–10000. |
|
||||||
|
| `BANGUI_PREVIEW_MAX_LINES` | int | `100` | Maximum IP lines returned in a blocklist source preview. Must be ≥ 1. |
|
||||||
|
| `BANGUI_HISTORY_RETENTION_DAYS` | int | `90` | Number of days historical ban records are retained before archival cleanup. Must be ≥ 1. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
| Variable | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `BANGUI_LOG_LEVEL` | string | `info` | Application log level. Valid values: `debug`, `info`, `warning`, `error`, `critical`. |
|
||||||
|
| `BANGUI_ENABLE_DOCS` | bool | `false` | Enable FastAPI interactive docs at `/api/docs` (Swagger UI) and `/api/redoc` (ReDoc). Enable only in development. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate a session secret
|
||||||
|
python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
|
||||||
|
# Minimal production .env
|
||||||
|
BANGUI_SESSION_SECRET=<your-32-plus-char-secret>
|
||||||
|
BANGUI_CORS_ALLOWED_ORIGINS=https://your-frontend.example.com
|
||||||
|
BANGUI_TIMEZONE=America/New_York
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `manual-Jail` Fail2ban Jail (E2E Test Dependency)
|
||||||
|
|
||||||
|
The E2E test **E2E-3** (`e2e/tests/02_ban_records.robot`) writes authentication-failure lines via `Docker/simulate_failed_logins.sh` and asserts that the resulting ban appears in the BanGUI UI. The test depends on the following `manual-Jail` configuration in `Docker/fail2ban-dev-config/fail2ban/jail.d/manual-Jail.conf`:
|
||||||
|
|
||||||
|
| Parameter | Value | Relevance to E2E-3 |
|
||||||
|
|-----------|-------|---------------------|
|
||||||
|
| `maxretry` | `3` | Ban triggers after 3 matching lines. `simulate_failed_logins.sh` writes 5 lines by default — enough to trigger the ban reliably. |
|
||||||
|
| `findtime` | `120` | Time window in seconds during which `maxretry` failures accumulate. |
|
||||||
|
| `bantime` | `60` | Ban duration in seconds. Teardown unbans via `check_ban_status.sh --unban` regardless of bantime. |
|
||||||
|
| `logpath` | `/remotelogs/bangui/auth.log` | fail2ban reads this path inside the container. `simulate_failed_logins.sh` writes to `Docker/logs/auth.log`, which must be volume-mapped to `/remotelogs/bangui/auth.log`. |
|
||||||
|
| `backend` | `polling` | fail2ban re-reads the log file on its own interval (not event-driven). A 15 s sleep in the E2E test gives fail2ban time to detect the ban. |
|
||||||
|
| `ignoreip` | `127.0.0.0/8 ::1 172.16.0.0/12` | Test IP `192.168.100.99` is not ignored. Ensure local overrides do not add this IP to `ignoreip`. |
|
||||||
|
|
||||||
|
**Log path mapping (Docker/Podman compose):** The host file `Docker/logs/auth.log` must be mounted to `/remotelogs/bangui/auth.log` inside the `bangui-fail2ban-dev` container. If the volume mapping is changed, `simulate_failed_logins.sh` will write to a path fail2ban does not watch, and the test will fail at Step 2 with no ban recorded.
|
||||||
|
|
||||||
|
**Test IP:** `192.168.100.99` (non-routable link-local test subnet, RFC 3927). Safe to use because it is outside all `ignoreip` ranges and unlikely to appear in real traffic.
|
||||||
|
|
||||||
|
**Scheduling note:** The backend does not receive push notifications from fail2ban. `GET /api/bans/active` queries the fail2ban Unix socket directly (on-demand). The history archive is populated by `history_sync`, a periodic job running every 300 s (`HISTORY_SYNC_INTERVAL` in `backend/app/tasks/history_sync.py`). The E2E test uses `GET /api/bans/active` for the API assertion (avoids the archive lag) and the History page with `?page_size=500` for the UI assertion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-References
|
||||||
|
|
||||||
|
- [Deployment.md](./Deployment.md) — Docker configuration, health checks, graceful shutdown
|
||||||
|
- [Security.md](./Security.md) — Security recommendations and hardening
|
||||||
|
- [Observability.md](./Observability.md) — Logging, metrics, and monitoring
|
||||||
|
- [Backend-Development.md](./Backend-Development.md) — Backend coding conventions
|
||||||
347
Docs/DATABASE_SCHEMA.md
Normal file
347
Docs/DATABASE_SCHEMA.md
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
# Database Schema Documentation
|
||||||
|
|
||||||
|
BanGUI uses two SQLite databases:
|
||||||
|
|
||||||
|
| Database | Purpose | Location |
|
||||||
|
|---|---|---|
|
||||||
|
| **BanGUI app DB** | Own configuration, sessions, blocklist sources, import logs, geo cache | `bangui.db` |
|
||||||
|
| **fail2ban DB** | fail2ban's internal ban/jail data (read-only) | Configured via `FAIL2BAN_DB` env var |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. BanGUI Application Schema
|
||||||
|
|
||||||
|
Single source of truth: `backend/app/db.py`.
|
||||||
|
|
||||||
|
### 1.1 `settings`
|
||||||
|
|
||||||
|
Key-value store for application configuration.
|
||||||
|
|
||||||
|
| Column | Type | Constraints |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||||||
|
| `key` | TEXT | NOT NULL UNIQUE |
|
||||||
|
| `value` | TEXT | NOT NULL |
|
||||||
|
| `created_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
|
||||||
|
| `updated_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
|
||||||
|
|
||||||
|
**Indexes:** PK only.
|
||||||
|
|
||||||
|
**Purpose:** Stores app-wide settings (e.g., timezone, UI preferences). All settings access goes through `settings_repo` / `settings_service`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 `sessions`
|
||||||
|
|
||||||
|
Session tokens for web authentication.
|
||||||
|
|
||||||
|
| Column | Type | Constraints |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||||||
|
| `token_hash` | TEXT | NOT NULL UNIQUE |
|
||||||
|
| `created_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
|
||||||
|
| `expires_at` | TEXT | NOT NULL |
|
||||||
|
|
||||||
|
**Indexes:** `idx_sessions_token_hash` (UNIQUE) on `token_hash`.
|
||||||
|
|
||||||
|
**Purpose:** Web session management. Tokens are SHA-256 hashed before storage. Sessions expire and are cleaned up by `session_cleanup` task. See `auth_service.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 `blocklist_sources`
|
||||||
|
|
||||||
|
Blocklist source definitions for the import pipeline.
|
||||||
|
|
||||||
|
| Column | Type | Constraints |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||||||
|
| `name` | TEXT | NOT NULL |
|
||||||
|
| `url` | TEXT | NOT NULL UNIQUE |
|
||||||
|
| `enabled` | INTEGER | NOT NULL DEFAULT 1 (boolean) |
|
||||||
|
| `created_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
|
||||||
|
| `updated_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
|
||||||
|
|
||||||
|
**Indexes:** PK only.
|
||||||
|
|
||||||
|
**Purpose:** Defines sources for blocklist imports. See `blocklist_repo`, `blocklist_service`, `blocklist_import_workflow`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.4 `import_log`
|
||||||
|
|
||||||
|
Audit log of individual blocklist import operations.
|
||||||
|
|
||||||
|
| Column | Type | Constraints |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||||||
|
| `source_id` | INTEGER | REFERENCES `blocklist_sources(id)` ON DELETE RESTRICT |
|
||||||
|
| `source_url` | TEXT | NOT NULL |
|
||||||
|
| `timestamp` | INTEGER | NOT NULL (UNIX epoch) |
|
||||||
|
| `ips_imported` | INTEGER | NOT NULL DEFAULT 0 |
|
||||||
|
| `ips_skipped` | INTEGER | NOT NULL DEFAULT 0 |
|
||||||
|
| `errors` | TEXT | |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- `idx_import_log_id_desc` on `(id DESC)` — cursor pagination
|
||||||
|
- `idx_import_log_source_id_desc` on `(source_id, id DESC)` — filtered pagination
|
||||||
|
|
||||||
|
**Purpose:** Audit trail for imports. `source_id` RESTRICT prevents source deletion when logs exist. See migration 9.
|
||||||
|
|
||||||
|
**Migration 8:** `timestamp` migrated from TEXT ISO 8601 to INTEGER UNIX epoch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.5 `geo_cache`
|
||||||
|
|
||||||
|
Geo-IP lookup cache for ban IP metadata.
|
||||||
|
|
||||||
|
| Column | Type | Constraints |
|
||||||
|
|---|---|---|
|
||||||
|
| `ip` | TEXT | PRIMARY KEY |
|
||||||
|
| `country_code` | TEXT | |
|
||||||
|
| `country_name` | TEXT | |
|
||||||
|
| `asn` | TEXT | |
|
||||||
|
| `org` | TEXT | |
|
||||||
|
| `cached_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
|
||||||
|
|
||||||
|
**Additional (migration 3):**
|
||||||
|
| Column | Type | Constraints |
|
||||||
|
|---|---|---|
|
||||||
|
| `last_seen` | TEXT | NOT NULL DEFAULT ISO 8601 |
|
||||||
|
|
||||||
|
**Indexes:** PK only.
|
||||||
|
|
||||||
|
**Purpose:** Caches GeoIP results to reduce third-party API calls. TTL managed by `geo_cache_cleanup` task. See `geo_cache_repo`, `geo_service`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.6 `history_archive`
|
||||||
|
|
||||||
|
Archived ban/unban history mirrored from fail2ban DB.
|
||||||
|
|
||||||
|
| Column | Type | Constraints |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||||||
|
| `jail` | TEXT | NOT NULL |
|
||||||
|
| `ip` | TEXT | NOT NULL |
|
||||||
|
| `timeofban` | INTEGER | NOT NULL (UNIX epoch) |
|
||||||
|
| `bancount` | INTEGER | NOT NULL |
|
||||||
|
| `data` | TEXT | NOT NULL (JSON) |
|
||||||
|
| `action` | TEXT | NOT NULL CHECK IN ('ban', 'unban') |
|
||||||
|
| `created_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
|
||||||
|
|
||||||
|
**Constraints:** `UNIQUE(ip, jail, action, timeofban)` prevents duplicate archive rows.
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- `idx_history_archive_jail_timeofban` on `(jail, timeofban DESC)` — dashboard filter by jail + time ordering
|
||||||
|
- `idx_history_archive_timeofban_jail_action` on `(timeofban DESC, jail, action)` — timeline filters
|
||||||
|
- `idx_history_archive_ip` on `(ip)` — IP prefix/exact searches
|
||||||
|
- `idx_history_archive_action` on `(action)` — ban/unban filtering
|
||||||
|
|
||||||
|
**Purpose:** Long-term ban history. Synced from fail2ban DB by `history_sync` task. See `history_archive_repo`, `history_service`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.7 `scheduler_lock`
|
||||||
|
|
||||||
|
Database-backed mutex for multi-worker scheduler safety.
|
||||||
|
|
||||||
|
| Column | Type | Constraints |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INTEGER | PRIMARY KEY CHECK (id = 1) — singleton row |
|
||||||
|
| `pid` | INTEGER | NOT NULL |
|
||||||
|
| `hostname` | TEXT | NOT NULL |
|
||||||
|
| `created_at` | REAL | NOT NULL (UNIX epoch) |
|
||||||
|
| `heartbeat_at` | REAL | NOT NULL (UNIX epoch) |
|
||||||
|
|
||||||
|
**Indexes:** PK only (singleton constraint).
|
||||||
|
|
||||||
|
**Purpose:** Only one worker process holds the scheduler lock at a time. Lock is heartbeat-renewed by `scheduler_lock_heartbeat` task. Uses `BEGIN IMMEDIATE` transaction to acquire atomically. See `scheduler_lock.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.8 `import_runs`
|
||||||
|
|
||||||
|
Tracks unique blocklist imports for idempotent retries.
|
||||||
|
|
||||||
|
| Column | Type | Constraints |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||||||
|
| `source_id` | INTEGER | NOT NULL REFERENCES `blocklist_sources(id)` ON DELETE CASCADE |
|
||||||
|
| `content_hash` | TEXT | NOT NULL |
|
||||||
|
| `status` | TEXT | NOT NULL CHECK IN ('pending', 'completed', 'failed') |
|
||||||
|
| `imported_count` | INTEGER | NOT NULL DEFAULT 0 |
|
||||||
|
| `skipped_count` | INTEGER | NOT NULL DEFAULT 0 |
|
||||||
|
| `error_message` | TEXT | |
|
||||||
|
| `created_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
|
||||||
|
| `updated_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
|
||||||
|
|
||||||
|
**Constraints:** `UNIQUE(source_id, content_hash)` — same source + content = same import run.
|
||||||
|
|
||||||
|
**Indexes:** `idx_import_runs_source_status` on `(source_id, status)` — lookup completed imports by source.
|
||||||
|
|
||||||
|
**Purpose:** Prevents duplicate IP bans on import crash/retry. See migration 6 and `blocklist_import_workflow`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.9 `schema_migrations`
|
||||||
|
|
||||||
|
Tracks applied schema versions.
|
||||||
|
|
||||||
|
| Column | Type | Constraints |
|
||||||
|
|---|---|---|
|
||||||
|
| `version` | INTEGER | PRIMARY KEY |
|
||||||
|
| `migrated_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
|
||||||
|
|
||||||
|
**Indexes:** PK only.
|
||||||
|
|
||||||
|
**Purpose:** Idempotent schema migration tracker. Records each applied version number. See `init_db()` and `_migrate_schema()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Fail2ban Database Schema
|
||||||
|
|
||||||
|
Read-only access via `fail2ban_db_repo`. Fail2ban manages this DB; BanGUI mirrors data into `history_archive`.
|
||||||
|
|
||||||
|
### 2.1 `fail2banDb`
|
||||||
|
|
||||||
|
| Column | Type | Constraints |
|
||||||
|
|---|---|---|
|
||||||
|
| `version` | INTEGER | |
|
||||||
|
|
||||||
|
Single row tracking DB schema version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 `jails`
|
||||||
|
|
||||||
|
| Column | Type | Constraints |
|
||||||
|
|---|---|---|
|
||||||
|
| `name` | TEXT | NOT NULL UNIQUE |
|
||||||
|
| `enabled` | INTEGER | NOT NULL DEFAULT 1 |
|
||||||
|
|
||||||
|
**Indexes:** `jails_name` on `(name)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 `logs`
|
||||||
|
|
||||||
|
| Column | Type | Constraints |
|
||||||
|
|---|---|---|
|
||||||
|
| `jail` | TEXT | NOT NULL FK → `jails(name)` ON DELETE CASCADE |
|
||||||
|
| `path` | TEXT | |
|
||||||
|
| `firstlinemd5` | TEXT | |
|
||||||
|
| `lastfilepos` | INTEGER | DEFAULT 0 |
|
||||||
|
| `UNIQUE(jail, path)` | | |
|
||||||
|
| `UNIQUE(jail, path, firstlinemd5)` | | |
|
||||||
|
|
||||||
|
**Indexes:** `logs_path` on `(path)`, `logs_jail_path` on `(jail, path)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 `bans`
|
||||||
|
|
||||||
|
| Column | Type | Constraints |
|
||||||
|
|---|---|---|
|
||||||
|
| `jail` | TEXT | NOT NULL FK → `jails(name)` |
|
||||||
|
| `ip` | TEXT | |
|
||||||
|
| `timeofban` | INTEGER | NOT NULL |
|
||||||
|
| `bantime` | INTEGER | NOT NULL |
|
||||||
|
| `bancount` | INTEGER | NOT NULL DEFAULT 1 |
|
||||||
|
| `data` | JSON | |
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
- `bans_jail_timeofban_ip` on `(jail, timeofban)`
|
||||||
|
- `bans_jail_ip` on `(jail, ip)`
|
||||||
|
- `bans_ip` on `(ip)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.5 `bips`
|
||||||
|
|
||||||
|
Backup IPs table (ban backup).
|
||||||
|
|
||||||
|
| Column | Type | Constraints |
|
||||||
|
|---|---|---|
|
||||||
|
| `ip` | TEXT | NOT NULL |
|
||||||
|
| `jail` | TEXT | NOT NULL FK → `jails(name)` |
|
||||||
|
| `timeofban` | INTEGER | NOT NULL |
|
||||||
|
| `bantime` | INTEGER | NOT NULL |
|
||||||
|
| `bancount` | INTEGER | NOT NULL DEFAULT 1 |
|
||||||
|
| `data` | JSON | |
|
||||||
|
| PRIMARY KEY | `(ip, jail)` | |
|
||||||
|
|
||||||
|
**Indexes:** `bips_timeofban` on `(timeofban)`, `bips_ip` on `(ip)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Relationships and Constraints
|
||||||
|
|
||||||
|
```
|
||||||
|
blocklist_sources (1) ──(id)──→ import_log.source_id [RESTRICT on delete]
|
||||||
|
└──→ import_runs.source_id [CASCADE on delete]
|
||||||
|
|
||||||
|
settings: standalone (key-value, no FK)
|
||||||
|
sessions: standalone (token hash, no FK)
|
||||||
|
geo_cache: standalone (IP → geo data, no FK)
|
||||||
|
history_archive: standalone (archived ban history, no FK)
|
||||||
|
scheduler_lock: singleton row (id=1), no FK
|
||||||
|
schema_migrations: standalone (migration tracking, no FK)
|
||||||
|
```
|
||||||
|
|
||||||
|
Fail2ban tables are separate and read-only from BanGUI's perspective.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Indexes Summary
|
||||||
|
|
||||||
|
| Table | Index | Columns |
|
||||||
|
|---|---|---|
|
||||||
|
| `sessions` | `idx_sessions_token_hash` | `token_hash` UNIQUE |
|
||||||
|
| `import_log` | `idx_import_log_id_desc` | `id DESC` |
|
||||||
|
| `import_log` | `idx_import_log_source_id_desc` | `source_id, id DESC` |
|
||||||
|
| `import_runs` | `idx_import_runs_source_status` | `source_id, status` |
|
||||||
|
| `history_archive` | `idx_history_archive_jail_timeofban` | `jail, timeofban DESC` |
|
||||||
|
| `history_archive` | `idx_history_archive_timeofban_jail_action` | `timeofban DESC, jail, action` |
|
||||||
|
| `history_archive` | `idx_history_archive_ip` | `ip` |
|
||||||
|
| `history_archive` | `idx_history_archive_action` | `action` |
|
||||||
|
| `jails` | `jails_name` | `name` |
|
||||||
|
| `logs` | `logs_path` | `path` |
|
||||||
|
| `logs` | `logs_jail_path` | `jail, path` |
|
||||||
|
| `bans` | `bans_jail_timeofban_ip` | `jail, timeofban` |
|
||||||
|
| `bans` | `bans_jail_ip` | `jail, ip` |
|
||||||
|
| `bans` | `bans_ip` | `ip` |
|
||||||
|
| `bips` | `bips_timeofban` | `timeofban` |
|
||||||
|
| `bips` | `bips_ip` | `ip` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Migration History
|
||||||
|
|
||||||
|
| Version | Description |
|
||||||
|
|---|---|
|
||||||
|
| 1 | Initial schema: `settings`, `sessions`, `blocklist_sources`, `import_log`, `geo_cache`, `history_archive`, `schema_migrations` |
|
||||||
|
| 2 | Hash session tokens (`token_hash` column). Invalidates all existing sessions. |
|
||||||
|
| 3 | Add `last_seen` to `geo_cache` for retention policy. |
|
||||||
|
| 4 | Add `scheduler_lock` table for multi-worker scheduler mutex. |
|
||||||
|
| 5 | Add indexes to `history_archive` for query performance (4 indexes). |
|
||||||
|
| 6 | Add `import_runs` table for idempotent import tracking. |
|
||||||
|
| 7 | Add indexes to `import_log` for cursor-based pagination. |
|
||||||
|
| 8 | Migrate `import_log.timestamp` from TEXT ISO 8601 → INTEGER UNIX epoch. |
|
||||||
|
| 9 | Change `import_log.source_id` FK to `ON DELETE RESTRICT` (prevents orphaned logs). Recreate table with new FK semantics. |
|
||||||
|
|
||||||
|
**Current schema version:** 9 (`_CURRENT_SCHEMA_VERSION` in `db.py`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Performance Notes
|
||||||
|
|
||||||
|
- **WAL mode** (`PRAGMA journal_mode=WAL`) — concurrent reads allowed, better write performance under concurrency.
|
||||||
|
- **Foreign keys enforced** (`PRAGMA foreign_keys=ON`) — data integrity at DB level.
|
||||||
|
- **Busy timeout** 5000 ms — prevents "database is locked" errors under contention.
|
||||||
|
- **`history_archive` indexes** — tuned for dashboard filter + time ordering + pagination. See migration 5 and `PERFORMANCE.md`.
|
||||||
|
- **`import_log` indexes** — tuned for cursor-based pagination (newest-first by id). See migration 7.
|
||||||
|
- **`geo_cache` PK on `ip`** — O(1) lookup for geo enrichment on ban events.
|
||||||
|
- **`scheduler_lock` singleton** (`CHECK (id = 1)`) — trivial lock existence check.
|
||||||
|
|
||||||
|
For detailed query patterns and benchmarks, see `Docs/PERFORMANCE.md`.
|
||||||
124
Docs/DOMAIN_MODELS.md
Normal file
124
Docs/DOMAIN_MODELS.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# Domain Models — Reference Guide
|
||||||
|
|
||||||
|
This document explains the domain model pattern used in BanGUI's backend and where to find examples.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Are Domain Models?
|
||||||
|
|
||||||
|
Domain models (e.g., `DomainActiveBanList`, `DomainJailConfig`) are **frozen dataclasses** that represent pure business logic. They are defined in `app/models/{domain}_domain.py` and are **returned by services**.
|
||||||
|
|
||||||
|
Response models (e.g., `ActiveBanListResponse`, `JailConfigResponse`) are **Pydantic models** defined in `app/models/{domain}.py`. They are used **only by routers** for HTTP serialization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why This Separation?
|
||||||
|
|
||||||
|
```
|
||||||
|
Service (returns domain model)
|
||||||
|
↓
|
||||||
|
Router (converts domain → response via mapper)
|
||||||
|
↓
|
||||||
|
HTTP Response (Pydantic model)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Domain logic evolves without affecting API shape
|
||||||
|
- Services are reusable across different frontends (GraphQL, gRPC, CLI)
|
||||||
|
- Testing is simpler (no Pydantic overhead)
|
||||||
|
- Changes to endpoint responses don't require service changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Existing Domain Models
|
||||||
|
|
||||||
|
| Domain | Domain Model(s) | Mapper Module |
|
||||||
|
|--------|----------------|---------------|
|
||||||
|
| **Ban** | `DomainActiveBanList`, `DomainActiveBan`, `DomainBansByCountry` | `ban_mappers.py` |
|
||||||
|
| **Jail** | `DomainJailList`, `DomainJailDetail`, `DomainJailBannedIps`, `DomainActiveBan` | `jail_mappers.py` |
|
||||||
|
| **Config** | `DomainJailConfig`, `DomainJailConfigList`, `DomainGlobalConfig`, `DomainServiceStatus`, `DomainBantimeEscalation`, `DomainFilterConfig`, `DomainFilterList`, `DomainRegexTest`, `DomainMapColorThresholds` | `config_mappers.py` |
|
||||||
|
| **History** | `DomainHistoryList`, `DomainHistoryBanItem`, `DomainIpDetail`, `DomainIpTimelineEvent` | `history_mappers.py` |
|
||||||
|
| **Server** | `DomainServerSettings`, `DomainServerSettingsResult` | `server_mappers.py` |
|
||||||
|
| **Blocklist** | `DomainBlocklistSource`, `DomainImportLogEntry`, `DomainImportLogList`, `DomainImportSourceResult`, `DomainImportRunResult`, `DomainPreviewResult`, `DomainScheduleConfig`, `DomainScheduleInfo` | `blocklist_mappers.py` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Pattern — Step by Step
|
||||||
|
|
||||||
|
### Step 1: Define Domain Model in `app/models/{domain}_domain.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainJailConfig:
|
||||||
|
"""Configuration snapshot of a single jail (domain model)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
ban_time: int
|
||||||
|
max_retry: int
|
||||||
|
find_time: int
|
||||||
|
fail_regex: list[str]
|
||||||
|
actions: list[str] # ← no default BEFORE default = FIELD ORDER ERROR
|
||||||
|
date_pattern: str | None = None # ← all fields with defaults come AFTER
|
||||||
|
log_encoding: LogEncoding = "UTF-8"
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Field Order Rule:** All fields without defaults must appear before all fields with defaults.
|
||||||
|
|
||||||
|
### Step 2: Add Mapper in `app/mappers/{domain}_mappers.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def map_domain_jail_config_to_response(domain: DomainJailConfig) -> JailConfig:
|
||||||
|
"""Convert domain jail config to response model."""
|
||||||
|
return JailConfig(
|
||||||
|
name=domain.name,
|
||||||
|
ban_time=domain.ban_time,
|
||||||
|
...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Service Returns Domain Model
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In app/services/jail_service.py
|
||||||
|
from app.models.config_domain import DomainJailConfig, DomainJailConfigList
|
||||||
|
|
||||||
|
async def get_jail_config(socket_path: str, name: str) -> DomainJailConfig:
|
||||||
|
...
|
||||||
|
return DomainJailConfig(...) # ← return domain model
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Router Uses Mapper at Boundary
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In app/routers/jail_config.py
|
||||||
|
from app.mappers import config_mappers
|
||||||
|
|
||||||
|
@router.get("/{name}", response_model=JailConfigResponse)
|
||||||
|
async def get_jail_config(...) -> JailConfigResponse:
|
||||||
|
domain_result = await config_service.get_jail_config(socket_path, name)
|
||||||
|
return config_mappers.map_domain_jail_config_to_response(domain_result)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference Implementation
|
||||||
|
|
||||||
|
`ban_service.py` + `ban_mappers.py` is the canonical example of the correct pattern. Study it first when adding a new service.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### Field Ordering Error
|
||||||
|
|
||||||
|
```
|
||||||
|
TypeError: non-default argument 'actions' follows default argument
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix:** Move all fields with defaults (`field: T | None = None`) after all fields without defaults.
|
||||||
|
|
||||||
|
### Forgetting the Mapper
|
||||||
|
|
||||||
|
If you refactor a service to return a domain model but forget to update the router, you'll get a type mismatch at the boundary. Always update router + service together.
|
||||||
1071
Docs/Deployment.md
Normal file
1071
Docs/Deployment.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ A web application to monitor, manage, and configure fail2ban from a clean, acces
|
|||||||
|
|
||||||
### Options
|
### Options
|
||||||
|
|
||||||
- **Master Password** — Set a single global password that protects the entire web interface.
|
- **Master Password** — Set a single global password that protects the entire web interface. Must be between 8 and 72 characters long (72-byte limit is due to bcrypt truncation) and include one uppercase letter, one number, and one special character from `!@#$%^&*()`.
|
||||||
- **Database Path** — Define where the application stores its own SQLite database.
|
- **Database Path** — Define where the application stores its own SQLite database.
|
||||||
- **fail2ban Connection** — Specify how the application connects to the running fail2ban instance (socket path or related settings).
|
- **fail2ban Connection** — Specify how the application connects to the running fail2ban instance (socket path or related settings).
|
||||||
- **General Preferences** — Any additional application-level settings such as default time zone, date format, or session duration.
|
- **General Preferences** — Any additional application-level settings such as default time zone, date format, or session duration.
|
||||||
@@ -30,6 +30,22 @@ A web application to monitor, manage, and configure fail2ban from a clean, acces
|
|||||||
- After entering the correct password the user is taken to the page they originally requested.
|
- After entering the correct password the user is taken to the page they originally requested.
|
||||||
- A logout option is available from every page so the user can end their session.
|
- A logout option is available from every page so the user can end their session.
|
||||||
|
|
||||||
|
### Session Validation on App Load
|
||||||
|
|
||||||
|
- On app mount (page reload or initial load), the frontend validates the cached session with the backend by calling `GET /api/auth/session`.
|
||||||
|
- While the validation check is in flight, a loading spinner is displayed to avoid UI flicker.
|
||||||
|
- If the backend returns **200**, the session is valid and the app proceeds normally.
|
||||||
|
- If the backend returns **401**, the session has expired or been revoked (server-side DB deletion, restart, etc.), and the user is logged out and redirected to the login page.
|
||||||
|
- If a **network error** occurs (backend temporarily unreachable), the user is not logged out — the app assumes the backend will recover and continues with the cached session state. The next API call will trigger a 401 if the session is actually invalid.
|
||||||
|
|
||||||
|
### Login Rate Limiting
|
||||||
|
|
||||||
|
- The login endpoint (`POST /api/auth/login`) is protected against brute-force attacks with per-IP rate limiting.
|
||||||
|
- **Rate limit:** 5 login attempts per minute per IP address.
|
||||||
|
- When the limit is exceeded, the server returns **HTTP 429 Too Many Requests** with a `Retry-After` header indicating when requests will be accepted again.
|
||||||
|
- Each failed login attempt triggers a progressive server-side delay (exponential back-off from 1 to 10 seconds) to further slow down attack attempts, on top of the bcrypt password hashing cost. The penalty grows with consecutive failures and resets after the rate-limit window expires.
|
||||||
|
- The rate limiter tracks attempts in memory per IP, ensuring that rapid-fire attacks from a single source are quickly throttled.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Ban Overview (Dashboard)
|
## 3. Ban Overview (Dashboard)
|
||||||
@@ -52,6 +68,8 @@ The main landing page after login. Shows recent ban activity at a glance.
|
|||||||
- Last 7 days (week)
|
- Last 7 days (week)
|
||||||
- Last 30 days (month)
|
- Last 30 days (month)
|
||||||
- Last 365 days (year)
|
- Last 365 days (year)
|
||||||
|
- **Data source selection:** The "Last 24 hours" preset queries fail2ban's live database directly for real-time accuracy. All longer presets (7 days, 30 days, 365 days) query the BanGUI long-term archive, because fail2ban's own database only retains the last 24 hours by default.
|
||||||
|
- A **data-source badge** next to the time-range selector indicates whether the current view is showing **Live (fail2ban DB)** or **Archive (BanGUI DB)** data.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -70,14 +88,21 @@ A geographical overview of ban activity.
|
|||||||
- Colors are smoothly interpolated between the thresholds (e.g., 35 bans shows a yellow-green blend)
|
- Colors are smoothly interpolated between the thresholds (e.g., 35 bans shows a yellow-green blend)
|
||||||
- The color threshold values are configurable through the application settings
|
- The color threshold values are configurable through the application settings
|
||||||
- **Interactive zoom and pan:** Users can zoom in/out using mouse wheel or touch gestures, and pan by clicking and dragging. This allows detailed inspection of densely-affected regions. Zoom controls (zoom in, zoom out, reset view) are provided as overlay buttons in the top-right corner.
|
- **Interactive zoom and pan:** Users can zoom in/out using mouse wheel or touch gestures, and pan by clicking and dragging. This allows detailed inspection of densely-affected regions. Zoom controls (zoom in, zoom out, reset view) are provided as overlay buttons in the top-right corner.
|
||||||
- For every country that has bans, the total count is displayed centred inside that country's borders in the selected time range.
|
- For every country that has bans, the total count is shown only in the country tooltip, not rendered on the map itself.
|
||||||
- Countries with zero banned IPs show no number and no label — they remain blank and transparent.
|
- Countries with zero banned IPs show no tooltip and remain blank and transparent.
|
||||||
- Clicking a country filters the companion table below to show only bans from that country.
|
- Clicking a country filters the companion table below to show only bans from that country. When a country is selected the server returns the **complete** list of bans for that country in the chosen time window — the default 200-row companion cap is lifted for filtered queries. Clicking the same country again or using the "Clear filter" button reverts to the standard unfiltered view.
|
||||||
- Time-range selector with the same quick presets:
|
- Time-range selector with the same quick presets:
|
||||||
- Last 24 hours
|
- Last 24 hours
|
||||||
- Last 7 days
|
- Last 7 days
|
||||||
- Last 30 days
|
- Last 30 days
|
||||||
- Last 365 days
|
- Last 365 days
|
||||||
|
- **Data source selection:** Same rule as the Dashboard — "Last 24 hours" uses the live fail2ban database; all other ranges use the BanGUI archive.
|
||||||
|
- A **data-source badge** is displayed alongside the time-range selector indicating **Live (fail2ban DB)** or **Archive (BanGUI DB)**.
|
||||||
|
|
||||||
|
### Companion Table
|
||||||
|
|
||||||
|
- The column header row is always visible at the top of the scrollable table area (sticky positioning) so column labels remain readable regardless of scroll position.
|
||||||
|
- The pagination / page-size bar is always visible at the bottom of the scrollable table area (sticky positioning) so the user can navigate pages without scrolling back down.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -187,11 +212,12 @@ A page to inspect and modify the fail2ban configuration without leaving the web
|
|||||||
|
|
||||||
- Option to register additional log files that fail2ban should monitor.
|
- Option to register additional log files that fail2ban should monitor.
|
||||||
- For each new log, specify:
|
- For each new log, specify:
|
||||||
- The path to the log file.
|
- The path to the log file (must be within allowed directories to prevent unauthorized access to sensitive files).
|
||||||
- One or more regex patterns that define what constitutes a failure.
|
- One or more regex patterns that define what constitutes a failure.
|
||||||
- The jail name and basic jail settings (ban time, retries, etc.).
|
- The jail name and basic jail settings (ban time, retries, etc.).
|
||||||
- Choose whether the file should be read from the beginning or only new lines (head vs. tail).
|
- Choose whether the file should be read from the beginning or only new lines (head vs. tail).
|
||||||
- Preview matching lines from the log against the provided regex before saving, so the user can verify the pattern works.
|
- Preview matching lines from the log against the provided regex before saving, so the user can verify the pattern works.
|
||||||
|
- **Log Path Security:** Added log paths must resolve to locations within a configured allowlist of safe directories (default: `/var/log` and `/config/log`). This prevents authenticated users from instructing fail2ban to monitor sensitive system files. Paths containing symlinks are resolved to their canonical targets before validation.
|
||||||
|
|
||||||
### Regex Tester
|
### Regex Tester
|
||||||
|
|
||||||
@@ -202,8 +228,10 @@ A page to inspect and modify the fail2ban configuration without leaving the web
|
|||||||
|
|
||||||
### Server Settings
|
### Server Settings
|
||||||
|
|
||||||
- View and change the fail2ban log level (e.g. Critical, Error, Warning, Info, Debug).
|
- View and change the fail2ban log level using valid values: `CRITICAL`, `ERROR`, `WARNING`, `NOTICE`, `INFO`, `DEBUG`.
|
||||||
- View and change the log target (file path, stdout, stderr, syslog, systemd journal).
|
- View and change the log target, which can be:
|
||||||
|
- Special values: `STDOUT`, `STDERR`, `SYSLOG`
|
||||||
|
- A file path that resolves to one of the configured safe log directories (default: `/var/log` and `/config/log`). Symlinks are resolved to their canonical targets before validation.
|
||||||
- View and change the syslog socket if syslog is used.
|
- View and change the syslog socket if syslog is used.
|
||||||
- Flush and re-open log files (useful after log rotation).
|
- Flush and re-open log files (useful after log rotation).
|
||||||
- View and change the fail2ban database file location.
|
- View and change the fail2ban database file location.
|
||||||
@@ -238,20 +266,22 @@ A page to inspect and modify the fail2ban configuration without leaving the web
|
|||||||
- **Auto-refresh** toggle with interval selector (5 s / 10 s / 30 s) for live monitoring.
|
- **Auto-refresh** toggle with interval selector (5 s / 10 s / 30 s) for live monitoring.
|
||||||
- Truncation notice when the total log file line count exceeds the requested tail limit.
|
- Truncation notice when the total log file line count exceeds the requested tail limit.
|
||||||
- Container automatically scrolls to the bottom after each data update.
|
- Container automatically scrolls to the bottom after each data update.
|
||||||
- When fail2ban is configured to log to a non-file target (STDOUT, STDERR, SYSLOG, SYSTEMD-JOURNAL), an informational banner explains that file-based log viewing is unavailable.
|
- When fail2ban is configured to log to a non-file target (`STDOUT`, `STDERR`, or `SYSLOG`), an informational banner explains that file-based log viewing is unavailable.
|
||||||
- The log file path is validated against a safe prefix allowlist on the backend to prevent path-traversal reads.
|
- Log file paths are validated against a configurable allowlist of safe directories on the backend to prevent unauthorized reads of sensitive system files.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Ban History
|
## 7. Ban History
|
||||||
|
|
||||||
A view for exploring historical ban data stored in the fail2ban database.
|
A view for exploring historical ban data stored in the BanGUI long-term archive.
|
||||||
|
|
||||||
### History Table
|
### History Table
|
||||||
|
|
||||||
- Browse all past bans across all jails, not just the currently active ones.
|
- Browse all past bans across all jails, not just the currently active ones.
|
||||||
- **Columns:** Time of ban, IP address, jail, ban duration, ban count (how many times this IP was banned), country.
|
- **Columns:** Time of ban, IP address, jail, ban duration, ban count (how many times this IP was banned), country.
|
||||||
- Filter by jail, by IP address, or by time range.
|
- Filter by jail, by IP address, or by time range.
|
||||||
|
- The default time range on first load is **Last 7 days** and the data source is always the **BanGUI archive**, ensuring the full retention window is visible regardless of fail2ban's `dbpurgeage` setting.
|
||||||
|
- A **data-source badge** is displayed indicating **Archive (BanGUI DB)**.
|
||||||
- See at a glance which IPs are repeat offenders (high ban count).
|
- See at a glance which IPs are repeat offenders (high ban count).
|
||||||
|
|
||||||
### Per-IP History
|
### Per-IP History
|
||||||
@@ -259,6 +289,17 @@ A view for exploring historical ban data stored in the fail2ban database.
|
|||||||
- Select any IP to see its full ban timeline: every ban event, which jail triggered it, when it started, and how long it lasted.
|
- Select any IP to see its full ban timeline: every ban event, which jail triggered it, when it started, and how long it lasted.
|
||||||
- Merged view showing total failures and matched log lines aggregated across all bans for that IP.
|
- Merged view showing total failures and matched log lines aggregated across all bans for that IP.
|
||||||
|
|
||||||
|
### Persistent Historical Archive
|
||||||
|
|
||||||
|
- BanGUI stores a separate long-term historical ban archive in its own application database, independent from fail2ban's database retention settings.
|
||||||
|
- On each configured sync cycle (default every 5 minutes), BanGUI reads latest entries from fail2ban `bans` table and appends any new events to BanGUI history storage.
|
||||||
|
- Supports both `ban` and `unban` events; audit record includes: `timestamp`, `ip`, `jail`, `action`, `duration`, `origin` (manual, auto, blocklist, etc.), `failures`, `matches`, and optional `country` / `ASN` enrichment.
|
||||||
|
- Includes incremental import logic with dedupe: using unique constraint on (ip, jail, action, timeofban) to prevent duplication across sync cycles.
|
||||||
|
- Provides backfill mode for initial startup: import the last 7.5 days of existing fail2ban history into BanGUI to avoid dark gaps after restart. Requires fail2ban's `dbpurgeage` to be set to at least `648000` (7.5 days) — BanGUI ships with this value pre-configured in its Docker setup.
|
||||||
|
- Includes configurable archive purge policy in BanGUI (default 365 days), separate from fail2ban `dbpurgeage`, to keep app storage bounded while preserving audit data.
|
||||||
|
- Expose API endpoints for querying persistent history, with filters for timeframe, jail, origin, IP, and current ban status.
|
||||||
|
- On fail2ban connectivity failure, BanGUI continues serving historical data; next successful sync resumes ingestion without data loss.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. External Blocklist Importer
|
## 8. External Blocklist Importer
|
||||||
@@ -273,6 +314,17 @@ Automated downloading and applying of external IP blocklists to block known mali
|
|||||||
- Support for plain-text lists with one IP address per line.
|
- Support for plain-text lists with one IP address per line.
|
||||||
- Preview the contents of a blocklist URL before enabling it (download and display a sample of entries).
|
- Preview the contents of a blocklist URL before enabling it (download and display a sample of entries).
|
||||||
|
|
||||||
|
#### URL Validation & Security
|
||||||
|
|
||||||
|
- **Scheme restriction:** Only `http://` and `https://` schemes are accepted. `file://`, `ftp://`, and other schemes are rejected.
|
||||||
|
- **Hostname validation:** The hostname is resolved via DNS and the resulting IP address is validated to prevent SSRF attacks:
|
||||||
|
- Private IP ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) are rejected.
|
||||||
|
- Loopback addresses (`127.0.0.1`, `::1`) are rejected.
|
||||||
|
- Link-local addresses (`169.254.0.0/16`, `fe80::/10`) are rejected.
|
||||||
|
- Reserved and multicast addresses are rejected.
|
||||||
|
- **Error handling:** If a URL fails validation (invalid scheme, unresolvable hostname, or resolves to a private IP), the API returns a `400 Bad Request` with a descriptive error message.
|
||||||
|
- **Ports:** URLs may specify custom ports (e.g. `https://example.com:8443/list.txt`), but the hostname must still resolve to a public IP address.
|
||||||
|
|
||||||
### Schedule
|
### Schedule
|
||||||
|
|
||||||
- Configure when the blocklist import runs using a simple time-and-frequency picker (no raw cron syntax required).
|
- Configure when the blocklist import runs using a simple time-and-frequency picker (no raw cron syntax required).
|
||||||
@@ -284,6 +336,12 @@ Automated downloading and applying of external IP blocklists to block known mali
|
|||||||
- Option to run an import manually at any time via a "Run Now" button.
|
- Option to run an import manually at any time via a "Run Now" button.
|
||||||
- Show the date and time of the last successful import and the next scheduled run.
|
- Show the date and time of the last successful import and the next scheduled run.
|
||||||
|
|
||||||
|
#### Scheduling Reliability
|
||||||
|
|
||||||
|
- **Deterministic updates:** Schedule changes are applied immediately and deterministically. The schedule update endpoint waits for the reschedule operation to complete and surface any errors before returning the response.
|
||||||
|
- **Error observability:** If a schedule update fails (e.g., due to a database error), the HTTP response will reflect the error with an appropriate status code and error message. The user is never left wondering whether their schedule change took effect.
|
||||||
|
- **Atomicity:** The schedule is persisted to the database and the APScheduler job is updated in a coordinated manner. Both operations are completed before the update request returns success to the client.
|
||||||
|
|
||||||
### Import Behaviour
|
### Import Behaviour
|
||||||
|
|
||||||
- On each scheduled run, download all enabled blocklist sources.
|
- On each scheduled run, download all enabled blocklist sources.
|
||||||
@@ -300,6 +358,13 @@ Automated downloading and applying of external IP blocklists to block known mali
|
|||||||
- Display the import log in the web interface, filterable by source and date range.
|
- Display the import log in the web interface, filterable by source and date range.
|
||||||
- Show a warning badge in the navigation if the most recent import encountered errors.
|
- Show a warning badge in the navigation if the most recent import encountered errors.
|
||||||
|
|
||||||
|
### Data Retention & Deletion
|
||||||
|
|
||||||
|
- Import logs are retained for audit and troubleshooting purposes.
|
||||||
|
- A blocklist source **cannot be deleted** while it has associated import logs (foreign key RESTRICT constraint).
|
||||||
|
- Before deleting a source, delete all its import logs first via the API.
|
||||||
|
- Attempting to delete a source with logs returns **HTTP 409 Conflict** with error code `blocklist_source_has_logs`.
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
- If a blocklist URL is unreachable, log the error and continue with remaining sources.
|
- If a blocklist URL is unreachable, log the error and continue with remaining sources.
|
||||||
|
|||||||
@@ -72,13 +72,8 @@ Supporting documentation you must know and respect:
|
|||||||
|
|
||||||
Repeat the following cycle for every task. Do not skip steps.
|
Repeat the following cycle for every task. Do not skip steps.
|
||||||
|
|
||||||
### Step 1 — Pick a Task
|
|
||||||
|
|
||||||
- Open `tasks.md` and pick the next unfinished task (highest priority first).
|
### Step 1 — Plan Your Steps
|
||||||
- Mark the task as **in progress**.
|
|
||||||
- Read the task description thoroughly. Understand the expected outcome before proceeding.
|
|
||||||
|
|
||||||
### Step 2 — Plan Your Steps
|
|
||||||
|
|
||||||
- Break the task into concrete implementation steps.
|
- Break the task into concrete implementation steps.
|
||||||
- Identify which files need to be created, modified, or deleted.
|
- Identify which files need to be created, modified, or deleted.
|
||||||
@@ -86,7 +81,7 @@ Repeat the following cycle for every task. Do not skip steps.
|
|||||||
- Identify edge cases and error scenarios.
|
- Identify edge cases and error scenarios.
|
||||||
- Write down your plan before touching any code.
|
- Write down your plan before touching any code.
|
||||||
|
|
||||||
### Step 3 — Write Code
|
### Step 2 — Write Code
|
||||||
|
|
||||||
- Implement the feature or fix following the plan.
|
- Implement the feature or fix following the plan.
|
||||||
- Follow all rules from the relevant development docs:
|
- Follow all rules from the relevant development docs:
|
||||||
@@ -97,14 +92,14 @@ Repeat the following cycle for every task. Do not skip steps.
|
|||||||
- Write clean, well-structured, fully typed code.
|
- Write clean, well-structured, fully typed code.
|
||||||
- Keep commits atomic — one logical change per commit.
|
- Keep commits atomic — one logical change per commit.
|
||||||
|
|
||||||
### Step 4 — Add Logging
|
### Step 3 — Add Logging
|
||||||
|
|
||||||
- Add structured log statements at key points in new or modified code.
|
- Add structured log statements at key points in new or modified code.
|
||||||
- Backend: use **structlog** with contextual key-value pairs — never `print()`.
|
- Backend: use **structlog** with contextual key-value pairs — never `print()`.
|
||||||
- Log at appropriate levels: `info` for operational events, `warning` for recoverable issues, `error` for failures.
|
- Log at appropriate levels: `info` for operational events, `warning` for recoverable issues, `error` for failures.
|
||||||
- Never log sensitive data (passwords, tokens, session IDs).
|
- Never log sensitive data (passwords, tokens, session IDs).
|
||||||
|
|
||||||
### Step 5 — Write Tests
|
### Step 4 — Write Tests
|
||||||
|
|
||||||
- Write tests for every new or changed piece of functionality.
|
- Write tests for every new or changed piece of functionality.
|
||||||
- Backend: use `pytest` + `pytest-asyncio` + `httpx.AsyncClient`. See [Backend-Development.md § 9](Backend-Development.md).
|
- Backend: use `pytest` + `pytest-asyncio` + `httpx.AsyncClient`. See [Backend-Development.md § 9](Backend-Development.md).
|
||||||
@@ -113,24 +108,24 @@ Repeat the following cycle for every task. Do not skip steps.
|
|||||||
- Mock external dependencies — tests must never touch real infrastructure.
|
- Mock external dependencies — tests must never touch real infrastructure.
|
||||||
- Follow the naming pattern: `test_<unit>_<scenario>_<expected>`.
|
- Follow the naming pattern: `test_<unit>_<scenario>_<expected>`.
|
||||||
|
|
||||||
### Step 6 — Review Your Code
|
### Step 5 — Review Your Code
|
||||||
|
|
||||||
Run a thorough self-review before considering the task done. Check **all** of the following:
|
Run a thorough self-review before considering the task done. Check **all** of the following:
|
||||||
|
|
||||||
#### 6.1 — Warnings and Errors
|
#### 5.1 — Warnings and Errors
|
||||||
|
|
||||||
- Backend: run `ruff check` and `mypy --strict` (or `pyright --strict`). Fix every warning and error.
|
- Backend: run `ruff check` and `mypy --strict` (or `pyright --strict`). Fix every warning and error.
|
||||||
- Frontend: run `tsc --noEmit` and `eslint`. Fix every warning and error.
|
- Frontend: run `tsc --noEmit` and `eslint`. Fix every warning and error.
|
||||||
- Zero warnings, zero errors — no exceptions.
|
- Zero warnings, zero errors — no exceptions.
|
||||||
|
|
||||||
#### 6.2 — Test Coverage
|
#### 5.2 — Test Coverage
|
||||||
|
|
||||||
- Run the test suite with coverage enabled.
|
- Run the test suite with coverage enabled.
|
||||||
- Aim for **>80 % line coverage** overall.
|
- Aim for **>80 % line coverage** overall.
|
||||||
- Critical paths (auth, banning, scheduling, API endpoints) must be **100 %** covered.
|
- Critical paths (auth, banning, scheduling, API endpoints) must be **100 %** covered.
|
||||||
- If coverage is below the threshold, write additional tests before proceeding.
|
- If coverage is below the threshold, write additional tests before proceeding.
|
||||||
|
|
||||||
#### 6.3 — Coding Principles
|
#### 5.3 — Coding Principles
|
||||||
|
|
||||||
Verify your code against the coding principles defined in [Backend-Development.md § 13](Backend-Development.md) and [Web-Development.md](Web-Development.md):
|
Verify your code against the coding principles defined in [Backend-Development.md § 13](Backend-Development.md) and [Web-Development.md](Web-Development.md):
|
||||||
|
|
||||||
@@ -141,7 +136,7 @@ Verify your code against the coding principles defined in [Backend-Development.m
|
|||||||
- [ ] **KISS** — The simplest correct solution is used. No over-engineering.
|
- [ ] **KISS** — The simplest correct solution is used. No over-engineering.
|
||||||
- [ ] **Type Safety** — All types are explicit. No `any` / `Any`. No `# type: ignore` without justification.
|
- [ ] **Type Safety** — All types are explicit. No `any` / `Any`. No `# type: ignore` without justification.
|
||||||
|
|
||||||
#### 6.4 — Architecture Compliance
|
#### 5.4 — Architecture Compliance
|
||||||
|
|
||||||
Verify against [Architekture.md](Architekture.md) and the project structure rules:
|
Verify against [Architekture.md](Architekture.md) and the project structure rules:
|
||||||
|
|
||||||
@@ -153,7 +148,7 @@ Verify against [Architekture.md](Architekture.md) and the project structure rule
|
|||||||
- [ ] Pydantic models separate request, response, and domain shapes.
|
- [ ] Pydantic models separate request, response, and domain shapes.
|
||||||
- [ ] Frontend types live in `types/`, not scattered across components.
|
- [ ] Frontend types live in `types/`, not scattered across components.
|
||||||
|
|
||||||
### Step 7 — Update Documentation
|
### Step 6 — Update Documentation
|
||||||
|
|
||||||
- If your change introduces new features, new endpoints, new components, or changes existing behaviour, update the relevant docs:
|
- If your change introduces new features, new endpoints, new components, or changes existing behaviour, update the relevant docs:
|
||||||
- [Features.md](Features.md) — if feature behaviour changed.
|
- [Features.md](Features.md) — if feature behaviour changed.
|
||||||
@@ -161,51 +156,6 @@ Verify against [Architekture.md](Architekture.md) and the project structure rule
|
|||||||
- [Backend-Development.md](Backend-Development.md) or [Web-Development.md](Web-Development.md) — if new conventions were established.
|
- [Backend-Development.md](Backend-Development.md) or [Web-Development.md](Web-Development.md) — if new conventions were established.
|
||||||
- Keep documentation accurate and in sync with the code. Outdated docs are worse than no docs.
|
- Keep documentation accurate and in sync with the code. Outdated docs are worse than no docs.
|
||||||
|
|
||||||
### Step 8 — Mark Task Complete
|
|
||||||
|
|
||||||
- Open `tasks.md` and mark the task as **done**.
|
|
||||||
- Add a brief summary of what was implemented or changed.
|
|
||||||
|
|
||||||
### Step 9 — Commit
|
|
||||||
|
|
||||||
- Stage all changed files.
|
|
||||||
- Write a commit message in **imperative tense**, max 72 characters for the subject line.
|
|
||||||
- Good: `Add jail reload endpoint`
|
|
||||||
- Bad: `added stuff` / `WIP` / `fix`
|
|
||||||
- If the change is large, include a body explaining **why**, not just **what**.
|
|
||||||
- Branch naming: `feature/<short-description>`, `fix/<short-description>`, `chore/<short-description>`.
|
|
||||||
- Ensure the commit passes: linter, type checker, all tests.
|
|
||||||
|
|
||||||
### Step 10 — Next Task
|
|
||||||
|
|
||||||
- Return to **Step 1** and pick the next task.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Workflow Summary
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ 1. Pick task from tasks.md │
|
|
||||||
│ 2. Plan your steps │
|
|
||||||
│ 3. Write code │
|
|
||||||
│ 4. Add logging │
|
|
||||||
│ 5. Write tests │
|
|
||||||
│ 6. Review your code │
|
|
||||||
│ ├── 6.1 Check warnings & errors │
|
|
||||||
│ ├── 6.2 Check test coverage │
|
|
||||||
│ ├── 6.3 Check coding principles │
|
|
||||||
│ └── 6.4 Check architecture │
|
|
||||||
│ 7. Update documentation if needed │
|
|
||||||
│ 8. Mark task complete in tasks.md │
|
|
||||||
│ 9. Git commit │
|
|
||||||
│ 10. Pick next task ──────── loop ───┐ │
|
|
||||||
│ ▲ │ │
|
|
||||||
│ └───────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. When You Are Stuck
|
## 5. When You Are Stuck
|
||||||
|
|
||||||
@@ -229,7 +179,37 @@ Verify against [Architekture.md](Architekture.md) and the project structure rule
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Dev Quick-Reference
|
## 7. First-Run Setup
|
||||||
|
|
||||||
|
### Initialize the Development Environment
|
||||||
|
|
||||||
|
Before starting the stack for the first time, set up the required environment variables:
|
||||||
|
|
||||||
|
1. **Copy the example environment file:**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Generate a session secret:**
|
||||||
|
```bash
|
||||||
|
python -c 'import secrets; print(secrets.token_hex(32))'
|
||||||
|
```
|
||||||
|
Copy the output and paste it as the value for `BANGUI_SESSION_SECRET` in your `.env` file.
|
||||||
|
|
||||||
|
3. **Optional: Customize other settings**
|
||||||
|
- Edit `.env` to adjust timezone, port numbers, or other settings
|
||||||
|
- Default values are sensible for development (UTC, ports 8000/5173)
|
||||||
|
|
||||||
|
4. **Start the stack:**
|
||||||
|
```bash
|
||||||
|
make up
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** The session secret is critical for security. Do not commit `.env` to version control — it is already in `.gitignore`. Each environment (dev, staging, production) must have its own unique secret.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Dev Quick-Reference
|
||||||
|
|
||||||
### Start / stop the stack
|
### Start / stop the stack
|
||||||
|
|
||||||
@@ -244,16 +224,17 @@ Backend: `http://127.0.0.1:8000` · Frontend (Vite proxy): `http://127.0.0.1:517
|
|||||||
### API login (dev)
|
### API login (dev)
|
||||||
|
|
||||||
The frontend SHA256-hashes the password before sending it to the API.
|
The frontend SHA256-hashes the password before sending it to the API.
|
||||||
|
The initial setup password must be at least 8 characters long and include one uppercase letter, one number, and one special character from `!@#$%^&*()`.
|
||||||
The session cookie is named `bangui_session`.
|
The session cookie is named `bangui_session`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Dev master password: Hallo123!
|
# Dev master password: Hallo123!
|
||||||
HASHED=$(echo -n "Hallo123!" | sha256sum | awk '{print $1}')
|
HASHED=$(echo -n "Hallo123!" | sha256sum | awk '{print $1}')
|
||||||
TOKEN=$(curl -s -X POST http://127.0.0.1:8000/api/auth/login \
|
TOKEN=$(curl -s -X POST http://127.0.0.1:8000/api/v1/auth/login \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d "{\"password\":\"$HASHED\"}" \
|
-d "{\"password\":\"$HASHED\"}" \
|
||||||
| python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])')
|
| python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])')
|
||||||
|
|
||||||
# Use token in subsequent requests:
|
# Use token in subsequent requests:
|
||||||
curl -H "Cookie: bangui_session=$TOKEN" http://127.0.0.1:8000/api/dashboard/status
|
curl -H "Cookie: bangui_session=$TOKEN" http://127.0.0.1:8000/api/v1/dashboard/status
|
||||||
```
|
```
|
||||||
|
|||||||
845
Docs/Observability.md
Normal file
845
Docs/Observability.md
Normal file
@@ -0,0 +1,845 @@
|
|||||||
|
# Observability
|
||||||
|
|
||||||
|
BanGUI provides comprehensive observability through structured logging, metrics, and tracing capabilities. This document outlines the observability architecture and how to configure it for production deployments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Logging Architecture
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
BanGUI uses **structlog** to emit structured, machine-readable logs in JSON format. All logs are automatically enriched with:
|
||||||
|
|
||||||
|
- **Timestamps** in ISO 8601 format (`timestamp`)
|
||||||
|
- **Log levels** (`level` - debug, info, warning, error, critical)
|
||||||
|
- **Logger names** (`logger_name`)
|
||||||
|
- **Correlation IDs** for request tracking (`correlation_id`)
|
||||||
|
- **Custom context** from business logic (via context variables)
|
||||||
|
|
||||||
|
### Log Output
|
||||||
|
|
||||||
|
By default, logs are written to **stdout** in JSON format, making them suitable for:
|
||||||
|
- Container environments (Docker, Kubernetes)
|
||||||
|
- Log aggregation systems (ELK, Datadog, Papertrail)
|
||||||
|
- CI/CD pipelines and monitoring platforms
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example log output (formatted for readability)
|
||||||
|
{
|
||||||
|
"timestamp": "2024-05-01T18:17:19.080+02:00",
|
||||||
|
"level": "info",
|
||||||
|
"logger_name": "app.main",
|
||||||
|
"event": "bangui_starting_up",
|
||||||
|
"database_path": "/var/lib/bangui/bangui.db",
|
||||||
|
"pid": 1234
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sensitive Data Handling
|
||||||
|
|
||||||
|
**CRITICAL: Never log sensitive data.** The following must NEVER appear in logs:
|
||||||
|
|
||||||
|
- Session tokens or cookies
|
||||||
|
- API keys or secrets
|
||||||
|
- Passwords or password hashes
|
||||||
|
- Private cryptographic keys
|
||||||
|
- Personal information (PII)
|
||||||
|
- Full IP addresses (when not required for security auditing)
|
||||||
|
|
||||||
|
When logging authentication or sensitive operations:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✓ Correct: Log event type and result, not credentials
|
||||||
|
log.info("user_login_attempt", username=username, ip=client_ip, success=True)
|
||||||
|
|
||||||
|
# ✓ Correct: Log sanitized identifiers
|
||||||
|
log.error("auth_token_validation_failed", token_hash=hashlib.sha256(token).hexdigest()[:16])
|
||||||
|
|
||||||
|
# ✗ WRONG: Don't do this
|
||||||
|
log.debug("raw_token", token=token) # Never!
|
||||||
|
log.info("password_check", password=password_hash) # Never!
|
||||||
|
```
|
||||||
|
|
||||||
|
Structlog provides context variable filtering to prevent accidental logging of sensitive data. Code reviews must verify compliance with this rule.
|
||||||
|
|
||||||
|
### Log Sanitization
|
||||||
|
|
||||||
|
All external output (subprocess results, API responses, config file contents) passed to structlog **must** be sanitized first using `sanitize_for_logging()` from `app.utils.log_sanitizer`.
|
||||||
|
|
||||||
|
This prevents sensitive data — passwords, API keys, tokens, private keys — from leaking into logs.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.utils.log_sanitizer import sanitize_for_logging
|
||||||
|
|
||||||
|
# ✓ Correct: Sanitize before logging
|
||||||
|
log.error(
|
||||||
|
"fail2ban_start_failed",
|
||||||
|
command=" ".join(start_cmd_parts),
|
||||||
|
returncode=process.returncode,
|
||||||
|
stdout=sanitize_for_logging(stdout.decode("utf-8", errors="replace")),
|
||||||
|
stderr=sanitize_for_logging(stderr.decode("utf-8", errors="replace")),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ✗ Wrong: Raw output may contain secrets
|
||||||
|
log.error("fail2ban_start_failed", stdout=stdout_raw, stderr=stderr_raw) # Never!
|
||||||
|
```
|
||||||
|
|
||||||
|
`sanitize_for_logging()` redacts the following patterns:
|
||||||
|
|
||||||
|
| Pattern | Example match | Replacement |
|
||||||
|
|---------|---------------|-------------|
|
||||||
|
| `password=X` | `password=Secret123` | `password=***` |
|
||||||
|
| `api_key=X` / `api-key=X` | `api_key=key123` | `api_key=***` |
|
||||||
|
| `token=X` | `token=eyJhbG...` | `token=***` |
|
||||||
|
| `Authorization: Bearer X` | `Authorization: Bearer tok...` | `Authorization: ***` |
|
||||||
|
| `secret=X` | `secret=myvalue` | `secret=***` |
|
||||||
|
| `-----BEGIN RSA PRIVATE KEY-----` | (key header) | `*** PRIVATE KEY ***` |
|
||||||
|
| `AKIA...` | `AKIAIOSFODNN7EXAMPLE` | `AKIA***` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Third-Party Library Logs
|
||||||
|
|
||||||
|
BanGUI uses **structlog** for all application logs, but third-party libraries often emit plain text through Python's standard `logging` module. To maintain uniform JSON output and reduce noise, the following libraries have their log levels overridden to `WARNING`:
|
||||||
|
|
||||||
|
| Library | Logger Name | Level | Rationale |
|
||||||
|
|---------|-------------|-------|-----------|
|
||||||
|
| APScheduler | `apscheduler` | `WARNING` | Suppresses routine scheduler polling ("Looking for jobs to run", "Next wakeup is due at...") while preserving job failure warnings. |
|
||||||
|
| aiosqlite | `aiosqlite` | `WARNING` | Suppresses database operation traces and connection details while preserving connection errors. |
|
||||||
|
|
||||||
|
These overrides are applied in `backend/app/main.py::_configure_logging()` immediately after `logging.basicConfig()`.
|
||||||
|
|
||||||
|
### Disabling Suppression
|
||||||
|
|
||||||
|
Set the environment variable `BANGUI_SUPPRESS_THIRD_PARTY_LOGS=false` to allow APScheduler and aiosqlite to emit their normal DEBUG/INFO logs. This is useful when troubleshooting scheduler or database issues in development.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BANGUI_SUPPRESS_THIRD_PARTY_LOGS=false python -m uvicorn app.main:create_app
|
||||||
|
```
|
||||||
|
|
||||||
|
When suppression is disabled, the loggers inherit the application's `BANGUI_LOG_LEVEL` (e.g., `debug`).
|
||||||
|
|
||||||
|
### Uniform JSON Formatting
|
||||||
|
|
||||||
|
All stdlib logs — including those from third-party libraries — are intercepted by `structlog.stdlib.ProcessorFormatter` and rendered as JSON. This ensures every log line in `bangui.log` is machine-readable, regardless of its source.
|
||||||
|
|
||||||
|
### Adding New Overrides
|
||||||
|
|
||||||
|
When integrating a new library that emits verbose DEBUG logs:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In backend/app/main.py, inside _configure_logging()
|
||||||
|
logging.getLogger("new_library").setLevel(logging.WARNING)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `WARNING` as the default to still capture errors and warnings. Only use `ERROR` if the library is exceptionally noisy and its warnings are not actionable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structured Logging Best Practices
|
||||||
|
|
||||||
|
### Log Levels
|
||||||
|
|
||||||
|
Use log levels consistently:
|
||||||
|
|
||||||
|
| Level | Use Case | Example |
|
||||||
|
|-------|----------|---------|
|
||||||
|
| **debug** | Verbose diagnostic information | `log.debug("parsing_config_file", lines=1024)` |
|
||||||
|
| **info** | Operational events | `log.info("jail_created", jail_name="sshd", action_count=3)` |
|
||||||
|
| **warning** | Recoverable issues | `log.warning("config_reload_skipped", reason="no_changes")` |
|
||||||
|
| **error** | Failures that impact functionality | `log.error("fail2ban_connection_lost", error=str(e))` |
|
||||||
|
| **critical** | System failures | `log.critical("database_corrupted", error=str(e))` |
|
||||||
|
|
||||||
|
### Context Variables
|
||||||
|
|
||||||
|
Use structlog's context variables to automatically include request-scoped information in all logs within a request:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
# In middleware or early in request processing
|
||||||
|
structlog.contextvars.clear_contextvars()
|
||||||
|
structlog.contextvars.bind_contextvars(
|
||||||
|
correlation_id=request_id,
|
||||||
|
user_id=user_id,
|
||||||
|
client_ip=client_ip,
|
||||||
|
)
|
||||||
|
|
||||||
|
# All subsequent logs in this request will include these context variables
|
||||||
|
log.info("user_action", action="create_jail") # Automatically includes correlation_id, user_id, etc.
|
||||||
|
|
||||||
|
# Clear context at end of request
|
||||||
|
structlog.contextvars.clear_contextvars()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Background Task Correlation
|
||||||
|
|
||||||
|
Background tasks (APScheduler jobs) run outside the HTTP request context.
|
||||||
|
Use :mod:`app.utils.correlation` to propagate correlation IDs through tasks:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.utils.correlation import get_correlation_id, reset_correlation_id, set_correlation_id
|
||||||
|
|
||||||
|
async def my_background_task(correlation_id: str | None = None) -> None:
|
||||||
|
# Generate a new ID if not provided (scheduled tasks have no parent request)
|
||||||
|
if correlation_id is None:
|
||||||
|
import uuid
|
||||||
|
correlation_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Set the correlation ID for all logs in this task
|
||||||
|
token = set_correlation_id(correlation_id)
|
||||||
|
try:
|
||||||
|
log.info("task_started") # Now includes correlation_id
|
||||||
|
# ... task logic ...
|
||||||
|
finally:
|
||||||
|
reset_correlation_id(token)
|
||||||
|
|
||||||
|
# When scheduling, optionally pass the current correlation ID:
|
||||||
|
# scheduler.add_job(my_background_task, kwargs={"correlation_id": get_correlation_id()})
|
||||||
|
```
|
||||||
|
|
||||||
|
Scheduled tasks (no parent request) generate a fresh UUID for each run.
|
||||||
|
Tasks triggered by a request inherit the request's correlation ID.
|
||||||
|
|
||||||
|
### Event Naming Convention
|
||||||
|
|
||||||
|
Use snake_case for event names, prefixed with the component or module name:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✓ Good naming
|
||||||
|
log.info("service_initialized", service="BanService", version="1.0")
|
||||||
|
log.warning("blocklist_import_slow", duration_ms=5000)
|
||||||
|
log.error("fail2ban_command_failed", command="list", exit_code=1)
|
||||||
|
|
||||||
|
# ✗ Bad naming
|
||||||
|
log.info("init") # Too generic
|
||||||
|
log.warning("slow operation") # Not machine-readable
|
||||||
|
log.error("ERROR: FAIL2BAN FAILED!") # Inconsistent formatting
|
||||||
|
```
|
||||||
|
|
||||||
|
### Attaching Structured Data
|
||||||
|
|
||||||
|
Always provide context as key-value pairs, not as unstructured strings:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✓ Correct: Structured, queryable
|
||||||
|
log.info(
|
||||||
|
"ban_executed",
|
||||||
|
jail="sshd",
|
||||||
|
ip="192.0.2.1",
|
||||||
|
duration_seconds=3600,
|
||||||
|
reason="brute_force",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ✗ Wrong: Unstructured, hard to query
|
||||||
|
log.info(f"Banned {ip} in jail {jail} for 3600 seconds because brute_force")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Centralized Logging Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
External logging is configured via environment variables (all prefixed with `BANGUI_`):
|
||||||
|
|
||||||
|
#### Datadog
|
||||||
|
|
||||||
|
Enable logging to Datadog via HTTP API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BANGUI_EXTERNAL_LOGGING_ENABLED=true
|
||||||
|
BANGUI_EXTERNAL_LOGGING_PROVIDER=datadog
|
||||||
|
BANGUI_DATADOG_API_KEY=your-api-key-here
|
||||||
|
BANGUI_DATADOG_SITE=datadoghq.com # or datadoghq.eu for EU
|
||||||
|
BANGUI_DATADOG_BATCH_SIZE=10 # Optional: logs per batch
|
||||||
|
BANGUI_DATADOG_FLUSH_INTERVAL_SECONDS=5 # Optional: flush interval
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Papertrail
|
||||||
|
|
||||||
|
Enable logging to Papertrail via Syslog protocol:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BANGUI_EXTERNAL_LOGGING_ENABLED=true
|
||||||
|
BANGUI_EXTERNAL_LOGGING_PROVIDER=papertrail
|
||||||
|
BANGUI_PAPERTRAIL_HOST=logs1.papertrailapp.com
|
||||||
|
BANGUI_PAPERTRAIL_PORT=12345
|
||||||
|
BANGUI_PAPERTRAIL_PROGRAM_NAME=bangui # Optional: program name in syslog
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ELK Stack
|
||||||
|
|
||||||
|
Enable logging to Elasticsearch/Logstash:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BANGUI_EXTERNAL_LOGGING_ENABLED=true
|
||||||
|
BANGUI_EXTERNAL_LOGGING_PROVIDER=elasticsearch
|
||||||
|
BANGUI_ELASTICSEARCH_HOSTS=http://elasticsearch:9200
|
||||||
|
BANGUI_ELASTICSEARCH_INDEX_PREFIX=bangui # Optional: index prefix
|
||||||
|
BANGUI_ELASTICSEARCH_BATCH_SIZE=10 # Optional: docs per batch
|
||||||
|
BANGUI_ELASTICSEARCH_FLUSH_INTERVAL_SECONDS=5 # Optional: flush interval
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Development (Disabled by Default)
|
||||||
|
|
||||||
|
External logging is **disabled by default**. In development, logs continue to write to stdout only:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# No configuration needed — logs go to stdout
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
To enable external logging in development for testing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BANGUI_EXTERNAL_LOGGING_ENABLED=true \
|
||||||
|
BANGUI_EXTERNAL_LOGGING_PROVIDER=datadog \
|
||||||
|
BANGUI_DATADOG_API_KEY=test-key \
|
||||||
|
python -m uvicorn app.main:create_app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance and Reliability
|
||||||
|
|
||||||
|
### Non-Blocking Delivery
|
||||||
|
|
||||||
|
External log delivery uses **asynchronous buffering** to prevent blocking the application:
|
||||||
|
|
||||||
|
1. Logs are written to an in-memory buffer
|
||||||
|
2. After the configured flush interval or batch size, the buffer is sent asynchronously
|
||||||
|
3. Send failures do not block application logic
|
||||||
|
4. Retries use exponential backoff (up to 5 attempts)
|
||||||
|
|
||||||
|
This ensures that external logging never degrades application performance.
|
||||||
|
|
||||||
|
### Failure Modes
|
||||||
|
|
||||||
|
If external logging becomes unavailable:
|
||||||
|
|
||||||
|
- **Transient failures** (network timeouts, temporary 5xx errors): Logs are retried with exponential backoff
|
||||||
|
- **Permanent failures** (invalid API key, host unreachable): A warning is logged; application continues
|
||||||
|
- **Steady-state**: Logs are buffered up to a maximum queue size (default: 1000 logs); older logs are dropped if buffer fills
|
||||||
|
|
||||||
|
The application **never crashes** due to external logging failures.
|
||||||
|
|
||||||
|
### Log Volume and Rate Limiting
|
||||||
|
|
||||||
|
Large log volumes can increase data transfer and storage costs. To manage log volume:
|
||||||
|
|
||||||
|
1. **Reduce log level in production**: Set `BANGUI_LOG_LEVEL=warning` or `error` to suppress debug/info logs
|
||||||
|
2. **Sample logs**: Some providers (Datadog, Papertrail) support sampling rules
|
||||||
|
3. **Filter sensitive paths**: Middleware can suppress verbose logging for noisy endpoints
|
||||||
|
|
||||||
|
Monitor actual log volume and adjust settings based on usage patterns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration Examples
|
||||||
|
|
||||||
|
### Docker Compose (Development with Datadog)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
bangui:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Docker/Dockerfile.app
|
||||||
|
environment:
|
||||||
|
BANGUI_EXTERNAL_LOGGING_ENABLED: "true"
|
||||||
|
BANGUI_EXTERNAL_LOGGING_PROVIDER: "datadog"
|
||||||
|
BANGUI_DATADOG_API_KEY: "${DATADOG_API_KEY}"
|
||||||
|
BANGUI_DATADOG_SITE: "datadoghq.com"
|
||||||
|
BANGUI_LOG_LEVEL: "info"
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kubernetes Deployment (Papertrail)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: bangui-logging
|
||||||
|
data:
|
||||||
|
BANGUI_EXTERNAL_LOGGING_ENABLED: "true"
|
||||||
|
BANGUI_EXTERNAL_LOGGING_PROVIDER: "papertrail"
|
||||||
|
BANGUI_PAPERTRAIL_HOST: "logs1.papertrailapp.com"
|
||||||
|
BANGUI_PAPERTRAIL_PORT: "12345"
|
||||||
|
BANGUI_PAPERTRAIL_PROGRAM_NAME: "bangui"
|
||||||
|
BANGUI_LOG_LEVEL: "info"
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: bangui
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: bangui
|
||||||
|
image: bangui:latest
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: bangui-logging
|
||||||
|
env:
|
||||||
|
- name: BANGUI_DATADOG_API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: bangui-secrets
|
||||||
|
key: datadog-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring Logging Infrastructure
|
||||||
|
|
||||||
|
### Datadog Dashboard Query
|
||||||
|
|
||||||
|
Search for all BanGUI logs:
|
||||||
|
|
||||||
|
```
|
||||||
|
service:bangui
|
||||||
|
```
|
||||||
|
|
||||||
|
Search for errors in authentication:
|
||||||
|
|
||||||
|
```
|
||||||
|
service:bangui status:error component:auth
|
||||||
|
```
|
||||||
|
|
||||||
|
### Papertrail Search
|
||||||
|
|
||||||
|
Search for all startup events:
|
||||||
|
|
||||||
|
```
|
||||||
|
program:bangui bangui_starting_up
|
||||||
|
```
|
||||||
|
|
||||||
|
Search for authentication failures:
|
||||||
|
|
||||||
|
```
|
||||||
|
program:bangui auth_token_validation_failed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Elasticsearch Query (ELK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": {
|
||||||
|
"bool": {
|
||||||
|
"must": [
|
||||||
|
{ "match": { "logger_name": "app.auth" } },
|
||||||
|
{ "match": { "level": "error" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing and Debugging
|
||||||
|
|
||||||
|
### Verify JSON Output
|
||||||
|
|
||||||
|
Inspect the actual JSON emitted by the logging system:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the app and capture logs
|
||||||
|
python -m uvicorn app.main:create_app --host 0.0.0.0 --port 8000 2>&1 | head -10 | python -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2024-05-01T18:20:45.123456+02:00",
|
||||||
|
"level": "info",
|
||||||
|
"logger_name": "app.main",
|
||||||
|
"event": "bangui_starting_up",
|
||||||
|
"database_path": "/var/lib/bangui/bangui.db"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enable Debug Logging for External Log Delivery
|
||||||
|
|
||||||
|
Set the log level to `debug` to see internal logs from the external logging system:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BANGUI_LOG_LEVEL=debug BANGUI_EXTERNAL_LOGGING_ENABLED=true python -m uvicorn app.main:create_app
|
||||||
|
```
|
||||||
|
|
||||||
|
This will emit logs like:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"level": "debug",
|
||||||
|
"event": "external_log_batch_sent",
|
||||||
|
"provider": "datadog",
|
||||||
|
"batch_size": 10,
|
||||||
|
"duration_ms": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validate Configuration
|
||||||
|
|
||||||
|
Validate external logging configuration on startup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c "from app.config import get_settings; s = get_settings(); print(s.model_dump())"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### API Key Rotation
|
||||||
|
|
||||||
|
Rotate API keys regularly:
|
||||||
|
|
||||||
|
1. Update `BANGUI_DATADOG_API_KEY` with the new key
|
||||||
|
2. Restart the application
|
||||||
|
3. Old keys can be revoked after restart
|
||||||
|
|
||||||
|
### Network Security
|
||||||
|
|
||||||
|
When sending logs over the network:
|
||||||
|
|
||||||
|
- **Datadog HTTP API**: Uses HTTPS, encrypted in transit
|
||||||
|
- **Papertrail Syslog**: Use TLS-enabled Syslog (if supported) or send over VPN/private network
|
||||||
|
- **Elasticsearch**: Use HTTPS and HTTP Basic Auth or API Key authentication
|
||||||
|
|
||||||
|
Never send logs over unencrypted channels in production.
|
||||||
|
|
||||||
|
### Compliance
|
||||||
|
|
||||||
|
Ensure that your external logging platform complies with your organization's data protection requirements:
|
||||||
|
|
||||||
|
- **GDPR**: Verify the platform's data processing agreements
|
||||||
|
- **HIPAA**: Ensure the provider is HIPAA-eligible
|
||||||
|
- **SOC 2**: Request audit reports from your logging provider
|
||||||
|
- **Data retention**: Configure appropriate log retention policies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Logs Not Appearing in External System
|
||||||
|
|
||||||
|
1. **Verify configuration**: Check that environment variables are set correctly
|
||||||
|
2. **Check API credentials**: Ensure the API key or credentials are valid
|
||||||
|
3. **Check network connectivity**: Verify the external system is reachable
|
||||||
|
4. **Review logs locally**: Run with `BANGUI_LOG_LEVEL=debug` and check stdout for errors
|
||||||
|
5. **Check disk space**: Ensure the local buffer directory has sufficient disk space
|
||||||
|
|
||||||
|
### Performance Degradation
|
||||||
|
|
||||||
|
1. **Check buffer size**: If the buffer is full, logs are dropped; increase `BANGUI_EXTERNAL_LOGGING_BUFFER_SIZE`
|
||||||
|
2. **Adjust flush interval**: Decrease flush interval if experiencing large batches
|
||||||
|
3. **Reduce log level**: Set `BANGUI_LOG_LEVEL=warning` to reduce log volume
|
||||||
|
4. **Monitor network**: Check bandwidth usage between application and external system
|
||||||
|
|
||||||
|
### Lost Logs
|
||||||
|
|
||||||
|
In the rare event that logs are lost:
|
||||||
|
|
||||||
|
1. **Buffer overflow**: The in-memory buffer has a maximum size; excess logs are dropped with a warning
|
||||||
|
2. **Network failure during batch send**: Logs are retried; after max retries, a warning is logged
|
||||||
|
3. **External system outage**: Logs may be dropped if buffer fills before service is restored
|
||||||
|
|
||||||
|
To minimize data loss:
|
||||||
|
|
||||||
|
- Increase buffer size (`BANGUI_EXTERNAL_LOGGING_BUFFER_SIZE`)
|
||||||
|
- Use persistent external logging platforms
|
||||||
|
- Monitor for warnings in application logs about dropped batches
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Application Performance Monitoring (Metrics)
|
||||||
|
|
||||||
|
BanGUI collects comprehensive metrics for request performance, application health, and resource utilization through **Prometheus**. Metrics are exposed in standard Prometheus text format and can be scraped by monitoring systems.
|
||||||
|
|
||||||
|
### Backend Metrics
|
||||||
|
|
||||||
|
#### HTTP Request Metrics
|
||||||
|
|
||||||
|
The backend automatically tracks HTTP request performance:
|
||||||
|
|
||||||
|
- **`bangui_http_requests_total`** (Counter) — Total HTTP requests by method, endpoint, and status code
|
||||||
|
```
|
||||||
|
bangui_http_requests_total{method="GET",endpoint="/api/jails",status_code="200"} 125
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`bangui_http_request_duration_seconds`** (Histogram) — Request latency distribution by method and endpoint
|
||||||
|
```
|
||||||
|
bangui_http_request_duration_seconds_bucket{method="GET",endpoint="/api/jails",le="0.1"} 120
|
||||||
|
bangui_http_request_duration_seconds_sum{method="GET",endpoint="/api/jails"} 45.23
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`bangui_http_active_requests`** (Gauge) — Current number of in-flight requests by method and endpoint
|
||||||
|
```
|
||||||
|
bangui_http_active_requests{method="GET",endpoint="/api/jails"} 5
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Application Metrics
|
||||||
|
|
||||||
|
Domain-specific metrics track application state:
|
||||||
|
|
||||||
|
- **`bangui_bans_total`** (Gauge) — Total number of currently banned IPs across all jails
|
||||||
|
- **`bangui_jails_total`** (Gauge) — Total number of fail2ban jails
|
||||||
|
- **`bangui_fail2ban_connection_errors_total`** (Counter) — Total fail2ban connection errors
|
||||||
|
|
||||||
|
#### Accessing Metrics
|
||||||
|
|
||||||
|
Prometheus metrics are exposed at the `/metrics` endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
Response format:
|
||||||
|
```
|
||||||
|
# HELP bangui_http_requests_total Total HTTP requests by method, endpoint, and status code
|
||||||
|
# TYPE bangui_http_requests_total counter
|
||||||
|
bangui_http_requests_total{method="GET",endpoint="/api/dashboard/status",status_code="200"} 1523.0
|
||||||
|
|
||||||
|
# HELP bangui_http_request_duration_seconds HTTP request latency in seconds by method and endpoint
|
||||||
|
# TYPE bangui_http_request_duration_seconds histogram
|
||||||
|
bangui_http_request_duration_seconds_bucket{method="GET",endpoint="/api/dashboard/status",le="0.01"} 1200.0
|
||||||
|
bangui_http_request_duration_seconds_sum{method="GET",endpoint="/api/dashboard/status"} 156.78
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Metrics
|
||||||
|
|
||||||
|
#### Web Vitals
|
||||||
|
|
||||||
|
The frontend automatically measures Core Web Vitals using the `web-vitals` library:
|
||||||
|
|
||||||
|
- **Cumulative Layout Shift (CLS)** — Visual stability score (good: ≤0.1)
|
||||||
|
- **First Contentful Paint (FCP)** — Time until first content appears (good: ≤1.8s)
|
||||||
|
- **First Input Delay (FID)** — Responsiveness to user input (good: ≤100ms)
|
||||||
|
- **Largest Contentful Paint (LCP)** — Time until largest content is visible (good: ≤2.5s)
|
||||||
|
- **Time to First Byte (TTFB)** — Server response time (good: ≤600ms)
|
||||||
|
|
||||||
|
#### API Call Metrics
|
||||||
|
|
||||||
|
API calls are automatically tracked with:
|
||||||
|
|
||||||
|
- HTTP method and endpoint
|
||||||
|
- Response status code
|
||||||
|
- Duration in milliseconds
|
||||||
|
- Timestamp
|
||||||
|
|
||||||
|
### Integrating with Monitoring Systems
|
||||||
|
|
||||||
|
#### Prometheus + Grafana
|
||||||
|
|
||||||
|
Configure Prometheus to scrape BanGUI metrics:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# prometheus.yml
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: "bangui"
|
||||||
|
static_configs:
|
||||||
|
- targets: ["localhost:8000"]
|
||||||
|
metrics_path: "/metrics"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then import a Grafana dashboard to visualize:
|
||||||
|
|
||||||
|
- Request rates by endpoint
|
||||||
|
- Latency percentiles (p50, p95, p99)
|
||||||
|
- Error rate trends
|
||||||
|
- Active request counts
|
||||||
|
|
||||||
|
#### Datadog
|
||||||
|
|
||||||
|
Configure BanGUI to send metrics via StatsD or HTTP API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BANGUI_METRICS_ENABLED=true
|
||||||
|
BANGUI_METRICS_PROVIDER=datadog
|
||||||
|
BANGUI_DATADOG_API_KEY=your-api-key
|
||||||
|
BANGUI_DATADOG_SITE=datadoghq.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### New Relic
|
||||||
|
|
||||||
|
Send metrics to New Relic (custom event collection):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BANGUI_METRICS_ENABLED=true
|
||||||
|
BANGUI_METRICS_PROVIDER=newrelic
|
||||||
|
BANGUI_NEWRELIC_API_KEY=your-api-key
|
||||||
|
BANGUI_NEWRELIC_ACCOUNT_ID=your-account-id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Metrics Best Practices
|
||||||
|
|
||||||
|
#### Cardinality Management
|
||||||
|
|
||||||
|
Metric labels (tags) can cause cardinality explosion if not carefully managed. BanGUI uses:
|
||||||
|
|
||||||
|
- Path normalization — `/api/jails/123` becomes `/api/{id}` to prevent unique labels per resource
|
||||||
|
- Status code grouping — errors are grouped by category, not individual codes
|
||||||
|
- Endpoint aggregation — only significant endpoints are tracked
|
||||||
|
|
||||||
|
#### Performance Considerations
|
||||||
|
|
||||||
|
- Metrics collection has negligible performance impact (<1ms per request)
|
||||||
|
- In-memory buffering prevents database writes on every request
|
||||||
|
- High-cardinality labels are avoided
|
||||||
|
- Metric export (scraping) does not block request processing
|
||||||
|
|
||||||
|
#### PII Protection
|
||||||
|
|
||||||
|
**NEVER include sensitive data in metric labels:**
|
||||||
|
|
||||||
|
- User IDs or session tokens
|
||||||
|
- Passwords or API keys
|
||||||
|
- Private IP addresses
|
||||||
|
- Full request/response bodies
|
||||||
|
|
||||||
|
Allowed: HTTP method, endpoint path (normalized), status code, duration, timestamp.
|
||||||
|
|
||||||
|
### Query Examples
|
||||||
|
|
||||||
|
#### Prometheus Queries
|
||||||
|
|
||||||
|
Find p95 request latency for `/api/jails`:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
histogram_quantile(0.95, bangui_http_request_duration_seconds_bucket{endpoint="/api/jails"})
|
||||||
|
```
|
||||||
|
|
||||||
|
Find error rate (5xx responses):
|
||||||
|
|
||||||
|
```promql
|
||||||
|
rate(bangui_http_requests_total{status_code=~"5.."}[5m])
|
||||||
|
```
|
||||||
|
|
||||||
|
Find active requests per endpoint:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
bangui_http_active_requests
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Grafana Dashboard
|
||||||
|
|
||||||
|
Recommended panels:
|
||||||
|
|
||||||
|
1. **Request Rate** — `rate(bangui_http_requests_total[1m])` by endpoint
|
||||||
|
2. **Latency Percentiles** — `histogram_quantile([0.5, 0.95, 0.99], ...)`
|
||||||
|
3. **Error Rate** — `rate(bangui_http_requests_total{status_code=~"5.."}[5m])`
|
||||||
|
4. **Active Requests** — `bangui_http_active_requests` (gauge)
|
||||||
|
5. **fail2ban Connection Health** — `rate(bangui_fail2ban_connection_errors_total[5m])`
|
||||||
|
|
||||||
|
### Troubleshooting Metrics
|
||||||
|
|
||||||
|
#### Metrics endpoint not responding
|
||||||
|
|
||||||
|
1. Verify the `/metrics` endpoint is accessible: `curl http://localhost:8000/metrics`
|
||||||
|
2. Check application logs for errors during middleware initialization
|
||||||
|
3. Ensure prometheus-client is installed: `pip show prometheus-client`
|
||||||
|
|
||||||
|
#### High cardinality warnings
|
||||||
|
|
||||||
|
If Prometheus warns about high cardinality:
|
||||||
|
|
||||||
|
1. Check if custom labels are being added to metrics
|
||||||
|
2. Ensure path normalization is working (IDs should be replaced with `{id}`)
|
||||||
|
3. Consider sampling metrics for high-volume endpoints
|
||||||
|
|
||||||
|
#### Missing metrics
|
||||||
|
|
||||||
|
1. Check that endpoints are being called (look for 200 responses in logs)
|
||||||
|
2. Verify the metrics middleware is registered (check `app.add_middleware(MetricsMiddleware)`)
|
||||||
|
3. Ensure metrics are being recorded (call `recordApiCall()` on frontend)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Planned observability improvements:
|
||||||
|
|
||||||
|
- [x] Application metrics collection (Prometheus)
|
||||||
|
- [x] Web Vitals tracking (frontend)
|
||||||
|
- [ ] Distributed tracing (OpenTelemetry integration)
|
||||||
|
- [ ] Custom metric hooks for business events
|
||||||
|
- [ ] Alerting rules and thresholds
|
||||||
|
- [ ] Log sampling strategies
|
||||||
|
- [ ] Additional provider support (Splunk, New Relic, CloudWatch)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scheduler Lock Health Monitoring
|
||||||
|
|
||||||
|
The scheduler lock ensures only one instance runs background tasks. Monitoring its health is critical for production reliability.
|
||||||
|
|
||||||
|
### Key Metrics
|
||||||
|
|
||||||
|
Monitor these log events for scheduler lock health:
|
||||||
|
|
||||||
|
| Event | Level | Meaning |
|
||||||
|
|-------|-------|---------|
|
||||||
|
| `scheduler_lock_acquired` | info | Successfully acquired the scheduler lock |
|
||||||
|
| `scheduler_lock_held_by_other_instance` | warning | Another instance holds the lock (expected during normal multi-instance operation) |
|
||||||
|
| `scheduler_lock_stale_overwrite` | info | Took over a stale lock from a crashed instance |
|
||||||
|
| `scheduler_lock_heartbeat_lost` | warning | Heartbeat update failed; we lost the lock |
|
||||||
|
| `scheduler_lock_release_mismatch` | warning | Release attempted but we don't hold the lock |
|
||||||
|
|
||||||
|
### Lock Health Check
|
||||||
|
|
||||||
|
Query current lock status via `get_lock_health()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.utils.scheduler_lock import get_lock_health
|
||||||
|
|
||||||
|
health = await get_lock_health(db)
|
||||||
|
# Returns: {"locked": bool, "pid": int|None, "hostname": str|None,
|
||||||
|
# "age_seconds": float|None, "is_stale": bool, "ttl_remaining": float|None}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alerting Rules
|
||||||
|
|
||||||
|
**Critical alerts:**
|
||||||
|
- `scheduler_lock_acquired` not seen for >5 minutes during startup → Instance may not have acquired lock
|
||||||
|
- `scheduler_lock_heartbeat_lost` repeated >3 times → Lock keeps being stolen, possible contention issue
|
||||||
|
|
||||||
|
**Warning alerts:**
|
||||||
|
- `scheduler_lock_held_by_other_instance` every few minutes → Normal if multiple instances, abnormal if single instance
|
||||||
|
|
||||||
|
### Database Query
|
||||||
|
|
||||||
|
Check lock state directly in SQLite:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT pid, hostname, heartbeat_at, heartbeat_timeout,
|
||||||
|
(datetime('now') - datetime(heartbeat_at, 'unixepoch')) as age
|
||||||
|
FROM scheduler_lock WHERE id = 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Lock not acquired on startup**: Check logs for `scheduler_lock_held_by_other_instance`. If another instance holds it, verify if that instance is healthy.
|
||||||
|
|
||||||
|
2. **Background tasks not running**: Use `get_lock_health()` to verify the lock is held. If not held, the instance cannot run scheduled tasks.
|
||||||
|
|
||||||
|
3. **Frequent lock steals**: If `scheduler_lock_stale_overwrite` occurs frequently, the heartbeat interval may be too long or network latency is causing false staleness detection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [structlog Documentation](https://www.structlog.org/)
|
||||||
|
- [Datadog Logging Documentation](https://docs.datadoghq.com/logs/)
|
||||||
|
- [Papertrail Documentation](https://help.papertrailapp.com/)
|
||||||
|
- [Elasticsearch JSON Logging](https://www.elastic.co/guide/en/elasticsearch/reference/current/logging.html)
|
||||||
|
- [Observability Best Practices (OpenTelemetry)](https://opentelemetry.io/docs/concepts/observability-primer/)
|
||||||
146
Docs/PERFORMANCE.md
Normal file
146
Docs/PERFORMANCE.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# Performance Guidelines
|
||||||
|
|
||||||
|
Query optimization patterns for BanGUI backend services.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Never Load Unbounded Result Sets
|
||||||
|
|
||||||
|
Loading large result sets into Python memory causes OOM crashes, slow responses, and unbounded growth. Every query that processes large datasets must use one of the following strategies.
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
|
||||||
|
With millions of ban records:
|
||||||
|
- Loading all rows as Python dicts → 200-400 MB+ memory spike
|
||||||
|
- Python loop aggregation (O(n) per item) → seconds of CPU time
|
||||||
|
- Offset pagination on large tables → O(n) scan before returning results
|
||||||
|
|
||||||
|
### The Solution: SQL Aggregation
|
||||||
|
|
||||||
|
SQL GROUP BY executes inside SQLite's optimized query planner, using indexes where available, and returns only the aggregated result (typically a few KB).
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD: loads 1M rows into Python
|
||||||
|
all_rows = await get_all_archived_history(db, since=since)
|
||||||
|
agg = {}
|
||||||
|
for row in all_rows: # O(n) Python loop
|
||||||
|
agg[row["ip"]] = agg.get(row["ip"], 0) + 1
|
||||||
|
|
||||||
|
# GOOD: SQL aggregation, returns lightweight {ip, count} pairs
|
||||||
|
ip_counts = await get_ip_ban_counts(db, since=since)
|
||||||
|
# [{ip: "1.2.3.4", event_count: 42}, ...] — a few KB regardless of table size
|
||||||
|
```
|
||||||
|
|
||||||
|
### Aggregation Reference
|
||||||
|
|
||||||
|
| Use Case | SQL Pattern | Repository Function |
|
||||||
|
|----------|-------------|-------------------|
|
||||||
|
| Ban count per IP | `SELECT ip, COUNT(*) FROM history_archive ... GROUP BY ip` | `get_ip_ban_counts()` |
|
||||||
|
| Ban count per jail | `SELECT jail, COUNT(*) FROM history_archive ... GROUP BY jail ORDER BY COUNT(*) DESC` | `get_jail_ban_counts()` |
|
||||||
|
| Ban count per time bucket | `SELECT CAST((timeofban - ?) / ? AS INTEGER), COUNT(*) ... GROUP BY bucket_idx` | `get_ban_counts_by_bucket()` |
|
||||||
|
| Paginated rows (no offset) | `WHERE id < ? ORDER BY id DESC LIMIT ?` | `get_archived_history_keyset()` |
|
||||||
|
| Total count | `SELECT COUNT(*) FROM ...` (fast with where clause) | included in `get_jail_ban_counts()` return |
|
||||||
|
|
||||||
|
### Pagination vs Aggregation
|
||||||
|
|
||||||
|
Use **aggregation** when:
|
||||||
|
- Displaying summary data (counts, totals, group-by results)
|
||||||
|
- Building country/jail/timeline dashboards
|
||||||
|
- Only need counts, not individual row data
|
||||||
|
|
||||||
|
Use **pagination** when:
|
||||||
|
- Displaying individual records (ban list, history)
|
||||||
|
- Clients need access to specific rows
|
||||||
|
- Exporting or bulk operations
|
||||||
|
|
||||||
|
### Batch Geo Lookups
|
||||||
|
|
||||||
|
When you need geo data for many IPs, batch in a single call rather than per-IP:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD: N sequential API calls
|
||||||
|
for ip in unique_ips:
|
||||||
|
geo = await geo_service.lookup(ip) # 45 req/min rate limit × N calls
|
||||||
|
|
||||||
|
# GOOD: one batch call, geo_service handles rate limiting
|
||||||
|
geo_map, uncached = geo_cache_lookup(unique_ips) # uses in-memory cache
|
||||||
|
if uncached:
|
||||||
|
asyncio.create_task(geo_cache.lookup_batch(uncached, http_session)) # fire-and-forget
|
||||||
|
```
|
||||||
|
|
||||||
|
### Index Requirements
|
||||||
|
|
||||||
|
SQLite needs indexes on:
|
||||||
|
- Columns used in WHERE clauses (timeofban, jail, action)
|
||||||
|
- Columns used in GROUP BY (ip, jail, bucket index)
|
||||||
|
- Sort columns for pagination (id)
|
||||||
|
|
||||||
|
Current indexes on `history_archive`:
|
||||||
|
- `idx_history_archive_timeofban` — for time-range filtering
|
||||||
|
- `idx_history_archive_jail_timeofban` — for jail + time filtering
|
||||||
|
- `idx_history_archive_action_timeofban` — for action + time filtering
|
||||||
|
- `idx_history_archive_id` — for keyset pagination
|
||||||
|
|
||||||
|
Before adding a new query pattern, verify it uses an existing index or add one with a benchmark test.
|
||||||
|
|
||||||
|
### Memory Monitoring
|
||||||
|
|
||||||
|
Watch for these warning signs:
|
||||||
|
- Python RSS > 500 MB in container metrics
|
||||||
|
- Response time > 5s for dashboard endpoints
|
||||||
|
- Query time > 1s in SQLite EXPLAIN ANALYZE output
|
||||||
|
|
||||||
|
Use `EXPLAIN QUERY PLAN` to verify index usage:
|
||||||
|
```sql
|
||||||
|
EXPLAIN QUERY PLAN SELECT ip, COUNT(*) FROM history_archive WHERE timeofban >= ? GROUP BY ip;
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `USING INDEX idx_history_archive_timeofban` in the output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fail2ban Database Indexes
|
||||||
|
|
||||||
|
BanGUI reads from fail2ban's SQLite database (`/var/run/fail2ban/fail2ban.db`). Query performance degrades without appropriate indexes.
|
||||||
|
|
||||||
|
### Current fail2ban bans Indexes
|
||||||
|
|
||||||
|
Fail2ban creates these indexes on the `bans` table:
|
||||||
|
- `bans_jail_timeofban_ip` — composite (jail, timeofban, ip)
|
||||||
|
- `bans_jail_ip` — composite (jail, ip)
|
||||||
|
- `bans_ip` — single (ip)
|
||||||
|
|
||||||
|
**Missing**: standalone index on `timeofban` alone.
|
||||||
|
|
||||||
|
### BanGUI Automatic Index Creation
|
||||||
|
|
||||||
|
On startup, BanGUI calls `ensure_fail2ban_indexes()` to add missing indexes idempotently:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# From fail2ban_db_utils.py
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bans_timeofban_desc ON bans(timeofban DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
This improves queries like:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM bans WHERE timeofban >= ? ORDER BY timeofban DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verifying Index Usage
|
||||||
|
|
||||||
|
Check if a query uses the index:
|
||||||
|
```sql
|
||||||
|
EXPLAIN QUERY PLAN SELECT * FROM bans WHERE timeofban >= 1700000000 ORDER BY timeofban DESC;
|
||||||
|
-- With index: SEARCH USING INDEX idx_bans_timeofban_desc
|
||||||
|
-- Without: SCAN TABLE bans
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Indexes to Migrations
|
||||||
|
|
||||||
|
For BanGUI's own `history_archive` table, indexes go in migrations via `_ Migration.add_table_indexes()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _add_history_archive_indexes(m: Migration) -> None:
|
||||||
|
m.add_index("history_archive", ["timeofban"], unique=False, if_not_exists=True)
|
||||||
|
m.add_index("history_archive", ["jail", "timeofban"], unique=False, if_not_exists=True)
|
||||||
|
```
|
||||||
@@ -3,3 +3,20 @@
|
|||||||
This document catalogues architecture violations, code smells, and structural issues found during a full project review. Issues are grouped by category and prioritised.
|
This document catalogues architecture violations, code smells, and structural issues found during a full project review. Issues are grouped by category and prioritised.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Security Fixes
|
||||||
|
|
||||||
|
- Fixed open redirect vulnerability in `frontend/src/pages/LoginPage.tsx` by validating the `?next=` parameter to ensure it is a relative path (starts with `/` but not `//`). The validation regex `/^\/(?!\/)/.test(next)` prevents protocol-relative URLs and external redirects. Invalid paths fall back to `"/"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Completed Refactors
|
||||||
|
|
||||||
|
- Moved `Fail2BanConnectionError` and `Fail2BanProtocolError` from `backend/app/utils/fail2ban_client.py` into `backend/app/exceptions.py`. Updated all router, service, and test call sites to import these domain exceptions from `app.exceptions` and retained backward compatibility through re-exporting in `app.utils.fail2ban_client`.
|
||||||
|
- Moved config file exceptions (`ConfigDirError`, `ConfigFileNotFoundError`, `ConfigFileExistsError`, `ConfigFileWriteError`, `ConfigFileNameError`) from `backend/app/services/raw_config_io_service.py` into `backend/app/exceptions.py`. Updated router and tests to import the shared domain exceptions from `app.exceptions`.
|
||||||
|
- Added global domain exception handlers to `backend/app/main.py` so domain exceptions like `JailNotFoundError`, `ConfigValidationError`, and `ConfigWriteError` map consistently to 404, 400, and 500 responses.
|
||||||
|
- Fixed stale activation tracking in `backend/app/routers/jail_config.py` by recording `last_activation` only after a successful jail activation and preventing a failed activation attempt from leaving a stale runtime state record.
|
||||||
|
- Fixed infinite re-fetch loop in `frontend/src/hooks/useJailConfigs.ts` by wrapping the `onSuccess` callback in `useCallback` with empty dependencies. The bug occurred because `useListData` includes `onSuccess` in its internal `refresh` function's dependency array; an inline callback created a new reference on each render, causing `refresh` to be recreated, which triggered the `useEffect` again, leading to an unbounded fetch loop. Callers of `useListData` must always wrap `onSuccess` callbacks in `useCallback` to maintain reference stability.
|
||||||
|
- **T-11 — Repository module-as-Protocol structural type-safety:** Resolved the fragile `cast()` pattern where repository modules were loosely typed against Protocol interfaces. Created a **validation script** (`backend/scripts/validate_repository_protocols.py`) that runs at CI time to ensure all repository modules satisfy their Protocol interfaces. Fixed signature mismatches in `protocols.py` to match actual implementations in `session_repo`, `settings_repo`, `blocklist_repo`, `import_log_repo`, `geo_cache_repo`, `history_archive_repo`, and `fail2ban_db_repo` (correcting return types like `dict[str, Any]` vs `dict[str, object]`, `Sequence` vs `Iterable`, and typed models). Updated `backend/app/dependencies.py` with explicit documentation linking each repository provider to the pattern explained in Backend-Development.md § 13.7.1. **Option B (minimal):** Instead of refactoring to class-based repositories (Option A), the pattern is now formally documented and validated, preventing silent breakage.
|
||||||
|
- **T-3 — Blocklist import flow refactoring:** Extracted the monolithic `import_source()` function (776 lines with mixed responsibilities) into focused, testable components. Created `BlocklistDownloader` (HTTP download with retry logic), `BlocklistParser` (parsing and validation), `BanExecutor` (ban execution with error handling), and `BlocklistImportWorkflow` (thin orchestrator). This separation improves testability, evolution, and error handling. Each component has a single responsibility and clear boundaries. All 53 existing tests pass; added 17 new component unit tests achieving 96%+ coverage on new modules.
|
||||||
|
|
||||||
|
|||||||
176
Docs/Security.md
Normal file
176
Docs/Security.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# Security — Guidelines and Implementation
|
||||||
|
|
||||||
|
Security considerations and implementation details for BanGUI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTP Security Headers
|
||||||
|
|
||||||
|
BanGUI implements defense-in-depth against client-side attacks by sending security-related HTTP response headers on all responses.
|
||||||
|
|
||||||
|
### Headers Implemented
|
||||||
|
|
||||||
|
| Header | Value | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `Content-Security-Policy` | `default-src 'self'` | Prevents XSS attacks by restricting script, style, font, image, and other resource origins to `self` only. Browsers refuse to load resources from other origins. |
|
||||||
|
| `X-Frame-Options` | `DENY` | Prevents clickjacking attacks by forbidding the page from being embedded in `<iframe>` tags on any origin. |
|
||||||
|
| `X-Content-Type-Options` | `nosniff` | Prevents MIME-type sniffing attacks by forcing browsers to respect the declared `Content-Type`. Blocks execution of misidentified scripts. |
|
||||||
|
| `X-XSS-Protection` | `1; mode=block` | Enables browser XSS filters (legacy header for older browsers). Modern browsers prioritize CSP. |
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
**Backend:** The `SecurityHeadersMiddleware` in `backend/app/main.py` adds these headers to every HTTP response, including error responses and non-API routes.
|
||||||
|
|
||||||
|
```python
|
||||||
|
response.headers["Content-Security-Policy"] = "default-src 'self'"
|
||||||
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend:** The `<meta http-equiv="Content-Security-Policy" content="default-src 'self'" />` tag in `frontend/index.html` provides an additional defense layer in case the backend headers are ever stripped (e.g., by a proxy).
|
||||||
|
|
||||||
|
### CSP Policy Details
|
||||||
|
|
||||||
|
The current policy `default-src 'self'` means:
|
||||||
|
|
||||||
|
- **Allowed:** Inline scripts, stylesheets, fonts, images, and other resources from the same origin (`self`)
|
||||||
|
- **Blocked:** Resources from external domains, inline event handlers, `eval()`, and `setTimeout(string)`
|
||||||
|
|
||||||
|
**Why no `'unsafe-inline'`?**
|
||||||
|
- `'unsafe-inline'` defeats CSP's primary purpose (XSS prevention) by allowing arbitrarily-embedded scripts
|
||||||
|
- All scripts and styles must be in separate files (never inline), which is best practice anyway
|
||||||
|
- The frontend build system (Vite) automatically handles asset bundling and file separation
|
||||||
|
|
||||||
|
**If external CDN resources are needed:**
|
||||||
|
1. Explicitly add the CDN origin to the CSP policy, e.g.: `default-src 'self' https://cdn.example.com`
|
||||||
|
2. Document the CDN addition with a justification comment
|
||||||
|
3. Ensure the CDN certificate chain is valid and trusted
|
||||||
|
4. Consider using Subresource Integrity (SRI) to verify resource authenticity
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
To verify headers are being sent correctly:
|
||||||
|
|
||||||
|
1. **Chrome DevTools:**
|
||||||
|
- Open DevTools (F12)
|
||||||
|
- Go to Network tab
|
||||||
|
- Reload the page
|
||||||
|
- Click on any request and open the Response Headers section
|
||||||
|
- Look for `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, `X-XSS-Protection`
|
||||||
|
|
||||||
|
2. **Command line (curl):**
|
||||||
|
```bash
|
||||||
|
curl -I http://localhost:8000/
|
||||||
|
curl -I http://localhost:5173/
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Online tools:**
|
||||||
|
- Use [securityheaders.com](https://securityheaders.com) or [csp-evaluator.withgoogle.com](https://csp-evaluator.withgoogle.com)
|
||||||
|
|
||||||
|
### Future Improvements
|
||||||
|
|
||||||
|
- **Stricter CSP:** If functionality allows, tighten to `default-src 'none'` and explicitly allow individual resources
|
||||||
|
- **SRI (Subresource Integrity):** Add integrity attributes to external script/style tags to prevent tampering
|
||||||
|
- **Preload headers:** Use `Link: <...>; rel=preload` to optimize critical resource delivery
|
||||||
|
- **HSTS:** Consider adding `Strict-Transport-Security` for production deployments to force HTTPS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSRF Protection
|
||||||
|
|
||||||
|
BanGUI protects cookie-authenticated state-mutating requests (POST, PUT, DELETE, PATCH) with a custom header check. Requests using the session cookie must include the header `X-BanGUI-Request: 1`. Bearer token authentication is exempt since tokens in headers are not CSRF-vulnerable.
|
||||||
|
|
||||||
|
### Single Source of Truth
|
||||||
|
|
||||||
|
The header name and value are defined once in `backend/app/utils/constants.py` (`CSRF_HEADER_NAME` and `CSRF_HEADER_VALUE`) and consumed by:
|
||||||
|
|
||||||
|
- `backend/app/middleware/csrf.py` — validates the header on incoming requests
|
||||||
|
- `frontend/src/api/client.ts` — attaches the header to state-mutating fetch calls
|
||||||
|
- `frontend/src/utils/constants.ts` — mirrors the values for type-safe import
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
**`GET /api/v1/config/security-headers`** — returns the CSRF header name and value to authenticated clients:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"csrf_header_name": "X-BanGUI-Request",
|
||||||
|
"csrf_header_value": "1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows the frontend to discover the required header at runtime. Both frontend and backend constants must remain in sync — a build-time check is recommended when updating either constant.
|
||||||
|
|
||||||
|
### Header Rationale
|
||||||
|
|
||||||
|
The custom header is required because browsers block cross-site requests from setting custom headers without a CORS preflight, which BanGUI rejects for non-allowed origins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Security
|
||||||
|
|
||||||
|
See `backend/app/middleware/csrf.py` and `backend/app/middleware/rate_limit.py` for CSRF protection and rate limiting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Password Security
|
||||||
|
|
||||||
|
- Passwords are hashed with SHA256 on the frontend before transmission
|
||||||
|
- The backend never stores plain-text passwords
|
||||||
|
- See `backend/app/services/auth.py` for authentication implementation
|
||||||
|
- **Common password prevention:** The setup validator rejects a list of ~75 common plaintext passwords that pass structural complexity checks (e.g., `Password1!`). The list is embedded in `backend/app/models/setup.py` and is checked case-insensitively.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Security
|
||||||
|
|
||||||
|
- The SQLite database contains no sensitive data (no passwords, API keys, or tokens stored)
|
||||||
|
- Database queries use parameterized statements to prevent SQL injection
|
||||||
|
- See `backend/app/repositories/` for data access patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regex (ReDoS) Protection
|
||||||
|
|
||||||
|
BanGUI validates all user-supplied regex patterns before they are compiled or stored.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Static analysis** via [regexploit](https://github.com/doyensec/regexploit) detects catastrophic backtracking patterns before compilation
|
||||||
|
2. **Timeout enforcement** stops compilation if it exceeds 2 seconds (prevents hanging on pathological patterns)
|
||||||
|
3. **Length limit** (1000 characters) prevents memory exhaustion via bloated patterns
|
||||||
|
|
||||||
|
### Protected Endpoints
|
||||||
|
|
||||||
|
All endpoints that accept regex patterns validate them:
|
||||||
|
- Filter configuration (`prefregex`, `failregex`, `ignorregex`)
|
||||||
|
- Action configuration (any regex used in actions)
|
||||||
|
- Direct config editing
|
||||||
|
|
||||||
|
### ReDoS Pattern Examples
|
||||||
|
|
||||||
|
Patterns with nested quantifiers on overlapping text are blocked:
|
||||||
|
|
||||||
|
| Pattern | Why Blocked |
|
||||||
|
|---------|-------------|
|
||||||
|
| `(a+)+b` | Plus inside plus — exponential backtracking |
|
||||||
|
| `([a-z]+)*d` | Quantifier inside quantifier |
|
||||||
|
| `(x+)+y` | Nested quantifiers |
|
||||||
|
| `a[bcd]*e[bcd]*e` | Multiple unbounded quantifiers |
|
||||||
|
|
||||||
|
### Legitimate Complex Patterns
|
||||||
|
|
||||||
|
Not all complex patterns are blocked. Email and IP validation patterns typically pass:
|
||||||
|
|
||||||
|
```python
|
||||||
|
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" # OK
|
||||||
|
r"^(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$" # OK
|
||||||
|
```
|
||||||
|
|
||||||
|
### If Your Pattern Is Rejected
|
||||||
|
|
||||||
|
1. Rewrite to avoid nested quantifiers on the same text
|
||||||
|
2. Use atomic groups or possessive quantifiers: `(?>a+)+b` instead of `(a+)+b`
|
||||||
|
3. Test locally with Python's `re` module before deploying
|
||||||
|
4. If you believe the pattern is safe, check with [regexploit](https://github.com/doyensec/regexploit) directly
|
||||||
115
Docs/Service-Development.md
Normal file
115
Docs/Service-Development.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Service Development Guide
|
||||||
|
|
||||||
|
How to write and maintain services in BanGUI.
|
||||||
|
|
||||||
|
## Error Handling Contracts
|
||||||
|
|
||||||
|
Every service method must document which error handling pattern it follows.
|
||||||
|
This lets callers know what to expect without reading the implementation.
|
||||||
|
|
||||||
|
### The Three Patterns
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.services.error_handling import ABORT_ON_ERROR, RETURN_DEFAULT, PARTIAL_RESULT
|
||||||
|
```
|
||||||
|
|
||||||
|
**ABORT_ON_ERROR** — Raise an exception, let the router convert it to HTTP.
|
||||||
|
Used for: auth, writes, state changes, any operation where partial success is meaningless.
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def start_jail(socket_path: str, name: str) -> None:
|
||||||
|
"""Start a stopped fail2ban jail.
|
||||||
|
|
||||||
|
Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404),
|
||||||
|
JailOperationError (409), Fail2BanConnectionError (503).
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**RETURN_DEFAULT** — Return empty result and log warning. Never raises.
|
||||||
|
Used for: informational reads (list, get) where infrastructure unavailability
|
||||||
|
should not block the UI.
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_settings(socket_path: str) -> DomainServerSettingsResult:
|
||||||
|
"""Return current fail2ban server-level settings.
|
||||||
|
|
||||||
|
Error contract: RETURN_DEFAULT. Returns DomainServerSettingsResult
|
||||||
|
with default values if socket is unreachable. Never raises.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**PARTIAL_RESULT** — Return (result, errors) tuple. Errors collected, not raised.
|
||||||
|
Used for: batch operations on collections where one item failing does not
|
||||||
|
invalidate the rest.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Not yet used in codebase; define as needed for batch operations.
|
||||||
|
```
|
||||||
|
|
||||||
|
### When to Use Which
|
||||||
|
|
||||||
|
| Operation type | Pattern |
|
||||||
|
|---------------|---------|
|
||||||
|
| Auth / session | ABORT_ON_ERROR |
|
||||||
|
| Write / state change | ABORT_ON_ERROR |
|
||||||
|
| Config updates | ABORT_ON_ERROR |
|
||||||
|
| Single-item read (jail, ban) | ABORT_ON_ERROR |
|
||||||
|
| Multi-item read (list) | RETURN_DEFAULT |
|
||||||
|
| Server settings read | RETURN_DEFAULT |
|
||||||
|
| Batch / parallel fetch | PARTIAL_RESULT |
|
||||||
|
|
||||||
|
### Changing Patterns
|
||||||
|
|
||||||
|
Switching a method's error contract is a **breaking change**. Update the docstring,
|
||||||
|
add a changelog entry, and bump the major version if this is a public API.
|
||||||
|
|
||||||
|
## Service Structure
|
||||||
|
|
||||||
|
Services live in `backend/app/services/`. They contain **no** HTTP/FastAPI concerns.
|
||||||
|
|
||||||
|
```
|
||||||
|
app/services/
|
||||||
|
ban_service.py # ban/unban, ban history queries
|
||||||
|
jail_service.py # jail lifecycle, ignore lists
|
||||||
|
server_service.py # server-level settings
|
||||||
|
geo_service.py # geolocation
|
||||||
|
...
|
||||||
|
error_handling.py # contract definitions
|
||||||
|
protocols.py # Protocol interfaces for DI
|
||||||
|
```
|
||||||
|
|
||||||
|
## Protocols
|
||||||
|
|
||||||
|
Each service has a corresponding protocol in `protocols.py` for dependency injection.
|
||||||
|
Protocol methods include the error contract in their docstring:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class JailService(Protocol):
|
||||||
|
async def list_jails(self, socket_path: str) -> DomainJailList:
|
||||||
|
"""Error contract: ABORT_ON_ERROR."""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Router Error Handling
|
||||||
|
|
||||||
|
Routers must not catch and silently swallow exceptions from services using
|
||||||
|
ABORT_ON_ERROR unless they convert to a specific HTTP response.
|
||||||
|
Let domain exceptions propagate — the global exception handlers handle them.
|
||||||
|
|
||||||
|
Exception handler registration (in `main.py`):
|
||||||
|
- `DomainError` → JSON error response
|
||||||
|
- `Fail2BanConnectionError` → HTTP 503
|
||||||
|
- `JailNotFoundError` → HTTP 404
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Log at the service layer using structlog:
|
||||||
|
|
||||||
|
```python
|
||||||
|
log.info("jail_started", jail=name)
|
||||||
|
log.warning("socket_unreachable_using_default", socket_path=socket_path)
|
||||||
|
```
|
||||||
|
|
||||||
|
Never log sensitive data (tokens, passwords, IPs in full).
|
||||||
487
Docs/TROUBLESHOOTING.md
Normal file
487
Docs/TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
# Troubleshooting Guide
|
||||||
|
|
||||||
|
## Scheduler Lock Issues
|
||||||
|
|
||||||
|
### Lock Held by Crashed Instance (Orphaned Lock)
|
||||||
|
|
||||||
|
**Symptom:** Background tasks stop running. Logs show `scheduler_lock_held_by_other_instance` but no other instance is running.
|
||||||
|
|
||||||
|
**Diagnosis:**
|
||||||
|
```bash
|
||||||
|
sqlite3 /var/lib/bangui/bangui.db "SELECT pid, hostname, heartbeat_at FROM scheduler_lock;"
|
||||||
|
```
|
||||||
|
|
||||||
|
If `heartbeat_at` is older than 5 minutes and the PID no longer exists, the lock is orphaned.
|
||||||
|
|
||||||
|
**Recovery:**
|
||||||
|
```bash
|
||||||
|
sqlite3 /var/lib/bangui/bangui.db "DELETE FROM scheduler_lock;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the backend. It will acquire the lock fresh.
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
- Monitor `scheduler_lock_heartbeat_lost` events in logs
|
||||||
|
- If >3 occurrences per hour, investigate database I/O performance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Two Instances Both Running Scheduler
|
||||||
|
|
||||||
|
**Symptom:** Duplicate blocklist imports, duplicate geo cache cleanups, or duplicate history syncs.
|
||||||
|
|
||||||
|
**Cause:** Both instances believe they hold the lock.
|
||||||
|
|
||||||
|
**Diagnosis:**
|
||||||
|
1. Check which instance holds the lock: `SELECT pid, hostname FROM scheduler_lock;`
|
||||||
|
2. Compare with running processes: `ps aux | grep bangui`
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Stop one instance immediately
|
||||||
|
2. Clear lock: `DELETE FROM scheduler_lock;`
|
||||||
|
3. Restart the remaining instance
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
- Ensure only one instance starts before heartbeat begins
|
||||||
|
- Check `BANGUI_SINGLE_INSTANCE=true` is set if single-instance operation is required
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Heartbeat Update Failures
|
||||||
|
|
||||||
|
**Symptom:** Logs show `scheduler_lock_heartbeat_lost` repeatedly, then lock is lost.
|
||||||
|
|
||||||
|
**Cause:** Database writes failing or extremely slow (>5 seconds per write).
|
||||||
|
|
||||||
|
**Diagnosis:**
|
||||||
|
```bash
|
||||||
|
time sqlite3 /var/lib/bangui/bangui.db "UPDATE scheduler_lock SET heartbeat_at = unixepoch();"
|
||||||
|
```
|
||||||
|
|
||||||
|
If this takes >1 second, database I/O is degraded.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Check disk health: `sqlite3 /var/lib/bangui/bangui.db "PRAGMA integrity_check;"`
|
||||||
|
2. Move database to faster storage (SSD)
|
||||||
|
3. Check for other I/O bottlenecks on the host
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Lock Not Acquired at Startup
|
||||||
|
|
||||||
|
**Symptom:** Instance fails to start with error "Could not acquire scheduler lock".
|
||||||
|
|
||||||
|
**Cause:** Another instance already holds the lock and appears healthy.
|
||||||
|
|
||||||
|
**Diagnosis:**
|
||||||
|
```bash
|
||||||
|
sqlite3 /var/lib/bangui/bangui.db "SELECT pid, hostname, heartbeat_at FROM scheduler_lock;"
|
||||||
|
ps aux | grep <pid>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- If other instance is healthy and should run scheduler: this instance must wait
|
||||||
|
- If other instance is crashed: `DELETE FROM scheduler_lock;` then restart this instance
|
||||||
|
- If running single instance: ensure no other instances are running before startup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
### Getting 429 Too Many Requests
|
||||||
|
|
||||||
|
**Symptom:** API returns HTTP 429 with `rate_limit_exceeded` error code.
|
||||||
|
|
||||||
|
**Cause:** You have exceeded the per-IP rate limit for a specific operation.
|
||||||
|
|
||||||
|
**Diagnosis:**
|
||||||
|
1. Check the `Retry-After` header in the response — this tells you how many seconds to wait
|
||||||
|
2. Look for the log event `*_rate_limit_exceeded` which shows the bucket and client IP
|
||||||
|
|
||||||
|
**Rate limit buckets:**
|
||||||
|
| Bucket | Limit | Window | Operations |
|
||||||
|
|--------|-------|--------|------------|
|
||||||
|
| `bans:ban` | 100 | 1 minute | Ban IP addresses |
|
||||||
|
| `bans:unban` | 100 | 1 minute | Unban IP addresses |
|
||||||
|
| `blocklist:import` | 10 | 1 hour | Import blocklists |
|
||||||
|
| `config:update` | 50 | 1 minute | Update configuration |
|
||||||
|
| `jail:update` | 100 | 1 minute | Update jail config |
|
||||||
|
| `jail:create` | 100 | 1 minute | Add log paths, assign filters/actions |
|
||||||
|
| `jail:delete` | 100 | 1 minute | Remove log paths, actions |
|
||||||
|
| `jail:activate` | 100 | 1 minute | Activate jails |
|
||||||
|
| `jail:deactivate` | 100 | 1 minute | Deactivate jails |
|
||||||
|
| `filter:update` | 50 | 1 minute | Update filters |
|
||||||
|
| `filter:create` | 50 | 1 minute | Create filters |
|
||||||
|
| `filter:delete` | 50 | 1 minute | Delete filters |
|
||||||
|
| `action:update` | 50 | 1 minute | Update actions |
|
||||||
|
| `action:create` | 50 | 1 minute | Create actions |
|
||||||
|
| `action:delete` | 50 | 1 minute | Delete actions |
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Wait for the `Retry-After` period before retrying
|
||||||
|
2. If you hit the limit during legitimate bulk operations, consider batching requests
|
||||||
|
3. For blocklist imports (10/hour), ensure automated imports are not more frequent
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
- Monitor `*_rate_limit_exceeded` log events
|
||||||
|
- Adjust limits via environment variables if needed (see `Docs/CONFIGURATION.md`)
|
||||||
|
- For bulk operations, implement client-side throttling
|
||||||
|
|
||||||
|
**Note:** If rate limiting triggers unexpectedly for legitimate use, check for:
|
||||||
|
- Internal monitoring scripts hitting endpoints too frequently
|
||||||
|
- Multiple users behind the same proxy IP
|
||||||
|
- Stale rate limit state after process restart (uses in-memory tracking)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Migration Failures
|
||||||
|
|
||||||
|
### Application Won't Start After Upgrade
|
||||||
|
|
||||||
|
**Symptom:** Application fails to start. Logs show migration errors.
|
||||||
|
|
||||||
|
**Cause:** Migration failed mid-transaction. Database left in inconsistent state.
|
||||||
|
|
||||||
|
**Diagnosis:**
|
||||||
|
```bash
|
||||||
|
# Check current schema version
|
||||||
|
sqlite3 /var/lib/bangui/bangui.db "SELECT MAX(version) FROM schema_migrations;"
|
||||||
|
|
||||||
|
# List all tables
|
||||||
|
sqlite3 /var/lib/bangui/bangui.db "SELECT name FROM sqlite_master WHERE type='table';"
|
||||||
|
|
||||||
|
# Check logs for specific error
|
||||||
|
grep -i migration /var/log/bangui.log
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
1. **If migration was auto-rolled back**: Startup will retry the same migration. Run application again.
|
||||||
|
2. **If migration keeps failing**: Check if table already exists:
|
||||||
|
```bash
|
||||||
|
sqlite3 /var/lib/bangui/bangui.db "SELECT name FROM sqlite_master WHERE type='table' AND name='<table>';"
|
||||||
|
```
|
||||||
|
If it exists, manually insert the migration record:
|
||||||
|
```bash
|
||||||
|
sqlite3 /var/lib/bangui/bangui.db "INSERT INTO schema_migrations (version) VALUES (?);"
|
||||||
|
```
|
||||||
|
3. **Full database reset** (development only):
|
||||||
|
```bash
|
||||||
|
rm /var/lib/bangui/bangui.db /var/lib/bangui/bangui.db-wal /var/lib/bangui/bangui.db-shm
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
- Always backup before upgrades: `cp bangui.db bangui.db.backup`
|
||||||
|
- Never manually modify database schema
|
||||||
|
- Monitor `migrating_database_schema` log events during upgrades
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Schema Version Mismatch
|
||||||
|
|
||||||
|
**Symptom:** Error: "database schema version X is newer than supported version Y"
|
||||||
|
|
||||||
|
**Cause:** Downgraded to older BanGUI version that doesn't support current schema.
|
||||||
|
|
||||||
|
**Solution:** Upgrade to a version compatible with the current schema, or restore from backup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 502 Bad Gateway Errors
|
||||||
|
|
||||||
|
### Symptom: Nginx returns 502 Bad Gateway
|
||||||
|
|
||||||
|
**Cause:** The backend container is unreachable — either down, restarting, or not yet healthy.
|
||||||
|
|
||||||
|
**Diagnosis:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check backend container status
|
||||||
|
docker ps -a | grep bangui-backend
|
||||||
|
|
||||||
|
# Check if backend is responding directly (on the container network)
|
||||||
|
docker exec bangui-frontend curl -f http://bangui-backend:8000/api/v1/health
|
||||||
|
|
||||||
|
# Check backend logs
|
||||||
|
docker logs bangui-backend --tail 50
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common causes and solutions:**
|
||||||
|
|
||||||
|
| Cause | Diagnosis | Solution |
|
||||||
|
|---|---|---|
|
||||||
|
| Backend restarting | `docker ps` shows backend repeatedly restarting | Check health check timing; may need longer `start_period` |
|
||||||
|
| Health check failing | Backend log shows socket errors | Verify fail2ban container is healthy before backend starts |
|
||||||
|
| Startup too slow | `start_period: 40s` not enough on slow hosts | Increase `start_period` in compose file |
|
||||||
|
| Port misconfiguration | `expose` vs `ports` mismatch | Ensure backend exposes 8000 and frontend proxies to it |
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
|
||||||
|
- The `depends_on: condition: service_healthy` ensures the backend is fully started before the frontend proxies requests.
|
||||||
|
- The health check returns 503 when fail2ban is offline, triggering container restart automatically.
|
||||||
|
- Health check parameters are tuned for typical startup time — adjust `start_period` if the host is slow or resource-constrained.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Graceful Shutdown Issues
|
||||||
|
|
||||||
|
### Container Killed Before Tasks Complete
|
||||||
|
|
||||||
|
**Symptom:** Logs show `pending_tasks_timeout` and tasks are cancelled mid-execution.
|
||||||
|
|
||||||
|
**Cause:** Docker's `stop_grace_period` is too short, or tasks take longer than the 25s graceful timeout.
|
||||||
|
|
||||||
|
**Diagnosis:**
|
||||||
|
```bash
|
||||||
|
# Check if container was killed by SIGKILL
|
||||||
|
docker inspect bangui-backend --format '{{.State.ExitCode}}'
|
||||||
|
# Exit code 137 = SIGKILL
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Increase `stop_grace_period` in `docker-compose.yml`:
|
||||||
|
```yaml
|
||||||
|
backend:
|
||||||
|
stop_grace_period: 60s
|
||||||
|
```
|
||||||
|
2. The Python graceful timeout is 25s (leaving margin before Docker kill)
|
||||||
|
3. If tasks still timeout, check task code — long-running tasks should handle cancellation gracefully
|
||||||
|
|
||||||
|
### Scheduler Lock Not Released
|
||||||
|
|
||||||
|
**Symptom:** After container restart, logs show `Could not acquire scheduler lock`.
|
||||||
|
|
||||||
|
**Cause:** Previous instance shut down without releasing the lock, or lock TTL hasn't expired.
|
||||||
|
|
||||||
|
**Diagnosis:**
|
||||||
|
```bash
|
||||||
|
sqlite3 /var/lib/bangui/bangui.db "SELECT * FROM scheduler_lock;"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Clear stale lock
|
||||||
|
sqlite3 /var/lib/bangui/bangui.db "DELETE FROM scheduler_lock;"
|
||||||
|
# Restart container
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
- Graceful shutdown releases lock immediately (not waiting for TTL expiry)
|
||||||
|
- Monitor logs for `scheduler_lock_released` on clean shutdown
|
||||||
|
|
||||||
|
### In-Flight Requests Dropped
|
||||||
|
|
||||||
|
**Symptom:** Client connections closed abruptly during shutdown.
|
||||||
|
|
||||||
|
**Cause:** Too short a graceful timeout, or clients not configured to retry.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Ensure clients implement proper retry logic with backoff
|
||||||
|
2. For critical operations, use background tasks with status polling
|
||||||
|
3. Increase graceful timeout if network latency is high
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## General Recovery Commands
|
||||||
|
|
||||||
|
Clear all locks:
|
||||||
|
```bash
|
||||||
|
sqlite3 /var/lib/bangui/bangui.db "DELETE FROM scheduler_lock;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Check lock status:
|
||||||
|
```bash
|
||||||
|
sqlite3 /var/lib/bangui/bangui.db "SELECT * FROM scheduler_lock;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify database integrity:
|
||||||
|
```bash
|
||||||
|
sqlite3 /var/lib/bangui/bangui.db "PRAGMA integrity_check;"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regex Pattern Rejected
|
||||||
|
|
||||||
|
### Symptom: Filter or action configuration fails with "Invalid regex" error
|
||||||
|
|
||||||
|
**Cause:** The regex pattern is either syntactically invalid or detected as a ReDoS (Regular Expression Denial of Service) vulnerability.
|
||||||
|
|
||||||
|
**Diagnosis:**
|
||||||
|
1. Check the error message — it indicates whether the pattern is syntactically invalid or flagged as dangerous
|
||||||
|
2. Look for log events: `regex_redos_detected` or `regex_compilation_timeout`
|
||||||
|
|
||||||
|
**Common ReDoS patterns that are rejected:**
|
||||||
|
| Pattern | Problem |
|
||||||
|
|---------|---------|
|
||||||
|
| `(a+)+b` | Nested quantifiers with overlap |
|
||||||
|
| `([a-z]+)*d` | Quantifier inside quantifier |
|
||||||
|
| `(x+)+y` | Nested plus operators |
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Rewrite the pattern to avoid nested quantifiers on overlapping groups
|
||||||
|
2. Use atomic groups or possessive quantifiers where possible: `(?>a+)+b`
|
||||||
|
3. Simplify complex alternations
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
- Test regex patterns in isolation before deploying
|
||||||
|
- Avoid patterns with quantified groups inside other quantifiers
|
||||||
|
- Prefer explicit character classes over `.*` where possible
|
||||||
|
- Use [regexploit](https://github.com/doyensec/regexploit) to audit patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enabling Debug Logs for Third-Party Libraries
|
||||||
|
|
||||||
|
BanGUI suppresses verbose DEBUG logs from APScheduler and aiosqlite by default (see `Docs/Observability.md`). When troubleshooting scheduler or database issues, you can temporarily re-enable these logs.
|
||||||
|
|
||||||
|
### Quick method (environment variable)
|
||||||
|
|
||||||
|
Set `BANGUI_SUPPRESS_THIRD_PARTY_LOGS=false` and ensure `BANGUI_LOG_LEVEL=debug`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BANGUI_SUPPRESS_THIRD_PARTY_LOGS=false \
|
||||||
|
BANGUI_LOG_LEVEL=debug \
|
||||||
|
python -m uvicorn app.main:create_app
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows APScheduler and aiosqlite to inherit the application log level without editing code.
|
||||||
|
|
||||||
|
### Code method (for permanent changes)
|
||||||
|
|
||||||
|
If you need to change the level for a specific library only, edit `backend/app/main.py` inside `_configure_logging()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
logging.getLogger("apscheduler").setLevel(logging.DEBUG)
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the application. You will see scheduler polling messages such as:
|
||||||
|
- `Looking for jobs to run`
|
||||||
|
- `Next wakeup is due at ...`
|
||||||
|
- `Running job ...`
|
||||||
|
|
||||||
|
### Reverting
|
||||||
|
|
||||||
|
Remove the environment variable or code change and restart. When suppression is re-enabled, the loggers return to `WARNING` level.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plain Text Logs Still Appearing
|
||||||
|
|
||||||
|
If `bangui.log` contains plain text lines that are not JSON, a library is bypassing structlog's `ProcessorFormatter`.
|
||||||
|
|
||||||
|
**Diagnosis:**
|
||||||
|
|
||||||
|
1. Identify the logger name in the plain text line (usually at the start of the line).
|
||||||
|
2. Check whether the logger is listed in `backend/app/main.py::_configure_logging()` under the third-party overrides.
|
||||||
|
3. Verify that `structlog.stdlib.ProcessorFormatter` is attached to all handlers:
|
||||||
|
```python
|
||||||
|
for handler in handlers:
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common causes:**
|
||||||
|
|
||||||
|
| Cause | Fix |
|
||||||
|
|-------|-----|
|
||||||
|
| Library initializes its own handler after startup | Add `logging.getLogger("library_name").setLevel(logging.WARNING)` in `_configure_logging()`. |
|
||||||
|
| Custom handler added outside `_configure_logging()` | Ensure all handlers use `structlog.stdlib.ProcessorFormatter`. |
|
||||||
|
| Log emitted before `_configure_logging()` is called | Move logging configuration earlier in the lifespan or app factory. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
If issues persist after following this guide:
|
||||||
|
|
||||||
|
1. Enable debug logging: `BANGUI_LOG_LEVEL=debug`
|
||||||
|
2. Collect logs around the failure time
|
||||||
|
3. Check `Docs/Deployment.md` for configuration guidance
|
||||||
|
4. Check `Docs/Observability.md` for monitoring setup
|
||||||
145
Docs/TYPE_SAFETY.md
Normal file
145
Docs/TYPE_SAFETY.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Type Safety Between Frontend and Backend
|
||||||
|
|
||||||
|
This document describes how BanGUI maintains type alignment between the TypeScript frontend and Python backend, and the constraints that keep runtime type mismatches from occurring.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The Problem
|
||||||
|
|
||||||
|
Frontend TypeScript types and backend Pydantic models are defined independently. Drift between them causes runtime errors:
|
||||||
|
|
||||||
|
- **Empty string vs. null** — `country_code: string | null` in TypeScript but backend returns `""` for unresolved geo lookups. Frontend truthiness check `if (ban.country_code)` passes for `""` but the value is meaningless.
|
||||||
|
- **Timestamp ambiguity** — Frontend expects ISO 8601 strings; backend was passing mixed UNIX integers in some paths.
|
||||||
|
- **Silent zero values** — `0` can be indistinguishable from "not set" in weakly-typed paths.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Shared Type Conventions
|
||||||
|
|
||||||
|
All JSON field names use `snake_case` in both backend (Python/Pydantic) and frontend (TypeScript). No alias generators are applied.
|
||||||
|
|
||||||
|
| Python type | TypeScript type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `str` | `string` | |
|
||||||
|
| `str \| None` | `string \| null` | Null-capable strings use explicit null |
|
||||||
|
| `int` | `number` | |
|
||||||
|
| `bool` | `boolean` | |
|
||||||
|
| `list[T]` | `T[]` | |
|
||||||
|
| `dict[str, T]` | `Record<string, T>` | |
|
||||||
|
|
||||||
|
### Country Code Constraint
|
||||||
|
|
||||||
|
`country_code` is always `string | null`. An empty string `""` is **never** a valid value. The backend normalises empty strings to `None` at the Pydantic validator level so the frontend always receives either a valid 2-char uppercase code or `null`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Backend Validation Layer
|
||||||
|
|
||||||
|
Every Pydantic response model that includes a country code has a `field_validator` that coerces empty strings to `None`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@field_validator("country_code")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_empty_country_code(cls, v: str | None) -> str | None:
|
||||||
|
if v == "":
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
```
|
||||||
|
|
||||||
|
Models affected:
|
||||||
|
|
||||||
|
- `DashboardBanItem` — `country_code`
|
||||||
|
- `ActiveBan` — `country`
|
||||||
|
- `Ban` — `country`
|
||||||
|
|
||||||
|
The same pattern should be applied to any new field that could arrive as `""` from the geo enrichment layer.
|
||||||
|
|
||||||
|
### Why the Validator Lives in the Model
|
||||||
|
|
||||||
|
Validation at the Pydantic model layer means:
|
||||||
|
- It fires on **every** API response, regardless of which router endpoint produced it.
|
||||||
|
- The mapper layer cannot accidentally skip normalisation.
|
||||||
|
- Serialisation from domain → response model is automatically safe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Timestamp Standard
|
||||||
|
|
||||||
|
All ban timestamps are transmitted as **ISO 8601 UTC strings** (`"2026-04-28T07:00:00+00:00"`). UNIX integers are used internally in repositories but converted to ISO strings using `ts_to_iso()` before entering the response model:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
def ts_to_iso(unix_ts: int) -> str:
|
||||||
|
return datetime.fromtimestamp(unix_ts, tz=UTC).isoformat()
|
||||||
|
```
|
||||||
|
|
||||||
|
This conversion happens once — in the service layer when building `DomainDashboardBanItem` and similar domain objects — so all response models receive pre-formatted strings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Frontend Type Narrowing Rules
|
||||||
|
|
||||||
|
When consuming `country_code` in TypeScript, use explicit null checks rather than truthiness:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BAD — empty string passes this check
|
||||||
|
if (ban.country_code) { ... }
|
||||||
|
|
||||||
|
// GOOD — only null/undefined are falsy
|
||||||
|
if (ban.country_code !== null) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend normalisation ensures that `!ban.country_code` and `ban.country_code === null` are equivalent, but the explicit form is clearer and defensive against future changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. CI Type Synchronisation
|
||||||
|
|
||||||
|
A type-generation script (planned) will emit a combined JSON schema from all Pydantic models and validate the generated TypeScript types against it on every build. Until that is in place:
|
||||||
|
|
||||||
|
- Any change to a Pydantic model field type must be mirrored in the corresponding TypeScript interface in `frontend/src/types/ban.ts`.
|
||||||
|
- Run `pytest tests/test_models.py` to verify model-level validation after changing `ban.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Adding New Shared Types
|
||||||
|
|
||||||
|
When adding a new response model to `backend/app/models/`:
|
||||||
|
|
||||||
|
1. Define the Pydantic model with explicit `str | None` (not `Optional[str]`) for nullable strings.
|
||||||
|
2. Add `field_validator` stubs for any field that could receive an empty string from a database or external API.
|
||||||
|
3. Add the corresponding TypeScript interface in `frontend/src/types/`.
|
||||||
|
4. Add model-level unit tests in `tests/test_models.py`.
|
||||||
|
5. Run the full test suite before committing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. TypedDict for Error Metadata
|
||||||
|
|
||||||
|
Error response metadata uses `ErrorMetadata` (a `TypedDict` with `total=False`) instead of generic `dict[str, str | int | float | bool | None]`. This enables type-safe field access in exception handlers and type checkers can verify correct field usage.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD — generic dict, no type narrowing
|
||||||
|
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
|
||||||
|
return {"jail_name": self.name}
|
||||||
|
|
||||||
|
# GOOD — TypedDict, type checker knows exact fields
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"jail_name": self.name}
|
||||||
|
```
|
||||||
|
|
||||||
|
When accessing error metadata in exception handlers, the type checker can now verify which keys are present:
|
||||||
|
|
||||||
|
```python
|
||||||
|
metadata = exc.get_error_metadata()
|
||||||
|
jail_name = metadata["jail_name"] # type checker verifies "jail_name" exists
|
||||||
|
```
|
||||||
|
|
||||||
|
`ErrorMetadata` is defined in `backend/app/models/response.py` and imported via `TYPE_CHECKING` blocks in `exceptions.py` and `main.py` to avoid circular dependencies at runtime.
|
||||||
|
|
||||||
|
## 9. Related Documents
|
||||||
|
|
||||||
|
- [Architekture.md](Architekture.md) — system architecture and data flow
|
||||||
|
- [Backend-Development.md](Backend-Development.md) — Python coding conventions, Pydantic usage
|
||||||
|
- [Web-Development.md](../frontend/Docs/Web-Development.md) — TypeScript conventions
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# BanGUI — Task List
|
|
||||||
|
|
||||||
This document breaks the entire BanGUI project into development stages, ordered so that each stage builds on the previous one. Every task is described in prose with enough detail for a developer to begin work. References point to the relevant documentation.
|
|
||||||
|
|
||||||
Reference: `Docs/Refactoring.md` for full analysis of each issue.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open Issues
|
|
||||||
|
|||||||
118
Docs/Testing-Requirements.md
Normal file
118
Docs/Testing-Requirements.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Testing Requirements
|
||||||
|
|
||||||
|
## Coverage Threshold
|
||||||
|
|
||||||
|
- **Minimum: 80% line coverage** for all backend code
|
||||||
|
- Critical paths (auth, banning, scheduling, API endpoints): **100%**
|
||||||
|
|
||||||
|
## CI Enforcement
|
||||||
|
|
||||||
|
`.github/workflows/ci.yml` runs pytest with `--cov-fail-under=80`. Build fails if coverage drops below threshold.
|
||||||
|
|
||||||
|
## Running Tests Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pytest --cov=app --cov-report=term-missing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage Reports
|
||||||
|
|
||||||
|
- Terminal: `--cov-report=term-missing`
|
||||||
|
- HTML: `--cov-report=html` (output in `htmlcov/`)
|
||||||
|
|
||||||
|
## Coverage Badge
|
||||||
|
|
||||||
|
Add to README once CI runs successfully:
|
||||||
|
|
||||||
|
```md
|
||||||
|
[](https://codecov.io/gh/<owner>/BanGUI)
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires codecov.io integration with repository.
|
||||||
|
|
||||||
|
## Writing Tests
|
||||||
|
|
||||||
|
- Follow pattern: `test_<unit>_<scenario>_<expected>`
|
||||||
|
- Mock external dependencies (fail2ban socket, aiohttp calls)
|
||||||
|
- Test happy path AND error/edge cases
|
||||||
|
- See `Docs/Backend-Development.md §9` for detailed testing guide
|
||||||
|
|
||||||
|
## E2E Testing
|
||||||
|
|
||||||
|
An end-to-end test suite using **Robot Framework** with the Browser library (Playwright-backed) exercises the full running stack: frontend → backend → fail2ban → database.
|
||||||
|
|
||||||
|
### Running E2E Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
- `BANGUI_SESSION_SECRET` env var must be set (see [Backend-Development.md](Backend-Development.md) for setup)
|
||||||
|
- Stack must be startable via `make up` (Docker/Podman + compose installed)
|
||||||
|
- `rfbrowser init` is run automatically by the `e2e` target (Playwright browsers downloaded on first run; re-run after `robotframework-browser` version changes)
|
||||||
|
|
||||||
|
### HTML Report
|
||||||
|
|
||||||
|
After a run, open `e2e/results/report.html` in a browser to view the detailed HTML report with screenshots on failure.
|
||||||
|
|
||||||
|
### Writing New E2E Tests
|
||||||
|
|
||||||
|
Place new `.robot` files in `e2e/tests/`. Use `e2e/resources/common.resource` for shared variables and setup/teardown, and `e2e/resources/auth.resource` for the `Login As Admin` keyword.
|
||||||
|
|
||||||
|
### E2E-3 — Ban Pipeline Timing
|
||||||
|
|
||||||
|
Test **E2E-3** (`e2e/tests/02_ban_records.robot`: *Simulated Failed Logins Appear As Ban Records*) exercises the full ban pipeline:
|
||||||
|
|
||||||
|
```
|
||||||
|
simulate_failed_logins.sh → fail2ban log scan → ban recorded in fail2ban DB
|
||||||
|
→ backend polls socket (on-demand, no push) → /api/bans/active
|
||||||
|
→ history_sync archive (every 300 s) → /api/history
|
||||||
|
```
|
||||||
|
|
||||||
|
Key timing facts:
|
||||||
|
|
||||||
|
- **fail2ban** (`manual-Jail`, `backend=polling`) re-reads `auth.log` on its own interval, not event-driven.
|
||||||
|
- **maxretry=3** means a ban triggers after the 3rd matching line. `simulate_failed_logins.sh` writes 5 lines to ensure the threshold is crossed.
|
||||||
|
- **15 s sleep** in the test gives fail2ban time to detect and record the ban before the first assertion. This is a heuristic — the actual polling interval depends on fail2ban's internal cycle.
|
||||||
|
- **history_sync** runs every 300 s (`HISTORY_SYNC_INTERVAL` in `backend/app/tasks/history_sync.py`). The History page reads from the archive DB, so it may lag up to 300 s behind real-time. The E2E test uses `GET /api/bans/active` (direct socket query) for the API assertion to avoid this lag.
|
||||||
|
- **Pagination**: the History page paginates results. Use `?page_size=500` to push the test IP onto the first page, or assert via the API.
|
||||||
|
|
||||||
|
If the test fails at Step 2 (no ban detected via API) but `check_ban_status.sh` shows the IP is banned inside the container, the backend-to-fail2ban socket path is broken. If `check_ban_status.sh` also shows no ban, the log volume mapping is wrong (fail2ban is not reading the file `simulate_failed_logins.sh` writes to).
|
||||||
|
|
||||||
|
### E2E-4 — Blocklist Import
|
||||||
|
|
||||||
|
Test **E2E-4** (`e2e/tests/03_blocklist_import.robot`: *Manual Blocklist Import Completes Without Error*) exercises the full import pipeline:
|
||||||
|
|
||||||
|
```
|
||||||
|
UI button click → POST /api/v1/blocklists/import → async background task
|
||||||
|
→ DNS validation → HTTP fetch (external or local mock)
|
||||||
|
→ IP parsing → fail2ban ban_ip call → DB write → import log entry
|
||||||
|
```
|
||||||
|
|
||||||
|
Key facts:
|
||||||
|
|
||||||
|
- **Rate limit**: `BANGUI_RATE_LIMIT_BLOCKLIST_IMPORT_PER_HOUR = 10` per client IP. E2E tests bypass this by sending a unique `X-Forwarded-For` header (e.g., `10.0.0.99`). The header is only honoured when the client IP is in `BANGUI_TRUSTED_PROXIES`.
|
||||||
|
- **Network dependency**: The import fetches the blocklist URL over HTTP. In CI environments without internet access the test starts a local Python `http.server` (port 8765) serving `e2e/test_blocklist.txt`. The `Ensure Blocklist Source Exists` keyword points the source URL at `http://localhost:8765/test.txt` when no internet is detected.
|
||||||
|
- **"Import ran" vs "bans added"**: These are separate outcomes. The test asserts that the log entry count increases — confirming the import ran to completion — regardless of whether any IPs were actually banned.
|
||||||
|
- **Timeout**: Large lists may exceed the 45 s button-wait timeout. Increase as needed.
|
||||||
|
- **Selector**: The import button is selected via `css=[data-testid="blocklist-import-button"],button`. The `data-testid` attribute must be added to the frontend component (see [E2E-6] in [Tasks.md](Tasks.md)). If the attribute is absent, the fallback `button` selector is used.
|
||||||
|
|
||||||
|
**Teardown**: `Cleanup Mock Server` stops the local HTTP server started in the test.
|
||||||
|
|
||||||
|
### E2E-5 — Config Field Edit Persistence
|
||||||
|
|
||||||
|
Test **E2E-5** (`e2e/tests/04_config_edit.robot`: *Config Field Edit Persists After Reload*) exercises the auto-save round-trip:
|
||||||
|
|
||||||
|
```
|
||||||
|
UI edit → useAutoSave debounce (500 ms) → PATCH /api/config/jails/:name
|
||||||
|
→ fail2ban config write → GET /api/jails rehydration on reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Key facts:
|
||||||
|
|
||||||
|
- **Debounce**: `useAutoSave` fires no HTTP request until 500 ms of inactivity after the last keystroke. The test waits for the "Saved" indicator (`[role="status"]:has-text("Saved")`) rather than a fixed `Sleep`, ensuring the PATCH actually fired before the reload.
|
||||||
|
- **Selector**: `input[aria-label="Ban Time"]` is used to locate the bantime field — no `data-*` attribute required. The `aria-label` is stable across refactors.
|
||||||
|
- **Teardown**: `Restore Original Ban Time` is set as `[Teardown]` so it runs even when the test fails mid-way. Config edits restart fail2ban internally; restoring state prevents subsequent tests from reading modified values.
|
||||||
|
- **Run order**: E2E-5 should run last in the suite to avoid destabilising fail2ban health for other tests.
|
||||||
@@ -41,6 +41,7 @@ BanGUI uses a **single custom theme** generated with the [Fluent UI Theme Design
|
|||||||
|
|
||||||
- The primary colour must have a **contrast ratio of at least 4.5 : 1** against `white` for text and **3 : 1** for large text and UI elements.
|
- The primary colour must have a **contrast ratio of at least 4.5 : 1** against `white` for text and **3 : 1** for large text and UI elements.
|
||||||
- Provide a **dark theme variant** alongside the default light theme. Both must share the same semantic slot names — only the palette values differ.
|
- Provide a **dark theme variant** alongside the default light theme. Both must share the same semantic slot names — only the palette values differ.
|
||||||
|
- Persist the user's explicit theme choice in `localStorage` and otherwise follow the operating system's `prefers-color-scheme` setting.
|
||||||
- Never reference Fluent UI palette slots (`themeDarker`, `neutralLight`, etc.) directly in components. Always go through semantic slots so theme switching works seamlessly.
|
- Never reference Fluent UI palette slots (`themeDarker`, `neutralLight`, etc.) directly in components. Always go through semantic slots so theme switching works seamlessly.
|
||||||
|
|
||||||
### Colour Rules
|
### Colour Rules
|
||||||
@@ -210,7 +211,7 @@ Use Fluent UI React components as the building blocks. The following mapping sho
|
|||||||
|
|
||||||
| Element | Fluent component | Notes |
|
| Element | Fluent component | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Data tables | `DetailsList` | All ban tables, jail overviews, history tables. Enable column sorting, selection, and shimmer loading. |
|
| Data tables | `DetailsList` | All ban tables, jail overviews, history tables. Enable column sorting, selection, and shimmer loading. Use clear pagination controls (page number + prev/next) and a page-size selector (25/50/100) for large result sets. |
|
||||||
| Stat cards | `DocumentCard` or custom `Stack` card | Dashboard status bar — server status, total bans, active jails. Use `Depth 4`. |
|
| Stat cards | `DocumentCard` or custom `Stack` card | Dashboard status bar — server status, total bans, active jails. Use `Depth 4`. |
|
||||||
| Status indicators | `Badge` / `Icon` + colour | Server online/offline, jail running/stopped/idle. |
|
| Status indicators | `Badge` / `Icon` + colour | Server online/offline, jail running/stopped/idle. |
|
||||||
| Country labels | Monospaced text + flag emoji or icon | Geo data next to IP addresses. |
|
| Country labels | Monospaced text + flag emoji or icon | Geo data next to IP addresses. |
|
||||||
@@ -234,12 +235,86 @@ Use Fluent UI React components as the building blocks. The following mapping sho
|
|||||||
| Success messages | `MessageBar` (success) | "IP 1.2.3.4 has been banned in jail sshd." |
|
| Success messages | `MessageBar` (success) | "IP 1.2.3.4 has been banned in jail sshd." |
|
||||||
| Error messages | `MessageBar` (error) | "Failed to connect to fail2ban server." |
|
| Error messages | `MessageBar` (error) | "Failed to connect to fail2ban server." |
|
||||||
| Warning messages | `MessageBar` (warning) | "Blocklist import encountered 12 invalid entries." |
|
| Warning messages | `MessageBar` (warning) | "Blocklist import encountered 12 invalid entries." |
|
||||||
| Loading states | `Shimmer` | Apply to `DetailsList` rows and stat cards while data loads. |
|
| Loading states | `SkeletonTable` / `SkeletonChart` (preferred) or `Shimmer` (legacy) | Show skeleton placeholders matching layout. Use `PageLoadingSkeleton` for page-level loading. |
|
||||||
| Empty states | Custom illustration + text | "No bans recorded in the last 24 hours." Centre on the content area. |
|
| Empty states | Custom illustration + text | "No bans recorded in the last 24 hours." Centre on the content area. |
|
||||||
| Tooltips | `Tooltip` / `TooltipHost` | Full IP info on hover, full regex on truncated text, icon-only button labels. |
|
| Tooltips | `Tooltip` / `TooltipHost` | Full IP info on hover, full regex on truncated text, icon-only button labels. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 8a. Loading States & Skeleton Components
|
||||||
|
|
||||||
|
Progressive loading states improve perceived responsiveness and reduce cognitive load during data fetches.
|
||||||
|
|
||||||
|
### When to Use Skeleton Placeholders
|
||||||
|
|
||||||
|
Use skeleton loading states instead of spinners for regions with a fixed, known layout:
|
||||||
|
- **Tables** — Show skeleton rows that match the actual column layout and row height
|
||||||
|
- **Charts** — Show skeleton bars matching the chart dimensions
|
||||||
|
- **Stat cards** — Show skeleton badges matching actual stat dimensions
|
||||||
|
- **Forms** — Show skeleton fields matching input dimensions
|
||||||
|
|
||||||
|
Use full-page spinners only when:
|
||||||
|
- Loading time is expected to be under 1 second
|
||||||
|
- Content layout is unknown or highly variable
|
||||||
|
- Entire page structure is being loaded
|
||||||
|
|
||||||
|
### Skeleton Components
|
||||||
|
|
||||||
|
BanGUI provides pre-built skeleton components in `src/components/skeletons/`:
|
||||||
|
|
||||||
|
| Component | Usage | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `<SkeletonTable>` | Table/grid loading | Pass `rowCount` and `cellCount` to match real layout. |
|
||||||
|
| `<SkeletonTableRow>` | Individual table rows | Renders a single animated skeleton row. |
|
||||||
|
| `<SkeletonChart>` | Chart/graph loading | Pass `barCount` and `height` to match container dimensions. |
|
||||||
|
| `<SkeletonStat>` | Stat badge loading | Shows label + value stacked. Pass `showLabel={false}` to hide label. |
|
||||||
|
| `<SkeletonFormField>` | Form input loading | Shows label + input placeholder. Pass `inputHeight` for custom sizing. |
|
||||||
|
| `<PageLoadingSkeleton>` | Page-level loading | Convenience wrapper. Pass `type="table"` or `type="chart"`. |
|
||||||
|
|
||||||
|
### Skeleton Implementation Details
|
||||||
|
|
||||||
|
- **Dimensions:** Skeletons must exactly match real content dimensions (height, width, spacing) to prevent layout shift when content arrives.
|
||||||
|
- **Animation:** All skeletons use a subtle 2-second pulse animation (`skeleton-pulse` keyframe). The animation respects `prefers-reduced-motion`.
|
||||||
|
- **Accessibility:** Skeleton elements are marked `aria-hidden="true"` and `role="presentation"` — they are not part of the accessible tree.
|
||||||
|
- **Theming:** Skeleton colour uses `colorNeutralBackground1Hover` token for automatic light/dark mode support.
|
||||||
|
|
||||||
|
### Usage Examples
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Loading table data
|
||||||
|
if (loading) {
|
||||||
|
return <SkeletonTable rowCount={10} cellCount={6} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading chart
|
||||||
|
if (chartLoading) {
|
||||||
|
return <PageLoadingSkeleton type="chart" itemCount={12} chartHeight={220} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading multiple stats
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)" }}>
|
||||||
|
{[1, 2, 3].map(i => <SkeletonStat key={i} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Loading form
|
||||||
|
<div style={{ gap: "16px", display: "flex", flexDirection: "column" }}>
|
||||||
|
<SkeletonFormField />
|
||||||
|
<SkeletonFormField />
|
||||||
|
<SkeletonFormField showLabel={false} />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avoiding Layout Shift
|
||||||
|
|
||||||
|
**Critical:** Skeleton components must reserve the exact space that real content will occupy. Test by:
|
||||||
|
1. Load skeleton while data is fetching
|
||||||
|
2. Observe the skeleton render to full height/width
|
||||||
|
3. Observe real content arriving — no movement or repaint of surrounding elements
|
||||||
|
|
||||||
|
If content shifts when it arrives, adjust skeleton dimensions or parent container constraints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 9. Tables & Data Grids
|
## 9. Tables & Data Grids
|
||||||
|
|
||||||
Tables are the primary UI element in BanGUI. They must be treated with extreme care.
|
Tables are the primary UI element in BanGUI. They must be treated with extreme care.
|
||||||
@@ -386,7 +461,7 @@ export const sideNavCollapsedWidth = 48;
|
|||||||
| Reference theme tokens for all colours | Hard-code hex values like `#ff0000` in components |
|
| Reference theme tokens for all colours | Hard-code hex values like `#ff0000` in components |
|
||||||
| Follow the 4 px spacing grid | Use arbitrary pixel values (13 px, 7 px, 19 px) |
|
| Follow the 4 px spacing grid | Use arbitrary pixel values (13 px, 7 px, 19 px) |
|
||||||
| Provide a tooltip for every icon-only button | Leave icons unlabelled and inaccessible |
|
| Provide a tooltip for every icon-only button | Leave icons unlabelled and inaccessible |
|
||||||
| Use `Shimmer` for loading states | Show a blank screen or a standalone spinner with no context |
|
| Use skeleton placeholders for loading states | Show a blank screen or a standalone spinner with no context |
|
||||||
| Design for both light and dark themes | Default to white backgrounds assuming light mode only |
|
| Design for both light and dark themes | Default to white backgrounds assuming light mode only |
|
||||||
| Use `DetailsList` for all tabular data | Use raw HTML `<table>` elements or a third-party data grid |
|
| Use `DetailsList` for all tabular data | Use raw HTML `<table>` elements or a third-party data grid |
|
||||||
| Use semantic colour slots (`errorText`, `bodyBackground`) | Use descriptive palette slots (`red`, `neutralLight`) directly |
|
| Use semantic colour slots (`errorText`, `bodyBackground`) | Use descriptive palette slots (`red`, `neutralLight`) directly |
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
154
Docs/runner.csx
Normal file
154
Docs/runner.csx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env dotnet-script
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
// ── Ctrl+C: kill active process and exit cleanly ──────────────────────────────
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
Process? activeProcess = null;
|
||||||
|
|
||||||
|
Console.CancelKeyPress += (_, e) =>
|
||||||
|
{
|
||||||
|
e.Cancel = true;
|
||||||
|
Console.WriteLine("\n[runner] Interrupted — shutting down...");
|
||||||
|
cts.Cancel();
|
||||||
|
try { activeProcess?.Kill(entireProcessTree: true); } catch { }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Paths ─────────────────────────────────────────────────────────────────────
|
||||||
|
var repoRoot = Directory.GetCurrentDirectory();
|
||||||
|
var tasksFile = Path.Combine(repoRoot, "Docs", "Tasks.md");
|
||||||
|
|
||||||
|
if (!File.Exists(tasksFile))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[runner] ERROR: Tasks.md not found at {tasksFile}");
|
||||||
|
Console.Error.WriteLine("[runner] Run this script from the repository root.");
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read & split by "---" separator lines ────────────────────────────────────
|
||||||
|
var content = File.ReadAllText(tasksFile);
|
||||||
|
var items = Regex
|
||||||
|
.Split(content, @"\r?\n---\r?\n")
|
||||||
|
.Select(s => s.Trim())
|
||||||
|
.Where(s => s.Length > 0)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Console.WriteLine($"[runner] Found {items.Count} section(s) in Tasks.md");
|
||||||
|
|
||||||
|
// ── Helper: run copilot and stream output, return full output ─────────────────
|
||||||
|
async Task<string> RunCopilot(IEnumerable<string> extraArgs, string prompt)
|
||||||
|
{
|
||||||
|
var output = new StringBuilder();
|
||||||
|
|
||||||
|
var argList = new List<string> { "launch", "copilot", "--model", "minimax-m2.7:cloud", "--yes", "--", "--allow-all-tools" };
|
||||||
|
argList.AddRange(extraArgs);
|
||||||
|
argList.Add("-p");
|
||||||
|
argList.Add(prompt);
|
||||||
|
|
||||||
|
var psi = new ProcessStartInfo("ollama")
|
||||||
|
{
|
||||||
|
WorkingDirectory = repoRoot,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
foreach (var a in argList)
|
||||||
|
psi.ArgumentList.Add(a);
|
||||||
|
|
||||||
|
activeProcess = new Process { StartInfo = psi };
|
||||||
|
|
||||||
|
activeProcess.OutputDataReceived += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.Data is null) return;
|
||||||
|
Console.WriteLine(e.Data);
|
||||||
|
output.AppendLine(e.Data);
|
||||||
|
};
|
||||||
|
activeProcess.ErrorDataReceived += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.Data is null) return;
|
||||||
|
Console.Error.WriteLine(e.Data);
|
||||||
|
output.AppendLine(e.Data);
|
||||||
|
};
|
||||||
|
|
||||||
|
activeProcess.Start();
|
||||||
|
activeProcess.BeginOutputReadLine();
|
||||||
|
activeProcess.BeginErrorReadLine();
|
||||||
|
|
||||||
|
await activeProcess.WaitForExitAsync(cts.Token);
|
||||||
|
activeProcess = null;
|
||||||
|
|
||||||
|
return output.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main loop ─────────────────────────────────────────────────────────────────
|
||||||
|
for (int i = 0; i < items.Count; i++)
|
||||||
|
{
|
||||||
|
var item = items[i];
|
||||||
|
if (cts.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("[runner] ══════════════════════════════════════════════");
|
||||||
|
Console.WriteLine($"[runner] Task:\n{item}");
|
||||||
|
Console.WriteLine("[runner] ══════════════════════════════════════════════");
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
// Step 1 — run the task prompt
|
||||||
|
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
|
||||||
|
await RunCopilot(new[] { "--continue" }, $"read ./Docs/Instructions.md. fix the following test and only that one. Keep in mind that i did many refactorings and test may is obsolet or need to be changed. {item}");
|
||||||
|
if (cts.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
// Step 2 — confirm completion in the same chat session
|
||||||
|
Console.WriteLine("\n[runner] Asking for task confirmation...\n");
|
||||||
|
var confirmation = await RunCopilot(
|
||||||
|
new[] { "--continue" },
|
||||||
|
"are you sure tasks is done. reply with yes"
|
||||||
|
);
|
||||||
|
if (cts.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
// Step 3 — check for "yes" in the reply, with retry logic for issue resolution
|
||||||
|
int maxRetries = 3;
|
||||||
|
int retryCount = 0;
|
||||||
|
bool taskConfirmed = confirmation.Contains("yes", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
while (!taskConfirmed && retryCount < maxRetries)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4 — commit the work
|
||||||
|
Console.WriteLine("\n[runner] Task confirmed. Making git commit...\n");
|
||||||
|
|
||||||
|
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
|
||||||
|
await RunCopilot(new[] { "--continue" }, "make git commit");
|
||||||
|
if (cts.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
// Step 5 — remove completed task from Tasks.md
|
||||||
|
var remaining = items.Skip(i + 1).ToList();
|
||||||
|
File.WriteAllText(tasksFile, string.Join("\n\n---\n\n", remaining));
|
||||||
|
Console.WriteLine("[runner] Removed completed task from Tasks.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("\n[runner] Finished.");
|
||||||
@@ -1 +0,0 @@
|
|||||||
https://lists.blocklist.de/lists/all.txt
|
|
||||||
71
Makefile
71
Makefile
@@ -12,6 +12,7 @@
|
|||||||
# make logs — tail logs for all debug services
|
# make logs — tail logs for all debug services
|
||||||
# make restart — restart the debug stack
|
# make restart — restart the debug stack
|
||||||
# make dev-ban-test — one-command smoke test of the ban pipeline
|
# make dev-ban-test — one-command smoke test of the ban pipeline
|
||||||
|
# make e2e — run the Robot Framework E2E test suite
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
COMPOSE_FILE := Docker/compose.debug.yml
|
COMPOSE_FILE := Docker/compose.debug.yml
|
||||||
@@ -36,45 +37,83 @@ DEV_IMAGES := \
|
|||||||
COMPOSE := $(shell command -v podman-compose 2>/dev/null \
|
COMPOSE := $(shell command -v podman-compose 2>/dev/null \
|
||||||
|| echo "podman compose")
|
|| echo "podman compose")
|
||||||
|
|
||||||
|
# Env file in the project root.
|
||||||
|
# Passed explicitly because docker compose v2 defaults to the compose file's
|
||||||
|
# directory as the project directory, not the shell's cwd.
|
||||||
|
ENV_FILE := .env
|
||||||
|
COMPOSE_OPTS := --env-file $(ENV_FILE) -f $(COMPOSE_FILE)
|
||||||
|
|
||||||
# Detect available container runtime (podman or docker).
|
# Detect available container runtime (podman or docker).
|
||||||
RUNTIME := $(shell command -v podman 2>/dev/null || echo "docker")
|
RUNTIME := $(shell command -v podman 2>/dev/null || echo "docker")
|
||||||
|
|
||||||
.PHONY: up down build restart logs clean dev-ban-test
|
.PHONY: up down build restart logs clean dev-ban-test e2e ensure-env
|
||||||
|
|
||||||
|
## Ensure .env exists with BANGUI_SESSION_SECRET set.
|
||||||
|
## Copies .env.example → .env on first run and auto-generates the secret.
|
||||||
|
ensure-env:
|
||||||
|
@if [ ! -f .env ]; then \
|
||||||
|
cp .env.example .env; \
|
||||||
|
python3 -c "\
|
||||||
|
import re, secrets; \
|
||||||
|
content = open('.env').read(); \
|
||||||
|
secret = secrets.token_hex(32); \
|
||||||
|
content = re.sub(r'(?m)^BANGUI_SESSION_SECRET=.*', 'BANGUI_SESSION_SECRET=' + secret, content); \
|
||||||
|
open('.env', 'w').write(content); \
|
||||||
|
print('Created .env with a generated BANGUI_SESSION_SECRET.')"; \
|
||||||
|
fi
|
||||||
|
|
||||||
## Start the debug stack (detached).
|
## Start the debug stack (detached).
|
||||||
## Ensures log stub files exist so fail2ban can open them on first start.
|
## Ensures log stub files exist so fail2ban can open them on first start.
|
||||||
up:
|
## All output is logged to /data/log/make-up.log.
|
||||||
@mkdir -p Docker/logs
|
up: ensure-env
|
||||||
@touch Docker/logs/auth.log
|
@mkdir -p data/log
|
||||||
$(COMPOSE) -f $(COMPOSE_FILE) up -d
|
@touch data/log/auth.log
|
||||||
|
$(COMPOSE) $(COMPOSE_OPTS) up -d 2>&1 | tee data/log/make-up.log
|
||||||
|
|
||||||
## Stop the debug stack.
|
## Stop the debug stack.
|
||||||
down:
|
down: ensure-env
|
||||||
$(COMPOSE) -f $(COMPOSE_FILE) down
|
$(COMPOSE) $(COMPOSE_OPTS) down
|
||||||
|
|
||||||
## (Re)build the backend image without starting containers.
|
## (Re)build the backend image without starting containers.
|
||||||
build:
|
build: ensure-env
|
||||||
$(COMPOSE) -f $(COMPOSE_FILE) build
|
$(COMPOSE) $(COMPOSE_OPTS) build
|
||||||
|
|
||||||
## Restart the debug stack.
|
## Restart the debug stack.
|
||||||
restart: down up
|
restart: down up
|
||||||
|
|
||||||
## Tail logs for all debug services.
|
## Tail logs for all debug services.
|
||||||
logs:
|
logs: ensure-env
|
||||||
$(COMPOSE) -f $(COMPOSE_FILE) logs -f
|
$(COMPOSE) $(COMPOSE_OPTS) logs -f
|
||||||
|
|
||||||
## Stop containers, remove ALL debug volumes and locally-built images.
|
## Stop containers, remove ALL debug volumes and locally-built images.
|
||||||
## The next 'make up' will rebuild images from scratch and start fresh.
|
## The next 'make up' will rebuild images from scratch and start fresh.
|
||||||
clean:
|
clean: ensure-env
|
||||||
$(COMPOSE) -f $(COMPOSE_FILE) down --remove-orphans
|
$(COMPOSE) $(COMPOSE_OPTS) down --remove-orphans
|
||||||
$(RUNTIME) volume rm $(DEV_VOLUMES) 2>/dev/null || true
|
$(RUNTIME) volume rm $(DEV_VOLUMES) 2>/dev/null || true
|
||||||
$(RUNTIME) rmi $(DEV_IMAGES) 2>/dev/null || true
|
$(RUNTIME) rmi $(DEV_IMAGES) 2>/dev/null || true
|
||||||
@echo "All debug volumes and local images removed. Run 'make up' to rebuild and start fresh."
|
rm -rf ./data
|
||||||
|
@echo "All debug volumes, local images, and ./data removed. Run 'make up' to rebuild and start fresh."
|
||||||
|
|
||||||
|
## Run the Robot Framework E2E test suite.
|
||||||
|
## Requires: stack up (make up), BANGUI_SESSION_SECRET env var set.
|
||||||
|
## Installs: pip install -r e2e/requirements.txt && rfbrowser init
|
||||||
|
e2e: down clean up
|
||||||
|
@echo "Waiting 2 minutes for services to initialize..."
|
||||||
|
@sleep 120
|
||||||
|
@echo "Waiting for stack to be healthy..."
|
||||||
|
@timeout=120; \
|
||||||
|
until curl -sf http://localhost:8000/api/v1/health > /dev/null 2>&1; do \
|
||||||
|
sleep 5; timeout=$$((timeout-5)); \
|
||||||
|
if [ $$timeout -le 0 ]; then echo "Backend not healthy after 120s"; exit 1; fi; \
|
||||||
|
done
|
||||||
|
pip install -r e2e/requirements.txt -q
|
||||||
|
rfbrowser init
|
||||||
|
robot --outputdir e2e/results e2e/tests/
|
||||||
|
|
||||||
## One-command smoke test for the ban pipeline:
|
## One-command smoke test for the ban pipeline:
|
||||||
## 1. Start fail2ban, 2. write failure lines, 3. check ban status.
|
## 1. Start fail2ban, 2. write failure lines, 3. check ban status.
|
||||||
dev-ban-test:
|
dev-ban-test: ensure-env
|
||||||
$(COMPOSE) -f $(COMPOSE_FILE) up -d fail2ban
|
$(COMPOSE) $(COMPOSE_OPTS) up -d fail2ban
|
||||||
sleep 5
|
sleep 5
|
||||||
bash Docker/simulate_failed_logins.sh
|
bash Docker/simulate_failed_logins.sh
|
||||||
sleep 3
|
sleep 3
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ BANGUI_DATABASE_PATH=bangui.db
|
|||||||
# Path to the fail2ban Unix domain socket.
|
# Path to the fail2ban Unix domain socket.
|
||||||
BANGUI_FAIL2BAN_SOCKET=/var/run/fail2ban/fail2ban.sock
|
BANGUI_FAIL2BAN_SOCKET=/var/run/fail2ban/fail2ban.sock
|
||||||
|
|
||||||
|
# Path to the fail2ban configuration directory used by the web UI.
|
||||||
|
BANGUI_FAIL2BAN_CONFIG_DIR=/config/fail2ban
|
||||||
|
|
||||||
|
# Shell command used to start fail2ban during recovery operations.
|
||||||
|
BANGUI_FAIL2BAN_START_COMMAND=fail2ban-client start
|
||||||
|
|
||||||
# Secret key used to sign session tokens. Use a long, random string.
|
# Secret key used to sign session tokens. Use a long, random string.
|
||||||
# Generate with: python -c "import secrets; print(secrets.token_hex(64))"
|
# Generate with: python -c "import secrets; print(secrets.token_hex(64))"
|
||||||
BANGUI_SESSION_SECRET=replace-this-with-a-long-random-secret
|
BANGUI_SESSION_SECRET=replace-this-with-a-long-random-secret
|
||||||
@@ -20,3 +26,21 @@ BANGUI_TIMEZONE=UTC
|
|||||||
|
|
||||||
# Application log level: debug | info | warning | error | critical
|
# Application log level: debug | info | warning | error | critical
|
||||||
BANGUI_LOG_LEVEL=info
|
BANGUI_LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Comma-separated list of allowed CORS origins when the frontend is served
|
||||||
|
# from a different origin than the backend.
|
||||||
|
# Leave this blank in production when the UI is served from the same origin.
|
||||||
|
BANGUI_CORS_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pagination & display limits
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Maximum records per paginated response. Must be between 1 and 10000.
|
||||||
|
BANGUI_MAX_PAGE_SIZE=500
|
||||||
|
|
||||||
|
# Maximum IP lines returned in a blocklist source preview. Must be at least 1.
|
||||||
|
BANGUI_PREVIEW_MAX_LINES=100
|
||||||
|
|
||||||
|
# Number of days to retain historical ban records before archival cleanup.
|
||||||
|
BANGUI_HISTORY_RETENTION_DAYS=90
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
# Config File Service Extraction Summary
|
|
||||||
|
|
||||||
## ✓ Extraction Complete
|
|
||||||
|
|
||||||
Three new service modules have been created by extracting functions from `config_file_service.py`.
|
|
||||||
|
|
||||||
### Files Created
|
|
||||||
|
|
||||||
| File | Lines | Status |
|
|
||||||
|------|-------|--------|
|
|
||||||
| [jail_config_service.py](jail_config_service.py) | 991 | ✓ Created |
|
|
||||||
| [filter_config_service.py](filter_config_service.py) | 765 | ✓ Created |
|
|
||||||
| [action_config_service.py](action_config_service.py) | 988 | ✓ Created |
|
|
||||||
| **Total** | **2,744** | **✓ Verified** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. JAIL_CONFIG Service (`jail_config_service.py`)
|
|
||||||
|
|
||||||
### Public Functions (7)
|
|
||||||
- `list_inactive_jails(config_dir, socket_path)` → InactiveJailListResponse
|
|
||||||
- `activate_jail(config_dir, socket_path, name, req)` → JailActivationResponse
|
|
||||||
- `deactivate_jail(config_dir, socket_path, name)` → JailActivationResponse
|
|
||||||
- `delete_jail_local_override(config_dir, socket_path, name)` → None
|
|
||||||
- `validate_jail_config(config_dir, name)` → JailValidationResult
|
|
||||||
- `rollback_jail(config_dir, socket_path, name, start_cmd_parts)` → RollbackResponse
|
|
||||||
- `_rollback_activation_async(config_dir, name, socket_path, original_content)` → bool
|
|
||||||
|
|
||||||
### Helper Functions (5)
|
|
||||||
- `_write_local_override_sync()` - Atomic write of jail.d/{name}.local
|
|
||||||
- `_restore_local_file_sync()` - Restore or delete .local file during rollback
|
|
||||||
- `_validate_regex_patterns()` - Validate failregex/ignoreregex patterns
|
|
||||||
- `_set_jail_local_key_sync()` - Update single key in jail section
|
|
||||||
- `_validate_jail_config_sync()` - Synchronous validation (filter/action files, patterns, logpath)
|
|
||||||
|
|
||||||
### Custom Exceptions (3)
|
|
||||||
- `JailNotFoundInConfigError`
|
|
||||||
- `JailAlreadyActiveError`
|
|
||||||
- `JailAlreadyInactiveError`
|
|
||||||
|
|
||||||
### Shared Dependencies Imported
|
|
||||||
- `_safe_jail_name()` - From config_file_service
|
|
||||||
- `_parse_jails_sync()` - From config_file_service
|
|
||||||
- `_build_inactive_jail()` - From config_file_service
|
|
||||||
- `_get_active_jail_names()` - From config_file_service
|
|
||||||
- `_probe_fail2ban_running()` - From config_file_service
|
|
||||||
- `wait_for_fail2ban()` - From config_file_service
|
|
||||||
- `start_daemon()` - From config_file_service
|
|
||||||
- `_resolve_filter()` - From config_file_service
|
|
||||||
- `_parse_multiline()` - From config_file_service
|
|
||||||
- `_SOCKET_TIMEOUT`, `_META_SECTIONS` - Constants
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. FILTER_CONFIG Service (`filter_config_service.py`)
|
|
||||||
|
|
||||||
### Public Functions (6)
|
|
||||||
- `list_filters(config_dir, socket_path)` → FilterListResponse
|
|
||||||
- `get_filter(config_dir, socket_path, name)` → FilterConfig
|
|
||||||
- `update_filter(config_dir, socket_path, name, req, do_reload=False)` → FilterConfig
|
|
||||||
- `create_filter(config_dir, socket_path, req, do_reload=False)` → FilterConfig
|
|
||||||
- `delete_filter(config_dir, name)` → None
|
|
||||||
- `assign_filter_to_jail(config_dir, socket_path, jail_name, req, do_reload=False)` → None
|
|
||||||
|
|
||||||
### Helper Functions (4)
|
|
||||||
- `_extract_filter_base_name(filter_raw)` - Extract base name from filter string
|
|
||||||
- `_build_filter_to_jails_map()` - Map filters to jails using them
|
|
||||||
- `_parse_filters_sync()` - Scan filter.d/ and return tuples
|
|
||||||
- `_write_filter_local_sync()` - Atomic write of filter.d/{name}.local
|
|
||||||
- `_validate_regex_patterns()` - Validate regex patterns (shared with jail_config)
|
|
||||||
|
|
||||||
### Custom Exceptions (5)
|
|
||||||
- `FilterNotFoundError`
|
|
||||||
- `FilterAlreadyExistsError`
|
|
||||||
- `FilterReadonlyError`
|
|
||||||
- `FilterInvalidRegexError`
|
|
||||||
- `FilterNameError` (re-exported from config_file_service)
|
|
||||||
|
|
||||||
### Shared Dependencies Imported
|
|
||||||
- `_safe_filter_name()` - From config_file_service
|
|
||||||
- `_safe_jail_name()` - From config_file_service
|
|
||||||
- `_parse_jails_sync()` - From config_file_service
|
|
||||||
- `_get_active_jail_names()` - From config_file_service
|
|
||||||
- `_resolve_filter()` - From config_file_service
|
|
||||||
- `_parse_multiline()` - From config_file_service
|
|
||||||
- `_SAFE_FILTER_NAME_RE` - Constant pattern
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. ACTION_CONFIG Service (`action_config_service.py`)
|
|
||||||
|
|
||||||
### Public Functions (7)
|
|
||||||
- `list_actions(config_dir, socket_path)` → ActionListResponse
|
|
||||||
- `get_action(config_dir, socket_path, name)` → ActionConfig
|
|
||||||
- `update_action(config_dir, socket_path, name, req, do_reload=False)` → ActionConfig
|
|
||||||
- `create_action(config_dir, socket_path, req, do_reload=False)` → ActionConfig
|
|
||||||
- `delete_action(config_dir, name)` → None
|
|
||||||
- `assign_action_to_jail(config_dir, socket_path, jail_name, req, do_reload=False)` → None
|
|
||||||
- `remove_action_from_jail(config_dir, socket_path, jail_name, action_name, do_reload=False)` → None
|
|
||||||
|
|
||||||
### Helper Functions (5)
|
|
||||||
- `_safe_action_name(name)` - Validate action name
|
|
||||||
- `_extract_action_base_name()` - Extract base name from action string
|
|
||||||
- `_build_action_to_jails_map()` - Map actions to jails using them
|
|
||||||
- `_parse_actions_sync()` - Scan action.d/ and return tuples
|
|
||||||
- `_append_jail_action_sync()` - Append action to jail.d/{name}.local
|
|
||||||
- `_remove_jail_action_sync()` - Remove action from jail.d/{name}.local
|
|
||||||
- `_write_action_local_sync()` - Atomic write of action.d/{name}.local
|
|
||||||
|
|
||||||
### Custom Exceptions (4)
|
|
||||||
- `ActionNotFoundError`
|
|
||||||
- `ActionAlreadyExistsError`
|
|
||||||
- `ActionReadonlyError`
|
|
||||||
- `ActionNameError`
|
|
||||||
|
|
||||||
### Shared Dependencies Imported
|
|
||||||
- `_safe_jail_name()` - From config_file_service
|
|
||||||
- `_parse_jails_sync()` - From config_file_service
|
|
||||||
- `_get_active_jail_names()` - From config_file_service
|
|
||||||
- `_build_parser()` - From config_file_service
|
|
||||||
- `_SAFE_ACTION_NAME_RE` - Constant pattern
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. SHARED Utilities (remain in `config_file_service.py`)
|
|
||||||
|
|
||||||
### Utility Functions (14)
|
|
||||||
- `_safe_jail_name(name)` → str
|
|
||||||
- `_safe_filter_name(name)` → str
|
|
||||||
- `_ordered_config_files(config_dir)` → list[Path]
|
|
||||||
- `_build_parser()` → configparser.RawConfigParser
|
|
||||||
- `_is_truthy(value)` → bool
|
|
||||||
- `_parse_int_safe(value)` → int | None
|
|
||||||
- `_parse_time_to_seconds(value, default)` → int
|
|
||||||
- `_parse_multiline(raw)` → list[str]
|
|
||||||
- `_resolve_filter(raw_filter, jail_name, mode)` → str
|
|
||||||
- `_parse_jails_sync(config_dir)` → tuple
|
|
||||||
- `_build_inactive_jail(name, settings, source_file, config_dir=None)` → InactiveJail
|
|
||||||
- `_get_active_jail_names(socket_path)` → set[str]
|
|
||||||
- `_probe_fail2ban_running(socket_path)` → bool
|
|
||||||
- `wait_for_fail2ban(socket_path, max_wait_seconds, poll_interval)` → bool
|
|
||||||
- `start_daemon(start_cmd_parts)` → bool
|
|
||||||
|
|
||||||
### Shared Exceptions (3)
|
|
||||||
- `JailNameError`
|
|
||||||
- `FilterNameError`
|
|
||||||
- `ConfigWriteError`
|
|
||||||
|
|
||||||
### Constants (7)
|
|
||||||
- `_SOCKET_TIMEOUT`
|
|
||||||
- `_SAFE_JAIL_NAME_RE`
|
|
||||||
- `_META_SECTIONS`
|
|
||||||
- `_TRUE_VALUES`
|
|
||||||
- `_FALSE_VALUES`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Import Dependencies
|
|
||||||
|
|
||||||
### jail_config_service imports:
|
|
||||||
```python
|
|
||||||
config_file_service: (shared utilities + private functions)
|
|
||||||
jail_service.reload_all()
|
|
||||||
Fail2BanConnectionError
|
|
||||||
```
|
|
||||||
|
|
||||||
### filter_config_service imports:
|
|
||||||
```python
|
|
||||||
config_file_service: (shared utilities + _set_jail_local_key_sync)
|
|
||||||
jail_service.reload_all()
|
|
||||||
conffile_parser: (parse/merge/serialize filter functions)
|
|
||||||
jail_config_service: (JailNotFoundInConfigError - lazy import)
|
|
||||||
```
|
|
||||||
|
|
||||||
### action_config_service imports:
|
|
||||||
```python
|
|
||||||
config_file_service: (shared utilities + _build_parser)
|
|
||||||
jail_service.reload_all()
|
|
||||||
conffile_parser: (parse/merge/serialize action functions)
|
|
||||||
jail_config_service: (JailNotFoundInConfigError - lazy import)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cross-Service Dependencies
|
|
||||||
|
|
||||||
**Circular imports handled via lazy imports:**
|
|
||||||
- `filter_config_service` imports `JailNotFoundInConfigError` from `jail_config_service` inside function
|
|
||||||
- `action_config_service` imports `JailNotFoundInConfigError` from `jail_config_service` inside function
|
|
||||||
|
|
||||||
**Shared functions re-used:**
|
|
||||||
- `_set_jail_local_key_sync()` exported from `jail_config_service`, used by `filter_config_service`
|
|
||||||
- `_append_jail_action_sync()` and `_remove_jail_action_sync()` internal to `action_config_service`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification Results
|
|
||||||
|
|
||||||
✓ **Syntax Check:** All three files compile without errors
|
|
||||||
✓ **Import Verification:** All imports resolved correctly
|
|
||||||
✓ **Total Lines:** 2,744 lines across three new files
|
|
||||||
✓ **Function Coverage:** 100% of specified functions extracted
|
|
||||||
✓ **Type Hints:** Preserved throughout
|
|
||||||
✓ **Docstrings:** All preserved with full documentation
|
|
||||||
✓ **Comments:** All inline comments preserved
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps (if needed)
|
|
||||||
|
|
||||||
1. **Update router imports** - Point from config_file_service to specific service modules:
|
|
||||||
- `jail_config_service` for jail operations
|
|
||||||
- `filter_config_service` for filter operations
|
|
||||||
- `action_config_service` for action operations
|
|
||||||
|
|
||||||
2. **Update config_file_service.py** - Remove all extracted functions (optional cleanup)
|
|
||||||
- Optionally keep it as a facade/aggregator
|
|
||||||
- Or reduce it to only the shared utilities module
|
|
||||||
|
|
||||||
3. **Add __all__ exports** to each new module for cleaner public API
|
|
||||||
|
|
||||||
4. **Update type hints** in models if needed for cross-service usage
|
|
||||||
|
|
||||||
5. **Testing** - Run existing tests to ensure no regressions
|
|
||||||
@@ -4,9 +4,21 @@ Follows pydantic-settings patterns: all values are prefixed with BANGUI_
|
|||||||
and validated at startup via the Settings singleton.
|
and validated at startup via the Settings singleton.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pydantic import Field
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import Field, field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
from app.utils.constants import (
|
||||||
|
DEFAULT_DATABASE_PATH,
|
||||||
|
DEFAULT_FAIL2BAN_SOCKET,
|
||||||
|
DEFAULT_SESSION_DURATION_MINUTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
"""BanGUI runtime configuration.
|
"""BanGUI runtime configuration.
|
||||||
@@ -18,38 +30,287 @@ class Settings(BaseSettings):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
database_path: str = Field(
|
database_path: str = Field(
|
||||||
default="bangui.db",
|
default=DEFAULT_DATABASE_PATH,
|
||||||
description="Filesystem path to the BanGUI SQLite application database.",
|
description="Filesystem path to the BanGUI SQLite application database.",
|
||||||
)
|
)
|
||||||
fail2ban_socket: str = Field(
|
fail2ban_socket: str = Field(
|
||||||
default="/var/run/fail2ban/fail2ban.sock",
|
default=DEFAULT_FAIL2BAN_SOCKET,
|
||||||
description="Path to the fail2ban Unix domain socket.",
|
description="Path to the fail2ban Unix domain socket.",
|
||||||
)
|
)
|
||||||
session_secret: str = Field(
|
session_secret: str = Field(
|
||||||
...,
|
...,
|
||||||
|
min_length=32,
|
||||||
description=(
|
description=(
|
||||||
"Secret key used when generating session tokens. "
|
"Secret key used when generating session tokens. "
|
||||||
"Must be unique and never committed to source control."
|
"Must be at least 32 characters. "
|
||||||
|
"Must be unique and never committed to source control. "
|
||||||
|
"Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\""
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session_secret_previous: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Previous session secret for rotation support. "
|
||||||
|
"Set this to the old secret during a rotation to accept tokens signed "
|
||||||
|
"with either the current or previous secret. Tokens valid with the "
|
||||||
|
"previous secret will be re-signed with the current secret. "
|
||||||
|
"After all old tokens have expired, unset this field to disable rotation."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
session_duration_minutes: int = Field(
|
session_duration_minutes: int = Field(
|
||||||
default=60,
|
default=DEFAULT_SESSION_DURATION_MINUTES,
|
||||||
ge=1,
|
ge=1,
|
||||||
description="Number of minutes a session token remains valid after creation.",
|
description="Number of minutes a session token remains valid after creation.",
|
||||||
)
|
)
|
||||||
|
session_cache_enabled: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"Enable the in-memory session validation cache. "
|
||||||
|
"Disable it in multi-worker deployments to avoid stale revoked sessions."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session_cache_ttl_seconds: float = Field(
|
||||||
|
default=10.0,
|
||||||
|
ge=0.0,
|
||||||
|
description=(
|
||||||
|
"How long (seconds) a cached session validation entry remains fresh. "
|
||||||
|
"Ignored when session_cache_enabled is false."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
http_request_timeout_seconds: float = Field(
|
||||||
|
default=20.0,
|
||||||
|
ge=0.0,
|
||||||
|
description="Maximum total time in seconds for outbound external HTTP requests.",
|
||||||
|
)
|
||||||
|
http_connect_timeout_seconds: float = Field(
|
||||||
|
default=5.0,
|
||||||
|
ge=0.0,
|
||||||
|
description="Maximum time in seconds to establish outbound external HTTP connections.",
|
||||||
|
)
|
||||||
|
http_max_connections: int = Field(
|
||||||
|
default=10,
|
||||||
|
ge=1,
|
||||||
|
description="Maximum number of concurrent outbound HTTP connections.",
|
||||||
|
)
|
||||||
|
http_keepalive_timeout_seconds: float = Field(
|
||||||
|
default=15.0,
|
||||||
|
ge=0.0,
|
||||||
|
description="How long idle keepalive connections are retained by the HTTP connector.",
|
||||||
|
)
|
||||||
timezone: str = Field(
|
timezone: str = Field(
|
||||||
default="UTC",
|
default="UTC",
|
||||||
description="IANA timezone name used when displaying timestamps in the UI.",
|
description="IANA timezone name used when displaying timestamps in the UI.",
|
||||||
)
|
)
|
||||||
|
session_cookie_httponly: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description=(
|
||||||
|
"Mark the session cookie as HttpOnly so browser scripts cannot access it."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session_cookie_samesite: Literal["lax", "strict", "none"] = Field(
|
||||||
|
default="lax",
|
||||||
|
description=(
|
||||||
|
"SameSite policy for the session cookie. "
|
||||||
|
"Use 'lax', 'strict', or 'none' depending on deployment requirements."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session_cookie_secure: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description=(
|
||||||
|
"Set the session cookie Secure flag when the backend is served over HTTPS. "
|
||||||
|
"Defaults to True for security. Set to False only for local development over HTTP."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
cors_allowed_origins: str | list[str] = Field(
|
||||||
|
default_factory=lambda: [
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
"https://localhost:5173",
|
||||||
|
"https://127.0.0.1:5173",
|
||||||
|
],
|
||||||
|
description=(
|
||||||
|
"Comma-separated list of allowed CORS origins when the frontend is "
|
||||||
|
"served from a different origin than the backend. "
|
||||||
|
"Defaults to common localhost development origins. "
|
||||||
|
"Override in production with the specific frontend domain."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@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")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_cors_origins(cls, value: str | list[str] | None) -> list[str]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [origin.strip() for origin in value.split(",") if origin.strip()]
|
||||||
|
return value
|
||||||
|
|
||||||
log_level: str = Field(
|
log_level: str = Field(
|
||||||
default="info",
|
default="info",
|
||||||
description="Application log level: debug | info | warning | error | critical.",
|
description="Application log level: debug | info | warning | error | critical.",
|
||||||
)
|
)
|
||||||
|
log_file: str | None = Field(
|
||||||
|
default="/data/log/bangui.log",
|
||||||
|
description="Optional file path for writing application logs. Set to null to disable file logging.",
|
||||||
|
)
|
||||||
|
suppress_third_party_logs: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description=(
|
||||||
|
"When true, sets APScheduler and aiosqlite loggers to WARNING level. "
|
||||||
|
"Set to false to allow third-party libraries to emit DEBUG/INFO logs."
|
||||||
|
),
|
||||||
|
)
|
||||||
geoip_db_path: str | None = Field(
|
geoip_db_path: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description=(
|
description=(
|
||||||
"Optional path to a MaxMind GeoLite2-Country .mmdb file. "
|
"Optional path to a MaxMind GeoLite2-Country .mmdb file. "
|
||||||
"When set, failed ip-api.com lookups fall back to local resolution."
|
"When set, it is used as the primary resolver for IP geolocation. "
|
||||||
|
"The ip-api.com HTTP API is only used as a fallback when the MMDB is unavailable or returns no result."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
geoip_allow_http_fallback: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"Allow fallback to ip-api.com HTTP API when the MaxMind database is unavailable. "
|
||||||
|
"WARNING: Enabling this sends unencrypted IP addresses over HTTP. "
|
||||||
|
"Only use this flag when the MMDB cannot be mounted and you understand the security implications. "
|
||||||
|
"Default is False (only use local MMDB, fail if unavailable)."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
fail2ban_config_dir: str = Field(
|
fail2ban_config_dir: str = Field(
|
||||||
@@ -60,21 +321,293 @@ class Settings(BaseSettings):
|
|||||||
"Used for listing, viewing, and editing configuration files through the web UI."
|
"Used for listing, viewing, and editing configuration files through the web UI."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
allowed_log_dirs: list[str] = Field(
|
||||||
|
default_factory=lambda: ["/var/log", "/config/log"],
|
||||||
|
description=(
|
||||||
|
"List of allowed directory prefixes for jail log paths. "
|
||||||
|
"Any log path added must resolve to a path within one of these directories. "
|
||||||
|
"Use absolute paths. Symlinks are resolved before validation."
|
||||||
|
),
|
||||||
|
)
|
||||||
fail2ban_start_command: str = Field(
|
fail2ban_start_command: str = Field(
|
||||||
default="fail2ban-client start",
|
default="fail2ban-client start",
|
||||||
description=(
|
description=(
|
||||||
"Shell command used to start (not reload) the fail2ban daemon during "
|
"Shell command used to start (not reload) the fail2ban daemon during "
|
||||||
"recovery rollback. Split by whitespace to build the argument list — "
|
"recovery rollback. Split by whitespace to build the argument list — "
|
||||||
"no shell interpretation is performed. "
|
"no shell interpretation is performed. "
|
||||||
"Example: 'systemctl start fail2ban' or 'fail2ban-client start'."
|
"Example: 'systemctl start fail2ban' or 'fail2ban-client start'."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
enable_docs: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"Enable FastAPI interactive API documentation at /api/docs (Swagger UI) "
|
||||||
|
"and /api/redoc (ReDoc). Should be true only in development environments. "
|
||||||
|
"In production, leave unset (defaults to false) to avoid exposing API schema."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
trusted_proxies: str | list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description=(
|
||||||
|
"Comma-separated list of trusted reverse proxy IP addresses or CIDR ranges. "
|
||||||
|
"Only requests from these IPs/ranges are allowed to set X-Forwarded-For and X-Real-IP headers. "
|
||||||
|
"Examples: '192.168.1.1' or '10.0.0.0/8' or '192.168.1.1,10.0.0.0/8'. "
|
||||||
|
"Leave empty to disable proxy header forwarding (default). "
|
||||||
|
"This is critical for correct client IP extraction behind reverse proxies like nginx."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("trusted_proxies", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_trusted_proxies(cls, value: str | list[str] | None) -> list[str]:
|
||||||
|
"""Normalize trusted_proxies from comma-separated string to list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: A comma-separated string or list of trusted proxy IPs/CIDRs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of normalized proxy IP/CIDR strings.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [proxy.strip() for proxy in value.split(",") if proxy.strip()]
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("trusted_proxies", mode="after")
|
||||||
|
@classmethod
|
||||||
|
def _validate_trusted_proxies(cls, value: list[str]) -> list[str]:
|
||||||
|
"""Validate trusted_proxies as valid IPs or CIDR ranges.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: A list of proxy IP addresses or CIDR ranges.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated list.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If any item is not a valid IP address or CIDR range.
|
||||||
|
"""
|
||||||
|
for proxy in value:
|
||||||
|
try:
|
||||||
|
# Try to parse as a CIDR network first
|
||||||
|
ipaddress.ip_network(proxy, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
# Fall back to parsing as a single IP address
|
||||||
|
ipaddress.ip_address(proxy)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid IP address or CIDR range: {proxy!r}. "
|
||||||
|
f"Expected format: '192.168.1.1' or '10.0.0.0/8'"
|
||||||
|
) from exc
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("fail2ban_start_command", mode="after")
|
||||||
|
@classmethod
|
||||||
|
def _validate_fail2ban_start_command(cls, value: str) -> str:
|
||||||
|
"""Validate fail2ban_start_command by attempting to parse it with shlex.
|
||||||
|
|
||||||
|
Ensures the command can be split into arguments without shell interpretation.
|
||||||
|
Raises ValueError if the command contains mismatched quotes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: The fail2ban start command string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated command string.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the command contains mismatched quotes.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
shlex.split(value)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"fail2ban_start_command contains mismatched quotes or is otherwise "
|
||||||
|
f"unparseable: {value!r} — {e}"
|
||||||
|
) from e
|
||||||
|
return value
|
||||||
|
|
||||||
|
external_logging_enabled: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"Enable sending logs to an external centralized logging platform. "
|
||||||
|
"When disabled (default), logs are written to stdout only. "
|
||||||
|
"When enabled, set external_logging_provider and provider-specific settings."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
external_logging_provider: Literal["datadog", "papertrail", "elasticsearch"] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"External logging platform provider. "
|
||||||
|
"Set to 'datadog', 'papertrail', or 'elasticsearch'. "
|
||||||
|
"Only used when external_logging_enabled is true."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
external_logging_buffer_size: int = Field(
|
||||||
|
default=1000,
|
||||||
|
ge=10,
|
||||||
|
description=(
|
||||||
|
"Maximum number of log records to buffer in memory before dropping oldest logs. "
|
||||||
|
"Prevents unbounded memory growth if the external system is temporarily unavailable."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
external_logging_flush_interval_seconds: float = Field(
|
||||||
|
default=5.0,
|
||||||
|
gt=0.0,
|
||||||
|
description=(
|
||||||
|
"Maximum time in seconds to buffer logs before sending to the external system. "
|
||||||
|
"Logs are sent earlier if the batch size is reached."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
external_log_required: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"When enabled and external logging is configured, startup aborts if the "
|
||||||
|
"external log handler fails to initialize. When disabled (default), a failed "
|
||||||
|
"handler is treated as a warning and the application continues without external "
|
||||||
|
"logging. Set to true in production environments where logs must reach the "
|
||||||
|
"monitoring system."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
datadog_api_key: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Datadog API key for sending logs. Required when external_logging_provider is 'datadog'. "
|
||||||
|
"Obtain from Datadog organization settings."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
datadog_site: str = Field(
|
||||||
|
default="datadoghq.com",
|
||||||
|
description=(
|
||||||
|
"Datadog site: 'datadoghq.com' for US or 'datadoghq.eu' for EU. "
|
||||||
|
"Only used when external_logging_provider is 'datadog'."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
datadog_batch_size: int = Field(
|
||||||
|
default=10,
|
||||||
|
ge=1,
|
||||||
|
description=(
|
||||||
|
"Number of log records to batch before sending to Datadog. "
|
||||||
|
"Smaller batches send logs faster; larger batches are more efficient."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
papertrail_host: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Papertrail host address (e.g., 'logs1.papertrailapp.com'). "
|
||||||
|
"Required when external_logging_provider is 'papertrail'."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
papertrail_port: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
ge=1,
|
||||||
|
le=65535,
|
||||||
|
description=(
|
||||||
|
"Papertrail port number. Required when external_logging_provider is 'papertrail'. "
|
||||||
|
"Typically 12345 or in range 10000-32768."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
papertrail_program_name: str = Field(
|
||||||
|
default="bangui",
|
||||||
|
description=(
|
||||||
|
"Program name to include in Syslog messages sent to Papertrail. "
|
||||||
|
"Useful for filtering logs by program in Papertrail UI."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
elasticsearch_hosts: str | list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description=(
|
||||||
|
"Elasticsearch host addresses. Can be comma-separated string or list. "
|
||||||
|
"Examples: 'http://elasticsearch:9200' or 'http://es1:9200,http://es2:9200'. "
|
||||||
|
"Required when external_logging_provider is 'elasticsearch'."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
elasticsearch_index_prefix: str = Field(
|
||||||
|
default="bangui",
|
||||||
|
description=(
|
||||||
|
"Prefix for Elasticsearch indices where logs are stored. "
|
||||||
|
"Final index names will be '{prefix}-{date}' or similar."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
elasticsearch_batch_size: int = Field(
|
||||||
|
default=10,
|
||||||
|
ge=1,
|
||||||
|
description=(
|
||||||
|
"Number of log documents to batch before sending to Elasticsearch. "
|
||||||
|
"Larger batches are more efficient but introduce slight latency."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Rate limit configuration (per IP)
|
||||||
|
rate_limit_bans_per_minute: int = Field(
|
||||||
|
default=100,
|
||||||
|
ge=1,
|
||||||
|
description="Max ban/unban requests per IP per minute.",
|
||||||
|
)
|
||||||
|
rate_limit_blocklist_import_per_hour: int = Field(
|
||||||
|
default=10,
|
||||||
|
ge=1,
|
||||||
|
description="Max blocklist import requests per IP per hour.",
|
||||||
|
)
|
||||||
|
rate_limit_config_update_per_minute: int = Field(
|
||||||
|
default=50,
|
||||||
|
ge=1,
|
||||||
|
description="Max config update requests per IP per minute.",
|
||||||
|
)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Pagination & display limits (configurable per deployment)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
max_page_size: int = Field(
|
||||||
|
default=500,
|
||||||
|
ge=1,
|
||||||
|
le=10000,
|
||||||
|
description=(
|
||||||
|
"Maximum number of records returned per paginated API response. "
|
||||||
|
"Individual endpoints may further limit this value. "
|
||||||
|
"Must be between 1 and 10000."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
blocklist_preview_max_lines: int = Field(
|
||||||
|
default=100,
|
||||||
|
ge=1,
|
||||||
|
description=(
|
||||||
|
"Maximum number of IP lines returned in a blocklist source preview. "
|
||||||
|
"Must be at least 1."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
history_retention_days: int = Field(
|
||||||
|
default=90,
|
||||||
|
ge=1,
|
||||||
|
description=(
|
||||||
|
"Number of days historical ban records are retained before being "
|
||||||
|
"archived or purged by the cleanup task. Must be at least 1."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("elasticsearch_hosts", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_elasticsearch_hosts(cls, value: str | list[str] | None) -> list[str]:
|
||||||
|
"""Normalize elasticsearch_hosts from comma-separated string to list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: A comma-separated string or list of host URLs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of normalized host URLs.
|
||||||
|
"""
|
||||||
|
if value is None or (isinstance(value, list) and len(value) == 0):
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [host.strip() for host in value.split(",") if host.strip()]
|
||||||
|
return value
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_prefix="BANGUI_",
|
env_prefix="BANGUI_",
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
case_sensitive=False,
|
case_sensitive=False,
|
||||||
|
extra="ignore",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -85,4 +618,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()
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ The fail2ban database is separate and is accessed read-only by the history
|
|||||||
and ban services.
|
and ban services.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import aiosqlite
|
from __future__ import annotations
|
||||||
import structlog
|
|
||||||
|
|
||||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
from app.utils.logging_compat import get_logger
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# DDL statements
|
# DDL statements
|
||||||
@@ -30,15 +35,15 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
|
|
||||||
_CREATE_SESSIONS: str = """
|
_CREATE_SESSIONS: str = """
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
token TEXT NOT NULL UNIQUE,
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
expires_at TEXT NOT NULL
|
expires_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_CREATE_SESSIONS_TOKEN_INDEX: str = """
|
_CREATE_SESSIONS_TOKEN_INDEX: str = """
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_token ON sessions (token);
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions (token_hash);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_CREATE_BLOCKLIST_SOURCES: str = """
|
_CREATE_BLOCKLIST_SOURCES: str = """
|
||||||
@@ -55,9 +60,9 @@ CREATE TABLE IF NOT EXISTS blocklist_sources (
|
|||||||
_CREATE_IMPORT_LOG: str = """
|
_CREATE_IMPORT_LOG: str = """
|
||||||
CREATE TABLE IF NOT EXISTS import_log (
|
CREATE TABLE IF NOT EXISTS import_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
source_id INTEGER REFERENCES blocklist_sources(id) ON DELETE SET NULL,
|
source_id INTEGER REFERENCES blocklist_sources(id) ON DELETE RESTRICT,
|
||||||
source_url TEXT NOT NULL,
|
source_url TEXT NOT NULL,
|
||||||
timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
timestamp INTEGER NOT NULL,
|
||||||
ips_imported INTEGER NOT NULL DEFAULT 0,
|
ips_imported INTEGER NOT NULL DEFAULT 0,
|
||||||
ips_skipped INTEGER NOT NULL DEFAULT 0,
|
ips_skipped INTEGER NOT NULL DEFAULT 0,
|
||||||
errors TEXT
|
errors TEXT
|
||||||
@@ -75,24 +80,385 @@ CREATE TABLE IF NOT EXISTS geo_cache (
|
|||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_CREATE_HISTORY_ARCHIVE: str = """
|
||||||
|
CREATE TABLE IF NOT EXISTS history_archive (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
jail TEXT NOT NULL,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
timeofban INTEGER NOT NULL,
|
||||||
|
bancount INTEGER NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL CHECK(action IN ('ban', 'unban')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
UNIQUE(ip, jail, action, timeofban)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CREATE_SCHEMA_MIGRATIONS: str = """
|
||||||
|
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'))
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
# Ordered list of DDL statements to execute on initialisation.
|
# Ordered list of DDL statements to execute on initialisation.
|
||||||
|
# NOTE: _CREATE_SESSIONS_TOKEN_INDEX is intentionally omitted here.
|
||||||
|
# The old 0.8.0 schema has a `sessions.token` column (not `token_hash`), so
|
||||||
|
# running CREATE INDEX … ON sessions (token_hash) in migration 1 would fail
|
||||||
|
# with "no such column: token_hash" on legacy databases. Migration 2 drops
|
||||||
|
# and recreates the sessions table with token_hash and also creates the index,
|
||||||
|
# so there is no need to create it in migration 1.
|
||||||
_SCHEMA_STATEMENTS: list[str] = [
|
_SCHEMA_STATEMENTS: list[str] = [
|
||||||
_CREATE_SETTINGS,
|
_CREATE_SETTINGS,
|
||||||
_CREATE_SESSIONS,
|
_CREATE_SESSIONS,
|
||||||
_CREATE_SESSIONS_TOKEN_INDEX,
|
|
||||||
_CREATE_BLOCKLIST_SOURCES,
|
_CREATE_BLOCKLIST_SOURCES,
|
||||||
_CREATE_IMPORT_LOG,
|
_CREATE_IMPORT_LOG,
|
||||||
_CREATE_GEO_CACHE,
|
_CREATE_GEO_CACHE,
|
||||||
|
_CREATE_HISTORY_ARCHIVE,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
_CURRENT_SCHEMA_VERSION: int = 9
|
||||||
|
|
||||||
|
_MIGRATIONS: dict[int, str] = {
|
||||||
|
1: "\n".join(_SCHEMA_STATEMENTS),
|
||||||
|
2: """
|
||||||
|
-- Migration 2: Hash session tokens for security.
|
||||||
|
-- Drop the old sessions table and recreate with token_hash column.
|
||||||
|
-- This invalidates all existing sessions, which is acceptable as the DB
|
||||||
|
-- contents were exposed in plaintext.
|
||||||
|
DROP TABLE IF EXISTS sessions;
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX idx_sessions_token_hash ON sessions (token_hash);
|
||||||
|
""",
|
||||||
|
3: """
|
||||||
|
-- Migration 3: Add last_seen timestamp to geo_cache for retention policy.
|
||||||
|
-- Tracks when each IP was last referenced to enable purging of stale entries.
|
||||||
|
-- SQLite rejects ALTER TABLE ADD COLUMN with a non-constant NOT NULL default
|
||||||
|
-- when the table already contains rows, so we rebuild the table instead.
|
||||||
|
-- Existing rows receive last_seen = cached_at as a reasonable approximation
|
||||||
|
-- (the IP was at least seen when it was first cached).
|
||||||
|
DROP TABLE IF EXISTS geo_cache_new;
|
||||||
|
CREATE TABLE geo_cache_new (
|
||||||
|
ip TEXT PRIMARY KEY,
|
||||||
|
country_code TEXT,
|
||||||
|
country_name TEXT,
|
||||||
|
asn TEXT,
|
||||||
|
org TEXT,
|
||||||
|
cached_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
INSERT INTO geo_cache_new (ip, country_code, country_name, asn, org, cached_at, last_seen)
|
||||||
|
SELECT ip, country_code, country_name, asn, org, cached_at, cached_at FROM geo_cache;
|
||||||
|
DROP TABLE geo_cache;
|
||||||
|
ALTER TABLE geo_cache_new RENAME TO geo_cache;
|
||||||
|
""",
|
||||||
|
4: """
|
||||||
|
-- Migration 4: Add scheduler_lock table for multi-worker safety.
|
||||||
|
-- Implements database-backed locking to ensure only one worker runs the scheduler.
|
||||||
|
-- Uses atomic transactions to prevent race conditions in container orchestration.
|
||||||
|
-- Lock is held by the process that successfully inserts the singleton row (id=1).
|
||||||
|
CREATE TABLE scheduler_lock (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
pid INTEGER NOT NULL,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
heartbeat_at REAL NOT NULL,
|
||||||
|
heartbeat_timeout REAL NOT NULL DEFAULT 300
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
5: """
|
||||||
|
-- Migration 5: Add indexes to history_archive table for query performance.
|
||||||
|
-- The history_archive table supports filtering by jail, IP, action, and time range,
|
||||||
|
-- combined with pagination (ORDER BY timeofban DESC LIMIT/OFFSET).
|
||||||
|
-- These indexes accelerate common dashboard and API queries.
|
||||||
|
-- See Docs/Backend-Development.md § Database Performance for details.
|
||||||
|
|
||||||
|
-- Composite index for common queries: jail + timeofban ordering (dashboard filter).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_archive_jail_timeofban
|
||||||
|
ON history_archive (jail, timeofban DESC);
|
||||||
|
|
||||||
|
-- Composite index for time-range + jail queries (history timeline filters).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_archive_timeofban_jail_action
|
||||||
|
ON history_archive (timeofban DESC, jail, action);
|
||||||
|
|
||||||
|
-- Index for single-column filters: supports IP prefix searches and exact matches.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_archive_ip
|
||||||
|
ON history_archive (ip);
|
||||||
|
|
||||||
|
-- Index for action-based queries: supports ban/unban filtering.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_archive_action
|
||||||
|
ON history_archive (action);
|
||||||
|
""",
|
||||||
|
6: """
|
||||||
|
-- Migration 6: Add import_runs table for tracking blocklist import idempotency.
|
||||||
|
-- Tracks unique imports by source and content hash to enable idempotent retries.
|
||||||
|
-- On import crash, retry will detect the operation_id and skip duplicate bans.
|
||||||
|
-- This prevents duplicate IP bans if the scheduler retries after a failure.
|
||||||
|
CREATE TABLE IF NOT EXISTS import_runs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source_id INTEGER NOT NULL REFERENCES blocklist_sources(id) ON DELETE CASCADE,
|
||||||
|
content_hash TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('pending', 'completed', 'failed')),
|
||||||
|
imported_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
skipped_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
UNIQUE(source_id, content_hash)
|
||||||
|
);
|
||||||
|
-- Index for looking up completed imports by source
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_import_runs_source_status
|
||||||
|
ON import_runs (source_id, status);
|
||||||
|
""",
|
||||||
|
7: """
|
||||||
|
-- Migration 7: Add indexes to import_log table for cursor-based pagination.
|
||||||
|
-- The import_log table is paginated by id (newest first) and filtered by source_id.
|
||||||
|
-- These indexes accelerate pagination queries and maintain consistent ordering.
|
||||||
|
-- See Docs/Backend-Development.md § Database Performance for details.
|
||||||
|
|
||||||
|
-- Index for ordering by id DESC for cursor-based pagination (newest first)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_import_log_id_desc
|
||||||
|
ON import_log (id DESC);
|
||||||
|
|
||||||
|
-- Composite index for source_id + id DESC ordering (filtered pagination)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_import_log_source_id_desc
|
||||||
|
ON import_log (source_id, id DESC);
|
||||||
|
""",
|
||||||
|
8: """
|
||||||
|
-- Migration 8: Migrate import_log.timestamp from TEXT ISO 8601 to INTEGER UNIX epoch.
|
||||||
|
-- Standardizes all BanGUI timestamps on INTEGER UNIX (seconds since epoch).
|
||||||
|
-- This aligns import_log with history_archive which already uses INTEGER timeofban.
|
||||||
|
-- TEXT ISO 8601: "2024-06-15T13:45:00.000Z"
|
||||||
|
-- INTEGER UNIX: 1718453100
|
||||||
|
ALTER TABLE import_log ADD COLUMN timestamp_unix INTEGER;
|
||||||
|
UPDATE import_log SET timestamp_unix = strftime('%s', timestamp);
|
||||||
|
ALTER TABLE import_log DROP COLUMN timestamp;
|
||||||
|
ALTER TABLE import_log RENAME COLUMN timestamp_unix TO timestamp;
|
||||||
|
""",
|
||||||
|
9: """
|
||||||
|
-- Migration 9: Change import_log.source_id foreign key to ON DELETE RESTRICT.
|
||||||
|
-- Previously, deleting a blocklist source set source_id to NULL, leaving orphaned
|
||||||
|
-- log records with populated URL but NULL source_id (meaningless/useless data).
|
||||||
|
-- Now, RESTRICT prevents source deletion if import logs exist, preserving data
|
||||||
|
-- integrity. Admin must delete logs before deleting source.
|
||||||
|
-- See Issue #11: Foreign Key ON DELETE Semantics Problem.
|
||||||
|
DROP INDEX IF EXISTS idx_import_log_source_id_desc;
|
||||||
|
DROP TABLE IF EXISTS _import_log_backup;
|
||||||
|
CREATE TABLE _import_log_backup (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source_id INTEGER REFERENCES blocklist_sources(id) ON DELETE RESTRICT,
|
||||||
|
source_url TEXT NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
ips_imported INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ips_skipped INTEGER NOT NULL DEFAULT 0,
|
||||||
|
errors TEXT
|
||||||
|
);
|
||||||
|
INSERT INTO _import_log_backup (id, source_id, source_url, timestamp, ips_imported, ips_skipped, errors)
|
||||||
|
SELECT id, source_id, source_url, timestamp, ips_imported, ips_skipped, errors FROM import_log;
|
||||||
|
DROP TABLE import_log;
|
||||||
|
ALTER TABLE _import_log_backup RENAME TO import_log;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_import_log_source_id_desc
|
||||||
|
ON import_log (source_id, id DESC);
|
||||||
|
""",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Public API
|
# Public API
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _configure_connection(db: aiosqlite.Connection) -> None:
|
||||||
|
"""Apply hardening pragmas to a newly-opened SQLite connection."""
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL;")
|
||||||
|
await db.execute("PRAGMA foreign_keys=ON;")
|
||||||
|
await db.execute("PRAGMA busy_timeout=5000;")
|
||||||
|
|
||||||
|
|
||||||
|
async def _cleanup_wal_files(db_path: str) -> None:
|
||||||
|
"""Remove orphaned WAL files after crashes.
|
||||||
|
|
||||||
|
When SQLite crashes in WAL mode, it may leave behind stale .wal and .shm
|
||||||
|
files that prevent the database from opening properly. This function removes
|
||||||
|
them if they exist and are not in use by any connection.
|
||||||
|
|
||||||
|
The actual recovery is done by SQLite automatically when opening the database.
|
||||||
|
This just cleans up orphaned files from previous crashes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to the database file.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
wal_path = Path(db_path + "-wal")
|
||||||
|
shm_path = Path(db_path + "-shm")
|
||||||
|
|
||||||
|
for path in (wal_path, shm_path):
|
||||||
|
if path.exists():
|
||||||
|
# Skip files that were modified recently — they likely belong to an
|
||||||
|
# active connection. Only remove stale files left by crashes.
|
||||||
|
mtime = path.stat().st_mtime
|
||||||
|
if time.time() - mtime < 10:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
log.warning("orphaned_sqlite_file_removed", path=str(path))
|
||||||
|
except OSError:
|
||||||
|
pass # File in use or permission denied
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_current_schema_version(db: aiosqlite.Connection) -> int:
|
||||||
|
"""Return the highest applied schema version for the given database."""
|
||||||
|
await db.execute(_CREATE_SCHEMA_MIGRATIONS)
|
||||||
|
async with db.execute("SELECT MAX(version) FROM schema_migrations;") as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None or row[0] is None:
|
||||||
|
return 0
|
||||||
|
return int(row[0])
|
||||||
|
|
||||||
|
|
||||||
|
async def _parse_migration_statements(script: str) -> list[str]:
|
||||||
|
"""Parse a migration script into individual SQL statements.
|
||||||
|
|
||||||
|
Splits on semicolons but ignores semicolons inside string literals and
|
||||||
|
comments. Handles both block (-- comment) and line comments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
script: The raw migration script.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of SQL statements (stripped of whitespace and comments).
|
||||||
|
"""
|
||||||
|
statements: list[str] = []
|
||||||
|
current_stmt: list[str] = []
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
while i < len(script):
|
||||||
|
char = script[i]
|
||||||
|
|
||||||
|
# Skip block comments (-- ...)
|
||||||
|
if i < len(script) - 1 and script[i : i + 2] == "--":
|
||||||
|
while i < len(script) and script[i] != "\n":
|
||||||
|
i += 1
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip line comments (/* ... */)
|
||||||
|
if i < len(script) - 1 and script[i : i + 2] == "/*":
|
||||||
|
i += 2
|
||||||
|
while i < len(script) - 1:
|
||||||
|
if script[i : i + 2] == "*/":
|
||||||
|
i += 2
|
||||||
|
break
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle string literals (single or double quotes)
|
||||||
|
if char in ("'", '"'):
|
||||||
|
quote = char
|
||||||
|
current_stmt.append(char)
|
||||||
|
i += 1
|
||||||
|
while i < len(script):
|
||||||
|
if script[i] == quote:
|
||||||
|
if i + 1 < len(script) and script[i + 1] == quote:
|
||||||
|
# Escaped quote
|
||||||
|
current_stmt.append(quote)
|
||||||
|
current_stmt.append(quote)
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
# End of string
|
||||||
|
current_stmt.append(quote)
|
||||||
|
i += 1
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
current_stmt.append(script[i])
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Statement separator
|
||||||
|
if char == ";":
|
||||||
|
stmt = "".join(current_stmt).strip()
|
||||||
|
if stmt:
|
||||||
|
statements.append(stmt)
|
||||||
|
current_stmt = []
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_stmt.append(char)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Add any remaining statement
|
||||||
|
stmt = "".join(current_stmt).strip()
|
||||||
|
if stmt:
|
||||||
|
statements.append(stmt)
|
||||||
|
|
||||||
|
return statements
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_migration(db: aiosqlite.Connection, version: int) -> None:
|
||||||
|
"""Apply a single migration step and record its completion atomically.
|
||||||
|
|
||||||
|
Wraps all DDL statements and the schema_migrations insert in a single
|
||||||
|
BEGIN IMMEDIATE ... COMMIT transaction to ensure atomicity. If any
|
||||||
|
statement fails, the entire migration is rolled back.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: An open aiosqlite.Connection.
|
||||||
|
version: The migration version number.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Any exception from executing the migration statements or inserting
|
||||||
|
the schema migration record will cause a rollback.
|
||||||
|
"""
|
||||||
|
migration_script = _MIGRATIONS[version]
|
||||||
|
statements = await _parse_migration_statements(migration_script)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db.execute("BEGIN IMMEDIATE;")
|
||||||
|
|
||||||
|
for statement in statements:
|
||||||
|
try:
|
||||||
|
await db.execute(statement)
|
||||||
|
except aiosqlite.OperationalError as exc:
|
||||||
|
# Ignore duplicate column / table errors so migrations remain
|
||||||
|
# idempotent when a legacy database already has the object.
|
||||||
|
msg = str(exc).lower()
|
||||||
|
if "duplicate column name" in msg or "table" in msg and "already exists" in msg:
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
await db.execute("INSERT INTO schema_migrations (version) VALUES (?);", (version,))
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_schema(db: aiosqlite.Connection) -> None:
|
||||||
|
"""Migrate the database schema to the latest supported version."""
|
||||||
|
current_version = await _get_current_schema_version(db)
|
||||||
|
if current_version == _CURRENT_SCHEMA_VERSION:
|
||||||
|
return
|
||||||
|
|
||||||
|
if current_version > _CURRENT_SCHEMA_VERSION:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"database schema version {current_version} is newer than supported version {_CURRENT_SCHEMA_VERSION}"
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info("migrating_database_schema", from_version=current_version, to_version=_CURRENT_SCHEMA_VERSION)
|
||||||
|
for next_version in range(current_version + 1, _CURRENT_SCHEMA_VERSION + 1):
|
||||||
|
await _apply_migration(db, next_version)
|
||||||
|
log.info("database_schema_ready", schema_version=_CURRENT_SCHEMA_VERSION)
|
||||||
|
|
||||||
|
|
||||||
async def init_db(db: aiosqlite.Connection) -> None:
|
async def init_db(db: aiosqlite.Connection) -> None:
|
||||||
"""Create all BanGUI application tables if they do not already exist.
|
"""Create or migrate the BanGUI application database schema.
|
||||||
|
|
||||||
This function is idempotent — calling it on an already-initialised
|
This function is idempotent — calling it on an already-initialised
|
||||||
database has no effect. It should be called once during application
|
database has no effect. It should be called once during application
|
||||||
@@ -102,11 +468,21 @@ async def init_db(db: aiosqlite.Connection) -> None:
|
|||||||
db: An open :class:`aiosqlite.Connection` to the application database.
|
db: An open :class:`aiosqlite.Connection` to the application database.
|
||||||
"""
|
"""
|
||||||
log.info("initialising_database_schema")
|
log.info("initialising_database_schema")
|
||||||
async with db.execute("PRAGMA journal_mode=WAL;"):
|
await _configure_connection(db)
|
||||||
pass
|
await _migrate_schema(db)
|
||||||
async with db.execute("PRAGMA foreign_keys=ON;"):
|
|
||||||
pass
|
|
||||||
for statement in _SCHEMA_STATEMENTS:
|
async def open_db(database_path: str) -> aiosqlite.Connection:
|
||||||
await db.executescript(statement)
|
"""Open a new application SQLite connection with the standard settings.
|
||||||
await db.commit()
|
|
||||||
log.info("database_schema_ready")
|
Args:
|
||||||
|
database_path: Path to the BanGUI SQLite database.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A configured :class:`aiosqlite.Connection` instance.
|
||||||
|
"""
|
||||||
|
await _cleanup_wal_files(database_path)
|
||||||
|
db = await aiosqlite.connect(database_path)
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
await _configure_connection(db)
|
||||||
|
return db
|
||||||
|
|||||||
@@ -1,33 +1,101 @@
|
|||||||
"""FastAPI dependency providers.
|
"""FastAPI dependency providers and composition root.
|
||||||
|
|
||||||
All ``Depends()`` callables that inject shared resources (database
|
This module is BanGUI's dependency injection composition root. All injectable
|
||||||
connection, settings, services, auth guard) are defined here.
|
resources — database connections, settings, services, repositories, and
|
||||||
Routers import directly from this module — never from ``app.state``
|
authentication guards — are defined here as provider functions.
|
||||||
directly — to keep coupling explicit and testable.
|
|
||||||
|
**Key Principles:**
|
||||||
|
|
||||||
|
1. **Composition Root Pattern**: No heavyweight DI container is used. Instead,
|
||||||
|
FastAPI's `Depends()` framework manages all dependencies, keeping the pattern
|
||||||
|
lightweight and explicit.
|
||||||
|
|
||||||
|
2. **Explicit Over Implicit**: Every dependency is declared in function signatures.
|
||||||
|
There is no hidden coupling or magic. This makes the dependency graph visible
|
||||||
|
to type checkers, debuggers, and developers.
|
||||||
|
|
||||||
|
3. **Service Context Dependencies**: Related resources (e.g., db + repository) are
|
||||||
|
bundled into context objects (SessionServiceContext, BlocklistServiceContext)
|
||||||
|
to prevent routers from accessing raw database connections.
|
||||||
|
|
||||||
|
4. **Repository Boundary Enforcement**: Routers must NOT import DbDep. They depend
|
||||||
|
on service context dependencies instead, which contain both the database
|
||||||
|
connection and the necessary repositories. This ensures repositories are the
|
||||||
|
only modules executing SQL.
|
||||||
|
|
||||||
|
See Architekture.md § 2.3 (Dependency Wiring and Service Composition) for a
|
||||||
|
complete guide to the DI pattern, including examples of adding new services.
|
||||||
|
|
||||||
|
See Backend-Development.md § 6 for dependency layering rules.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import datetime
|
||||||
from typing import Annotated, Protocol, cast
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Annotated, cast
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
import structlog
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[import-untyped]
|
||||||
from fastapi import Depends, HTTPException, Request, status
|
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
||||||
|
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
|
from app.exceptions import RateLimitError
|
||||||
from app.models.auth import Session
|
from app.models.auth import Session
|
||||||
from app.utils.time_utils import utc_now
|
from app.models.config import PendingRecovery
|
||||||
|
from app.models.server import ServerStatus
|
||||||
|
|
||||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
# Module-level imports for repositories and services
|
||||||
|
# These are safe at module level since no circular dependencies exist
|
||||||
|
from app.repositories import (
|
||||||
|
blocklist_repo,
|
||||||
|
fail2ban_db_repo,
|
||||||
|
geo_cache_repo,
|
||||||
|
history_archive_repo,
|
||||||
|
import_log_repo,
|
||||||
|
import_run_repo,
|
||||||
|
session_repo,
|
||||||
|
settings_repo,
|
||||||
|
)
|
||||||
|
from app.repositories.protocols import (
|
||||||
|
BlocklistRepository,
|
||||||
|
Fail2BanDbRepository,
|
||||||
|
GeoCacheRepository,
|
||||||
|
HistoryArchiveRepository,
|
||||||
|
ImportLogRepository,
|
||||||
|
ImportRunRepository,
|
||||||
|
SessionRepository,
|
||||||
|
SettingsRepository,
|
||||||
|
)
|
||||||
|
from app.services import auth_service, health_service
|
||||||
|
from app.services.fail2ban_metadata_service import default_fail2ban_metadata_service
|
||||||
|
from app.services.geo_cache import GeoCache
|
||||||
|
from app.services.protocols import Fail2BanMetadataService
|
||||||
|
from app.utils.constants import SESSION_COOKIE_NAME
|
||||||
|
from app.utils.logging_compat import get_logger
|
||||||
|
from app.utils.rate_limiter import GlobalRateLimiter
|
||||||
|
from app.utils.runtime_state import ApplicationState, JailServiceState, RuntimeState
|
||||||
|
from app.utils.session_cache import NoOpSessionCache, SessionCache
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AppState(Protocol):
|
@dataclass
|
||||||
"""Partial view of the FastAPI application state used by dependencies."""
|
class ApplicationContext:
|
||||||
|
"""A typed wrapper around shared application lifecycle resources."""
|
||||||
|
|
||||||
settings: Settings
|
settings: Settings
|
||||||
|
http_session: aiohttp.ClientSession | None
|
||||||
|
scheduler: AsyncIOScheduler | None
|
||||||
|
server_status: ServerStatus
|
||||||
|
pending_recovery: PendingRecovery | None
|
||||||
|
last_activation: dict[str, datetime.datetime] | None
|
||||||
|
runtime_settings: Settings | None
|
||||||
|
runtime_state: RuntimeState
|
||||||
|
session_cache: SessionCache | None
|
||||||
|
global_rate_limiter: GlobalRateLimiter
|
||||||
|
|
||||||
|
|
||||||
_COOKIE_NAME = "bangui_session"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Session validation cache
|
# Session validation cache
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -35,84 +103,547 @@ _COOKIE_NAME = "bangui_session"
|
|||||||
#: How long (seconds) a validated session token is served from the in-memory
|
#: How long (seconds) a validated session token is served from the in-memory
|
||||||
#: cache without re-querying SQLite. Eliminates repeated DB lookups for the
|
#: cache without re-querying SQLite. Eliminates repeated DB lookups for the
|
||||||
#: same token arriving in near-simultaneous parallel requests.
|
#: same token arriving in near-simultaneous parallel requests.
|
||||||
_SESSION_CACHE_TTL: float = 10.0
|
#:
|
||||||
|
#: NOTE: this cache is process-local and is not cluster-safe. In multi-worker
|
||||||
#: ``token → (Session, cache_expiry_monotonic_time)``
|
#: or distributed deployments, the configured cache backend should provide
|
||||||
_session_cache: dict[str, tuple[Session, float]] = {}
|
#: invalidation semantics appropriate for the deployment.
|
||||||
|
|
||||||
|
|
||||||
def clear_session_cache() -> None:
|
def _session_cache_enabled(settings: Settings) -> bool:
|
||||||
"""Flush the entire in-memory session validation cache.
|
"""Return whether the session validation cache should be used."""
|
||||||
|
return settings.session_cache_enabled and settings.session_cache_ttl_seconds > 0.0
|
||||||
Useful in tests to prevent stale state from leaking between test cases.
|
|
||||||
"""
|
|
||||||
_session_cache.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def invalidate_session_cache(token: str) -> None:
|
def _build_app_context(request: Request) -> ApplicationContext:
|
||||||
"""Evict *token* from the in-memory session cache.
|
state = cast("ApplicationState", request.app.state)
|
||||||
|
session_cache = getattr(state, "session_cache", None)
|
||||||
|
if session_cache is None:
|
||||||
|
session_cache = NoOpSessionCache()
|
||||||
|
|
||||||
Must be called during logout so the revoked token is no longer served
|
global_rate_limiter: GlobalRateLimiter = getattr(state, "global_rate_limiter", None)
|
||||||
from cache without a DB round-trip.
|
if global_rate_limiter is None:
|
||||||
|
global_rate_limiter = GlobalRateLimiter()
|
||||||
|
|
||||||
|
return ApplicationContext(
|
||||||
|
settings=state.settings,
|
||||||
|
http_session=getattr(state, "http_session", None),
|
||||||
|
scheduler=getattr(state, "scheduler", None),
|
||||||
|
server_status=getattr(state, "server_status", ServerStatus(online=False)),
|
||||||
|
pending_recovery=getattr(state, "pending_recovery", None),
|
||||||
|
last_activation=getattr(state, "last_activation", None),
|
||||||
|
runtime_settings=getattr(state, "runtime_settings", None),
|
||||||
|
runtime_state=state.runtime_state,
|
||||||
|
session_cache=session_cache,
|
||||||
|
global_rate_limiter=global_rate_limiter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_app_context(request: Request) -> ApplicationContext:
|
||||||
|
"""Provide the typed application context for the current request."""
|
||||||
|
return _build_app_context(request)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_settings(app_context: Annotated[ApplicationContext, Depends(get_app_context)]) -> Settings:
|
||||||
|
"""Provide the effective application settings for the current request."""
|
||||||
|
return app_context.runtime_settings if app_context.runtime_settings is not None else app_context.settings
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db(
|
||||||
|
settings: Annotated[Settings, Depends(get_settings)],
|
||||||
|
) -> AsyncGenerator[aiosqlite.Connection, None]:
|
||||||
|
"""Provide a request-scoped :class:`aiosqlite.Connection` for the current request.
|
||||||
|
|
||||||
|
Opens a fresh connection for every request and closes it when the request
|
||||||
|
is finished. This avoids contention and locking issues from a single shared
|
||||||
|
SQLite connection across concurrent requests.
|
||||||
|
|
||||||
|
The database path is taken from the effective application settings so
|
||||||
|
runtime overrides stored during setup are respected.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
token: The session token to remove.
|
settings: The effective application settings for the current request.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
An open :class:`aiosqlite.Connection` for the request.
|
||||||
"""
|
"""
|
||||||
_session_cache.pop(token, None)
|
from app.db import open_db # noqa: PLC0415
|
||||||
|
|
||||||
|
try:
|
||||||
async def get_db(request: Request) -> aiosqlite.Connection:
|
db = await open_db(settings.database_path)
|
||||||
"""Provide the shared :class:`aiosqlite.Connection` from ``app.state``.
|
except Exception as exc:
|
||||||
|
log.error("database_open_failed", error=str(exc))
|
||||||
Args:
|
|
||||||
request: The current FastAPI request (injected automatically).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The application-wide aiosqlite connection opened during startup.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 503 if the database has not been initialised.
|
|
||||||
"""
|
|
||||||
db: aiosqlite.Connection | None = getattr(request.app.state, "db", None)
|
|
||||||
if db is None:
|
|
||||||
log.error("database_not_initialised")
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="Database is not available.",
|
detail="Database is not available.",
|
||||||
)
|
) from exc
|
||||||
return db
|
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
async def get_settings(request: Request) -> Settings:
|
async def get_http_session(
|
||||||
"""Provide the :class:`~app.config.Settings` instance from ``app.state``.
|
app_context: Annotated[ApplicationContext, Depends(get_app_context)],
|
||||||
|
) -> aiohttp.ClientSession:
|
||||||
|
"""Provide the shared HTTP client session from application context.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: The current FastAPI request (injected automatically).
|
app_context: The injected shared application context.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The application settings loaded at startup.
|
A shared :class:`aiohttp.ClientSession` managed by the lifespan.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If the session is unavailable.
|
||||||
"""
|
"""
|
||||||
state = cast("AppState", request.app.state)
|
if app_context.http_session is None:
|
||||||
return state.settings
|
log.error("http_session_unavailable")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="HTTP session is not available.",
|
||||||
|
)
|
||||||
|
return app_context.http_session
|
||||||
|
|
||||||
|
|
||||||
|
async def get_scheduler(app_context: Annotated[ApplicationContext, Depends(get_app_context)]) -> AsyncIOScheduler:
|
||||||
|
"""Provide the shared scheduler from application context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app_context: The injected shared application context.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The :class:`apscheduler.schedulers.asyncio.AsyncIOScheduler` instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If the scheduler is unavailable.
|
||||||
|
"""
|
||||||
|
if app_context.scheduler is None:
|
||||||
|
log.error("scheduler_unavailable")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Scheduler is not available.",
|
||||||
|
)
|
||||||
|
return app_context.scheduler
|
||||||
|
|
||||||
|
|
||||||
|
async def get_fail2ban_socket(settings: Settings = Depends(get_settings)) -> str:
|
||||||
|
"""Provide the configured path to the fail2ban Unix domain socket."""
|
||||||
|
return settings.fail2ban_socket
|
||||||
|
|
||||||
|
|
||||||
|
async def get_fail2ban_config_dir(settings: Settings = Depends(get_settings)) -> str:
|
||||||
|
"""Provide the configured fail2ban configuration directory."""
|
||||||
|
return settings.fail2ban_config_dir
|
||||||
|
|
||||||
|
|
||||||
|
async def get_fail2ban_start_command(settings: Settings = Depends(get_settings)) -> str:
|
||||||
|
"""Provide the configured fail2ban start command."""
|
||||||
|
return settings.fail2ban_start_command
|
||||||
|
|
||||||
|
|
||||||
|
async def get_geo_cache(request: Request) -> GeoCache:
|
||||||
|
"""Provide the application's GeoCache instance."""
|
||||||
|
geo_cache: GeoCache = cast("GeoCache", request.app.state.geo_cache)
|
||||||
|
return geo_cache
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session_cache(app_context: Annotated[ApplicationContext, Depends(get_app_context)]) -> SessionCache:
|
||||||
|
"""Provide the configured session cache backend from application context."""
|
||||||
|
if app_context.session_cache is None:
|
||||||
|
log.error("session_cache_unavailable")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Session cache is not available.",
|
||||||
|
)
|
||||||
|
return app_context.session_cache
|
||||||
|
|
||||||
|
|
||||||
|
async def get_global_rate_limiter(
|
||||||
|
app_context: Annotated[ApplicationContext, Depends(get_app_context)],
|
||||||
|
) -> GlobalRateLimiter:
|
||||||
|
"""Provide the global rate limiter from application context."""
|
||||||
|
return app_context.global_rate_limiter
|
||||||
|
|
||||||
|
|
||||||
|
def rate_limit_dependency(
|
||||||
|
bucket: str,
|
||||||
|
max_requests: int,
|
||||||
|
window_seconds: int,
|
||||||
|
) -> Callable[[Request, "GlobalRateLimiter"], None]:
|
||||||
|
"""Create a rate limit dependency for a specific bucket and limit.
|
||||||
|
|
||||||
|
Use this factory to create per-endpoint rate limit dependencies.
|
||||||
|
Each call returns a configured dependency that enforces the
|
||||||
|
specified rate limit before the endpoint handler runs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bucket: Bucket name (e.g., "bans:ban", "blocklist:import").
|
||||||
|
max_requests: Maximum requests allowed within the window.
|
||||||
|
window_seconds: Time window for this bucket.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A callable that can be used as a FastAPI Depends() dependency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def check_rate_limit(
|
||||||
|
request: Request,
|
||||||
|
rate_limiter: GlobalRateLimiterDep,
|
||||||
|
) -> None:
|
||||||
|
from app.utils.client_ip import get_client_ip
|
||||||
|
|
||||||
|
settings: Settings = request.app.state.settings
|
||||||
|
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
|
||||||
|
|
||||||
|
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(bucket, client_ip, max_requests, window_seconds)
|
||||||
|
|
||||||
|
if not is_allowed:
|
||||||
|
log.warning(
|
||||||
|
"operation_rate_limit_exceeded",
|
||||||
|
client_ip=client_ip,
|
||||||
|
bucket=bucket,
|
||||||
|
path=request.url.path,
|
||||||
|
method=request.method,
|
||||||
|
retry_after=retry_after,
|
||||||
|
)
|
||||||
|
raise RateLimitError(
|
||||||
|
f"Rate limit exceeded for {bucket}. Please try again later.",
|
||||||
|
retry_after_seconds=retry_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
return check_rate_limit
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session_repo() -> SessionRepository:
|
||||||
|
"""Provide the concrete session repository implementation.
|
||||||
|
|
||||||
|
The session_repo module uses structural typing to satisfy the SessionRepository
|
||||||
|
Protocol interface — its top-level async functions must match the Protocol
|
||||||
|
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
|
||||||
|
"""
|
||||||
|
return session_repo
|
||||||
|
|
||||||
|
|
||||||
|
async def get_blocklist_repo() -> BlocklistRepository:
|
||||||
|
"""Provide the concrete blocklist repository implementation.
|
||||||
|
|
||||||
|
The blocklist_repo module uses structural typing to satisfy the BlocklistRepository
|
||||||
|
Protocol interface — its top-level async functions must match the Protocol
|
||||||
|
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
|
||||||
|
"""
|
||||||
|
return cast("BlocklistRepository", blocklist_repo)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_import_log_repo() -> ImportLogRepository:
|
||||||
|
"""Provide the concrete import log repository implementation.
|
||||||
|
|
||||||
|
The import_log_repo module uses structural typing to satisfy the ImportLogRepository
|
||||||
|
Protocol interface — its top-level async functions must match the Protocol
|
||||||
|
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
|
||||||
|
"""
|
||||||
|
return cast("ImportLogRepository", import_log_repo)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_import_run_repo() -> ImportRunRepository:
|
||||||
|
"""Provide the concrete import run repository implementation.
|
||||||
|
|
||||||
|
The import_run_repo module uses structural typing to satisfy the ImportRunRepository
|
||||||
|
Protocol interface for tracking blocklist imports for idempotency detection.
|
||||||
|
"""
|
||||||
|
return cast("ImportRunRepository", import_run_repo)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_settings_repo() -> SettingsRepository:
|
||||||
|
"""Provide the concrete settings repository implementation.
|
||||||
|
|
||||||
|
The settings_repo module uses structural typing to satisfy the SettingsRepository
|
||||||
|
Protocol interface — its top-level async functions must match the Protocol
|
||||||
|
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
|
||||||
|
"""
|
||||||
|
return cast("SettingsRepository", settings_repo)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_history_archive_repo() -> HistoryArchiveRepository:
|
||||||
|
"""Provide the concrete history archive repository implementation.
|
||||||
|
|
||||||
|
The history_archive_repo module uses structural typing to satisfy the
|
||||||
|
HistoryArchiveRepository Protocol interface — its top-level async functions
|
||||||
|
must match the Protocol signatures exactly. This is documented in
|
||||||
|
Backend-Development.md § 13.7.1.
|
||||||
|
"""
|
||||||
|
return cast("HistoryArchiveRepository", history_archive_repo)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_geo_cache_repo() -> GeoCacheRepository:
|
||||||
|
"""Provide the concrete geo cache repository implementation.
|
||||||
|
|
||||||
|
The geo_cache_repo module uses structural typing to satisfy the GeoCacheRepository
|
||||||
|
Protocol interface — its top-level async functions must match the Protocol
|
||||||
|
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
|
||||||
|
"""
|
||||||
|
return cast("GeoCacheRepository", geo_cache_repo)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_fail2ban_db_repo() -> Fail2BanDbRepository:
|
||||||
|
"""Provide the concrete fail2ban DB repository implementation.
|
||||||
|
|
||||||
|
The fail2ban_db_repo module uses structural typing to satisfy the
|
||||||
|
Fail2BanDbRepository Protocol interface — its top-level async functions must
|
||||||
|
match the Protocol signatures exactly. This is documented in
|
||||||
|
Backend-Development.md § 13.7.1.
|
||||||
|
"""
|
||||||
|
return cast("Fail2BanDbRepository", fail2ban_db_repo)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_app_state(app_context: Annotated[ApplicationContext, Depends(get_app_context)]) -> ApplicationContext:
|
||||||
|
"""Provide the application state object for the current request."""
|
||||||
|
return app_context
|
||||||
|
|
||||||
|
|
||||||
|
async def get_app(request: Request) -> FastAPI:
|
||||||
|
"""Provide the FastAPI application instance for the current request."""
|
||||||
|
return request.app
|
||||||
|
|
||||||
|
|
||||||
|
async def get_server_status(app_context: Annotated[ApplicationContext, Depends(get_app_context)]) -> ServerStatus:
|
||||||
|
"""Return the cached fail2ban server status snapshot from application context."""
|
||||||
|
if app_context.server_status is None:
|
||||||
|
return ServerStatus(online=False)
|
||||||
|
return app_context.server_status
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pending_recovery(
|
||||||
|
app_context: Annotated[ApplicationContext, Depends(get_app_context)],
|
||||||
|
) -> PendingRecovery | None:
|
||||||
|
"""Return the current pending recovery record from application context."""
|
||||||
|
return app_context.pending_recovery
|
||||||
|
|
||||||
|
|
||||||
|
async def get_jail_service_state(
|
||||||
|
app_context: Annotated[ApplicationContext, Depends(get_app_context)],
|
||||||
|
) -> JailServiceState:
|
||||||
|
"""Return the jail service state holder from runtime state.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The JailServiceState containing capability detection cache and
|
||||||
|
synchronization primitives for jail operations.
|
||||||
|
"""
|
||||||
|
return app_context.runtime_state.jail_service_state
|
||||||
|
|
||||||
|
|
||||||
|
async def get_health_probe() -> Callable[[str], Awaitable[ServerStatus]]:
|
||||||
|
"""Provide the health probe function for checking fail2ban connectivity.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A callable that probes the fail2ban socket and returns ServerStatus.
|
||||||
|
This allows explicit dependency injection to avoid hidden service coupling.
|
||||||
|
"""
|
||||||
|
return health_service.probe
|
||||||
|
|
||||||
|
|
||||||
|
async def get_fail2ban_metadata_service() -> object:
|
||||||
|
"""Provide the Fail2BanMetadataService instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The singleton Fail2BanMetadataService for resolving fail2ban metadata
|
||||||
|
(such as the database path) and caching results.
|
||||||
|
"""
|
||||||
|
return default_fail2ban_metadata_service
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Service facade dependencies (db + repositories combined)
|
||||||
|
# These are for routers that need database access through services.
|
||||||
|
# Routers should depend on these instead of raw database connections.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SessionServiceContext:
|
||||||
|
"""Context for session-related database operations.
|
||||||
|
|
||||||
|
Combines the database connection and session repository so that
|
||||||
|
routers don't need to import DbDep directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
db: aiosqlite.Connection
|
||||||
|
session_repo: SessionRepository
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session_service_context(
|
||||||
|
db: Annotated[aiosqlite.Connection, Depends(get_db)],
|
||||||
|
session_repo: Annotated[SessionRepository, Depends(get_session_repo)],
|
||||||
|
) -> SessionServiceContext:
|
||||||
|
"""Provide combined session database context for routers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Request-scoped database connection.
|
||||||
|
session_repo: Session repository implementation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SessionServiceContext with both db and repository.
|
||||||
|
"""
|
||||||
|
return SessionServiceContext(db=db, session_repo=session_repo)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BlocklistServiceContext:
|
||||||
|
"""Context for blocklist-related database operations.
|
||||||
|
|
||||||
|
Combines the database connection and blocklist-related repositories
|
||||||
|
so that routers don't need to import DbDep directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
db: aiosqlite.Connection
|
||||||
|
blocklist_repo: BlocklistRepository
|
||||||
|
import_log_repo: ImportLogRepository
|
||||||
|
settings_repo: SettingsRepository
|
||||||
|
|
||||||
|
|
||||||
|
async def get_blocklist_service_context(
|
||||||
|
db: Annotated[aiosqlite.Connection, Depends(get_db)],
|
||||||
|
blocklist_repo: Annotated[BlocklistRepository, Depends(get_blocklist_repo)],
|
||||||
|
import_log_repo: Annotated[ImportLogRepository, Depends(get_import_log_repo)],
|
||||||
|
settings_repo: Annotated[SettingsRepository, Depends(get_settings_repo)],
|
||||||
|
) -> BlocklistServiceContext:
|
||||||
|
"""Provide combined blocklist database context for routers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Request-scoped database connection.
|
||||||
|
blocklist_repo: Blocklist repository implementation.
|
||||||
|
import_log_repo: Import log repository implementation.
|
||||||
|
settings_repo: Settings repository implementation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BlocklistServiceContext with db and all blocklist repositories.
|
||||||
|
"""
|
||||||
|
return BlocklistServiceContext(
|
||||||
|
db=db,
|
||||||
|
blocklist_repo=blocklist_repo,
|
||||||
|
import_log_repo=import_log_repo,
|
||||||
|
settings_repo=settings_repo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SettingsServiceContext:
|
||||||
|
"""Context for settings-related database operations.
|
||||||
|
|
||||||
|
Combines the database connection and settings repository so that
|
||||||
|
routers don't need to import DbDep directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
db: aiosqlite.Connection
|
||||||
|
settings_repo: SettingsRepository
|
||||||
|
|
||||||
|
|
||||||
|
async def get_settings_service_context(
|
||||||
|
db: Annotated[aiosqlite.Connection, Depends(get_db)],
|
||||||
|
settings_repo: Annotated[SettingsRepository, Depends(get_settings_repo)],
|
||||||
|
) -> SettingsServiceContext:
|
||||||
|
"""Provide combined settings database context for routers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Request-scoped database connection.
|
||||||
|
settings_repo: Settings repository implementation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SettingsServiceContext with both db and repository.
|
||||||
|
"""
|
||||||
|
return SettingsServiceContext(db=db, settings_repo=settings_repo)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BanServiceContext:
|
||||||
|
"""Context for ban-related database operations.
|
||||||
|
|
||||||
|
Combines the database connection and fail2ban DB repository.
|
||||||
|
"""
|
||||||
|
|
||||||
|
db: aiosqlite.Connection
|
||||||
|
fail2ban_db_repo: Fail2BanDbRepository
|
||||||
|
|
||||||
|
|
||||||
|
async def get_ban_service_context(
|
||||||
|
db: Annotated[aiosqlite.Connection, Depends(get_db)],
|
||||||
|
fail2ban_db_repo: Annotated[Fail2BanDbRepository, Depends(get_fail2ban_db_repo)],
|
||||||
|
) -> BanServiceContext:
|
||||||
|
"""Provide combined ban database context for routers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Request-scoped database connection.
|
||||||
|
fail2ban_db_repo: Fail2Ban DB repository implementation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BanServiceContext with both db and repository.
|
||||||
|
"""
|
||||||
|
return BanServiceContext(db=db, fail2ban_db_repo=fail2ban_db_repo)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HistoryServiceContext:
|
||||||
|
"""Context for history-related database operations.
|
||||||
|
|
||||||
|
Combines database connection and history-related repositories.
|
||||||
|
"""
|
||||||
|
|
||||||
|
db: aiosqlite.Connection
|
||||||
|
fail2ban_db_repo: Fail2BanDbRepository
|
||||||
|
history_archive_repo: HistoryArchiveRepository
|
||||||
|
|
||||||
|
|
||||||
|
async def get_history_service_context(
|
||||||
|
db: Annotated[aiosqlite.Connection, Depends(get_db)],
|
||||||
|
fail2ban_db_repo: Annotated[Fail2BanDbRepository, Depends(get_fail2ban_db_repo)],
|
||||||
|
history_archive_repo: Annotated[HistoryArchiveRepository, Depends(get_history_archive_repo)],
|
||||||
|
) -> HistoryServiceContext:
|
||||||
|
"""Provide combined history database context for routers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Request-scoped database connection.
|
||||||
|
fail2ban_db_repo: Fail2Ban DB repository implementation.
|
||||||
|
history_archive_repo: History archive repository implementation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HistoryServiceContext with db and all history repositories.
|
||||||
|
"""
|
||||||
|
return HistoryServiceContext(
|
||||||
|
db=db,
|
||||||
|
fail2ban_db_repo=fail2ban_db_repo,
|
||||||
|
history_archive_repo=history_archive_repo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Internal database dependency for use by other dependencies only
|
||||||
|
# Routers should NOT import this - they should use repository dependencies instead
|
||||||
|
_DbDep = Annotated[aiosqlite.Connection, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
async def require_auth(
|
async def require_auth(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Annotated[aiosqlite.Connection, Depends(get_db)],
|
db: _DbDep,
|
||||||
|
settings: Annotated[Settings, Depends(get_settings)],
|
||||||
|
session_cache: Annotated[SessionCache, Depends(get_session_cache)],
|
||||||
|
session_repo: Annotated[SessionRepository, Depends(get_session_repo)],
|
||||||
) -> Session:
|
) -> Session:
|
||||||
"""Validate the session token and return the active session.
|
"""Validate the session token and return the active session.
|
||||||
|
|
||||||
The token is read from the ``bangui_session`` cookie or the
|
The token is read from the ``bangui_session`` cookie or the
|
||||||
``Authorization: Bearer`` header.
|
``Authorization: Bearer`` header.
|
||||||
|
|
||||||
Validated tokens are cached in memory for :data:`_SESSION_CACHE_TTL`
|
Validated tokens may be cached in memory for a short period so that
|
||||||
seconds so that concurrent requests sharing the same token avoid repeated
|
concurrent requests sharing the same token avoid repeated SQLite
|
||||||
SQLite round-trips. The cache is bypassed on expiry and explicitly
|
round-trips. This cache is disabled by default because process-local
|
||||||
cleared by :func:`invalidate_session_cache` on logout.
|
invalidation is not safe in multi-worker or clustered deployments.
|
||||||
|
When enabled, entries are bypassed on expiry and explicitly cleared by
|
||||||
|
the configured session cache backend on logout.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: The incoming FastAPI request.
|
request: The incoming FastAPI request.
|
||||||
db: Injected aiosqlite connection.
|
db: Injected aiosqlite connection (for repository operations).
|
||||||
|
settings: Application settings used for signed session token validation.
|
||||||
|
session_cache: Session validation cache backend.
|
||||||
|
session_repo: Session repository for persistence operations.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The active :class:`~app.models.auth.Session`.
|
The active :class:`~app.models.auth.Session`.
|
||||||
@@ -120,13 +651,12 @@ async def require_auth(
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: 401 if no valid session token is found.
|
HTTPException: 401 if no valid session token is found.
|
||||||
"""
|
"""
|
||||||
from app.services import auth_service # noqa: PLC0415
|
|
||||||
|
|
||||||
token: str | None = request.cookies.get(_COOKIE_NAME)
|
token: str | None = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
if not token:
|
if not token:
|
||||||
auth_header: str = request.headers.get("Authorization", "")
|
auth_header: str = request.headers.get("Authorization", "")
|
||||||
if auth_header.startswith("Bearer "):
|
if auth_header.startswith("Bearer "):
|
||||||
token = auth_header[len("Bearer "):]
|
token = auth_header[len("Bearer ") :]
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -135,18 +665,20 @@ async def require_auth(
|
|||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fast path: serve from in-memory cache when the entry is still fresh and
|
cache_enabled = _session_cache_enabled(settings)
|
||||||
# the session itself has not yet exceeded its own expiry time.
|
if cache_enabled:
|
||||||
cached = _session_cache.get(token)
|
cached = session_cache.get(token)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
session, cache_expires_at = cached
|
return cached
|
||||||
if time.monotonic() < cache_expires_at and session.expires_at > utc_now().isoformat():
|
|
||||||
return session
|
|
||||||
# Stale cache entry — evict and fall through to DB.
|
|
||||||
_session_cache.pop(token, None)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session = await auth_service.validate_session(db, token)
|
session = await auth_service.validate_session(
|
||||||
|
db,
|
||||||
|
token,
|
||||||
|
settings.session_secret,
|
||||||
|
settings.session_secret_previous,
|
||||||
|
session_repo=session_repo,
|
||||||
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
@@ -154,11 +686,51 @@ async def require_auth(
|
|||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
_session_cache[token] = (session, time.monotonic() + _SESSION_CACHE_TTL)
|
if cache_enabled:
|
||||||
|
session_cache.set(token, session, settings.session_cache_ttl_seconds)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
# Convenience type aliases for route signatures.
|
# Convenience type aliases for route signatures.
|
||||||
DbDep = Annotated[aiosqlite.Connection, Depends(get_db)]
|
# NOTE: Database connections are NOT exported to routers. Routers should depend on
|
||||||
|
# repository dependencies (SessionRepoDep, BlocklistRepositoryDep, etc.) instead.
|
||||||
|
# See Backend-Development.md for the dependency layering rules.
|
||||||
|
|
||||||
SettingsDep = Annotated[Settings, Depends(get_settings)]
|
SettingsDep = Annotated[Settings, Depends(get_settings)]
|
||||||
|
HttpSessionDep = Annotated[aiohttp.ClientSession, Depends(get_http_session)]
|
||||||
|
SchedulerDep = Annotated[AsyncIOScheduler, Depends(get_scheduler)]
|
||||||
|
Fail2BanSocketDep = Annotated[str, Depends(get_fail2ban_socket)]
|
||||||
|
Fail2BanConfigDirDep = Annotated[str, Depends(get_fail2ban_config_dir)]
|
||||||
|
Fail2BanStartCommandDep = Annotated[str, Depends(get_fail2ban_start_command)]
|
||||||
|
GeoCacheDep = Annotated[GeoCache, Depends(get_geo_cache)]
|
||||||
|
ServerStatusDep = Annotated[ServerStatus, Depends(get_server_status)]
|
||||||
|
PendingRecoveryDep = Annotated[PendingRecovery | None, Depends(get_pending_recovery)]
|
||||||
|
JailServiceStateDep = Annotated[JailServiceState, Depends(get_jail_service_state)]
|
||||||
|
HealthProbeDep = Annotated[Callable[[str], Awaitable[ServerStatus]], Depends(get_health_probe)]
|
||||||
|
SessionCacheDep = Annotated[SessionCache, Depends(get_session_cache)]
|
||||||
|
SessionRepoDep = Annotated[SessionRepository, Depends(get_session_repo)]
|
||||||
|
SettingsRepoDep = Annotated[SettingsRepository, Depends(get_settings_repo)]
|
||||||
|
HistoryArchiveRepositoryDep = Annotated[HistoryArchiveRepository, Depends(get_history_archive_repo)]
|
||||||
|
BlocklistRepositoryDep = Annotated[BlocklistRepository, Depends(get_blocklist_repo)]
|
||||||
|
ImportLogRepositoryDep = Annotated[ImportLogRepository, Depends(get_import_log_repo)]
|
||||||
|
ImportRunRepositoryDep = Annotated[ImportRunRepository, Depends(get_import_run_repo)]
|
||||||
|
GeoCacheRepositoryDep = Annotated[GeoCacheRepository, Depends(get_geo_cache_repo)]
|
||||||
|
Fail2BanDbRepositoryDep = Annotated[Fail2BanDbRepository, Depends(get_fail2ban_db_repo)]
|
||||||
|
AppStateDep = Annotated[ApplicationContext, Depends(get_app_state)]
|
||||||
|
AppDep = Annotated[FastAPI, Depends(get_app)]
|
||||||
AuthDep = Annotated[Session, Depends(require_auth)]
|
AuthDep = Annotated[Session, Depends(require_auth)]
|
||||||
|
GlobalRateLimiterDep = Annotated[GlobalRateLimiter, Depends(get_global_rate_limiter)]
|
||||||
|
Fail2BanMetadataServiceDep = Annotated[Fail2BanMetadataService, Depends(get_fail2ban_metadata_service)]
|
||||||
|
|
||||||
|
# Service context dependencies (db + repositories combined for routers)
|
||||||
|
# Routers should use these instead of importing DbDep directly.
|
||||||
|
SessionServiceContextDep = Annotated[SessionServiceContext, Depends(get_session_service_context)]
|
||||||
|
BlocklistServiceContextDep = Annotated[BlocklistServiceContext, Depends(get_blocklist_service_context)]
|
||||||
|
SettingsServiceContextDep = Annotated[SettingsServiceContext, Depends(get_settings_service_context)]
|
||||||
|
BanServiceContextDep = Annotated[BanServiceContext, Depends(get_ban_service_context)]
|
||||||
|
HistoryServiceContextDep = Annotated[HistoryServiceContext, Depends(get_history_service_context)]
|
||||||
|
|
||||||
|
# DEPRECATED: DbDep is provided for backward compatibility only.
|
||||||
|
# DO NOT use in new code. Use repository dependencies instead (SessionRepoDep, BlocklistRepositoryDep, etc.)
|
||||||
|
# See Backend-Development.md § 6 for dependency layering rules.
|
||||||
|
DbDep = _DbDep
|
||||||
|
|||||||
@@ -1,53 +1,527 @@
|
|||||||
"""Shared domain exception classes used across routers and services."""
|
"""Shared domain exception classes used across routers and services.
|
||||||
|
|
||||||
|
Exception Taxonomy
|
||||||
|
==================
|
||||||
|
|
||||||
|
All domain exceptions inherit from one of these base categories:
|
||||||
|
|
||||||
|
- **NotFoundError** (404): Domain entity not found
|
||||||
|
- **BadRequestError** (400): Invalid input, validation failure, invalid identifiers
|
||||||
|
- **ConflictError** (409): State conflict, resource already exists, invalid state transition
|
||||||
|
- **OperationError** (500): Operation failure, write errors
|
||||||
|
- **ServiceUnavailableError** (503): Infrastructure/external service issues
|
||||||
|
- **AuthenticationError** (401): Authentication or authorization failure
|
||||||
|
- **RateLimitError** (429): Rate limit exceeded
|
||||||
|
|
||||||
|
Service exceptions inherit from the appropriate category, allowing routers to
|
||||||
|
handle categories rather than individual exception types. Exception handlers in
|
||||||
|
main.py register only base category types.
|
||||||
|
|
||||||
|
Every exception class has:
|
||||||
|
- **error_code**: A machine-readable error code for client-side branching
|
||||||
|
- **get_error_metadata()**: Returns structured metadata for the API response
|
||||||
|
|
||||||
|
Example:
|
||||||
|
def get_jail(name: str) -> Jail:
|
||||||
|
# Raises JailNotFoundError (subclass of NotFoundError)
|
||||||
|
...
|
||||||
|
|
||||||
|
@app.exception_handler(NotFoundError)
|
||||||
|
async def handle_not_found(request, exc):
|
||||||
|
return JSONResponse(status_code=404, content=ErrorResponse(
|
||||||
|
code=exc.error_code,
|
||||||
|
detail=str(exc),
|
||||||
|
metadata=exc.get_error_metadata()
|
||||||
|
).model_dump())
|
||||||
|
|
||||||
|
See Backend-Development.md for the complete exception contract.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
class JailNotFoundError(Exception):
|
from app.utils.display_sanitizer import sanitize_for_display
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.response import ErrorMetadata
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Exception Base Classes (Categories)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class DomainError(Exception):
|
||||||
|
"""Base class for all domain exceptions.
|
||||||
|
|
||||||
|
All domain exceptions must:
|
||||||
|
1. Define an `error_code` class attribute (machine-readable error code)
|
||||||
|
2. Implement `get_error_metadata()` to return structured error context
|
||||||
|
"""
|
||||||
|
|
||||||
|
error_code: str = "internal_error"
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
"""Return structured metadata for the API error response.
|
||||||
|
|
||||||
|
Subclasses should override to expose only safe, relevant metadata.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dictionary of metadata key-value pairs safe for client consumption.
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(DomainError):
|
||||||
|
"""Raised when a requested domain entity is not found. HTTP 404."""
|
||||||
|
|
||||||
|
error_code: str = "not_found"
|
||||||
|
|
||||||
|
|
||||||
|
class BadRequestError(DomainError):
|
||||||
|
"""Raised for invalid input, validation failures, or invalid identifiers. HTTP 400."""
|
||||||
|
|
||||||
|
error_code: str = "invalid_input"
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictError(DomainError):
|
||||||
|
"""Raised for state conflicts or resource constraints. HTTP 409."""
|
||||||
|
|
||||||
|
error_code: str = "conflict"
|
||||||
|
|
||||||
|
|
||||||
|
class OperationError(DomainError):
|
||||||
|
"""Raised when a domain operation fails (write, update, etc.). HTTP 500."""
|
||||||
|
|
||||||
|
error_code: str = "operation_failed"
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceUnavailableError(DomainError):
|
||||||
|
"""Raised for infrastructure or external service issues. HTTP 503."""
|
||||||
|
|
||||||
|
error_code: str = "service_unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationError(DomainError):
|
||||||
|
"""Raised for authentication or authorization failures. HTTP 401."""
|
||||||
|
|
||||||
|
error_code: str = "authentication_required"
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitError(DomainError):
|
||||||
|
"""Raised when a client exceeds rate limits. HTTP 429."""
|
||||||
|
|
||||||
|
error_code: str = "rate_limit_exceeded"
|
||||||
|
|
||||||
|
def __init__(self, message: str, retry_after_seconds: float = 60.0) -> None:
|
||||||
|
"""Initialize with a message and optional retry-after time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Description of the rate limit violation.
|
||||||
|
retry_after_seconds: Estimated seconds to wait before retrying (default 60).
|
||||||
|
"""
|
||||||
|
self.retry_after_seconds: float = retry_after_seconds
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"retry_after_seconds": self.retry_after_seconds}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Jail-Specific Exceptions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class JailNotFoundError(NotFoundError):
|
||||||
"""Raised when a requested jail name does not exist."""
|
"""Raised when a requested jail name does not exist."""
|
||||||
|
|
||||||
|
error_code: str = "jail_not_found"
|
||||||
|
|
||||||
def __init__(self, name: str) -> None:
|
def __init__(self, name: str) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
super().__init__(f"Jail not found: {name!r}")
|
super().__init__(f"Jail not found: {sanitize_for_display(name)!r}")
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"jail_name": self.name}
|
||||||
|
|
||||||
|
|
||||||
class JailOperationError(Exception):
|
class JailOperationError(ConflictError):
|
||||||
"""Raised when a fail2ban jail operation fails."""
|
"""Raised when a jail state operation fails (e.g. start/stop already in progress)."""
|
||||||
|
|
||||||
|
error_code: str = "jail_operation_failed"
|
||||||
|
|
||||||
|
|
||||||
class ConfigValidationError(Exception):
|
class ConfigValidationError(BadRequestError):
|
||||||
"""Raised when config values fail validation before applying."""
|
"""Raised when config values fail validation before applying."""
|
||||||
|
|
||||||
|
error_code: str = "config_validation_failed"
|
||||||
|
|
||||||
class ConfigOperationError(Exception):
|
|
||||||
|
class ConfigOperationError(BadRequestError):
|
||||||
"""Raised when a config payload update or command fails."""
|
"""Raised when a config payload update or command fails."""
|
||||||
|
|
||||||
|
error_code: str = "config_operation_failed"
|
||||||
|
|
||||||
class ServerOperationError(Exception):
|
|
||||||
|
class ConfigDirError(ServiceUnavailableError):
|
||||||
|
"""Raised when the fail2ban config directory is missing or inaccessible."""
|
||||||
|
|
||||||
|
error_code: str = "config_dir_unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFileNotFoundError(NotFoundError):
|
||||||
|
"""Raised when a requested config file does not exist."""
|
||||||
|
|
||||||
|
error_code: str = "config_file_not_found"
|
||||||
|
|
||||||
|
def __init__(self, filename: str) -> None:
|
||||||
|
"""Initialize with the filename that was not found.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: The filename that could not be located.
|
||||||
|
"""
|
||||||
|
self.filename = filename
|
||||||
|
super().__init__(f"Config file not found: {filename!r}")
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"filename": self.filename}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFileExistsError(ConflictError):
|
||||||
|
"""Raised when trying to create a file that already exists."""
|
||||||
|
|
||||||
|
error_code: str = "config_file_exists"
|
||||||
|
|
||||||
|
def __init__(self, filename: str) -> None:
|
||||||
|
"""Initialize with the filename that already exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: The filename that conflicts.
|
||||||
|
"""
|
||||||
|
self.filename = filename
|
||||||
|
super().__init__(f"Config file already exists: {filename!r}")
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"filename": self.filename}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFileWriteError(OperationError):
|
||||||
|
"""Raised when a file cannot be written (permissions, disk full, etc.)."""
|
||||||
|
|
||||||
|
error_code: str = "config_file_write_failed"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFileNameError(BadRequestError):
|
||||||
|
"""Raised when a supplied filename is invalid or unsafe."""
|
||||||
|
|
||||||
|
error_code: str = "config_file_name_invalid"
|
||||||
|
|
||||||
|
|
||||||
|
class ServerOperationError(BadRequestError):
|
||||||
"""Raised when a server control command (e.g. refresh) fails."""
|
"""Raised when a server control command (e.g. refresh) fails."""
|
||||||
|
|
||||||
|
error_code: str = "server_operation_failed"
|
||||||
|
|
||||||
class FilterInvalidRegexError(Exception):
|
|
||||||
|
class Fail2BanConnectionError(ServiceUnavailableError):
|
||||||
|
"""Raised when the fail2ban socket is unreachable or returns an error."""
|
||||||
|
|
||||||
|
error_code: str = "fail2ban_unreachable"
|
||||||
|
|
||||||
|
def __init__(self, message: str, socket_path: str) -> None:
|
||||||
|
"""Initialize with a human-readable message and the socket path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Description of the connection problem.
|
||||||
|
socket_path: The fail2ban socket path that was targeted.
|
||||||
|
"""
|
||||||
|
self.socket_path: str = socket_path
|
||||||
|
super().__init__(f"{message} (socket: {socket_path})")
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"socket_path": self.socket_path}
|
||||||
|
|
||||||
|
|
||||||
|
class Fail2BanProtocolError(ServiceUnavailableError):
|
||||||
|
"""Raised when the response from fail2ban cannot be parsed."""
|
||||||
|
|
||||||
|
error_code: str = "fail2ban_protocol_error"
|
||||||
|
|
||||||
|
|
||||||
|
class FilterInvalidRegexError(BadRequestError):
|
||||||
"""Raised when a regex pattern fails to compile."""
|
"""Raised when a regex pattern fails to compile."""
|
||||||
|
|
||||||
|
error_code: str = "filter_invalid_regex"
|
||||||
|
|
||||||
def __init__(self, pattern: str, error: str) -> None:
|
def __init__(self, pattern: str, error: str) -> None:
|
||||||
"""Initialize with the invalid pattern and compile error."""
|
"""Initialize with the invalid pattern and compile error."""
|
||||||
self.pattern = pattern
|
self.pattern = pattern
|
||||||
self.error = error
|
self.error = error
|
||||||
super().__init__(f"Invalid regex {pattern!r}: {error}")
|
super().__init__(f"Invalid regex {pattern!r}: {error}")
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"pattern": self.pattern, "error": self.error}
|
||||||
|
|
||||||
class JailNotFoundInConfigError(Exception):
|
|
||||||
|
class FilterRegexTooLongError(BadRequestError):
|
||||||
|
"""Raised when a regex pattern exceeds the maximum length."""
|
||||||
|
|
||||||
|
error_code: str = "filter_regex_too_long"
|
||||||
|
|
||||||
|
def __init__(self, pattern: str, max_length: int) -> None:
|
||||||
|
"""Initialize with the pattern and maximum allowed length.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern: The regex pattern that is too long.
|
||||||
|
max_length: The maximum allowed length.
|
||||||
|
"""
|
||||||
|
self.pattern = pattern
|
||||||
|
self.max_length = max_length
|
||||||
|
self.actual_length = len(pattern)
|
||||||
|
super().__init__(
|
||||||
|
f"Regex pattern exceeds maximum length of {max_length} characters: {self.actual_length} provided"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {
|
||||||
|
"pattern_length": self.actual_length,
|
||||||
|
"max_length": self.max_length,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FilterRegexTimeoutError(BadRequestError):
|
||||||
|
"""Raised when a regex pattern compilation times out (possible ReDoS attack)."""
|
||||||
|
|
||||||
|
error_code: str = "filter_regex_timeout"
|
||||||
|
|
||||||
|
def __init__(self, pattern: str, timeout_seconds: int) -> None:
|
||||||
|
"""Initialize with the pattern and timeout value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern: The regex pattern that timed out.
|
||||||
|
timeout_seconds: The timeout value in seconds.
|
||||||
|
"""
|
||||||
|
self.pattern = pattern
|
||||||
|
self.timeout_seconds = timeout_seconds
|
||||||
|
super().__init__(
|
||||||
|
f"Regex pattern compilation timed out after {timeout_seconds}s "
|
||||||
|
f"(possible ReDoS attack). Pattern is too complex or causes catastrophic backtracking."
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"timeout_seconds": self.timeout_seconds}
|
||||||
|
|
||||||
|
|
||||||
|
class JailNotFoundInConfigError(NotFoundError):
|
||||||
"""Raised when the requested jail name is not defined in any config file."""
|
"""Raised when the requested jail name is not defined in any config file."""
|
||||||
|
|
||||||
|
error_code: str = "jail_not_in_config"
|
||||||
|
|
||||||
def __init__(self, name: str) -> None:
|
def __init__(self, name: str) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
super().__init__(f"Jail not found in config: {name!r}")
|
super().__init__(f"Jail not found in config: {sanitize_for_display(name)!r}")
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"jail_name": self.name}
|
||||||
|
|
||||||
|
|
||||||
class ConfigWriteError(Exception):
|
class ConfigWriteError(OperationError):
|
||||||
"""Raised when writing a configuration file modification fails."""
|
"""Raised when writing a configuration file modification fails."""
|
||||||
|
|
||||||
|
error_code: str = "config_write_failed"
|
||||||
|
|
||||||
def __init__(self, message: str) -> None:
|
def __init__(self, message: str) -> None:
|
||||||
self.message = message
|
self.message = message
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"message": self.message}
|
||||||
|
|
||||||
|
|
||||||
|
class JailNameError(BadRequestError):
|
||||||
|
"""Raised when a jail name contains invalid characters."""
|
||||||
|
|
||||||
|
error_code: str = "jail_name_invalid"
|
||||||
|
|
||||||
|
|
||||||
|
class JailAlreadyActiveError(ConflictError):
|
||||||
|
"""Raised when trying to activate a jail that is already active."""
|
||||||
|
|
||||||
|
error_code: str = "jail_already_active"
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
self.name = name
|
||||||
|
super().__init__(f"Jail is already active: {sanitize_for_display(name)!r}")
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"jail_name": self.name}
|
||||||
|
|
||||||
|
|
||||||
|
class JailAlreadyInactiveError(ConflictError):
|
||||||
|
"""Raised when trying to deactivate a jail that is already inactive."""
|
||||||
|
|
||||||
|
error_code: str = "jail_already_inactive"
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
self.name = name
|
||||||
|
super().__init__(f"Jail is already inactive: {sanitize_for_display(name)!r}")
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"jail_name": self.name}
|
||||||
|
|
||||||
|
|
||||||
|
class FilterNotFoundError(NotFoundError):
|
||||||
|
"""Raised when the requested filter name is not found."""
|
||||||
|
|
||||||
|
error_code: str = "filter_not_found"
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
self.name = name
|
||||||
|
super().__init__(f"Filter not found: {name!r}")
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"filter_name": self.name}
|
||||||
|
|
||||||
|
|
||||||
|
class FilterAlreadyExistsError(ConflictError):
|
||||||
|
"""Raised when trying to create a filter whose `.conf` or `.local` already exists."""
|
||||||
|
|
||||||
|
error_code: str = "filter_already_exists"
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
self.name = name
|
||||||
|
super().__init__(f"Filter already exists: {name!r}")
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"filter_name": self.name}
|
||||||
|
|
||||||
|
|
||||||
|
class FilterNameError(BadRequestError):
|
||||||
|
"""Raised when a filter name contains invalid characters."""
|
||||||
|
|
||||||
|
error_code: str = "filter_name_invalid"
|
||||||
|
|
||||||
|
|
||||||
|
class FilterReadonlyError(ConflictError):
|
||||||
|
"""Raised when trying to delete a shipped `.conf` filter with no `.local` override."""
|
||||||
|
|
||||||
|
error_code: str = "filter_readonly"
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
self.name = name
|
||||||
|
super().__init__(
|
||||||
|
f"Filter {name!r} is a shipped default (.conf only); only user-created .local files can be deleted."
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"filter_name": self.name}
|
||||||
|
|
||||||
|
|
||||||
|
class ActionNotFoundError(NotFoundError):
|
||||||
|
"""Raised when the requested action name is not found."""
|
||||||
|
|
||||||
|
error_code: str = "action_not_found"
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
self.name = name
|
||||||
|
super().__init__(f"Action not found: {name!r}")
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"action_name": self.name}
|
||||||
|
|
||||||
|
|
||||||
|
class ActionAlreadyExistsError(ConflictError):
|
||||||
|
"""Raised when trying to create an action whose `.conf` or `.local` already exists."""
|
||||||
|
|
||||||
|
error_code: str = "action_already_exists"
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
self.name = name
|
||||||
|
super().__init__(f"Action already exists: {name!r}")
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"action_name": self.name}
|
||||||
|
|
||||||
|
|
||||||
|
class ActionNameError(BadRequestError):
|
||||||
|
"""Raised when an action name contains invalid characters."""
|
||||||
|
|
||||||
|
error_code: str = "action_name_invalid"
|
||||||
|
|
||||||
|
|
||||||
|
class ActionReadonlyError(ConflictError):
|
||||||
|
"""Raised when trying to delete a shipped `.conf` action with no `.local` override."""
|
||||||
|
|
||||||
|
error_code: str = "action_readonly"
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
self.name = name
|
||||||
|
super().__init__(
|
||||||
|
f"Action {name!r} is a shipped default (.conf only); only user-created .local files can be deleted."
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"action_name": self.name}
|
||||||
|
|
||||||
|
|
||||||
|
class SetupAlreadyCompleteError(ConflictError):
|
||||||
|
"""Raised when attempting to run setup when it has already been completed."""
|
||||||
|
|
||||||
|
error_code: str = "setup_already_complete"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("Setup has already been completed.")
|
||||||
|
|
||||||
|
|
||||||
|
class BlocklistSourceNotFoundError(NotFoundError):
|
||||||
|
"""Raised when a blocklist source is not found."""
|
||||||
|
|
||||||
|
error_code: str = "blocklist_source_not_found"
|
||||||
|
|
||||||
|
def __init__(self, source_id: int) -> None:
|
||||||
|
self.source_id = source_id
|
||||||
|
super().__init__(f"Blocklist source not found: {source_id}")
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"source_id": self.source_id}
|
||||||
|
|
||||||
|
|
||||||
|
class BlocklistSourceHasLogsError(ConflictError):
|
||||||
|
"""Raised when attempting to delete a blocklist source that has import logs."""
|
||||||
|
|
||||||
|
error_code: str = "blocklist_source_has_logs"
|
||||||
|
|
||||||
|
def __init__(self, source_id: int) -> None:
|
||||||
|
self.source_id = source_id
|
||||||
|
super().__init__(
|
||||||
|
f"Blocklist source {source_id} cannot be deleted because it has import logs. Delete the import logs first."
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"source_id": self.source_id}
|
||||||
|
|
||||||
|
|
||||||
|
class BlocklistSourceAlreadyExistsError(ConflictError):
|
||||||
|
"""Raised when a blocklist source with the same URL already exists."""
|
||||||
|
|
||||||
|
error_code: str = "blocklist_source_already_exists"
|
||||||
|
|
||||||
|
def __init__(self, url: str) -> None:
|
||||||
|
self.url = url
|
||||||
|
super().__init__(f"Blocklist source with URL already exists: {url}")
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"url": self.url}
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryNotFoundError(NotFoundError):
|
||||||
|
"""Raised when no history is found for the given IP."""
|
||||||
|
|
||||||
|
error_code: str = "history_not_found"
|
||||||
|
|
||||||
|
def __init__(self, ip: str) -> None:
|
||||||
|
self.ip = ip
|
||||||
|
super().__init__(f"No history found for IP: {ip}")
|
||||||
|
|
||||||
|
def get_error_metadata(self) -> ErrorMetadata:
|
||||||
|
return {"ip": self.ip}
|
||||||
|
|||||||
1136
backend/app/main.py
1136
backend/app/main.py
File diff suppressed because it is too large
Load Diff
27
backend/app/mappers/__init__.py
Normal file
27
backend/app/mappers/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Response mappers.
|
||||||
|
|
||||||
|
Convert domain models (from services) to response models (for HTTP API).
|
||||||
|
|
||||||
|
This is the mapping layer at the router boundary, ensuring the service layer
|
||||||
|
remains independent of HTTP response shapes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.mappers.ban_mappers import (
|
||||||
|
map_domain_active_ban_list_to_response,
|
||||||
|
map_domain_active_ban_to_response,
|
||||||
|
map_domain_ban_trend_to_response,
|
||||||
|
map_domain_bans_by_country_to_response,
|
||||||
|
map_domain_bans_by_jail_to_response,
|
||||||
|
map_domain_dashboard_ban_item_to_response,
|
||||||
|
map_domain_dashboard_ban_list_to_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"map_domain_active_ban_to_response",
|
||||||
|
"map_domain_active_ban_list_to_response",
|
||||||
|
"map_domain_dashboard_ban_item_to_response",
|
||||||
|
"map_domain_dashboard_ban_list_to_response",
|
||||||
|
"map_domain_bans_by_country_to_response",
|
||||||
|
"map_domain_ban_trend_to_response",
|
||||||
|
"map_domain_bans_by_jail_to_response",
|
||||||
|
]
|
||||||
119
backend/app/mappers/ban_mappers.py
Normal file
119
backend/app/mappers/ban_mappers.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""Ban response mappers.
|
||||||
|
|
||||||
|
Convert domain models (from ban_service) to response models (for HTTP API).
|
||||||
|
|
||||||
|
This is the mapping layer at the router boundary, ensuring the service layer
|
||||||
|
remains independent of HTTP response shapes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.models.ban import (
|
||||||
|
ActiveBan,
|
||||||
|
ActiveBanListResponse,
|
||||||
|
BansByCountryResponse,
|
||||||
|
BansByJailResponse,
|
||||||
|
BanTrendBucket,
|
||||||
|
BanTrendResponse,
|
||||||
|
DashboardBanItem,
|
||||||
|
DashboardBanListResponse,
|
||||||
|
JailBanCount,
|
||||||
|
)
|
||||||
|
from app.models.ban_domain import (
|
||||||
|
DomainActiveBan,
|
||||||
|
DomainActiveBanList,
|
||||||
|
DomainBansByCountry,
|
||||||
|
DomainBansByJail,
|
||||||
|
DomainBanTrend,
|
||||||
|
DomainDashboardBanItem,
|
||||||
|
DomainDashboardBanList,
|
||||||
|
)
|
||||||
|
from app.utils.pagination import create_pagination_metadata
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_active_ban_to_response(domain_ban: DomainActiveBan) -> ActiveBan:
|
||||||
|
"""Convert a domain active ban to a response model."""
|
||||||
|
return ActiveBan(
|
||||||
|
ip=domain_ban.ip,
|
||||||
|
jail=domain_ban.jail,
|
||||||
|
banned_at=domain_ban.banned_at,
|
||||||
|
expires_at=domain_ban.expires_at,
|
||||||
|
ban_count=domain_ban.ban_count,
|
||||||
|
country=domain_ban.country,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_active_ban_list_to_response(
|
||||||
|
domain_list: DomainActiveBanList,
|
||||||
|
) -> ActiveBanListResponse:
|
||||||
|
"""Convert a domain active ban list to a response model."""
|
||||||
|
return ActiveBanListResponse(
|
||||||
|
items=[map_domain_active_ban_to_response(ban) for ban in domain_list.bans],
|
||||||
|
total=domain_list.total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_dashboard_ban_item_to_response(
|
||||||
|
domain_item: DomainDashboardBanItem,
|
||||||
|
) -> DashboardBanItem:
|
||||||
|
"""Convert a domain dashboard ban item to a response model."""
|
||||||
|
return DashboardBanItem(
|
||||||
|
ip=domain_item.ip,
|
||||||
|
jail=domain_item.jail,
|
||||||
|
banned_at=domain_item.banned_at,
|
||||||
|
service=domain_item.service,
|
||||||
|
country_code=domain_item.country_code,
|
||||||
|
country_name=domain_item.country_name,
|
||||||
|
asn=domain_item.asn,
|
||||||
|
org=domain_item.org,
|
||||||
|
ban_count=domain_item.ban_count,
|
||||||
|
origin=domain_item.origin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_dashboard_ban_list_to_response(
|
||||||
|
domain_list: DomainDashboardBanList,
|
||||||
|
) -> DashboardBanListResponse:
|
||||||
|
"""Convert a domain dashboard ban list to a response model."""
|
||||||
|
return DashboardBanListResponse(
|
||||||
|
items=[
|
||||||
|
map_domain_dashboard_ban_item_to_response(item) for item in domain_list.items
|
||||||
|
],
|
||||||
|
pagination=create_pagination_metadata(domain_list.total, domain_list.page, domain_list.page_size),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_bans_by_country_to_response(
|
||||||
|
domain_data: DomainBansByCountry,
|
||||||
|
) -> BansByCountryResponse:
|
||||||
|
"""Convert domain bans-by-country data to a response model."""
|
||||||
|
return BansByCountryResponse(
|
||||||
|
countries=domain_data.countries,
|
||||||
|
country_names=domain_data.country_names,
|
||||||
|
bans=[map_domain_dashboard_ban_item_to_response(item) for item in domain_data.items],
|
||||||
|
total=domain_data.total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_ban_trend_to_response(domain_trend: DomainBanTrend) -> BanTrendResponse:
|
||||||
|
"""Convert domain ban trend data to a response model."""
|
||||||
|
return BanTrendResponse(
|
||||||
|
buckets=[
|
||||||
|
BanTrendBucket(timestamp=bucket.timestamp, count=bucket.count)
|
||||||
|
for bucket in domain_trend.buckets
|
||||||
|
],
|
||||||
|
bucket_size=domain_trend.bucket_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_bans_by_jail_to_response(
|
||||||
|
domain_data: DomainBansByJail,
|
||||||
|
) -> BansByJailResponse:
|
||||||
|
"""Convert domain bans-by-jail data to a response model."""
|
||||||
|
return BansByJailResponse(
|
||||||
|
jails=[
|
||||||
|
JailBanCount(jail=jail_count.jail, count=jail_count.count)
|
||||||
|
for jail_count in domain_data.jails
|
||||||
|
],
|
||||||
|
total=domain_data.total,
|
||||||
|
)
|
||||||
141
backend/app/mappers/blocklist_mappers.py
Normal file
141
backend/app/mappers/blocklist_mappers.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""Blocklist response mappers.
|
||||||
|
|
||||||
|
Convert domain models (from blocklist_service) to response models (for HTTP API).
|
||||||
|
|
||||||
|
This is the mapping layer at the router boundary, ensuring the service layer
|
||||||
|
remains independent of HTTP response shapes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.models.blocklist import (
|
||||||
|
BlocklistSource,
|
||||||
|
ImportLogEntry,
|
||||||
|
ImportLogListResponse,
|
||||||
|
ImportRunResult,
|
||||||
|
ImportSourceResult,
|
||||||
|
PreviewResponse,
|
||||||
|
ScheduleConfig,
|
||||||
|
ScheduleFrequency,
|
||||||
|
ScheduleInfo,
|
||||||
|
)
|
||||||
|
from app.models.blocklist_domain import (
|
||||||
|
DomainBlocklistSource,
|
||||||
|
DomainImportLogEntry,
|
||||||
|
DomainImportLogList,
|
||||||
|
DomainImportRunResult,
|
||||||
|
DomainImportSourceResult,
|
||||||
|
DomainPreviewResult,
|
||||||
|
DomainScheduleConfig,
|
||||||
|
DomainScheduleFrequency,
|
||||||
|
DomainScheduleInfo,
|
||||||
|
)
|
||||||
|
from app.utils.pagination import create_pagination_metadata
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_blocklist_source_to_response(
|
||||||
|
domain: DomainBlocklistSource,
|
||||||
|
) -> BlocklistSource:
|
||||||
|
"""Convert domain blocklist source to response model."""
|
||||||
|
return BlocklistSource(
|
||||||
|
id=domain.id,
|
||||||
|
name=domain.name,
|
||||||
|
url=domain.url,
|
||||||
|
enabled=domain.enabled,
|
||||||
|
created_at=domain.created_at,
|
||||||
|
updated_at=domain.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_import_log_entry_to_response(
|
||||||
|
domain: DomainImportLogEntry,
|
||||||
|
) -> ImportLogEntry:
|
||||||
|
"""Convert domain import log entry to response model."""
|
||||||
|
return ImportLogEntry(
|
||||||
|
id=domain.id,
|
||||||
|
source_id=domain.source_id,
|
||||||
|
source_url=domain.source_url,
|
||||||
|
timestamp=domain.timestamp,
|
||||||
|
ips_imported=domain.ips_imported,
|
||||||
|
ips_skipped=domain.ips_skipped,
|
||||||
|
errors=domain.errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_import_log_list_to_response(
|
||||||
|
domain_list: DomainImportLogList,
|
||||||
|
) -> ImportLogListResponse:
|
||||||
|
"""Convert domain import log list to response model."""
|
||||||
|
return ImportLogListResponse(
|
||||||
|
items=[map_domain_import_log_entry_to_response(i) for i in domain_list.items],
|
||||||
|
pagination=create_pagination_metadata(
|
||||||
|
domain_list.total, domain_list.page, domain_list.page_size
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_schedule_frequency_to_response(
|
||||||
|
domain: DomainScheduleFrequency,
|
||||||
|
) -> ScheduleFrequency:
|
||||||
|
"""Convert domain schedule frequency to response model."""
|
||||||
|
return ScheduleFrequency(domain.value)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_schedule_config_to_response(
|
||||||
|
domain: DomainScheduleConfig,
|
||||||
|
) -> ScheduleConfig:
|
||||||
|
"""Convert domain schedule config to response model."""
|
||||||
|
return ScheduleConfig(
|
||||||
|
frequency=map_domain_schedule_frequency_to_response(domain.frequency),
|
||||||
|
interval_hours=domain.interval_hours,
|
||||||
|
hour=domain.hour,
|
||||||
|
minute=domain.minute,
|
||||||
|
day_of_week=domain.day_of_week,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_schedule_info_to_response(domain: DomainScheduleInfo) -> ScheduleInfo:
|
||||||
|
"""Convert domain schedule info to response model."""
|
||||||
|
return ScheduleInfo(
|
||||||
|
config=map_domain_schedule_config_to_response(domain.config),
|
||||||
|
next_run_at=domain.next_run_at,
|
||||||
|
last_run_at=domain.last_run_at,
|
||||||
|
last_run_errors=domain.last_run_errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_preview_result_to_response(domain: DomainPreviewResult) -> PreviewResponse:
|
||||||
|
"""Convert domain preview result to response model."""
|
||||||
|
return PreviewResponse(
|
||||||
|
entries=domain.entries,
|
||||||
|
total_lines=domain.total_lines,
|
||||||
|
valid_count=domain.valid_count,
|
||||||
|
skipped_count=domain.skipped_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_import_source_result_to_response(
|
||||||
|
domain: DomainImportSourceResult,
|
||||||
|
) -> ImportSourceResult:
|
||||||
|
"""Convert domain import source result to response model."""
|
||||||
|
return ImportSourceResult(
|
||||||
|
source_id=domain.source_id,
|
||||||
|
source_url=domain.source_url,
|
||||||
|
ips_imported=domain.ips_imported,
|
||||||
|
ips_skipped=domain.ips_skipped,
|
||||||
|
error=domain.error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_import_run_result_to_response(
|
||||||
|
domain: DomainImportRunResult,
|
||||||
|
) -> ImportRunResult:
|
||||||
|
"""Convert domain import run result to response model."""
|
||||||
|
return ImportRunResult(
|
||||||
|
results=[
|
||||||
|
map_domain_import_source_result_to_response(r) for r in domain.results
|
||||||
|
],
|
||||||
|
total_imported=domain.total_imported,
|
||||||
|
total_skipped=domain.total_skipped,
|
||||||
|
errors_count=domain.errors_count,
|
||||||
|
)
|
||||||
151
backend/app/mappers/config_mappers.py
Normal file
151
backend/app/mappers/config_mappers.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""Config response mappers.
|
||||||
|
|
||||||
|
Convert domain models (from config_service) to response models (for HTTP API).
|
||||||
|
|
||||||
|
This is the mapping layer at the router boundary, ensuring the service layer
|
||||||
|
remains independent of HTTP response shapes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.models.config import (
|
||||||
|
BantimeEscalation,
|
||||||
|
FilterConfig,
|
||||||
|
FilterListResponse,
|
||||||
|
GlobalConfigResponse,
|
||||||
|
JailConfig,
|
||||||
|
JailConfigListResponse,
|
||||||
|
MapColorThresholdsResponse,
|
||||||
|
RegexTestResponse,
|
||||||
|
ServiceStatusResponse,
|
||||||
|
)
|
||||||
|
from app.models.config_domain import (
|
||||||
|
DomainBantimeEscalation,
|
||||||
|
DomainFilterConfig,
|
||||||
|
DomainFilterList,
|
||||||
|
DomainGlobalConfig,
|
||||||
|
DomainJailConfig,
|
||||||
|
DomainJailConfigList,
|
||||||
|
DomainMapColorThresholds,
|
||||||
|
DomainRegexTest,
|
||||||
|
DomainServiceStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_domain_bantime_escalation(domain: DomainBantimeEscalation) -> BantimeEscalation:
|
||||||
|
"""Convert domain bantime escalation to response model."""
|
||||||
|
return BantimeEscalation(
|
||||||
|
increment=domain.increment,
|
||||||
|
factor=domain.factor,
|
||||||
|
formula=domain.formula,
|
||||||
|
multipliers=domain.multipliers,
|
||||||
|
max_time=domain.max_time,
|
||||||
|
rnd_time=domain.rnd_time,
|
||||||
|
overall_jails=domain.overall_jails,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_jail_config_to_response(domain: DomainJailConfig) -> JailConfig:
|
||||||
|
"""Convert domain jail config to response model."""
|
||||||
|
return JailConfig(
|
||||||
|
name=domain.name,
|
||||||
|
ban_time=domain.ban_time,
|
||||||
|
max_retry=domain.max_retry,
|
||||||
|
find_time=domain.find_time,
|
||||||
|
fail_regex=domain.fail_regex,
|
||||||
|
ignore_regex=domain.ignore_regex,
|
||||||
|
log_paths=domain.log_paths,
|
||||||
|
date_pattern=domain.date_pattern,
|
||||||
|
log_encoding=domain.log_encoding,
|
||||||
|
backend=domain.backend,
|
||||||
|
use_dns=domain.use_dns,
|
||||||
|
prefregex=domain.prefregex,
|
||||||
|
actions=domain.actions,
|
||||||
|
bantime_escalation=(
|
||||||
|
_map_domain_bantime_escalation(domain.bantime_escalation) if domain.bantime_escalation else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_jail_config_list_to_response(
|
||||||
|
domain_list: DomainJailConfigList,
|
||||||
|
) -> JailConfigListResponse:
|
||||||
|
"""Convert domain jail config list to response model."""
|
||||||
|
return JailConfigListResponse(
|
||||||
|
items=[map_domain_jail_config_to_response(c) for c in domain_list.items],
|
||||||
|
total=domain_list.total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_global_config_to_response(domain: DomainGlobalConfig) -> GlobalConfigResponse:
|
||||||
|
"""Convert domain global config to response model."""
|
||||||
|
return GlobalConfigResponse(
|
||||||
|
log_level=domain.log_level,
|
||||||
|
log_target=domain.log_target,
|
||||||
|
db_purge_age=domain.db_purge_age,
|
||||||
|
db_max_matches=domain.db_max_matches,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_service_status_to_response(
|
||||||
|
domain: DomainServiceStatus,
|
||||||
|
) -> ServiceStatusResponse:
|
||||||
|
"""Convert domain service status to response model."""
|
||||||
|
return ServiceStatusResponse(
|
||||||
|
online=domain.online,
|
||||||
|
version=domain.version or "",
|
||||||
|
jail_count=domain.jail_count,
|
||||||
|
total_bans=domain.total_bans,
|
||||||
|
total_failures=domain.total_failures,
|
||||||
|
log_level=domain.log_level or "UNKNOWN",
|
||||||
|
log_target=domain.log_target or "UNKNOWN",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_map_color_thresholds_to_response(
|
||||||
|
domain: DomainMapColorThresholds,
|
||||||
|
) -> MapColorThresholdsResponse:
|
||||||
|
"""Convert domain map color thresholds to response model."""
|
||||||
|
return MapColorThresholdsResponse(
|
||||||
|
threshold_high=domain.threshold_high,
|
||||||
|
threshold_medium=domain.threshold_medium,
|
||||||
|
threshold_low=domain.threshold_low,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_regex_test_to_response(domain: DomainRegexTest) -> RegexTestResponse:
|
||||||
|
"""Convert domain regex test to response model."""
|
||||||
|
return RegexTestResponse(
|
||||||
|
matched=domain.matched,
|
||||||
|
groups=domain.groups,
|
||||||
|
error=domain.error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_filter_config_to_response(domain: DomainFilterConfig) -> FilterConfig:
|
||||||
|
"""Convert domain filter config to response model."""
|
||||||
|
return FilterConfig(
|
||||||
|
name=domain.name,
|
||||||
|
filename=domain.filename,
|
||||||
|
before=domain.before,
|
||||||
|
after=domain.after,
|
||||||
|
variables=domain.variables or {},
|
||||||
|
prefregex=domain.prefregex,
|
||||||
|
failregex=domain.failregex or [],
|
||||||
|
ignoreregex=domain.ignoreregex or [],
|
||||||
|
maxlines=domain.maxlines,
|
||||||
|
datepattern=domain.datepattern,
|
||||||
|
journalmatch=domain.journalmatch,
|
||||||
|
active=domain.active,
|
||||||
|
used_by_jails=domain.used_by_jails or [],
|
||||||
|
source_file=domain.source_file,
|
||||||
|
has_local_override=domain.has_local_override,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_filter_list_to_response(domain_list: DomainFilterList) -> FilterListResponse:
|
||||||
|
"""Convert domain filter list to response model."""
|
||||||
|
return FilterListResponse(
|
||||||
|
filters=[map_domain_filter_config_to_response(f) for f in domain_list.items],
|
||||||
|
total=domain_list.total,
|
||||||
|
)
|
||||||
23
backend/app/mappers/health_mappers.py
Normal file
23
backend/app/mappers/health_mappers.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""Health response mappers.
|
||||||
|
|
||||||
|
Convert domain models (from health_service) to response models (for HTTP API).
|
||||||
|
|
||||||
|
This is the mapping layer at the router boundary, ensuring the service layer
|
||||||
|
remains independent of HTTP response shapes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.models.health_domain import DomainServerStatus
|
||||||
|
from app.models.server import ServerStatus
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_server_status_to_response(domain: DomainServerStatus) -> ServerStatus:
|
||||||
|
"""Convert domain server status to response model."""
|
||||||
|
return ServerStatus(
|
||||||
|
online=domain.online,
|
||||||
|
version=domain.version,
|
||||||
|
active_jails=domain.active_jails,
|
||||||
|
total_bans=domain.total_bans,
|
||||||
|
total_failures=domain.total_failures,
|
||||||
|
)
|
||||||
81
backend/app/mappers/history_mappers.py
Normal file
81
backend/app/mappers/history_mappers.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""History response mappers.
|
||||||
|
|
||||||
|
Convert domain models (from history_service) to response models (for HTTP API).
|
||||||
|
|
||||||
|
This is the mapping layer at the router boundary, ensuring the service layer
|
||||||
|
remains independent of HTTP response shapes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.models.history import (
|
||||||
|
HistoryBanItem,
|
||||||
|
HistoryListResponse,
|
||||||
|
IpDetailResponse,
|
||||||
|
IpTimelineEvent,
|
||||||
|
)
|
||||||
|
from app.models.history_domain import (
|
||||||
|
DomainHistoryBanItem,
|
||||||
|
DomainHistoryList,
|
||||||
|
DomainIpDetail,
|
||||||
|
DomainIpTimelineEvent,
|
||||||
|
)
|
||||||
|
from app.utils.pagination import create_pagination_metadata
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_history_ban_item_to_response(
|
||||||
|
domain: DomainHistoryBanItem,
|
||||||
|
) -> HistoryBanItem:
|
||||||
|
"""Convert domain history ban item to response model."""
|
||||||
|
return HistoryBanItem(
|
||||||
|
ip=domain.ip,
|
||||||
|
jail=domain.jail,
|
||||||
|
banned_at=domain.banned_at,
|
||||||
|
ban_count=domain.ban_count,
|
||||||
|
failures=domain.failures,
|
||||||
|
matches=domain.matches or [],
|
||||||
|
country_code=domain.country_code,
|
||||||
|
country_name=domain.country_name,
|
||||||
|
asn=domain.asn,
|
||||||
|
org=domain.org,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_history_list_to_response(domain: DomainHistoryList) -> HistoryListResponse:
|
||||||
|
"""Convert domain history list to response model."""
|
||||||
|
return HistoryListResponse(
|
||||||
|
items=[map_domain_history_ban_item_to_response(i) for i in domain.items],
|
||||||
|
pagination=create_pagination_metadata(
|
||||||
|
domain.total, domain.page, domain.page_size
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_ip_timeline_event_to_response(
|
||||||
|
domain: DomainIpTimelineEvent,
|
||||||
|
) -> IpTimelineEvent:
|
||||||
|
"""Convert domain IP timeline event to response model."""
|
||||||
|
return IpTimelineEvent(
|
||||||
|
jail=domain.jail,
|
||||||
|
banned_at=domain.banned_at,
|
||||||
|
ban_count=domain.ban_count,
|
||||||
|
failures=domain.failures,
|
||||||
|
matches=domain.matches or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_ip_detail_to_response(domain: DomainIpDetail) -> IpDetailResponse:
|
||||||
|
"""Convert domain IP detail to response model."""
|
||||||
|
return IpDetailResponse(
|
||||||
|
ip=domain.ip,
|
||||||
|
total_bans=domain.total_bans,
|
||||||
|
total_failures=domain.total_failures,
|
||||||
|
last_ban_at=domain.last_ban_at,
|
||||||
|
country_code=domain.country_code,
|
||||||
|
country_name=domain.country_name,
|
||||||
|
asn=domain.asn,
|
||||||
|
org=domain.org,
|
||||||
|
timeline=[
|
||||||
|
map_domain_ip_timeline_event_to_response(t) for t in (domain.timeline or [])
|
||||||
|
],
|
||||||
|
)
|
||||||
133
backend/app/mappers/jail_mappers.py
Normal file
133
backend/app/mappers/jail_mappers.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""Jail response mappers.
|
||||||
|
|
||||||
|
Convert domain models (from jail_service) to response models (for HTTP API).
|
||||||
|
|
||||||
|
This is the mapping layer at the router boundary, ensuring the service layer
|
||||||
|
remains independent of HTTP response shapes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.models.ban import ActiveBan, JailBannedIpsResponse
|
||||||
|
from app.models.ban_domain import DomainActiveBan
|
||||||
|
from app.models.jail import (
|
||||||
|
Jail,
|
||||||
|
JailDetailResponse,
|
||||||
|
JailListResponse,
|
||||||
|
JailStatus,
|
||||||
|
JailSummary,
|
||||||
|
)
|
||||||
|
from app.models.jail_domain import (
|
||||||
|
DomainJailBannedIps,
|
||||||
|
DomainBantimeEscalation,
|
||||||
|
DomainJail,
|
||||||
|
DomainJailDetail,
|
||||||
|
DomainJailList,
|
||||||
|
DomainJailStatus,
|
||||||
|
DomainJailSummary,
|
||||||
|
)
|
||||||
|
from app.utils.pagination import create_pagination_metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _map_domain_jail_status(domain: DomainJailStatus) -> JailStatus:
|
||||||
|
"""Convert domain jail status to response model."""
|
||||||
|
return JailStatus(
|
||||||
|
currently_banned=domain.currently_banned,
|
||||||
|
total_banned=domain.total_banned,
|
||||||
|
currently_failed=domain.currently_failed,
|
||||||
|
total_failed=domain.total_failed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_domain_bantime_escalation(domain: DomainBantimeEscalation) -> object:
|
||||||
|
"""Convert domain bantime escalation to response model."""
|
||||||
|
from app.models.config import BantimeEscalation
|
||||||
|
|
||||||
|
return BantimeEscalation(
|
||||||
|
increment=domain.increment,
|
||||||
|
factor=domain.factor,
|
||||||
|
formula=domain.formula,
|
||||||
|
multipliers=domain.multipliers,
|
||||||
|
max_time=domain.max_time,
|
||||||
|
rnd_time=domain.rnd_time,
|
||||||
|
overall_jails=domain.overall_jails,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_jail_summary_to_response(domain: DomainJailSummary) -> JailSummary:
|
||||||
|
"""Convert domain jail summary to response model."""
|
||||||
|
return JailSummary(
|
||||||
|
name=domain.name,
|
||||||
|
enabled=domain.enabled,
|
||||||
|
running=domain.running,
|
||||||
|
idle=domain.idle,
|
||||||
|
backend=domain.backend,
|
||||||
|
find_time=domain.find_time,
|
||||||
|
ban_time=domain.ban_time,
|
||||||
|
max_retry=domain.max_retry,
|
||||||
|
status=_map_domain_jail_status(domain.status) if domain.status else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_jail_list_to_response(domain_list: DomainJailList) -> JailListResponse:
|
||||||
|
"""Convert domain jail list to response model."""
|
||||||
|
return JailListResponse(
|
||||||
|
items=[map_domain_jail_summary_to_response(j) for j in domain_list.items],
|
||||||
|
total=domain_list.total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_jail_to_response(domain: DomainJail) -> Jail:
|
||||||
|
"""Convert domain jail to response model."""
|
||||||
|
return Jail(
|
||||||
|
name=domain.name,
|
||||||
|
enabled=domain.enabled,
|
||||||
|
running=domain.running,
|
||||||
|
idle=domain.idle,
|
||||||
|
backend=domain.backend,
|
||||||
|
log_paths=domain.log_paths,
|
||||||
|
fail_regex=domain.fail_regex,
|
||||||
|
ignore_regex=domain.ignore_regex,
|
||||||
|
ignore_ips=domain.ignore_ips,
|
||||||
|
date_pattern=domain.date_pattern,
|
||||||
|
log_encoding=domain.log_encoding,
|
||||||
|
find_time=domain.find_time,
|
||||||
|
ban_time=domain.ban_time,
|
||||||
|
max_retry=domain.max_retry,
|
||||||
|
actions=domain.actions,
|
||||||
|
bantime_escalation=(
|
||||||
|
_map_domain_bantime_escalation(domain.bantime_escalation)
|
||||||
|
if domain.bantime_escalation
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
status=_map_domain_jail_status(domain.status) if domain.status else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_jail_detail_to_response(domain: DomainJailDetail) -> JailDetailResponse:
|
||||||
|
"""Convert domain jail detail to response model."""
|
||||||
|
return JailDetailResponse(
|
||||||
|
jail=map_domain_jail_to_response(domain.jail),
|
||||||
|
ignore_list=domain.ignore_list,
|
||||||
|
ignore_self=domain.ignore_self,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_jail_banned_ips_to_response(
|
||||||
|
domain: DomainJailBannedIps,
|
||||||
|
) -> JailBannedIpsResponse:
|
||||||
|
"""Convert domain jail banned IPs to response model."""
|
||||||
|
return JailBannedIpsResponse(
|
||||||
|
items=[
|
||||||
|
ActiveBan(
|
||||||
|
ip=ban.ip,
|
||||||
|
jail=ban.jail,
|
||||||
|
banned_at=ban.banned_at,
|
||||||
|
expires_at=ban.expires_at,
|
||||||
|
ban_count=ban.ban_count,
|
||||||
|
country=ban.country,
|
||||||
|
)
|
||||||
|
for ban in domain.items
|
||||||
|
],
|
||||||
|
pagination=create_pagination_metadata(domain.total, domain.page, domain.page_size),
|
||||||
|
)
|
||||||
37
backend/app/mappers/server_mappers.py
Normal file
37
backend/app/mappers/server_mappers.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""Server response mappers.
|
||||||
|
|
||||||
|
Convert domain models (from server_service) to response models (for HTTP API).
|
||||||
|
|
||||||
|
This is the mapping layer at the router boundary, ensuring the service layer
|
||||||
|
remains independent of HTTP response shapes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.models.server import ServerSettings, ServerSettingsResponse, ServerSettingsUpdate
|
||||||
|
from app.models.server_domain import DomainServerSettings, DomainServerSettingsResult
|
||||||
|
from app.utils.pagination import create_pagination_metadata
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_server_settings_to_response(
|
||||||
|
domain_settings: DomainServerSettings,
|
||||||
|
) -> ServerSettings:
|
||||||
|
"""Convert domain server settings to response model."""
|
||||||
|
return ServerSettings(
|
||||||
|
log_level=domain_settings.log_level,
|
||||||
|
log_target=domain_settings.log_target,
|
||||||
|
syslog_socket=domain_settings.syslog_socket,
|
||||||
|
db_path=domain_settings.db_path,
|
||||||
|
db_purge_age=domain_settings.db_purge_age,
|
||||||
|
db_max_matches=domain_settings.db_max_matches,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_domain_server_settings_result_to_response(
|
||||||
|
domain_result: DomainServerSettingsResult,
|
||||||
|
) -> ServerSettingsResponse:
|
||||||
|
"""Convert domain server settings result to response model."""
|
||||||
|
return ServerSettingsResponse(
|
||||||
|
settings=map_domain_server_settings_to_response(domain_result.settings),
|
||||||
|
warnings=domain_result.warnings,
|
||||||
|
)
|
||||||
3
backend/app/middleware/__init__.py
Normal file
3
backend/app/middleware/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Application middleware."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
96
backend/app/middleware/correlation.py
Normal file
96
backend/app/middleware/correlation.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""Correlation ID middleware for distributed tracing.
|
||||||
|
|
||||||
|
This middleware generates or extracts a correlation ID from each request,
|
||||||
|
stores it in request state, and includes it in error responses.
|
||||||
|
This enables correlating logs across frontend and backend for a single
|
||||||
|
user action or request flow.
|
||||||
|
|
||||||
|
Correlation IDs flow through the request lifecycle:
|
||||||
|
1. Frontend generates/passes via `X-Correlation-ID` header
|
||||||
|
2. Middleware extracts or generates a UUID4
|
||||||
|
3. Stores on request.state for use by error handlers and log filters
|
||||||
|
4. Error responses include the correlation ID for client-side correlation
|
||||||
|
|
||||||
|
Processing order
|
||||||
|
-----------------
|
||||||
|
This middleware must be the outermost in the security-critical chain so it
|
||||||
|
executes first on incoming requests (outermost = first to see request,
|
||||||
|
last to see response). In the required chain:
|
||||||
|
|
||||||
|
CorrelationIdMiddleware → CsrfMiddleware → RateLimitMiddleware
|
||||||
|
|
||||||
|
The registration order in ``main.py`` must be:
|
||||||
|
RateLimitMiddleware, CsrfMiddleware, CorrelationIdMiddleware
|
||||||
|
(last registered = outermost in Starlette's reverse application).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.utils.logging_compat import get_logger
|
||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response as StarletteResponse
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
# Standard header name for correlation IDs (follows W3C Trace Context conventions)
|
||||||
|
_CORRELATION_ID_HEADER: str = "X-Correlation-ID"
|
||||||
|
|
||||||
|
# Key name for storing correlation ID in request state
|
||||||
|
CORRELATION_ID_CONTEXT_KEY: str = "correlation_id"
|
||||||
|
|
||||||
|
|
||||||
|
class CorrelationIdMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Extract or generate correlation ID and store on request state.
|
||||||
|
|
||||||
|
For each request, this middleware:
|
||||||
|
1. Checks for `X-Correlation-ID` header (trusted from frontend)
|
||||||
|
2. Generates a new UUID4 if header not present
|
||||||
|
3. Stores on request.state for use by error handlers and log filters
|
||||||
|
|
||||||
|
The correlation ID enables tracing a single user action or request flow
|
||||||
|
across both frontend and backend systems using structured logs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self,
|
||||||
|
request: Request,
|
||||||
|
call_next: Callable[[Request], Awaitable[StarletteResponse]],
|
||||||
|
) -> StarletteResponse:
|
||||||
|
"""Intercept requests to extract or generate correlation ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
call_next: The next middleware / router handler.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The response from the next middleware / router, with correlation ID
|
||||||
|
in the request state for use by exception handlers.
|
||||||
|
"""
|
||||||
|
# Extract correlation ID from request header, or generate a new one
|
||||||
|
correlation_id: str = request.headers.get(
|
||||||
|
_CORRELATION_ID_HEADER,
|
||||||
|
str(uuid.uuid4()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store on request.state for use by exception handlers
|
||||||
|
request.state.correlation_id = correlation_id
|
||||||
|
|
||||||
|
log.debug(
|
||||||
|
"request_received",
|
||||||
|
extra={"method": request.method, "path": request.url.path},
|
||||||
|
)
|
||||||
|
|
||||||
|
response: StarletteResponse = await call_next(request)
|
||||||
|
|
||||||
|
# Add correlation ID to response header so frontend can correlate errors
|
||||||
|
response.headers[_CORRELATION_ID_HEADER] = correlation_id
|
||||||
|
|
||||||
|
return response
|
||||||
99
backend/app/middleware/csrf.py
Normal file
99
backend/app/middleware/csrf.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""CSRF protection middleware for cookie-authenticated state-mutating requests.
|
||||||
|
|
||||||
|
This middleware enforces explicit CSRF protection on POST, PUT, DELETE, and PATCH
|
||||||
|
requests that use cookie-based authentication. Requests must include the custom
|
||||||
|
header `X-BanGUI-Request: 1` to proceed.
|
||||||
|
|
||||||
|
Bearer token authentication (via Authorization header) bypasses this check as it
|
||||||
|
is not CSRF-vulnerable. GET, HEAD, and OPTIONS requests are also exempt.
|
||||||
|
|
||||||
|
Cross-site requests cannot set custom headers without CORS preflight, which the
|
||||||
|
backend rejects for non-allowed origins, providing defense-in-depth.
|
||||||
|
|
||||||
|
Processing order
|
||||||
|
----------------
|
||||||
|
This middleware must be the middle component in the security-critical chain:
|
||||||
|
|
||||||
|
CorrelationIdMiddleware → CsrfMiddleware → RateLimitMiddleware
|
||||||
|
|
||||||
|
It runs after CorrelationIdMiddleware has attached a correlation ID (so rate-limit
|
||||||
|
errors can include it in their log context), and before RateLimitMiddleware
|
||||||
|
(so rate-limit counters are only incremented for requests that pass CSRF checks).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from app.utils.logging_compat import get_logger
|
||||||
|
from fastapi import status
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
from app.utils.constants import CSRF_HEADER_NAME, CSRF_HEADER_VALUE, SESSION_COOKIE_NAME
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response as StarletteResponse
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
# HTTP methods that require CSRF protection.
|
||||||
|
_CSRF_PROTECTED_METHODS: frozenset[str] = frozenset({"POST", "PUT", "DELETE", "PATCH"})
|
||||||
|
|
||||||
|
|
||||||
|
class CsrfMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Protect cookie-authenticated state-mutating requests with custom header check.
|
||||||
|
|
||||||
|
For requests using POST, PUT, DELETE, or PATCH methods that are authenticated
|
||||||
|
via the session cookie (not Bearer token), this middleware requires the presence
|
||||||
|
of a custom header to prevent CSRF attacks. Bearer token requests and safe
|
||||||
|
HTTP methods are exempt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self,
|
||||||
|
request: Request,
|
||||||
|
call_next: Callable[[Request], Awaitable[StarletteResponse]],
|
||||||
|
) -> StarletteResponse:
|
||||||
|
"""Intercept requests to enforce CSRF protection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
call_next: The next middleware / router handler.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Either a 403 Forbidden response if CSRF validation fails, or the
|
||||||
|
normal router response.
|
||||||
|
"""
|
||||||
|
# Skip check for safe methods.
|
||||||
|
if request.method not in _CSRF_PROTECTED_METHODS:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Skip check if using Bearer token authentication (not CSRF-vulnerable).
|
||||||
|
auth_header: str = request.headers.get("Authorization", "")
|
||||||
|
if auth_header.startswith("Bearer "):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Skip check if not using cookie-based authentication.
|
||||||
|
if SESSION_COOKIE_NAME not in request.cookies:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Enforce CSRF header for cookie-authenticated state-mutating requests.
|
||||||
|
csrf_header: str | None = request.headers.get(CSRF_HEADER_NAME)
|
||||||
|
if csrf_header != CSRF_HEADER_VALUE:
|
||||||
|
log.warning(
|
||||||
|
"csrf_validation_failed",
|
||||||
|
method=request.method,
|
||||||
|
path=request.url.path,
|
||||||
|
has_cookie=True,
|
||||||
|
csrf_header_present=csrf_header is not None,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
content={"detail": "CSRF validation failed. Request rejected."},
|
||||||
|
)
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
107
backend/app/middleware/deprecation.py
Normal file
107
backend/app/middleware/deprecation.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""Deprecation header middleware for versioned API endpoints.
|
||||||
|
|
||||||
|
Adds ``Deprecation``, ``Sunset``, and ``Link`` response headers to endpoints
|
||||||
|
that have been scheduled for removal, following RFC 8599 and the BanGUI
|
||||||
|
API_VERSIONING.md lifecycle policy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime # noqa: TC003 # Used in stringized type annotations (future annotations)
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response as StarletteResponse
|
||||||
|
|
||||||
|
|
||||||
|
class DeprecatedEndpoint:
|
||||||
|
"""Describes a deprecated API endpoint and its removal schedule."""
|
||||||
|
|
||||||
|
__slots__ = ("path_prefix", "sunset_date", "successor_url")
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path_prefix: str,
|
||||||
|
sunset_date: datetime,
|
||||||
|
successor_url: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.path_prefix = path_prefix
|
||||||
|
self.sunset_date = sunset_date
|
||||||
|
self.successor_url = successor_url
|
||||||
|
|
||||||
|
|
||||||
|
# Registry of deprecated endpoints.
|
||||||
|
# Add entries here when an endpoint is scheduled for removal.
|
||||||
|
# sunset_date must be a timezone-aware datetime in UTC.
|
||||||
|
_DEPRECATED_ENDPOINTS: list[DeprecatedEndpoint] = []
|
||||||
|
|
||||||
|
|
||||||
|
def register_deprecated_endpoint(
|
||||||
|
path_prefix: str,
|
||||||
|
sunset_date: datetime,
|
||||||
|
successor_url: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Register a deprecated endpoint for deprecation header injection."""
|
||||||
|
_DEPRECATED_ENDPOINTS.append(
|
||||||
|
DeprecatedEndpoint(path_prefix, sunset_date, successor_url)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_deprecated(path: str) -> DeprecatedEndpoint | None:
|
||||||
|
for endpoint in _DEPRECATED_ENDPOINTS:
|
||||||
|
if path.startswith(endpoint.path_prefix):
|
||||||
|
return endpoint
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_rfc5322(dt: datetime) -> str:
|
||||||
|
return dt.strftime("%a, %d %b %Y %H:%M:%S GMT")
|
||||||
|
|
||||||
|
|
||||||
|
class DeprecationHeaderMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Inject deprecation headers on responses from deprecated endpoints.
|
||||||
|
|
||||||
|
For any response from a path registered in ``_DEPRECATED_ENDPOINTS``,
|
||||||
|
this middleware appends:
|
||||||
|
|
||||||
|
- ``Deprecation: true``
|
||||||
|
- ``Sunset: <RFC-5322 date>``
|
||||||
|
- ``Link: <<successor_url>>; rel="successor-version"`` (if successor_url set)
|
||||||
|
|
||||||
|
The middleware runs after the response is generated so it has access
|
||||||
|
to the final status code and can choose to only add headers for 2xx
|
||||||
|
responses (non-error responses from a deprecated endpoint are what
|
||||||
|
clients need to be warned about).
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self,
|
||||||
|
request: Request,
|
||||||
|
call_next: Callable[[Request], Awaitable[StarletteResponse]],
|
||||||
|
) -> StarletteResponse:
|
||||||
|
response: StarletteResponse = await call_next(request)
|
||||||
|
|
||||||
|
# Add deprecation headers for 2xx and 3xx responses from deprecated paths.
|
||||||
|
# Skipping 4xx/5xx avoids polluting error responses with deprecation headers.
|
||||||
|
if response.status_code < 200 or response.status_code >= 400:
|
||||||
|
return response
|
||||||
|
|
||||||
|
deprecated = _is_deprecated(request.url.path)
|
||||||
|
if deprecated is None:
|
||||||
|
return response
|
||||||
|
|
||||||
|
# All deprecation dates are stored in UTC.
|
||||||
|
sunset_str = _format_rfc5322(deprecated.sunset_date)
|
||||||
|
|
||||||
|
response.headers["Deprecation"] = "true"
|
||||||
|
response.headers["Sunset"] = sunset_str
|
||||||
|
|
||||||
|
if deprecated.successor_url:
|
||||||
|
response.headers["Link"] = f'<{deprecated.successor_url}>; rel="successor-version"'
|
||||||
|
|
||||||
|
return response
|
||||||
95
backend/app/middleware/metrics.py
Normal file
95
backend/app/middleware/metrics.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""Metrics collection middleware for BanGUI.
|
||||||
|
|
||||||
|
Tracks HTTP request count, latency, and active requests.
|
||||||
|
Excludes the /metrics endpoint to prevent recursive metrics collection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from app.utils.logging_compat import get_logger
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
from app.utils.metrics import http_active_requests, http_request_count, http_request_latency
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
# Paths excluded from detailed metrics (to avoid cardinality explosion)
|
||||||
|
EXCLUDED_PATHS = {"/metrics", "/health", "/api/health"}
|
||||||
|
|
||||||
|
# Pattern to normalize endpoint paths (convert IDs to placeholders)
|
||||||
|
PATH_PATTERN = re.compile(r"/api/[^/]+/[a-f0-9\-]{36}|/api/[^/]+/\d+")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_path(path: str) -> str:
|
||||||
|
"""Normalize path by replacing IDs with placeholders.
|
||||||
|
|
||||||
|
Converts paths like /api/resource/123 to /api/resource/{id}
|
||||||
|
to prevent cardinality explosion from dynamic IDs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: The request path.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalized path with IDs replaced by {id}.
|
||||||
|
"""
|
||||||
|
return PATH_PATTERN.sub(r"/api/{id}", path)
|
||||||
|
|
||||||
|
|
||||||
|
class MetricsMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Middleware to collect Prometheus metrics for HTTP requests."""
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self,
|
||||||
|
request: Request,
|
||||||
|
call_next: Callable[[Request], Awaitable[Response]],
|
||||||
|
) -> Response:
|
||||||
|
"""Collect metrics for the request and response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming request.
|
||||||
|
call_next: The next middleware/route handler.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The response.
|
||||||
|
"""
|
||||||
|
# Skip metrics for excluded paths
|
||||||
|
if request.url.path in EXCLUDED_PATHS:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
method: str = request.method
|
||||||
|
endpoint: str = _normalize_path(request.url.path)
|
||||||
|
|
||||||
|
# Track active requests
|
||||||
|
http_active_requests.labels(method=method, endpoint=endpoint).inc()
|
||||||
|
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
status_code = 500
|
||||||
|
|
||||||
|
try:
|
||||||
|
response: Response = await call_next(request)
|
||||||
|
status_code = response.status_code
|
||||||
|
return response
|
||||||
|
finally:
|
||||||
|
# Record metrics
|
||||||
|
duration: float = time.perf_counter() - start_time
|
||||||
|
http_request_latency.labels(method=method, endpoint=endpoint).observe(duration)
|
||||||
|
http_request_count.labels(method=method, endpoint=endpoint, status_code=status_code).inc()
|
||||||
|
http_active_requests.labels(method=method, endpoint=endpoint).dec()
|
||||||
|
|
||||||
|
log.debug(
|
||||||
|
"http_request_recorded",
|
||||||
|
method=method,
|
||||||
|
endpoint=endpoint,
|
||||||
|
status_code=status_code,
|
||||||
|
duration_ms=duration * 1000,
|
||||||
|
)
|
||||||
178
backend/app/middleware/rate_limit.py
Normal file
178
backend/app/middleware/rate_limit.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""Global rate limiting middleware.
|
||||||
|
|
||||||
|
Implements per-IP request rate limiting for all endpoints using a configurable
|
||||||
|
sliding window algorithm. Intercepts requests before they reach route handlers
|
||||||
|
and blocks those exceeding the per-IP limit with a 429 response.
|
||||||
|
|
||||||
|
Rate limits can be customized per endpoint or use a global default.
|
||||||
|
IP addresses are extracted using the same trusted-proxy-aware logic as
|
||||||
|
authentication to ensure consistent behavior across all rate limiting.
|
||||||
|
|
||||||
|
**Process-local implementation** — Each worker process maintains its own
|
||||||
|
independent counter store. In multi-worker deployments (N workers), an
|
||||||
|
attacker can send up to N × limit requests before any single worker triggers
|
||||||
|
the limit. This is a fundamental limitation of in-process stores.
|
||||||
|
|
||||||
|
**Short-term mitigation:** Deploy with a single worker (enforced by the
|
||||||
|
scheduler lock). The startup warning log documents this constraint.
|
||||||
|
|
||||||
|
**Long-term solution:** Replace the in-process GlobalRateLimiter with a
|
||||||
|
Redis-backed adapter that uses atomic INCR + EXPIRE semantics. The
|
||||||
|
check_allowed() and check_allowed_for_bucket() interfaces are designed
|
||||||
|
to make this swap-in without touching middleware or router code.
|
||||||
|
|
||||||
|
Processing order
|
||||||
|
----------------
|
||||||
|
This middleware must be the innermost in the security-critical chain:
|
||||||
|
|
||||||
|
CorrelationIdMiddleware → CsrfMiddleware → RateLimitMiddleware
|
||||||
|
|
||||||
|
Rate limiting is last so that requests blocked by CsrfMiddleware do not
|
||||||
|
consume rate-limit budget, and so that rate-limit log entries (which are
|
||||||
|
unusual and potentially suspicious) always carry a correlation ID for tracing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import JSONResponse, Response
|
||||||
|
|
||||||
|
from app.exceptions import RateLimitError
|
||||||
|
from app.utils.client_ip import get_client_ip
|
||||||
|
from app.utils.logging_compat import get_logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.utils.rate_limiter import GlobalRateLimiter
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Enforce per-IP request rate limiting on matching endpoints.
|
||||||
|
|
||||||
|
Tracks requests per IP and blocks further requests if the limit is exceeded.
|
||||||
|
Uses the application's GlobalRateLimiter instance and trusted-proxy settings
|
||||||
|
for consistent IP extraction.
|
||||||
|
|
||||||
|
Each middleware instance is scoped to a set of path prefixes (or all paths
|
||||||
|
if no prefixes are given). This allows multiple instances to coexist
|
||||||
|
without double-counting requests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
app: object,
|
||||||
|
rate_limiter: GlobalRateLimiter,
|
||||||
|
settings: Settings,
|
||||||
|
bucket_override: str | None = None,
|
||||||
|
bucket_max_requests: int | None = None,
|
||||||
|
bucket_window_seconds: int | None = None,
|
||||||
|
path_prefixes: list[str] | None = None,
|
||||||
|
skip_paths: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the rate limit middleware.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: The FastAPI application.
|
||||||
|
rate_limiter: The GlobalRateLimiter instance to use for checking limits.
|
||||||
|
settings: Application settings (used for trusted proxies).
|
||||||
|
bucket_override: Optional named bucket to use instead of the default limiter.
|
||||||
|
bucket_max_requests: Max requests for the bucket override.
|
||||||
|
bucket_window_seconds: Window for the bucket override.
|
||||||
|
path_prefixes: If provided, only apply rate limiting to paths that
|
||||||
|
start with one of these prefixes. If ``None``, all paths are
|
||||||
|
matched.
|
||||||
|
skip_paths: If provided, do not apply rate limiting to paths that
|
||||||
|
start with one of these prefixes. Evaluated after
|
||||||
|
``path_prefixes``.
|
||||||
|
"""
|
||||||
|
super().__init__(app) # type: ignore[arg-type]
|
||||||
|
self.rate_limiter: GlobalRateLimiter = rate_limiter
|
||||||
|
self.settings: Settings = settings
|
||||||
|
self.bucket_override = bucket_override
|
||||||
|
self.bucket_max_requests = bucket_max_requests
|
||||||
|
self.bucket_window_seconds = bucket_window_seconds
|
||||||
|
self.path_prefixes = path_prefixes or []
|
||||||
|
self.skip_paths = skip_paths or []
|
||||||
|
|
||||||
|
def _should_check(self, path: str) -> bool:
|
||||||
|
"""Return whether the given path should be rate-limited by this instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: The request URL path.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``True`` if this instance should enforce its limit on the path.
|
||||||
|
"""
|
||||||
|
if self.skip_paths and any(path.startswith(p) for p in self.skip_paths):
|
||||||
|
return False
|
||||||
|
if self.path_prefixes:
|
||||||
|
return any(path.startswith(p) for p in self.path_prefixes)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self,
|
||||||
|
request: Request,
|
||||||
|
call_next: Callable[[Request], Awaitable[Response]],
|
||||||
|
) -> Response:
|
||||||
|
"""Check rate limit before passing request to next middleware/handler.
|
||||||
|
|
||||||
|
If the client IP has exceeded the request limit, returns a 429 response
|
||||||
|
immediately. Otherwise passes the request through normally.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming HTTP request.
|
||||||
|
call_next: Callable to pass the request to the next middleware/handler.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A response object (either rate limit response or from handler).
|
||||||
|
"""
|
||||||
|
path = request.url.path
|
||||||
|
|
||||||
|
if not self._should_check(path):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
client_ip = get_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
|
||||||
|
|
||||||
|
if self.bucket_override and self.bucket_max_requests and self.bucket_window_seconds:
|
||||||
|
is_allowed, retry_after = self.rate_limiter.check_allowed_for_bucket(
|
||||||
|
self.bucket_override,
|
||||||
|
client_ip,
|
||||||
|
self.bucket_max_requests,
|
||||||
|
self.bucket_window_seconds,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
is_allowed, retry_after = self.rate_limiter.check_allowed(client_ip)
|
||||||
|
|
||||||
|
if not is_allowed:
|
||||||
|
log.warning(
|
||||||
|
"global_rate_limit_exceeded",
|
||||||
|
client_ip=client_ip,
|
||||||
|
path=path,
|
||||||
|
method=request.method,
|
||||||
|
retry_after=retry_after,
|
||||||
|
)
|
||||||
|
rate_limit_error = RateLimitError(
|
||||||
|
"Too many requests. Please try again later.",
|
||||||
|
retry_after_seconds=retry_after,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=429,
|
||||||
|
content={
|
||||||
|
"code": "rate_limit_exceeded",
|
||||||
|
"detail": str(rate_limit_error),
|
||||||
|
"metadata": rate_limit_error.get_error_metadata(),
|
||||||
|
"correlation_id": getattr(request.state, "correlation_id", None),
|
||||||
|
},
|
||||||
|
headers={"Retry-After": str(int(retry_after))},
|
||||||
|
)
|
||||||
|
|
||||||
|
response: Response = await call_next(request)
|
||||||
|
return response
|
||||||
49
backend/app/models/_common.py
Normal file
49
backend/app/models/_common.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""Shared types and constants used across multiple model modules.
|
||||||
|
|
||||||
|
This module defines types and constants that are used by multiple
|
||||||
|
model modules, ensuring a single source of truth for cross-model types.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
#: The four supported time-range presets for the dashboard views.
|
||||||
|
TimeRange = Literal["24h", "7d", "30d", "365d"]
|
||||||
|
|
||||||
|
#: Number of seconds represented by each preset.
|
||||||
|
TIME_RANGE_SECONDS: dict[str, int] = {
|
||||||
|
"24h": 24 * 3600,
|
||||||
|
"7d": 7 * 24 * 3600,
|
||||||
|
"30d": 30 * 24 * 3600,
|
||||||
|
"365d": 365 * 24 * 3600,
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Bucket size in seconds for each time-range preset.
|
||||||
|
BUCKET_SECONDS: dict[str, int] = {
|
||||||
|
"24h": 3_600, # 1 hour → 24 buckets
|
||||||
|
"7d": 6 * 3_600, # 6 hours → 28 buckets
|
||||||
|
"30d": 86_400, # 1 day → 30 buckets
|
||||||
|
"365d": 7 * 86_400, # 7 days → ~53 buckets
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Human-readable bucket size label for each time-range preset.
|
||||||
|
BUCKET_SIZE_LABEL: dict[str, str] = {
|
||||||
|
"24h": "1h",
|
||||||
|
"7d": "6h",
|
||||||
|
"30d": "1d",
|
||||||
|
"365d": "7d",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def bucket_count(range_: TimeRange) -> int:
|
||||||
|
"""Return the number of buckets needed to cover *range_* completely.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
range_: One of the supported time-range presets.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Ceiling division of the range duration by the bucket size so that
|
||||||
|
the last bucket is included even when the window is not an exact
|
||||||
|
multiple of the bucket size.
|
||||||
|
"""
|
||||||
|
return math.ceil(TIME_RANGE_SECONDS[range_] / BUCKET_SECONDS[range_])
|
||||||
@@ -3,43 +3,48 @@
|
|||||||
Request, response, and domain models used by the auth router and service.
|
Request, response, and domain models used by the auth router and service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import Field
|
||||||
|
|
||||||
|
from app.models.response import BanGuiBaseModel
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BanGuiBaseModel):
|
||||||
"""Payload for ``POST /api/auth/login``."""
|
"""Payload for ``POST /api/auth/login``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
password: str = Field(
|
||||||
|
...,
|
||||||
|
max_length=72,
|
||||||
|
description="Master password to authenticate with (max 72 bytes due to bcrypt truncation).",
|
||||||
|
)
|
||||||
|
|
||||||
password: str = Field(..., description="Master password to authenticate with.")
|
class LoginResponse(BanGuiBaseModel):
|
||||||
|
|
||||||
|
|
||||||
class LoginResponse(BaseModel):
|
|
||||||
"""Successful login response.
|
"""Successful login response.
|
||||||
|
|
||||||
The session token is also set as an ``HttpOnly`` cookie by the router.
|
The session token is set as an ``HttpOnly`` ``SameSite=Lax`` cookie by the
|
||||||
This model documents the JSON body for API-first consumers.
|
router, protecting it from JavaScript access. The JSON body contains only
|
||||||
|
the expiry timestamp, allowing the frontend to know when to prompt for
|
||||||
|
re-authentication.
|
||||||
|
|
||||||
|
For programmatic API clients that require a token in the response body,
|
||||||
|
use ``POST /api/auth/token`` instead, which does not set a cookie.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
token: str = Field(..., description="Session token for use in subsequent requests.")
|
|
||||||
expires_at: str = Field(..., description="ISO 8601 UTC expiry timestamp.")
|
expires_at: str = Field(..., description="ISO 8601 UTC expiry timestamp.")
|
||||||
|
|
||||||
|
class LogoutResponse(BanGuiBaseModel):
|
||||||
class LogoutResponse(BaseModel):
|
|
||||||
"""Response body for ``POST /api/auth/logout``."""
|
"""Response body for ``POST /api/auth/logout``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
message: str = Field(default="Logged out successfully.")
|
message: str = Field(default="Logged out successfully.")
|
||||||
|
|
||||||
|
class SessionValidResponse(BanGuiBaseModel):
|
||||||
|
"""Response for ``GET /api/auth/session`` confirming session validity."""
|
||||||
|
|
||||||
class Session(BaseModel):
|
valid: bool = Field(default=True, description="Whether the session is valid and active.")
|
||||||
|
|
||||||
|
|
||||||
|
class Session(BanGuiBaseModel):
|
||||||
"""Internal domain model representing a persisted session record."""
|
"""Internal domain model representing a persisted session record."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
id: int = Field(..., description="Auto-incremented row ID.")
|
id: int = Field(..., description="Auto-incremented row ID.")
|
||||||
token: str = Field(..., description="Opaque session token.")
|
token: str = Field(..., description="Opaque session token.")
|
||||||
created_at: str = Field(..., description="ISO 8601 UTC creation timestamp.")
|
created_at: str = Field(..., description="ISO 8601 UTC creation timestamp.")
|
||||||
|
|||||||
@@ -3,41 +3,22 @@
|
|||||||
Request, response, and domain models used by the ban router and service.
|
Request, response, and domain models used by the ban router and service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
from app.models.response import BanGuiBaseModel, CollectionResponse, PaginatedListResponse
|
||||||
# Time-range selector
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#: The four supported time-range presets for the dashboard views.
|
|
||||||
TimeRange = Literal["24h", "7d", "30d", "365d"]
|
|
||||||
|
|
||||||
#: Number of seconds represented by each preset.
|
|
||||||
TIME_RANGE_SECONDS: dict[str, int] = {
|
|
||||||
"24h": 24 * 3600,
|
|
||||||
"7d": 7 * 24 * 3600,
|
|
||||||
"30d": 30 * 24 * 3600,
|
|
||||||
"365d": 365 * 24 * 3600,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class BanRequest(BaseModel):
|
class BanRequest(BanGuiBaseModel):
|
||||||
"""Payload for ``POST /api/bans`` (ban an IP)."""
|
"""Payload for ``POST /api/bans`` (ban an IP)."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
ip: str = Field(..., description="IP address to ban.")
|
ip: str = Field(..., description="IP address to ban.")
|
||||||
jail: str = Field(..., description="Jail in which to apply the ban.")
|
jail: str = Field(..., description="Jail in which to apply the ban.")
|
||||||
|
|
||||||
|
class UnbanRequest(BanGuiBaseModel):
|
||||||
class UnbanRequest(BaseModel):
|
|
||||||
"""Payload for ``DELETE /api/bans`` (unban an IP)."""
|
"""Payload for ``DELETE /api/bans`` (unban an IP)."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
ip: str = Field(..., description="IP address to unban.")
|
ip: str = Field(..., description="IP address to unban.")
|
||||||
jail: str | None = Field(
|
jail: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
@@ -48,14 +29,12 @@ class UnbanRequest(BaseModel):
|
|||||||
description="When ``true`` the IP is unbanned from every jail.",
|
description="When ``true`` the IP is unbanned from every jail.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
#: Discriminator literal for the origin of a ban.
|
#: Discriminator literal for the origin of a ban.
|
||||||
BanOrigin = Literal["blocklist", "selfblock"]
|
BanOrigin = Literal["blocklist", "selfblock"]
|
||||||
|
|
||||||
#: Jail name used by the blocklist import service.
|
#: Jail name used by the blocklist import service.
|
||||||
BLOCKLIST_JAIL: str = "blocklist-import"
|
BLOCKLIST_JAIL: str = "blocklist-import"
|
||||||
|
|
||||||
|
|
||||||
def _derive_origin(jail: str) -> BanOrigin:
|
def _derive_origin(jail: str) -> BanOrigin:
|
||||||
"""Derive the ban origin from the jail name.
|
"""Derive the ban origin from the jail name.
|
||||||
|
|
||||||
@@ -68,12 +47,9 @@ def _derive_origin(jail: str) -> BanOrigin:
|
|||||||
"""
|
"""
|
||||||
return "blocklist" if jail == BLOCKLIST_JAIL else "selfblock"
|
return "blocklist" if jail == BLOCKLIST_JAIL else "selfblock"
|
||||||
|
|
||||||
|
class Ban(BanGuiBaseModel):
|
||||||
class Ban(BaseModel):
|
|
||||||
"""Domain model representing a single active or historical ban record."""
|
"""Domain model representing a single active or historical ban record."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
ip: str = Field(..., description="Banned IP address.")
|
ip: str = Field(..., description="Banned IP address.")
|
||||||
jail: str = Field(..., description="Jail that issued the ban.")
|
jail: str = Field(..., description="Jail that issued the ban.")
|
||||||
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
|
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
|
||||||
@@ -91,29 +67,38 @@ class Ban(BaseModel):
|
|||||||
description="Whether this ban came from a blocklist import or fail2ban itself.",
|
description="Whether this ban came from a blocklist import or fail2ban itself.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@field_validator("country")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_empty_country(cls, v: str | None) -> str | None:
|
||||||
|
"""Coerce empty strings to None for country.
|
||||||
|
|
||||||
class BanResponse(BaseModel):
|
Geo enrichment may produce an empty string instead of None for
|
||||||
|
unresolved IPs, which breaks frontend truthiness checks.
|
||||||
|
"""
|
||||||
|
if v == "":
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
|
class BanResponse(BanGuiBaseModel):
|
||||||
"""Response containing a single ban record."""
|
"""Response containing a single ban record."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
ban: Ban
|
ban: Ban
|
||||||
|
|
||||||
|
class BanListResponse(PaginatedListResponse[Ban]):
|
||||||
|
"""Paginated list of ban records.
|
||||||
|
|
||||||
class BanListResponse(BaseModel):
|
Request: `GET /api/bans` with optional pagination and filter parameters.
|
||||||
"""Paginated list of ban records."""
|
Response: Paginated collection of ban records with total count.
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
Note: Unlike most list endpoints, this endpoint uses `page` and `page_size`
|
||||||
|
for pagination. When using this response, ensure the router provides these fields.
|
||||||
|
"""
|
||||||
|
|
||||||
bans: list[Ban] = Field(default_factory=list)
|
pass
|
||||||
total: int = Field(..., ge=0, description="Total number of matching records.")
|
|
||||||
|
|
||||||
|
class ActiveBan(BanGuiBaseModel):
|
||||||
class ActiveBan(BaseModel):
|
|
||||||
"""A currently active ban entry returned by ``GET /api/bans/active``."""
|
"""A currently active ban entry returned by ``GET /api/bans/active``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
ip: str = Field(..., description="Banned IP address.")
|
ip: str = Field(..., description="Banned IP address.")
|
||||||
jail: str = Field(..., description="Jail holding the ban.")
|
jail: str = Field(..., description="Jail holding the ban.")
|
||||||
banned_at: str | None = Field(default=None, description="ISO 8601 UTC start of the ban.")
|
banned_at: str | None = Field(default=None, description="ISO 8601 UTC start of the ban.")
|
||||||
@@ -124,38 +109,46 @@ class ActiveBan(BaseModel):
|
|||||||
ban_count: int = Field(default=1, ge=1, description="Running ban count for this IP.")
|
ban_count: int = Field(default=1, ge=1, description="Running ban count for this IP.")
|
||||||
country: str | None = Field(default=None, description="ISO 3166-1 alpha-2 country code.")
|
country: str | None = Field(default=None, description="ISO 3166-1 alpha-2 country code.")
|
||||||
|
|
||||||
|
@field_validator("country")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_empty_country(cls, v: str | None) -> str | None:
|
||||||
|
"""Coerce empty strings to None for country.
|
||||||
|
|
||||||
class ActiveBanListResponse(BaseModel):
|
Geo enrichment may produce an empty string instead of None for
|
||||||
"""List of all currently active bans across all jails."""
|
unresolved IPs, which breaks frontend truthiness checks.
|
||||||
|
"""
|
||||||
|
if v == "":
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
class ActiveBanListResponse(CollectionResponse[ActiveBan]):
|
||||||
|
"""List of all currently active bans across all jails.
|
||||||
|
|
||||||
bans: list[ActiveBan] = Field(default_factory=list)
|
Request: `GET /api/bans/active` with optional filter parameters.
|
||||||
total: int = Field(..., ge=0)
|
Response: Non-paginated collection of currently active bans with total count.
|
||||||
|
|
||||||
|
Note: This endpoint does not support pagination. All matching bans are returned.
|
||||||
|
For paginated results, use individual jail endpoints or the dashboard ban-list view.
|
||||||
|
"""
|
||||||
|
|
||||||
class UnbanAllResponse(BaseModel):
|
pass
|
||||||
|
|
||||||
|
class UnbanAllResponse(BanGuiBaseModel):
|
||||||
"""Response for ``DELETE /api/bans/all``."""
|
"""Response for ``DELETE /api/bans/all``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
message: str = Field(..., description="Human-readable summary of the operation.")
|
message: str = Field(..., description="Human-readable summary of the operation.")
|
||||||
count: int = Field(..., ge=0, description="Number of IPs that were unbanned.")
|
count: int = Field(..., ge=0, description="Number of IPs that were unbanned.")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Dashboard ban-list view models
|
# Dashboard ban-list view models
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class DashboardBanItem(BanGuiBaseModel):
|
||||||
class DashboardBanItem(BaseModel):
|
|
||||||
"""A single row in the dashboard ban-list table.
|
"""A single row in the dashboard ban-list table.
|
||||||
|
|
||||||
Populated from the fail2ban database and enriched with geo data.
|
Populated from the fail2ban database and enriched with geo data.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
ip: str = Field(..., description="Banned IP address.")
|
ip: str = Field(..., description="Banned IP address.")
|
||||||
jail: str = Field(..., description="Jail that issued the ban.")
|
jail: str = Field(..., description="Jail that issued the ban.")
|
||||||
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
|
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
|
||||||
@@ -185,19 +178,30 @@ class DashboardBanItem(BaseModel):
|
|||||||
description="Whether this ban came from a blocklist import or fail2ban itself.",
|
description="Whether this ban came from a blocklist import or fail2ban itself.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@field_validator("country_code")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_empty_country_code(cls, v: str | None) -> str | None:
|
||||||
|
"""Coerce empty strings to None for country_code.
|
||||||
|
|
||||||
class DashboardBanListResponse(BaseModel):
|
The geo enrichment layer may produce an empty string instead of None
|
||||||
"""Paginated dashboard ban-list response."""
|
for unresolved IPs. Frontend type narrowing uses truthiness, so an
|
||||||
|
empty string would slip through ``if (ban.country_code)`` checks and
|
||||||
|
appear as a falsy-but-not-null value — breaking UI rendering.
|
||||||
|
"""
|
||||||
|
if v == "":
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
class DashboardBanListResponse(PaginatedListResponse[DashboardBanItem]):
|
||||||
|
"""Paginated dashboard ban-list response.
|
||||||
|
|
||||||
items: list[DashboardBanItem] = Field(default_factory=list)
|
Request: `GET /api/dashboard/bans` with time range, page, and filter parameters.
|
||||||
total: int = Field(..., ge=0, description="Total bans in the selected time window.")
|
Response: Paginated collection of dashboard ban items with geo-enrichment.
|
||||||
page: int = Field(..., ge=1)
|
"""
|
||||||
page_size: int = Field(..., ge=1)
|
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
class BansByCountryResponse(BaseModel):
|
class BansByCountryResponse(BanGuiBaseModel):
|
||||||
"""Response for the bans-by-country aggregation endpoint.
|
"""Response for the bans-by-country aggregation endpoint.
|
||||||
|
|
||||||
Contains a per-country ban count, a human-readable country name map, and
|
Contains a per-country ban count, a human-readable country name map, and
|
||||||
@@ -206,8 +210,6 @@ class BansByCountryResponse(BaseModel):
|
|||||||
single request.
|
single request.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
countries: dict[str, int] = Field(
|
countries: dict[str, int] = Field(
|
||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
description="ISO 3166-1 alpha-2 country code → ban count.",
|
description="ISO 3166-1 alpha-2 country code → ban count.",
|
||||||
@@ -222,56 +224,19 @@ class BansByCountryResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
total: int = Field(..., ge=0, description="Total ban count in the window.")
|
total: int = Field(..., ge=0, description="Total ban count in the window.")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Trend endpoint models
|
# Trend endpoint models
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#: Bucket size in seconds for each time-range preset.
|
|
||||||
BUCKET_SECONDS: dict[str, int] = {
|
|
||||||
"24h": 3_600, # 1 hour → 24 buckets
|
|
||||||
"7d": 6 * 3_600, # 6 hours → 28 buckets
|
|
||||||
"30d": 86_400, # 1 day → 30 buckets
|
|
||||||
"365d": 7 * 86_400, # 7 days → ~53 buckets
|
|
||||||
}
|
|
||||||
|
|
||||||
#: Human-readable bucket size label for each time-range preset.
|
|
||||||
BUCKET_SIZE_LABEL: dict[str, str] = {
|
|
||||||
"24h": "1h",
|
|
||||||
"7d": "6h",
|
|
||||||
"30d": "1d",
|
|
||||||
"365d": "7d",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def bucket_count(range_: TimeRange) -> int:
|
class BanTrendBucket(BanGuiBaseModel):
|
||||||
"""Return the number of buckets needed to cover *range_* completely.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
range_: One of the supported time-range presets.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Ceiling division of the range duration by the bucket size so that
|
|
||||||
the last bucket is included even when the window is not an exact
|
|
||||||
multiple of the bucket size.
|
|
||||||
"""
|
|
||||||
return math.ceil(TIME_RANGE_SECONDS[range_] / BUCKET_SECONDS[range_])
|
|
||||||
|
|
||||||
|
|
||||||
class BanTrendBucket(BaseModel):
|
|
||||||
"""A single time bucket in the ban trend series."""
|
"""A single time bucket in the ban trend series."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
timestamp: str = Field(..., description="ISO 8601 UTC start of the bucket.")
|
timestamp: str = Field(..., description="ISO 8601 UTC start of the bucket.")
|
||||||
count: int = Field(..., ge=0, description="Number of bans that started in this bucket.")
|
count: int = Field(..., ge=0, description="Number of bans that started in this bucket.")
|
||||||
|
|
||||||
|
class BanTrendResponse(BanGuiBaseModel):
|
||||||
class BanTrendResponse(BaseModel):
|
|
||||||
"""Response for the ``GET /api/dashboard/bans/trend`` endpoint."""
|
"""Response for the ``GET /api/dashboard/bans/trend`` endpoint."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
buckets: list[BanTrendBucket] = Field(
|
buckets: list[BanTrendBucket] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
description="Time-ordered list of ban-count buckets covering the full window.",
|
description="Time-ordered list of ban-count buckets covering the full window.",
|
||||||
@@ -281,55 +246,37 @@ class BanTrendResponse(BaseModel):
|
|||||||
description="Human-readable bucket size label (e.g. '1h', '6h', '1d', '7d').",
|
description="Human-readable bucket size label (e.g. '1h', '6h', '1d', '7d').",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# By-jail endpoint models
|
# By-jail endpoint models
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class JailBanCount(BanGuiBaseModel):
|
||||||
class JailBanCount(BaseModel):
|
|
||||||
"""A single jail entry in the bans-by-jail aggregation."""
|
"""A single jail entry in the bans-by-jail aggregation."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
jail: str = Field(..., description="Jail name.")
|
jail: str = Field(..., description="Jail name.")
|
||||||
count: int = Field(..., ge=0, description="Number of bans recorded in this jail.")
|
count: int = Field(..., ge=0, description="Number of bans recorded in this jail.")
|
||||||
|
|
||||||
|
class BansByJailResponse(BanGuiBaseModel):
|
||||||
class BansByJailResponse(BaseModel):
|
|
||||||
"""Response for the ``GET /api/dashboard/bans/by-jail`` endpoint."""
|
"""Response for the ``GET /api/dashboard/bans/by-jail`` endpoint."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
jails: list[JailBanCount] = Field(
|
jails: list[JailBanCount] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
description="Jails ordered by ban count descending.",
|
description="Jails ordered by ban count descending.",
|
||||||
)
|
)
|
||||||
total: int = Field(..., ge=0, description="Total ban count in the selected window.")
|
total: int = Field(..., ge=0, description="Total ban count in the selected window.")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Jail-specific paginated bans
|
# Jail-specific paginated bans
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class JailBannedIpsResponse(PaginatedListResponse[ActiveBan]):
|
||||||
class JailBannedIpsResponse(BaseModel):
|
|
||||||
"""Paginated response for ``GET /api/jails/{name}/banned``.
|
"""Paginated response for ``GET /api/jails/{name}/banned``.
|
||||||
|
|
||||||
Contains only the current page of active ban entries for a single jail,
|
Contains only the current page of active ban entries for a single jail,
|
||||||
geo-enriched exclusively for the page slice to avoid rate-limit issues.
|
geo-enriched exclusively for the page slice to avoid rate-limit issues.
|
||||||
|
|
||||||
|
Request: `GET /api/jails/{name}/banned` with page and page_size parameters.
|
||||||
|
Response: Paginated collection of active bans for the specified jail.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
pass
|
||||||
|
|
||||||
items: list[ActiveBan] = Field(
|
|
||||||
default_factory=list,
|
|
||||||
description="Active ban entries for the current page.",
|
|
||||||
)
|
|
||||||
total: int = Field(
|
|
||||||
...,
|
|
||||||
ge=0,
|
|
||||||
description="Total matching entries (after applying the search filter).",
|
|
||||||
)
|
|
||||||
page: int = Field(..., ge=1, description="Current page number (1-based).")
|
|
||||||
page_size: int = Field(..., ge=1, description="Number of items per page.")
|
|
||||||
|
|||||||
110
backend/app/models/ban_domain.py
Normal file
110
backend/app/models/ban_domain.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""Ban domain models (DTOs).
|
||||||
|
|
||||||
|
Internal domain-focused models used by ban_service. These represent the
|
||||||
|
business domain layer and are independent of HTTP response shapes.
|
||||||
|
|
||||||
|
Response models are defined in `app.models.ban` and mappers convert domain
|
||||||
|
models to response models at the router boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
# Domain-specific ban origin type
|
||||||
|
BanOriginDomain = Literal["blocklist", "selfblock"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainActiveBan:
|
||||||
|
"""A currently active ban entry (domain model).
|
||||||
|
|
||||||
|
This is the service-layer representation, independent of API response shape.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ip: str
|
||||||
|
jail: str
|
||||||
|
banned_at: str | None = None
|
||||||
|
expires_at: str | None = None
|
||||||
|
ban_count: int = 1
|
||||||
|
country: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainActiveBanList:
|
||||||
|
"""List of currently active bans (domain model)."""
|
||||||
|
|
||||||
|
bans: list[DomainActiveBan]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainDashboardBanItem:
|
||||||
|
"""A single row in the dashboard ban-list table (domain model).
|
||||||
|
|
||||||
|
Populated from the fail2ban database and enriched with geo data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ip: str
|
||||||
|
jail: str
|
||||||
|
banned_at: str
|
||||||
|
service: str | None = None
|
||||||
|
country_code: str | None = None
|
||||||
|
country_name: str | None = None
|
||||||
|
asn: str | None = None
|
||||||
|
org: str | None = None
|
||||||
|
ban_count: int = 1
|
||||||
|
origin: BanOriginDomain = "selfblock"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainDashboardBanList:
|
||||||
|
"""Paginated dashboard ban-list (domain model)."""
|
||||||
|
|
||||||
|
items: list[DomainDashboardBanItem]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainBansByCountry:
|
||||||
|
"""Bans aggregated by country (domain model)."""
|
||||||
|
|
||||||
|
countries: dict[str, int]
|
||||||
|
country_names: dict[str, str]
|
||||||
|
items: list[DomainDashboardBanItem]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainBanTrendBucket:
|
||||||
|
"""A single time bucket in the ban trend series (domain model)."""
|
||||||
|
|
||||||
|
timestamp: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainBanTrend:
|
||||||
|
"""Ban trend data over time (domain model)."""
|
||||||
|
|
||||||
|
buckets: list[DomainBanTrendBucket]
|
||||||
|
bucket_size: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainJailBanCount:
|
||||||
|
"""Ban count for a single jail (domain model)."""
|
||||||
|
|
||||||
|
jail: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainBansByJail:
|
||||||
|
"""Bans aggregated by jail (domain model)."""
|
||||||
|
|
||||||
|
jails: list[DomainJailBanCount]
|
||||||
|
total: int
|
||||||
@@ -8,18 +8,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import AnyHttpUrl, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.models.response import BanGuiBaseModel, PaginatedListResponse
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Blocklist source
|
# Blocklist source
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class BlocklistSource(BaseModel):
|
class BlocklistSource(BanGuiBaseModel):
|
||||||
"""Domain model for a blocklist source definition."""
|
"""Domain model for a blocklist source definition."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
url: str
|
url: str
|
||||||
@@ -28,31 +28,33 @@ class BlocklistSource(BaseModel):
|
|||||||
updated_at: str
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
class BlocklistSourceCreate(BaseModel):
|
class BlocklistSourceCreate(BanGuiBaseModel):
|
||||||
"""Payload for ``POST /api/blocklists``."""
|
"""Payload for ``POST /api/blocklists``.
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
URL must use http/https scheme. The hostname must resolve to a public IP
|
||||||
|
(not private, loopback, link-local, or reserved). Validation happens
|
||||||
|
asynchronously in the service layer.
|
||||||
|
"""
|
||||||
|
|
||||||
name: str = Field(..., min_length=1, max_length=100, description="Human-readable source name.")
|
name: str = Field(..., min_length=1, max_length=100, description="Human-readable source name.")
|
||||||
url: str = Field(..., min_length=1, description="URL of the blocklist file.")
|
url: AnyHttpUrl = Field(..., description="URL of the blocklist file (http/https only).")
|
||||||
enabled: bool = Field(default=True)
|
enabled: bool = Field(default=True)
|
||||||
|
|
||||||
|
|
||||||
class BlocklistSourceUpdate(BaseModel):
|
class BlocklistSourceUpdate(BanGuiBaseModel):
|
||||||
"""Payload for ``PUT /api/blocklists/{id}``. All fields are optional."""
|
"""Payload for ``PUT /api/blocklists/{id}``. All fields are optional.
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
If URL is provided, it must use http/https scheme.
|
||||||
|
"""
|
||||||
|
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||||
url: str | None = Field(default=None)
|
url: AnyHttpUrl | None = Field(default=None)
|
||||||
enabled: bool | None = Field(default=None)
|
enabled: bool | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
class BlocklistListResponse(BaseModel):
|
class BlocklistListResponse(BanGuiBaseModel):
|
||||||
"""Response for ``GET /api/blocklists``."""
|
"""Response for ``GET /api/blocklists``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
sources: list[BlocklistSource] = Field(default_factory=list)
|
sources: list[BlocklistSource] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -61,30 +63,49 @@ class BlocklistListResponse(BaseModel):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class ImportLogEntry(BaseModel):
|
class ImportLogEntry(BanGuiBaseModel):
|
||||||
"""A single blocklist import run record."""
|
"""A single blocklist import run record."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
source_id: int | None
|
source_id: int | None
|
||||||
source_url: str
|
source_url: str
|
||||||
timestamp: str
|
timestamp: int
|
||||||
ips_imported: int
|
ips_imported: int
|
||||||
ips_skipped: int
|
ips_skipped: int
|
||||||
errors: str | None
|
errors: str | None
|
||||||
|
|
||||||
|
|
||||||
class ImportLogListResponse(BaseModel):
|
class ImportLogListResponse(PaginatedListResponse[ImportLogEntry]):
|
||||||
"""Response for ``GET /api/blocklists/log``."""
|
"""Response for ``GET /api/blocklists/log``.
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
Paginated list of all blocklist import runs with timestamps, source info,
|
||||||
|
and per-source import/skip counts.
|
||||||
|
"""
|
||||||
|
|
||||||
items: list[ImportLogEntry] = Field(default_factory=list)
|
pass
|
||||||
total: int = Field(..., ge=0)
|
|
||||||
page: int = Field(default=1, ge=1)
|
|
||||||
page_size: int = Field(default=50, ge=1)
|
# ---------------------------------------------------------------------------
|
||||||
total_pages: int = Field(default=1, ge=1)
|
# Import run tracking (for idempotency)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ImportRunEntry(BanGuiBaseModel):
|
||||||
|
"""Tracks a unique blocklist import run by source and content hash.
|
||||||
|
|
||||||
|
Used to detect re-runs and prevent duplicate bans when the scheduler
|
||||||
|
retries after a crash.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
source_id: int
|
||||||
|
content_hash: str
|
||||||
|
status: str # 'pending' | 'completed' | 'failed'
|
||||||
|
imported_count: int
|
||||||
|
skipped_count: int
|
||||||
|
error_message: str | None
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -100,7 +121,7 @@ class ScheduleFrequency(StrEnum):
|
|||||||
weekly = "weekly"
|
weekly = "weekly"
|
||||||
|
|
||||||
|
|
||||||
class ScheduleConfig(BaseModel):
|
class ScheduleConfig(BanGuiBaseModel):
|
||||||
"""Import schedule configuration.
|
"""Import schedule configuration.
|
||||||
|
|
||||||
The interpretation of fields depends on *frequency*:
|
The interpretation of fields depends on *frequency*:
|
||||||
@@ -110,8 +131,10 @@ class ScheduleConfig(BaseModel):
|
|||||||
- ``weekly``: additionally uses ``day_of_week`` (0=Monday … 6=Sunday).
|
- ``weekly``: additionally uses ``day_of_week`` (0=Monday … 6=Sunday).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# No strict=True here: FastAPI and json.loads() both supply enum values as
|
# FastAPI and json.loads() both supply enum values as plain strings;
|
||||||
# plain strings; strict mode would reject string→enum coercion.
|
# strict mode would reject string→enum coercion, so we override the
|
||||||
|
# base model_config for this model only.
|
||||||
|
model_config = ConfigDict(strict=False)
|
||||||
|
|
||||||
frequency: ScheduleFrequency = ScheduleFrequency.daily
|
frequency: ScheduleFrequency = ScheduleFrequency.daily
|
||||||
interval_hours: int = Field(default=24, ge=1, le=168, description="Used when frequency=hourly")
|
interval_hours: int = Field(default=24, ge=1, le=168, description="Used when frequency=hourly")
|
||||||
@@ -125,11 +148,9 @@ class ScheduleConfig(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ScheduleInfo(BaseModel):
|
class ScheduleInfo(BanGuiBaseModel):
|
||||||
"""Current schedule configuration together with runtime metadata."""
|
"""Current schedule configuration together with runtime metadata."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
config: ScheduleConfig
|
config: ScheduleConfig
|
||||||
next_run_at: str | None
|
next_run_at: str | None
|
||||||
last_run_at: str | None
|
last_run_at: str | None
|
||||||
@@ -142,11 +163,9 @@ class ScheduleInfo(BaseModel):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class ImportSourceResult(BaseModel):
|
class ImportSourceResult(BanGuiBaseModel):
|
||||||
"""Result of importing a single blocklist source."""
|
"""Result of importing a single blocklist source."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
source_id: int | None
|
source_id: int | None
|
||||||
source_url: str
|
source_url: str
|
||||||
ips_imported: int
|
ips_imported: int
|
||||||
@@ -154,11 +173,9 @@ class ImportSourceResult(BaseModel):
|
|||||||
error: str | None
|
error: str | None
|
||||||
|
|
||||||
|
|
||||||
class ImportRunResult(BaseModel):
|
class ImportRunResult(BanGuiBaseModel):
|
||||||
"""Aggregated result from a full import run across all enabled sources."""
|
"""Aggregated result from a full import run across all enabled sources."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
results: list[ImportSourceResult] = Field(default_factory=list)
|
results: list[ImportSourceResult] = Field(default_factory=list)
|
||||||
total_imported: int
|
total_imported: int
|
||||||
total_skipped: int
|
total_skipped: int
|
||||||
@@ -170,11 +187,9 @@ class ImportRunResult(BaseModel):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class PreviewResponse(BaseModel):
|
class PreviewResponse(BanGuiBaseModel):
|
||||||
"""Response for ``GET /api/blocklists/{id}/preview``."""
|
"""Response for ``GET /api/blocklists/{id}/preview``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
entries: list[str] = Field(default_factory=list, description="Sample of valid IP entries")
|
entries: list[str] = Field(default_factory=list, description="Sample of valid IP entries")
|
||||||
total_lines: int
|
total_lines: int
|
||||||
valid_count: int
|
valid_count: int
|
||||||
|
|||||||
108
backend/app/models/blocklist_domain.py
Normal file
108
backend/app/models/blocklist_domain.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Blocklist domain models.
|
||||||
|
|
||||||
|
Internal domain-focused models used by blocklist_service. These represent the
|
||||||
|
business domain layer and are independent of HTTP response shapes.
|
||||||
|
|
||||||
|
Response models are defined in `app.models.blocklist` and mappers convert domain
|
||||||
|
models to response models at the router boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainBlocklistSource:
|
||||||
|
"""Blocklist source definition (domain model)."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
enabled: bool
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainImportLogEntry:
|
||||||
|
"""A single blocklist import run record (domain model)."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
source_id: int | None
|
||||||
|
source_url: str
|
||||||
|
timestamp: str
|
||||||
|
ips_imported: int
|
||||||
|
ips_skipped: int
|
||||||
|
errors: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainImportLogList:
|
||||||
|
"""Paginated list of import log entries (domain model)."""
|
||||||
|
|
||||||
|
items: list[DomainImportLogEntry]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class DomainScheduleFrequency(StrEnum):
|
||||||
|
"""Available import schedule frequency presets (domain model)."""
|
||||||
|
|
||||||
|
hourly = "hourly"
|
||||||
|
daily = "daily"
|
||||||
|
weekly = "weekly"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainScheduleConfig:
|
||||||
|
"""Import schedule configuration (domain model)."""
|
||||||
|
|
||||||
|
frequency: DomainScheduleFrequency
|
||||||
|
interval_hours: int = 24
|
||||||
|
hour: int = 3
|
||||||
|
minute: int = 0
|
||||||
|
day_of_week: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainScheduleInfo:
|
||||||
|
"""Current schedule configuration with runtime metadata (domain model)."""
|
||||||
|
|
||||||
|
config: DomainScheduleConfig
|
||||||
|
next_run_at: str | None = None
|
||||||
|
last_run_at: str | None = None
|
||||||
|
last_run_errors: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainPreviewResult:
|
||||||
|
"""Result of previewing a blocklist URL (domain model)."""
|
||||||
|
|
||||||
|
entries: list[str]
|
||||||
|
total_lines: int
|
||||||
|
valid_count: int
|
||||||
|
skipped_count: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainImportSourceResult:
|
||||||
|
"""Result of importing a single blocklist source (domain model)."""
|
||||||
|
|
||||||
|
source_id: int | None
|
||||||
|
source_url: str
|
||||||
|
ips_imported: int
|
||||||
|
ips_skipped: int
|
||||||
|
error: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainImportRunResult:
|
||||||
|
"""Aggregated result from a full import run (domain model)."""
|
||||||
|
|
||||||
|
results: list[DomainImportSourceResult]
|
||||||
|
total_imported: int
|
||||||
|
total_skipped: int
|
||||||
|
errors_count: int
|
||||||
@@ -4,19 +4,25 @@ Request, response, and domain models for the config router and service.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import Field
|
||||||
|
|
||||||
|
from app.models.response import BanGuiBaseModel, CollectionResponse
|
||||||
|
|
||||||
|
DNSMode = Literal["yes", "warn", "no", "raw"]
|
||||||
|
LogEncoding = Literal["auto", "ascii", "utf-8", "UTF-8", "latin-1"]
|
||||||
|
BackendType = Literal["auto", "polling", "pyinotify", "systemd", "gamin"]
|
||||||
|
LogLevel = Literal["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"]
|
||||||
|
LogTarget = Literal["STDOUT", "STDERR", "SYSLOG"]
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Ban-time escalation
|
# Ban-time escalation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class BantimeEscalation(BanGuiBaseModel):
|
||||||
class BantimeEscalation(BaseModel):
|
|
||||||
"""Incremental ban-time escalation configuration for a jail."""
|
"""Incremental ban-time escalation configuration for a jail."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
increment: bool = Field(
|
increment: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description="Whether incremental banning is enabled.",
|
description="Whether incremental banning is enabled.",
|
||||||
@@ -46,12 +52,9 @@ class BantimeEscalation(BaseModel):
|
|||||||
description="Count repeat offences across all jails, not just the current one.",
|
description="Count repeat offences across all jails, not just the current one.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class BantimeEscalationUpdate(BanGuiBaseModel):
|
||||||
class BantimeEscalationUpdate(BaseModel):
|
|
||||||
"""Partial update payload for ban-time escalation settings."""
|
"""Partial update payload for ban-time escalation settings."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
increment: bool | None = Field(default=None)
|
increment: bool | None = Field(default=None)
|
||||||
factor: float | None = Field(default=None)
|
factor: float | None = Field(default=None)
|
||||||
formula: str | None = Field(default=None)
|
formula: str | None = Field(default=None)
|
||||||
@@ -60,17 +63,13 @@ class BantimeEscalationUpdate(BaseModel):
|
|||||||
rnd_time: int | None = Field(default=None)
|
rnd_time: int | None = Field(default=None)
|
||||||
overall_jails: bool | None = Field(default=None)
|
overall_jails: bool | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Jail configuration models
|
# Jail configuration models
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class JailConfig(BanGuiBaseModel):
|
||||||
class JailConfig(BaseModel):
|
|
||||||
"""Configuration snapshot of a single jail (editable fields)."""
|
"""Configuration snapshot of a single jail (editable fields)."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
name: str = Field(..., description="Jail name as configured in fail2ban.")
|
name: str = Field(..., description="Jail name as configured in fail2ban.")
|
||||||
ban_time: int = Field(..., description="Ban duration in seconds. -1 for permanent.")
|
ban_time: int = Field(..., description="Ban duration in seconds. -1 for permanent.")
|
||||||
max_retry: int = Field(..., ge=1, description="Number of failures before a ban is issued.")
|
max_retry: int = Field(..., ge=1, description="Number of failures before a ban is issued.")
|
||||||
@@ -79,9 +78,9 @@ class JailConfig(BaseModel):
|
|||||||
ignore_regex: list[str] = Field(default_factory=list, description="Regex patterns that bypass the ban logic.")
|
ignore_regex: list[str] = Field(default_factory=list, description="Regex patterns that bypass the ban logic.")
|
||||||
log_paths: list[str] = Field(default_factory=list, description="Monitored log files.")
|
log_paths: list[str] = Field(default_factory=list, description="Monitored log files.")
|
||||||
date_pattern: str | None = Field(default=None, description="Custom date pattern for log parsing.")
|
date_pattern: str | None = Field(default=None, description="Custom date pattern for log parsing.")
|
||||||
log_encoding: str = Field(default="UTF-8", description="Log file encoding.")
|
log_encoding: LogEncoding = Field(default="UTF-8", description="Log file encoding.")
|
||||||
backend: str = Field(default="polling", description="Log monitoring backend.")
|
backend: BackendType = Field(default="polling", description="Log monitoring backend.")
|
||||||
use_dns: str = Field(default="warn", description="DNS lookup mode: yes | warn | no | raw.")
|
use_dns: DNSMode = Field(default="warn", description="DNS lookup mode: yes | warn | no | raw.")
|
||||||
prefregex: str = Field(default="", description="Prefix regex prepended to every failregex; empty means disabled.")
|
prefregex: str = Field(default="", description="Prefix regex prepended to every failregex; empty means disabled.")
|
||||||
actions: list[str] = Field(default_factory=list, description="Names of actions attached to this jail.")
|
actions: list[str] = Field(default_factory=list, description="Names of actions attached to this jail.")
|
||||||
bantime_escalation: BantimeEscalation | None = Field(
|
bantime_escalation: BantimeEscalation | None = Field(
|
||||||
@@ -89,29 +88,22 @@ class JailConfig(BaseModel):
|
|||||||
description="Incremental ban-time escalation settings, or None if not configured.",
|
description="Incremental ban-time escalation settings, or None if not configured.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class JailConfigResponse(BanGuiBaseModel):
|
||||||
class JailConfigResponse(BaseModel):
|
|
||||||
"""Response for ``GET /api/config/jails/{name}``."""
|
"""Response for ``GET /api/config/jails/{name}``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
jail: JailConfig
|
jail: JailConfig
|
||||||
|
|
||||||
|
class JailConfigListResponse(CollectionResponse[JailConfig]):
|
||||||
|
"""Response for ``GET /api/config/jails``.
|
||||||
|
|
||||||
class JailConfigListResponse(BaseModel):
|
Returns a non-paginated collection of jail configurations.
|
||||||
"""Response for ``GET /api/config/jails``."""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
pass
|
||||||
|
|
||||||
jails: list[JailConfig] = Field(default_factory=list)
|
class JailConfigUpdate(BanGuiBaseModel):
|
||||||
total: int = Field(..., ge=0)
|
|
||||||
|
|
||||||
|
|
||||||
class JailConfigUpdate(BaseModel):
|
|
||||||
"""Payload for ``PUT /api/config/jails/{name}``."""
|
"""Payload for ``PUT /api/config/jails/{name}``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
ban_time: int | None = Field(default=None, description="Ban duration in seconds. -1 for permanent.")
|
ban_time: int | None = Field(default=None, description="Ban duration in seconds. -1 for permanent.")
|
||||||
max_retry: int | None = Field(default=None, ge=1)
|
max_retry: int | None = Field(default=None, ge=1)
|
||||||
find_time: int | None = Field(default=None, ge=1)
|
find_time: int | None = Field(default=None, ge=1)
|
||||||
@@ -119,35 +111,28 @@ class JailConfigUpdate(BaseModel):
|
|||||||
ignore_regex: list[str] | None = Field(default=None)
|
ignore_regex: list[str] | None = Field(default=None)
|
||||||
prefregex: str | None = Field(default=None, description="Prefix regex; None = skip, '' = clear, non-empty = set.")
|
prefregex: str | None = Field(default=None, description="Prefix regex; None = skip, '' = clear, non-empty = set.")
|
||||||
date_pattern: str | None = Field(default=None)
|
date_pattern: str | None = Field(default=None)
|
||||||
dns_mode: str | None = Field(default=None, description="DNS lookup mode: yes | warn | no | raw.")
|
dns_mode: DNSMode | None = Field(default=None, description="DNS lookup mode: yes | warn | no | raw.")
|
||||||
backend: str | None = Field(default=None, description="Log monitoring backend.")
|
backend: BackendType | None = Field(default=None, description="Log monitoring backend.")
|
||||||
log_encoding: str | None = Field(default=None, description="Log file encoding.")
|
log_encoding: LogEncoding | None = Field(default=None, description="Log file encoding.")
|
||||||
enabled: bool | None = Field(default=None)
|
enabled: bool | None = Field(default=None)
|
||||||
bantime_escalation: BantimeEscalationUpdate | None = Field(
|
bantime_escalation: BantimeEscalationUpdate | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Incremental ban-time escalation settings to update.",
|
description="Incremental ban-time escalation settings to update.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Regex tester models
|
# Regex tester models
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class RegexTestRequest(BanGuiBaseModel):
|
||||||
class RegexTestRequest(BaseModel):
|
|
||||||
"""Payload for ``POST /api/config/regex-test``."""
|
"""Payload for ``POST /api/config/regex-test``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
log_line: str = Field(..., description="Sample log line to test against.")
|
log_line: str = Field(..., description="Sample log line to test against.")
|
||||||
fail_regex: str = Field(..., description="Regex pattern to match.")
|
fail_regex: str = Field(..., description="Regex pattern to match.")
|
||||||
|
|
||||||
|
class RegexTestResponse(BanGuiBaseModel):
|
||||||
class RegexTestResponse(BaseModel):
|
|
||||||
"""Result of a regex test."""
|
"""Result of a regex test."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
matched: bool = Field(..., description="Whether the pattern matched the log line.")
|
matched: bool = Field(..., description="Whether the pattern matched the log line.")
|
||||||
groups: list[str] = Field(
|
groups: list[str] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
@@ -158,98 +143,74 @@ class RegexTestResponse(BaseModel):
|
|||||||
description="Compilation error message if the regex is invalid.",
|
description="Compilation error message if the regex is invalid.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Global config models
|
# Global config models
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class GlobalConfigResponse(BanGuiBaseModel):
|
||||||
class GlobalConfigResponse(BaseModel):
|
|
||||||
"""Response for ``GET /api/config/global``."""
|
"""Response for ``GET /api/config/global``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
log_level: LogLevel
|
||||||
|
log_target: str = Field(..., description="Log target: STDOUT, STDERR, SYSLOG, or a validated file path.")
|
||||||
log_level: str
|
|
||||||
log_target: str
|
|
||||||
db_purge_age: int = Field(..., description="Seconds after which ban records are purged from the fail2ban DB.")
|
db_purge_age: int = Field(..., description="Seconds after which ban records are purged from the fail2ban DB.")
|
||||||
db_max_matches: int = Field(..., description="Maximum stored log-line matches per ban record.")
|
db_max_matches: int = Field(..., description="Maximum stored log-line matches per ban record.")
|
||||||
|
|
||||||
|
class GlobalConfigUpdate(BanGuiBaseModel):
|
||||||
class GlobalConfigUpdate(BaseModel):
|
|
||||||
"""Payload for ``PUT /api/config/global``."""
|
"""Payload for ``PUT /api/config/global``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
log_level: LogLevel | None = Field(
|
||||||
|
|
||||||
log_level: str | None = Field(
|
|
||||||
default=None,
|
default=None,
|
||||||
description="Log level: CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG.",
|
description="Log level: CRITICAL, ERROR, WARNING, NOTICE, INFO, or DEBUG.",
|
||||||
)
|
)
|
||||||
log_target: str | None = Field(
|
log_target: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Log target: STDOUT, STDERR, SYSLOG, SYSTEMD-JOURNAL, or a file path.",
|
description="Log target: STDOUT, STDERR, SYSLOG, or a validated file path.",
|
||||||
)
|
)
|
||||||
db_purge_age: int | None = Field(default=None, ge=0)
|
db_purge_age: int | None = Field(default=None, ge=0)
|
||||||
db_max_matches: int | None = Field(default=None, ge=0)
|
db_max_matches: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Log observation / preview models
|
# Log observation / preview models
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class AddLogPathRequest(BanGuiBaseModel):
|
||||||
class AddLogPathRequest(BaseModel):
|
|
||||||
"""Payload for ``POST /api/config/jails/{name}/logpath``."""
|
"""Payload for ``POST /api/config/jails/{name}/logpath``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
log_path: str = Field(..., description="Absolute path to the log file to monitor.")
|
log_path: str = Field(..., description="Absolute path to the log file to monitor.")
|
||||||
tail: bool = Field(
|
tail: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
description="If true, monitor from current end of file (tail). If false, read from the beginning.",
|
description="If true, monitor from current end of file (tail). If false, read from the beginning.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class LogPreviewRequest(BanGuiBaseModel):
|
||||||
class LogPreviewRequest(BaseModel):
|
|
||||||
"""Payload for ``POST /api/config/preview-log``."""
|
"""Payload for ``POST /api/config/preview-log``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
log_path: str = Field(..., description="Absolute path to the log file to preview.")
|
log_path: str = Field(..., description="Absolute path to the log file to preview.")
|
||||||
fail_regex: str = Field(..., description="Regex pattern to test against log lines.")
|
fail_regex: str = Field(..., description="Regex pattern to test against log lines.")
|
||||||
num_lines: int = Field(default=200, ge=1, le=5000, description="Number of lines to read from the end of the file.")
|
num_lines: int = Field(default=200, ge=1, le=5000, description="Number of lines to read from the end of the file.")
|
||||||
|
|
||||||
|
class LogPreviewLine(BanGuiBaseModel):
|
||||||
class LogPreviewLine(BaseModel):
|
|
||||||
"""A single log line with match information."""
|
"""A single log line with match information."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
line: str
|
line: str
|
||||||
matched: bool
|
matched: bool
|
||||||
groups: list[str] = Field(default_factory=list)
|
groups: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
class LogPreviewResponse(BanGuiBaseModel):
|
||||||
class LogPreviewResponse(BaseModel):
|
|
||||||
"""Response for ``POST /api/config/preview-log``."""
|
"""Response for ``POST /api/config/preview-log``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
lines: list[LogPreviewLine] = Field(default_factory=list)
|
lines: list[LogPreviewLine] = Field(default_factory=list)
|
||||||
total_lines: int = Field(..., ge=0)
|
total_lines: int = Field(..., ge=0)
|
||||||
matched_count: int = Field(..., ge=0)
|
matched_count: int = Field(..., ge=0)
|
||||||
regex_error: str | None = Field(default=None, description="Set if the regex failed to compile.")
|
regex_error: str | None = Field(default=None, description="Set if the regex failed to compile.")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Map color threshold models
|
# Map color threshold models
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MapColorThresholdsResponse(BanGuiBaseModel):
|
||||||
class MapColorThresholdsResponse(BaseModel):
|
|
||||||
"""Response for ``GET /api/config/map-thresholds``."""
|
"""Response for ``GET /api/config/map-thresholds``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
threshold_high: int = Field(
|
threshold_high: int = Field(
|
||||||
..., description="Ban count for red coloring."
|
..., description="Ban count for red coloring."
|
||||||
)
|
)
|
||||||
@@ -260,37 +221,30 @@ class MapColorThresholdsResponse(BaseModel):
|
|||||||
..., description="Ban count for green coloring."
|
..., description="Ban count for green coloring."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class MapColorThresholdsUpdate(BanGuiBaseModel):
|
||||||
class MapColorThresholdsUpdate(BaseModel):
|
|
||||||
"""Payload for ``PUT /api/config/map-thresholds``."""
|
"""Payload for ``PUT /api/config/map-thresholds``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
threshold_high: int = Field(..., gt=0, description="Ban count for red.")
|
threshold_high: int = Field(..., gt=0, description="Ban count for red.")
|
||||||
threshold_medium: int = Field(
|
threshold_medium: int = Field(
|
||||||
..., gt=0, description="Ban count for yellow."
|
..., gt=0, description="Ban count for yellow."
|
||||||
)
|
)
|
||||||
threshold_low: int = Field(..., gt=0, description="Ban count for green.")
|
threshold_low: int = Field(..., gt=0, description="Ban count for green.")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Parsed filter file models
|
# Parsed filter file models
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class FilterConfig(BanGuiBaseModel):
|
||||||
class FilterConfig(BaseModel):
|
|
||||||
"""Structured representation of a ``filter.d/*.conf`` file.
|
"""Structured representation of a ``filter.d/*.conf`` file.
|
||||||
|
|
||||||
The ``active``, ``used_by_jails``, ``source_file``, and
|
The ``active``, ``used_by_jails``, ``source_file``, and
|
||||||
``has_local_override`` fields are populated by
|
``has_local_override`` fields are populated by
|
||||||
:func:`~app.services.config_file_service.list_filters` and
|
:func:`~app.services.filter_config_service.list_filters` and
|
||||||
:func:`~app.services.config_file_service.get_filter`. When the model is
|
:func:`~app.services.filter_config_service.get_filter`. When the model is
|
||||||
returned from the raw file-based endpoints (``/filters/{name}/parsed``),
|
returned from the raw file-based endpoints (``/filters/{name}/parsed``),
|
||||||
these fields carry their default values.
|
these fields carry their default values.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
name: str = Field(..., description="Filter base name, e.g. ``sshd``.")
|
name: str = Field(..., description="Filter base name, e.g. ``sshd``.")
|
||||||
filename: str = Field(..., description="Actual filename, e.g. ``sshd.conf``.")
|
filename: str = Field(..., description="Actual filename, e.g. ``sshd.conf``.")
|
||||||
# [INCLUDES]
|
# [INCLUDES]
|
||||||
@@ -326,7 +280,7 @@ class FilterConfig(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Systemd journal match expression.",
|
description="Systemd journal match expression.",
|
||||||
)
|
)
|
||||||
# Active-status fields — populated by config_file_service.list_filters /
|
# Active-status fields — populated by filter_config_service.list_filters /
|
||||||
# get_filter; default to safe "inactive" values when not computed.
|
# get_filter; default to safe "inactive" values when not computed.
|
||||||
active: bool = Field(
|
active: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
@@ -354,15 +308,12 @@ class FilterConfig(BaseModel):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class FilterConfigUpdate(BanGuiBaseModel):
|
||||||
class FilterConfigUpdate(BaseModel):
|
|
||||||
"""Partial update payload for a parsed filter file.
|
"""Partial update payload for a parsed filter file.
|
||||||
|
|
||||||
Only explicitly set (non-``None``) fields are written back.
|
Only explicitly set (non-``None``) fields are written back.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
before: str | None = Field(default=None)
|
before: str | None = Field(default=None)
|
||||||
after: str | None = Field(default=None)
|
after: str | None = Field(default=None)
|
||||||
variables: dict[str, str] | None = Field(default=None)
|
variables: dict[str, str] | None = Field(default=None)
|
||||||
@@ -373,8 +324,7 @@ class FilterConfigUpdate(BaseModel):
|
|||||||
datepattern: str | None = Field(default=None)
|
datepattern: str | None = Field(default=None)
|
||||||
journalmatch: str | None = Field(default=None)
|
journalmatch: str | None = Field(default=None)
|
||||||
|
|
||||||
|
class FilterUpdateRequest(BanGuiBaseModel):
|
||||||
class FilterUpdateRequest(BaseModel):
|
|
||||||
"""Payload for ``PUT /api/config/filters/{name}``.
|
"""Payload for ``PUT /api/config/filters/{name}``.
|
||||||
|
|
||||||
Accepts only the user-editable ``[Definition]`` fields. Fields left as
|
Accepts only the user-editable ``[Definition]`` fields. Fields left as
|
||||||
@@ -382,8 +332,6 @@ class FilterUpdateRequest(BaseModel):
|
|||||||
preserved.
|
preserved.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
failregex: list[str] | None = Field(
|
failregex: list[str] | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Updated failure-detection regex patterns. ``None`` = keep existing.",
|
description="Updated failure-detection regex patterns. ``None`` = keep existing.",
|
||||||
@@ -401,15 +349,12 @@ class FilterUpdateRequest(BaseModel):
|
|||||||
description="Systemd journal match expression. ``None`` = keep existing.",
|
description="Systemd journal match expression. ``None`` = keep existing.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class FilterCreateRequest(BanGuiBaseModel):
|
||||||
class FilterCreateRequest(BaseModel):
|
|
||||||
"""Payload for ``POST /api/config/filters``.
|
"""Payload for ``POST /api/config/filters``.
|
||||||
|
|
||||||
Creates a new user-defined filter at ``filter.d/{name}.local``.
|
Creates a new user-defined filter at ``filter.d/{name}.local``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
name: str = Field(
|
name: str = Field(
|
||||||
...,
|
...,
|
||||||
description="Filter base name (e.g. ``my-custom-filter``). Must not already exist in ``filter.d/``.",
|
description="Filter base name (e.g. ``my-custom-filter``). Must not already exist in ``filter.d/``.",
|
||||||
@@ -435,23 +380,17 @@ class FilterCreateRequest(BaseModel):
|
|||||||
description="Systemd journal match expression.",
|
description="Systemd journal match expression.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class AssignFilterRequest(BanGuiBaseModel):
|
||||||
class AssignFilterRequest(BaseModel):
|
|
||||||
"""Payload for ``POST /api/config/jails/{jail_name}/filter``."""
|
"""Payload for ``POST /api/config/jails/{jail_name}/filter``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
filter_name: str = Field(
|
filter_name: str = Field(
|
||||||
...,
|
...,
|
||||||
description="Filter base name to assign to the jail (e.g. ``sshd``).",
|
description="Filter base name to assign to the jail (e.g. ``sshd``).",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class FilterListResponse(BanGuiBaseModel):
|
||||||
class FilterListResponse(BaseModel):
|
|
||||||
"""Response for ``GET /api/config/filters``."""
|
"""Response for ``GET /api/config/filters``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
filters: list[FilterConfig] = Field(
|
filters: list[FilterConfig] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
description=(
|
description=(
|
||||||
@@ -461,17 +400,13 @@ class FilterListResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
total: int = Field(..., ge=0, description="Total number of filters found.")
|
total: int = Field(..., ge=0, description="Total number of filters found.")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Parsed action file models
|
# Parsed action file models
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ActionConfig(BanGuiBaseModel):
|
||||||
class ActionConfig(BaseModel):
|
|
||||||
"""Structured representation of an ``action.d/*.conf`` file."""
|
"""Structured representation of an ``action.d/*.conf`` file."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
name: str = Field(..., description="Action base name, e.g. ``iptables``.")
|
name: str = Field(..., description="Action base name, e.g. ``iptables``.")
|
||||||
filename: str = Field(..., description="Actual filename, e.g. ``iptables.conf``.")
|
filename: str = Field(..., description="Actual filename, e.g. ``iptables.conf``.")
|
||||||
# [INCLUDES]
|
# [INCLUDES]
|
||||||
@@ -512,7 +447,7 @@ class ActionConfig(BaseModel):
|
|||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
description="Runtime parameters that can be overridden per jail.",
|
description="Runtime parameters that can be overridden per jail.",
|
||||||
)
|
)
|
||||||
# Active-status fields — populated by config_file_service.list_actions /
|
# Active-status fields — populated by action_config_service.list_actions /
|
||||||
# get_action; default to safe "inactive" values when not computed.
|
# get_action; default to safe "inactive" values when not computed.
|
||||||
active: bool = Field(
|
active: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
@@ -540,12 +475,9 @@ class ActionConfig(BaseModel):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class ActionConfigUpdate(BanGuiBaseModel):
|
||||||
class ActionConfigUpdate(BaseModel):
|
|
||||||
"""Partial update payload for a parsed action file."""
|
"""Partial update payload for a parsed action file."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
before: str | None = Field(default=None)
|
before: str | None = Field(default=None)
|
||||||
after: str | None = Field(default=None)
|
after: str | None = Field(default=None)
|
||||||
actionstart: str | None = Field(default=None)
|
actionstart: str | None = Field(default=None)
|
||||||
@@ -557,12 +489,9 @@ class ActionConfigUpdate(BaseModel):
|
|||||||
definition_vars: dict[str, str] | None = Field(default=None)
|
definition_vars: dict[str, str] | None = Field(default=None)
|
||||||
init_vars: dict[str, str] | None = Field(default=None)
|
init_vars: dict[str, str] | None = Field(default=None)
|
||||||
|
|
||||||
|
class ActionListResponse(BanGuiBaseModel):
|
||||||
class ActionListResponse(BaseModel):
|
|
||||||
"""Response for ``GET /api/config/actions``."""
|
"""Response for ``GET /api/config/actions``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
actions: list[ActionConfig] = Field(
|
actions: list[ActionConfig] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
description=(
|
description=(
|
||||||
@@ -572,16 +501,13 @@ class ActionListResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
total: int = Field(..., ge=0, description="Total number of actions found.")
|
total: int = Field(..., ge=0, description="Total number of actions found.")
|
||||||
|
|
||||||
|
class ActionUpdateRequest(BanGuiBaseModel):
|
||||||
class ActionUpdateRequest(BaseModel):
|
|
||||||
"""Payload for ``PUT /api/config/actions/{name}``.
|
"""Payload for ``PUT /api/config/actions/{name}``.
|
||||||
|
|
||||||
Accepts only the user-editable ``[Definition]`` lifecycle fields and
|
Accepts only the user-editable ``[Definition]`` lifecycle fields and
|
||||||
``[Init]`` parameters. Fields left as ``None`` are not changed.
|
``[Init]`` parameters. Fields left as ``None`` are not changed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
actionstart: str | None = Field(
|
actionstart: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Updated ``actionstart`` command. ``None`` = keep existing.",
|
description="Updated ``actionstart`` command. ``None`` = keep existing.",
|
||||||
@@ -615,15 +541,12 @@ class ActionUpdateRequest(BaseModel):
|
|||||||
description="``[Init]`` parameters to set. ``None`` = keep existing.",
|
description="``[Init]`` parameters to set. ``None`` = keep existing.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class ActionCreateRequest(BanGuiBaseModel):
|
||||||
class ActionCreateRequest(BaseModel):
|
|
||||||
"""Payload for ``POST /api/config/actions``.
|
"""Payload for ``POST /api/config/actions``.
|
||||||
|
|
||||||
Creates a new user-defined action at ``action.d/{name}.local``.
|
Creates a new user-defined action at ``action.d/{name}.local``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
name: str = Field(
|
name: str = Field(
|
||||||
...,
|
...,
|
||||||
description="Action base name (e.g. ``my-custom-action``). Must not already exist.",
|
description="Action base name (e.g. ``my-custom-action``). Must not already exist.",
|
||||||
@@ -643,12 +566,9 @@ class ActionCreateRequest(BaseModel):
|
|||||||
description="``[Init]`` runtime parameters.",
|
description="``[Init]`` runtime parameters.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class AssignActionRequest(BanGuiBaseModel):
|
||||||
class AssignActionRequest(BaseModel):
|
|
||||||
"""Payload for ``POST /api/config/jails/{jail_name}/action``."""
|
"""Payload for ``POST /api/config/jails/{jail_name}/action``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
action_name: str = Field(
|
action_name: str = Field(
|
||||||
...,
|
...,
|
||||||
description="Action base name to add to the jail (e.g. ``iptables-multiport``).",
|
description="Action base name to add to the jail (e.g. ``iptables-multiport``).",
|
||||||
@@ -661,17 +581,13 @@ class AssignActionRequest(BaseModel):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Jail file config models (Task 6.1)
|
# Jail file config models (Task 6.1)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class JailSectionConfig(BanGuiBaseModel):
|
||||||
class JailSectionConfig(BaseModel):
|
|
||||||
"""Settings within a single [jailname] section of a jail.d file."""
|
"""Settings within a single [jailname] section of a jail.d file."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
enabled: bool | None = Field(default=None, description="Whether this jail is enabled.")
|
enabled: bool | None = Field(default=None, description="Whether this jail is enabled.")
|
||||||
port: str | None = Field(default=None, description="Port(s) to monitor (e.g. 'ssh' or '22,2222').")
|
port: str | None = Field(default=None, description="Port(s) to monitor (e.g. 'ssh' or '22,2222').")
|
||||||
filter: str | None = Field(default=None, description="Filter name to use (e.g. 'sshd').")
|
filter: str | None = Field(default=None, description="Filter name to use (e.g. 'sshd').")
|
||||||
@@ -680,39 +596,31 @@ class JailSectionConfig(BaseModel):
|
|||||||
findtime: int | None = Field(default=None, ge=1, description="Time window in seconds for counting failures.")
|
findtime: int | None = Field(default=None, ge=1, description="Time window in seconds for counting failures.")
|
||||||
bantime: int | None = Field(default=None, description="Ban duration in seconds. -1 for permanent.")
|
bantime: int | None = Field(default=None, description="Ban duration in seconds. -1 for permanent.")
|
||||||
action: list[str] = Field(default_factory=list, description="Action references.")
|
action: list[str] = Field(default_factory=list, description="Action references.")
|
||||||
backend: str | None = Field(default=None, description="Log monitoring backend.")
|
backend: BackendType | None = Field(default=None, description="Log monitoring backend.")
|
||||||
extra: dict[str, str] = Field(default_factory=dict, description="Additional settings not captured by named fields.")
|
extra: dict[str, str] = Field(default_factory=dict, description="Additional settings not captured by named fields.")
|
||||||
|
|
||||||
|
class JailFileConfig(BanGuiBaseModel):
|
||||||
class JailFileConfig(BaseModel):
|
|
||||||
"""Structured representation of a jail.d/*.conf file."""
|
"""Structured representation of a jail.d/*.conf file."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
filename: str = Field(..., description="Filename including extension (e.g. 'sshd.conf').")
|
filename: str = Field(..., description="Filename including extension (e.g. 'sshd.conf').")
|
||||||
jails: dict[str, JailSectionConfig] = Field(
|
jails: dict[str, JailSectionConfig] = Field(
|
||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
description="Mapping of jail name → settings for each [section] in the file.",
|
description="Mapping of jail name → settings for each [section] in the file.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class JailFileConfigUpdate(BanGuiBaseModel):
|
||||||
class JailFileConfigUpdate(BaseModel):
|
|
||||||
"""Partial update payload for a jail.d file."""
|
"""Partial update payload for a jail.d file."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
jails: dict[str, JailSectionConfig] | None = Field(
|
jails: dict[str, JailSectionConfig] | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Jail section updates. Only jails present in this dict are updated.",
|
description="Jail section updates. Only jails present in this dict are updated.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Inactive jail models (Stage 1)
|
# Inactive jail models (Stage 1)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class InactiveJail(BanGuiBaseModel):
|
||||||
class InactiveJail(BaseModel):
|
|
||||||
"""A jail defined in fail2ban config files that is not currently active.
|
"""A jail defined in fail2ban config files that is not currently active.
|
||||||
|
|
||||||
A jail is considered inactive when its ``enabled`` key is ``false`` (or
|
A jail is considered inactive when its ``enabled`` key is ``false`` (or
|
||||||
@@ -721,8 +629,6 @@ class InactiveJail(BaseModel):
|
|||||||
running.
|
running.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
name: str = Field(..., description="Jail name from the config section header.")
|
name: str = Field(..., description="Jail name from the config section header.")
|
||||||
filter: str = Field(
|
filter: str = Field(
|
||||||
...,
|
...,
|
||||||
@@ -764,11 +670,11 @@ class InactiveJail(BaseModel):
|
|||||||
default=600,
|
default=600,
|
||||||
description="Failure-counting window in seconds, parsed from findtime string.",
|
description="Failure-counting window in seconds, parsed from findtime string.",
|
||||||
)
|
)
|
||||||
log_encoding: str = Field(
|
log_encoding: LogEncoding = Field(
|
||||||
default="auto",
|
default="auto",
|
||||||
description="Log encoding, e.g. ``utf-8`` or ``auto``.",
|
description="Log encoding, e.g. ``utf-8`` or ``auto``.",
|
||||||
)
|
)
|
||||||
backend: str = Field(
|
backend: BackendType = Field(
|
||||||
default="auto",
|
default="auto",
|
||||||
description="Log-monitoring backend, e.g. ``auto``, ``pyinotify``, ``polling``.",
|
description="Log-monitoring backend, e.g. ``auto``, ``pyinotify``, ``polling``.",
|
||||||
)
|
)
|
||||||
@@ -776,7 +682,7 @@ class InactiveJail(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Date pattern for log parsing, or None for auto-detect.",
|
description="Date pattern for log parsing, or None for auto-detect.",
|
||||||
)
|
)
|
||||||
use_dns: str = Field(
|
use_dns: DNSMode = Field(
|
||||||
default="warn",
|
default="warn",
|
||||||
description="DNS resolution mode: ``yes``, ``warn``, ``no``, or ``raw``.",
|
description="DNS resolution mode: ``yes``, ``warn``, ``no``, or ``raw``.",
|
||||||
)
|
)
|
||||||
@@ -816,17 +722,15 @@ class InactiveJail(BaseModel):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class InactiveJailListResponse(CollectionResponse[InactiveJail]):
|
||||||
|
"""Response for ``GET /api/config/jails/inactive``.
|
||||||
|
|
||||||
class InactiveJailListResponse(BaseModel):
|
Returns a non-paginated collection of inactive jail configurations.
|
||||||
"""Response for ``GET /api/config/jails/inactive``."""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
pass
|
||||||
|
|
||||||
jails: list[InactiveJail] = Field(default_factory=list)
|
class ActivateJailRequest(BanGuiBaseModel):
|
||||||
total: int = Field(..., ge=0)
|
|
||||||
|
|
||||||
|
|
||||||
class ActivateJailRequest(BaseModel):
|
|
||||||
"""Optional override values when activating an inactive jail.
|
"""Optional override values when activating an inactive jail.
|
||||||
|
|
||||||
All fields are optional. Omitted fields are not written to the
|
All fields are optional. Omitted fields are not written to the
|
||||||
@@ -834,8 +738,6 @@ class ActivateJailRequest(BaseModel):
|
|||||||
values.
|
values.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
bantime: str | None = Field(
|
bantime: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Override ban duration, e.g. ``1h`` or ``3600``.",
|
description="Override ban duration, e.g. ``1h`` or ``3600``.",
|
||||||
@@ -858,12 +760,9 @@ class ActivateJailRequest(BaseModel):
|
|||||||
description="Override log file paths.",
|
description="Override log file paths.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class JailActivationResponse(BanGuiBaseModel):
|
||||||
class JailActivationResponse(BaseModel):
|
|
||||||
"""Response for jail activation and deactivation endpoints."""
|
"""Response for jail activation and deactivation endpoints."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
name: str = Field(..., description="Name of the affected jail.")
|
name: str = Field(..., description="Name of the affected jail.")
|
||||||
active: bool = Field(
|
active: bool = Field(
|
||||||
...,
|
...,
|
||||||
@@ -892,29 +791,22 @@ class JailActivationResponse(BaseModel):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Jail validation models (Task 3)
|
# Jail validation models (Task 3)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class JailValidationIssue(BanGuiBaseModel):
|
||||||
class JailValidationIssue(BaseModel):
|
|
||||||
"""A single issue found during pre-activation validation of a jail config."""
|
"""A single issue found during pre-activation validation of a jail config."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
field: str = Field(
|
field: str = Field(
|
||||||
...,
|
...,
|
||||||
description="Config field associated with this issue, e.g. 'filter', 'failregex', 'logpath'.",
|
description="Config field associated with this issue, e.g. 'filter', 'failregex', 'logpath'.",
|
||||||
)
|
)
|
||||||
message: str = Field(..., description="Human-readable description of the issue.")
|
message: str = Field(..., description="Human-readable description of the issue.")
|
||||||
|
|
||||||
|
class JailValidationResult(BanGuiBaseModel):
|
||||||
class JailValidationResult(BaseModel):
|
|
||||||
"""Result of pre-activation validation of a single jail configuration."""
|
"""Result of pre-activation validation of a single jail configuration."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
jail_name: str = Field(..., description="Name of the validated jail.")
|
jail_name: str = Field(..., description="Name of the validated jail.")
|
||||||
valid: bool = Field(..., description="True when no issues were found.")
|
valid: bool = Field(..., description="True when no issues were found.")
|
||||||
issues: list[JailValidationIssue] = Field(
|
issues: list[JailValidationIssue] = Field(
|
||||||
@@ -922,17 +814,13 @@ class JailValidationResult(BaseModel):
|
|||||||
description="Validation issues found. Empty when valid=True.",
|
description="Validation issues found. Empty when valid=True.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Rollback response model (Task 3)
|
# Rollback response model (Task 3)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class RollbackResponse(BanGuiBaseModel):
|
||||||
class RollbackResponse(BaseModel):
|
|
||||||
"""Response for ``POST /api/config/jails/{name}/rollback``."""
|
"""Response for ``POST /api/config/jails/{name}/rollback``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
jail_name: str = Field(..., description="Name of the jail that was disabled.")
|
jail_name: str = Field(..., description="Name of the jail that was disabled.")
|
||||||
disabled: bool = Field(
|
disabled: bool = Field(
|
||||||
...,
|
...,
|
||||||
@@ -949,17 +837,13 @@ class RollbackResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
message: str = Field(..., description="Human-readable result message.")
|
message: str = Field(..., description="Human-readable result message.")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Pending recovery model (Task 3)
|
# Pending recovery model (Task 3)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class PendingRecovery(BanGuiBaseModel):
|
||||||
class PendingRecovery(BaseModel):
|
|
||||||
"""Records a probable activation-caused fail2ban crash pending user action."""
|
"""Records a probable activation-caused fail2ban crash pending user action."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
jail_name: str = Field(
|
jail_name: str = Field(
|
||||||
...,
|
...,
|
||||||
description="Name of the jail whose activation likely caused the crash.",
|
description="Name of the jail whose activation likely caused the crash.",
|
||||||
@@ -977,29 +861,22 @@ class PendingRecovery(BaseModel):
|
|||||||
description="Whether fail2ban has been successfully restarted.",
|
description="Whether fail2ban has been successfully restarted.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# fail2ban log viewer models
|
# fail2ban log viewer models
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Fail2BanLogResponse(BanGuiBaseModel):
|
||||||
class Fail2BanLogResponse(BaseModel):
|
|
||||||
"""Response for ``GET /api/config/fail2ban-log``."""
|
"""Response for ``GET /api/config/fail2ban-log``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
log_path: str = Field(..., description="Resolved absolute path of the log file being read.")
|
log_path: str = Field(..., description="Resolved absolute path of the log file being read.")
|
||||||
lines: list[str] = Field(default_factory=list, description="Log lines returned (tail, optionally filtered).")
|
lines: list[str] = Field(default_factory=list, description="Log lines returned (tail, optionally filtered).")
|
||||||
total_lines: int = Field(..., ge=0, description="Total number of lines in the file before filtering.")
|
total_lines: int = Field(..., ge=0, description="Total number of lines in the file before filtering.")
|
||||||
log_level: str = Field(..., description="Current fail2ban log level.")
|
log_level: str = Field(..., description="Current fail2ban log level.")
|
||||||
log_target: str = Field(..., description="Current fail2ban log target (file path or special value).")
|
log_target: str = Field(..., description="Current fail2ban log target (file path or special value).")
|
||||||
|
|
||||||
|
class ServiceStatusResponse(BanGuiBaseModel):
|
||||||
class ServiceStatusResponse(BaseModel):
|
|
||||||
"""Response for ``GET /api/config/service-status``."""
|
"""Response for ``GET /api/config/service-status``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
online: bool = Field(..., description="Whether fail2ban is reachable via its socket.")
|
online: bool = Field(..., description="Whether fail2ban is reachable via its socket.")
|
||||||
version: str | None = Field(default=None, description="BanGUI application version (or None when offline).")
|
version: str | None = Field(default=None, description="BanGUI application version (or None when offline).")
|
||||||
jail_count: int = Field(default=0, ge=0, description="Number of currently active jails.")
|
jail_count: int = Field(default=0, ge=0, description="Number of currently active jails.")
|
||||||
@@ -1007,3 +884,21 @@ class ServiceStatusResponse(BaseModel):
|
|||||||
total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.")
|
total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.")
|
||||||
log_level: str = Field(default="UNKNOWN", description="Current fail2ban log level.")
|
log_level: str = Field(default="UNKNOWN", description="Current fail2ban log level.")
|
||||||
log_target: str = Field(default="UNKNOWN", description="Current fail2ban log target.")
|
log_target: str = Field(default="UNKNOWN", description="Current fail2ban log target.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Security headers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityHeadersResponse(BanGuiBaseModel):
|
||||||
|
"""Security-relevant header names and values used by the frontend."""
|
||||||
|
|
||||||
|
csrf_header_name: str = Field(
|
||||||
|
...,
|
||||||
|
description="Name of the custom header required for state-mutating requests.",
|
||||||
|
)
|
||||||
|
csrf_header_value: str = Field(
|
||||||
|
...,
|
||||||
|
description="Required value of the CSRF header to pass validation.",
|
||||||
|
)
|
||||||
|
|||||||
130
backend/app/models/config_domain.py
Normal file
130
backend/app/models/config_domain.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""Config domain models.
|
||||||
|
|
||||||
|
Internal domain-focused models used by config_service. These represent the
|
||||||
|
business domain layer and are independent of HTTP response shapes.
|
||||||
|
|
||||||
|
Response models are defined in `app.models.config` and mappers convert domain
|
||||||
|
models to response models at the router boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
DNSMode = Literal["yes", "warn", "no", "raw"]
|
||||||
|
LogEncoding = Literal["auto", "ascii", "utf-8", "UTF-8", "latin-1"]
|
||||||
|
BackendType = Literal["auto", "polling", "pyinotify", "systemd", "gamin"]
|
||||||
|
LogLevel = Literal["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainBantimeEscalation:
|
||||||
|
"""Incremental ban-time escalation configuration (domain model)."""
|
||||||
|
|
||||||
|
increment: bool = False
|
||||||
|
factor: float | None = None
|
||||||
|
formula: str | None = None
|
||||||
|
multipliers: str | None = None
|
||||||
|
max_time: int | None = None
|
||||||
|
rnd_time: int | None = None
|
||||||
|
overall_jails: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainJailConfig:
|
||||||
|
"""Configuration snapshot of a single jail (domain model)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
ban_time: int
|
||||||
|
max_retry: int
|
||||||
|
find_time: int
|
||||||
|
fail_regex: list[str]
|
||||||
|
ignore_regex: list[str]
|
||||||
|
log_paths: list[str]
|
||||||
|
actions: list[str]
|
||||||
|
date_pattern: str | None = None
|
||||||
|
log_encoding: LogEncoding = "UTF-8"
|
||||||
|
backend: BackendType = "polling"
|
||||||
|
use_dns: DNSMode = "warn"
|
||||||
|
prefregex: str = ""
|
||||||
|
bantime_escalation: DomainBantimeEscalation | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainJailConfigList:
|
||||||
|
"""List of jail configurations (domain model)."""
|
||||||
|
|
||||||
|
items: list[DomainJailConfig]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainGlobalConfig:
|
||||||
|
"""Global fail2ban settings (domain model)."""
|
||||||
|
|
||||||
|
log_level: LogLevel
|
||||||
|
log_target: str
|
||||||
|
db_purge_age: int
|
||||||
|
db_max_matches: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainServiceStatus:
|
||||||
|
"""Fail2ban service health status (domain model)."""
|
||||||
|
|
||||||
|
online: bool
|
||||||
|
version: str | None = None
|
||||||
|
jail_count: int = 0
|
||||||
|
total_bans: int = 0
|
||||||
|
total_failures: int = 0
|
||||||
|
log_level: str | None = None
|
||||||
|
log_target: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainMapColorThresholds:
|
||||||
|
"""Map color threshold configuration (domain model)."""
|
||||||
|
|
||||||
|
threshold_high: int
|
||||||
|
threshold_medium: int
|
||||||
|
threshold_low: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainRegexTest:
|
||||||
|
"""Result of a regex test (domain model)."""
|
||||||
|
|
||||||
|
matched: bool
|
||||||
|
groups: list[str]
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainFilterConfig:
|
||||||
|
"""Structured representation of a filter.d/*.conf file (domain model)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
filename: str
|
||||||
|
before: str | None = None
|
||||||
|
after: str | None = None
|
||||||
|
variables: dict[str, str] | None = None
|
||||||
|
prefregex: str | None = None
|
||||||
|
failregex: list[str] | None = None
|
||||||
|
ignoreregex: list[str] | None = None
|
||||||
|
maxlines: int | None = None
|
||||||
|
datepattern: str | None = None
|
||||||
|
journalmatch: str | None = None
|
||||||
|
active: bool = False
|
||||||
|
used_by_jails: list[str] | None = None
|
||||||
|
source_file: str = ""
|
||||||
|
has_local_override: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainFilterList:
|
||||||
|
"""List of filter configurations (domain model)."""
|
||||||
|
|
||||||
|
items: list[DomainFilterConfig]
|
||||||
|
total: int
|
||||||
@@ -4,18 +4,18 @@ Covers jail config files (``jail.d/``), filter definitions (``filter.d/``),
|
|||||||
and action definitions (``action.d/``).
|
and action definitions (``action.d/``).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
|
from app.models.response import BanGuiBaseModel
|
||||||
|
from app.utils.constants import FAIL2BAN_RESERVED_JAIL_NAMES
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Jail config file models (Task 4a)
|
# Jail config file models (Task 4a)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class JailConfigFile(BanGuiBaseModel):
|
||||||
class JailConfigFile(BaseModel):
|
|
||||||
"""Metadata for a single jail configuration file in ``jail.d/``."""
|
"""Metadata for a single jail configuration file in ``jail.d/``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
name: str = Field(..., description="Jail name (file stem, e.g. ``sshd``).")
|
name: str = Field(..., description="Jail name (file stem, e.g. ``sshd``).")
|
||||||
filename: str = Field(..., description="Actual filename (e.g. ``sshd.conf``).")
|
filename: str = Field(..., description="Actual filename (e.g. ``sshd.conf``).")
|
||||||
enabled: bool = Field(
|
enabled: bool = Field(
|
||||||
@@ -26,84 +26,71 @@ class JailConfigFile(BaseModel):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class JailConfigFilesResponse(BanGuiBaseModel):
|
||||||
class JailConfigFilesResponse(BaseModel):
|
|
||||||
"""Response for ``GET /api/config/jail-files``."""
|
"""Response for ``GET /api/config/jail-files``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
files: list[JailConfigFile] = Field(default_factory=list)
|
files: list[JailConfigFile] = Field(default_factory=list)
|
||||||
total: int = Field(..., ge=0)
|
total: int = Field(..., ge=0)
|
||||||
|
|
||||||
|
class JailConfigFileContent(BanGuiBaseModel):
|
||||||
class JailConfigFileContent(BaseModel):
|
|
||||||
"""Single jail config file with its raw content."""
|
"""Single jail config file with its raw content."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
name: str = Field(..., description="Jail name (file stem).")
|
name: str = Field(..., description="Jail name (file stem).")
|
||||||
filename: str = Field(..., description="Actual filename.")
|
filename: str = Field(..., description="Actual filename.")
|
||||||
enabled: bool = Field(..., description="Whether the jail is enabled.")
|
enabled: bool = Field(..., description="Whether the jail is enabled.")
|
||||||
content: str = Field(..., description="Raw file content.")
|
content: str = Field(..., description="Raw file content.")
|
||||||
|
|
||||||
|
class JailConfigFileEnabledUpdate(BanGuiBaseModel):
|
||||||
class JailConfigFileEnabledUpdate(BaseModel):
|
|
||||||
"""Payload for ``PUT /api/config/jail-files/{filename}/enabled``."""
|
"""Payload for ``PUT /api/config/jail-files/{filename}/enabled``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
enabled: bool = Field(..., description="New enabled state for this jail.")
|
enabled: bool = Field(..., description="New enabled state for this jail.")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Generic conf-file entry (shared by filter.d and action.d)
|
# Generic conf-file entry (shared by filter.d and action.d)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ConfFileEntry(BanGuiBaseModel):
|
||||||
class ConfFileEntry(BaseModel):
|
|
||||||
"""Metadata for a single ``.conf`` or ``.local`` file."""
|
"""Metadata for a single ``.conf`` or ``.local`` file."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
name: str = Field(..., description="Base name without extension (e.g. ``sshd``).")
|
name: str = Field(..., description="Base name without extension (e.g. ``sshd``).")
|
||||||
filename: str = Field(..., description="Actual filename (e.g. ``sshd.conf``).")
|
filename: str = Field(..., description="Actual filename (e.g. ``sshd.conf``).")
|
||||||
|
|
||||||
|
class ConfFilesResponse(BanGuiBaseModel):
|
||||||
class ConfFilesResponse(BaseModel):
|
|
||||||
"""Response for list endpoints (``GET /api/config/filters`` and ``GET /api/config/actions``)."""
|
"""Response for list endpoints (``GET /api/config/filters`` and ``GET /api/config/actions``)."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
files: list[ConfFileEntry] = Field(default_factory=list)
|
files: list[ConfFileEntry] = Field(default_factory=list)
|
||||||
total: int = Field(..., ge=0)
|
total: int = Field(..., ge=0)
|
||||||
|
|
||||||
|
class ConfFileContent(BanGuiBaseModel):
|
||||||
class ConfFileContent(BaseModel):
|
|
||||||
"""A conf file with its raw text content."""
|
"""A conf file with its raw text content."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
name: str = Field(..., description="Base name without extension.")
|
name: str = Field(..., description="Base name without extension.")
|
||||||
filename: str = Field(..., description="Actual filename.")
|
filename: str = Field(..., description="Actual filename.")
|
||||||
content: str = Field(..., description="Raw file content.")
|
content: str = Field(..., description="Raw file content.")
|
||||||
|
|
||||||
|
class ConfFileUpdateRequest(BanGuiBaseModel):
|
||||||
class ConfFileUpdateRequest(BaseModel):
|
|
||||||
"""Payload for ``PUT /api/config/filters/{name}`` and ``PUT /api/config/actions/{name}``."""
|
"""Payload for ``PUT /api/config/filters/{name}`` and ``PUT /api/config/actions/{name}``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
content: str = Field(..., description="New raw file content (must not exceed 512 KB).")
|
content: str = Field(..., description="New raw file content (must not exceed 512 KB).")
|
||||||
|
|
||||||
|
class ConfFileCreateRequest(BanGuiBaseModel):
|
||||||
class ConfFileCreateRequest(BaseModel):
|
|
||||||
"""Payload for ``POST /api/config/filters`` and ``POST /api/config/actions``."""
|
"""Payload for ``POST /api/config/filters`` and ``POST /api/config/actions``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
name: str = Field(
|
name: str = Field(
|
||||||
...,
|
...,
|
||||||
description="New file base name (without extension). Must contain only "
|
description="New file base name (without extension). Must contain only "
|
||||||
"alphanumeric characters, hyphens, underscores, and dots.",
|
"alphanumeric characters, hyphens, underscores, and dots.",
|
||||||
)
|
)
|
||||||
content: str = Field(..., description="Initial raw file content (must not exceed 512 KB).")
|
content: str = Field(..., description="Initial raw file content (must not exceed 512 KB).")
|
||||||
|
|
||||||
|
@field_validator("name", mode="after")
|
||||||
|
@classmethod
|
||||||
|
def _reject_reserved_jail_name(cls, v: str) -> str:
|
||||||
|
"""Reject fail2ban reserved jail names."""
|
||||||
|
if v in FAIL2BAN_RESERVED_JAIL_NAMES:
|
||||||
|
|
||||||
|
valid_names = ", ".join(sorted(FAIL2BAN_RESERVED_JAIL_NAMES))
|
||||||
|
raise ValueError(
|
||||||
|
f"Jail name {v!r} is reserved by fail2ban ({valid_names})."
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|||||||
@@ -9,21 +9,20 @@ from collections.abc import Awaitable, Callable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import Field
|
||||||
|
|
||||||
|
from app.models.response import BanGuiBaseModel
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
|
class GeoDetail(BanGuiBaseModel):
|
||||||
class GeoDetail(BaseModel):
|
|
||||||
"""Enriched geolocation data for an IP address.
|
"""Enriched geolocation data for an IP address.
|
||||||
|
|
||||||
Populated from the ip-api.com free API.
|
Populated from the ip-api.com free API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
country_code: str | None = Field(
|
country_code: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="ISO 3166-1 alpha-2 country code.",
|
description="ISO 3166-1 alpha-2 country code.",
|
||||||
@@ -41,30 +40,60 @@ class GeoDetail(BaseModel):
|
|||||||
description="Organisation associated with the ASN.",
|
description="Organisation associated with the ASN.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class GeoCacheEntry(BanGuiBaseModel):
|
||||||
|
"""A single cached geolocation entry for an IP address.
|
||||||
|
|
||||||
class GeoCacheStatsResponse(BaseModel):
|
Represents a row from the ``geo_cache`` table in the application database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ip: str = Field(..., description="IP address (IPv4 or IPv6).")
|
||||||
|
country_code: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="ISO 3166-1 alpha-2 country code.",
|
||||||
|
)
|
||||||
|
country_name: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Human-readable country name.",
|
||||||
|
)
|
||||||
|
asn: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Autonomous System Number (e.g. ``'AS3320'``).",
|
||||||
|
)
|
||||||
|
org: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Organisation associated with the ASN.",
|
||||||
|
)
|
||||||
|
|
||||||
|
class GeoCacheStatsResponse(BanGuiBaseModel):
|
||||||
"""Response for ``GET /api/geo/stats``.
|
"""Response for ``GET /api/geo/stats``.
|
||||||
|
|
||||||
Exposes diagnostic counters of the geo cache subsystem so operators
|
Exposes diagnostic counters of the geo cache subsystem so operators
|
||||||
can assess resolution health from the UI or CLI.
|
can assess resolution health from the UI or CLI.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
cache_size: int = Field(..., description="Number of positive entries in the in-memory cache.")
|
cache_size: int = Field(..., description="Number of positive entries in the in-memory cache.")
|
||||||
unresolved: int = Field(..., description="Number of geo_cache rows with country_code IS NULL.")
|
unresolved: int = Field(..., description="Number of geo_cache rows with country_code IS NULL.")
|
||||||
neg_cache_size: int = Field(..., description="Number of entries in the in-memory negative cache.")
|
neg_cache_size: int = Field(..., description="Number of entries in the in-memory negative cache.")
|
||||||
dirty_size: int = Field(..., description="Number of newly resolved entries not yet flushed to disk.")
|
dirty_size: int = Field(..., description="Number of newly resolved entries not yet flushed to disk.")
|
||||||
|
hits: int = Field(default=0, description="Number of cache hits since last clear.")
|
||||||
|
misses: int = Field(default=0, description="Number of cache misses since last clear.")
|
||||||
|
|
||||||
|
class GeoReResolveResponse(BanGuiBaseModel):
|
||||||
|
"""Response for ``POST /api/geo/re-resolve``.
|
||||||
|
|
||||||
class IpLookupResponse(BaseModel):
|
Reports how many previously unresolved IPs were retried and how many
|
||||||
|
gained a resolved country code after the re-resolve operation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
resolved: int = Field(..., description="Number of IPs successfully resolved.")
|
||||||
|
total: int = Field(..., description="Number of IPs retried.")
|
||||||
|
|
||||||
|
class IpLookupResponse(BanGuiBaseModel):
|
||||||
"""Response for ``GET /api/geo/lookup/{ip}``.
|
"""Response for ``GET /api/geo/lookup/{ip}``.
|
||||||
|
|
||||||
Aggregates current ban status and geographical information for an IP.
|
Aggregates current ban status and geographical information for an IP.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
ip: str = Field(..., description="The queried IP address.")
|
ip: str = Field(..., description="The queried IP address.")
|
||||||
currently_banned_in: list[str] = Field(
|
currently_banned_in: list[str] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
@@ -75,12 +104,10 @@ class IpLookupResponse(BaseModel):
|
|||||||
description="Enriched geographical and network information.",
|
description="Enriched geographical and network information.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# shared service types
|
# shared service types
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GeoInfo:
|
class GeoInfo:
|
||||||
"""Geo resolution result used throughout backend services."""
|
"""Geo resolution result used throughout backend services."""
|
||||||
@@ -90,7 +117,6 @@ class GeoInfo:
|
|||||||
asn: str | None
|
asn: str | None
|
||||||
org: str | None
|
org: str | None
|
||||||
|
|
||||||
|
|
||||||
GeoEnricher = Callable[[str], Awaitable[GeoInfo | None]]
|
GeoEnricher = Callable[[str], Awaitable[GeoInfo | None]]
|
||||||
GeoBatchLookup = Callable[
|
GeoBatchLookup = Callable[
|
||||||
[list[str], "aiohttp.ClientSession", "aiosqlite.Connection | None"],
|
[list[str], "aiohttp.ClientSession", "aiosqlite.Connection | None"],
|
||||||
|
|||||||
23
backend/app/models/health_domain.py
Normal file
23
backend/app/models/health_domain.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""Health domain models.
|
||||||
|
|
||||||
|
Internal domain-focused models used by health_service. These represent the
|
||||||
|
business domain layer and are independent of HTTP response shapes.
|
||||||
|
|
||||||
|
Response models are defined in `app.models.config` and mappers convert domain
|
||||||
|
models to response models at the router boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainServerStatus:
|
||||||
|
"""Cached fail2ban server health snapshot (domain model)."""
|
||||||
|
|
||||||
|
online: bool
|
||||||
|
version: str | None = None
|
||||||
|
active_jails: int = 0
|
||||||
|
total_bans: int = 0
|
||||||
|
total_failures: int = 0
|
||||||
@@ -5,9 +5,10 @@ Request, response, and domain models used by the history router and service.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import Field
|
||||||
|
|
||||||
from app.models.ban import TimeRange
|
from app.models._common import TimeRange
|
||||||
|
from app.models.response import BanGuiBaseModel, PaginatedListResponse
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"HistoryBanItem",
|
"HistoryBanItem",
|
||||||
@@ -17,16 +18,13 @@ __all__ = [
|
|||||||
"TimeRange",
|
"TimeRange",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
class HistoryBanItem(BanGuiBaseModel):
|
||||||
class HistoryBanItem(BaseModel):
|
|
||||||
"""A single row in the history ban-list table.
|
"""A single row in the history ban-list table.
|
||||||
|
|
||||||
Populated from the fail2ban database and optionally enriched with
|
Populated from the fail2ban database and optionally enriched with
|
||||||
geolocation data.
|
geolocation data.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
ip: str = Field(..., description="Banned IP address.")
|
ip: str = Field(..., description="Banned IP address.")
|
||||||
jail: str = Field(..., description="Jail that issued the ban.")
|
jail: str = Field(..., description="Jail that issued the ban.")
|
||||||
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
|
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
|
||||||
@@ -57,31 +55,26 @@ class HistoryBanItem(BaseModel):
|
|||||||
description="Organisation name associated with the IP.",
|
description="Organisation name associated with the IP.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class HistoryListResponse(PaginatedListResponse[HistoryBanItem]):
|
||||||
|
"""Paginated history ban-list response.
|
||||||
|
|
||||||
class HistoryListResponse(BaseModel):
|
Request: ``GET /api/history`` with optional time-range, jail, IP, and
|
||||||
"""Paginated history ban-list response."""
|
origin filters plus pagination parameters.
|
||||||
|
Response: Paginated collection of historical ban records with geolocation.
|
||||||
model_config = ConfigDict(strict=True)
|
"""
|
||||||
|
|
||||||
items: list[HistoryBanItem] = Field(default_factory=list)
|
|
||||||
total: int = Field(..., ge=0, description="Total matching records.")
|
|
||||||
page: int = Field(..., ge=1)
|
|
||||||
page_size: int = Field(..., ge=1)
|
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Per-IP timeline
|
# Per-IP timeline
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class IpTimelineEvent(BanGuiBaseModel):
|
||||||
class IpTimelineEvent(BaseModel):
|
|
||||||
"""A single ban event in a per-IP timeline.
|
"""A single ban event in a per-IP timeline.
|
||||||
|
|
||||||
Represents one row from the fail2ban ``bans`` table for a specific IP.
|
Represents one row from the fail2ban ``bans`` table for a specific IP.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
jail: str = Field(..., description="Jail that triggered this ban.")
|
jail: str = Field(..., description="Jail that triggered this ban.")
|
||||||
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
|
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
|
||||||
ban_count: int = Field(
|
ban_count: int = Field(
|
||||||
@@ -99,16 +92,13 @@ class IpTimelineEvent(BaseModel):
|
|||||||
description="Matched log lines that triggered the ban.",
|
description="Matched log lines that triggered the ban.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class IpDetailResponse(BanGuiBaseModel):
|
||||||
class IpDetailResponse(BaseModel):
|
|
||||||
"""Full historical record for a single IP address.
|
"""Full historical record for a single IP address.
|
||||||
|
|
||||||
Contains aggregated totals and a chronological timeline of all ban events
|
Contains aggregated totals and a chronological timeline of all ban events
|
||||||
recorded in the fail2ban database for the given IP.
|
recorded in the fail2ban database for the given IP.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
ip: str = Field(..., description="The IP address.")
|
ip: str = Field(..., description="The IP address.")
|
||||||
total_bans: int = Field(..., ge=0, description="Total number of ban records.")
|
total_bans: int = Field(..., ge=0, description="Total number of ban records.")
|
||||||
total_failures: int = Field(
|
total_failures: int = Field(
|
||||||
|
|||||||
64
backend/app/models/history_domain.py
Normal file
64
backend/app/models/history_domain.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""History domain models.
|
||||||
|
|
||||||
|
Internal domain-focused models used by history_service. These represent the
|
||||||
|
business domain layer and are independent of HTTP response shapes.
|
||||||
|
|
||||||
|
Response models are defined in `app.models.history` and mappers convert domain
|
||||||
|
models to response models at the router boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainHistoryBanItem:
|
||||||
|
"""A single row in the history ban-list table (domain model)."""
|
||||||
|
|
||||||
|
ip: str
|
||||||
|
jail: str
|
||||||
|
banned_at: str
|
||||||
|
ban_count: int
|
||||||
|
failures: int = 0
|
||||||
|
matches: list[str] | None = None
|
||||||
|
country_code: str | None = None
|
||||||
|
country_name: str | None = None
|
||||||
|
asn: str | None = None
|
||||||
|
org: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainHistoryList:
|
||||||
|
"""Paginated history ban-list (domain model)."""
|
||||||
|
|
||||||
|
items: list[DomainHistoryBanItem]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainIpTimelineEvent:
|
||||||
|
"""A single ban event in a per-IP timeline (domain model)."""
|
||||||
|
|
||||||
|
jail: str
|
||||||
|
banned_at: str
|
||||||
|
ban_count: int
|
||||||
|
failures: int = 0
|
||||||
|
matches: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainIpDetail:
|
||||||
|
"""Full historical record for a single IP address (domain model)."""
|
||||||
|
|
||||||
|
ip: str
|
||||||
|
total_bans: int
|
||||||
|
total_failures: int
|
||||||
|
last_ban_at: str | None = None
|
||||||
|
country_code: str | None = None
|
||||||
|
country_name: str | None = None
|
||||||
|
asn: str | None = None
|
||||||
|
org: str | None = None
|
||||||
|
timeline: list[DomainIpTimelineEvent] | None = None
|
||||||
@@ -3,27 +3,22 @@
|
|||||||
Request, response, and domain models used by the jails router and service.
|
Request, response, and domain models used by the jails router and service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import Field
|
||||||
|
|
||||||
from app.models.config import BantimeEscalation
|
from app.models.config import BantimeEscalation
|
||||||
|
from app.models.response import BanGuiBaseModel, CommandResponse, CollectionResponse
|
||||||
|
|
||||||
|
class JailStatus(BanGuiBaseModel):
|
||||||
class JailStatus(BaseModel):
|
|
||||||
"""Runtime metrics for a single jail."""
|
"""Runtime metrics for a single jail."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
currently_banned: int = Field(..., ge=0)
|
currently_banned: int = Field(..., ge=0)
|
||||||
total_banned: int = Field(..., ge=0)
|
total_banned: int = Field(..., ge=0)
|
||||||
currently_failed: int = Field(..., ge=0)
|
currently_failed: int = Field(..., ge=0)
|
||||||
total_failed: int = Field(..., ge=0)
|
total_failed: int = Field(..., ge=0)
|
||||||
|
|
||||||
|
class Jail(BanGuiBaseModel):
|
||||||
class Jail(BaseModel):
|
|
||||||
"""Domain model for a single fail2ban jail with its full configuration."""
|
"""Domain model for a single fail2ban jail with its full configuration."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
name: str = Field(..., description="Jail name as configured in fail2ban.")
|
name: str = Field(..., description="Jail name as configured in fail2ban.")
|
||||||
enabled: bool = Field(..., description="Whether the jail is currently active.")
|
enabled: bool = Field(..., description="Whether the jail is currently active.")
|
||||||
running: bool = Field(..., description="Whether the jail backend is running.")
|
running: bool = Field(..., description="Whether the jail backend is running.")
|
||||||
@@ -45,12 +40,9 @@ class Jail(BaseModel):
|
|||||||
)
|
)
|
||||||
status: JailStatus | None = Field(default=None, description="Runtime counters.")
|
status: JailStatus | None = Field(default=None, description="Runtime counters.")
|
||||||
|
|
||||||
|
class JailSummary(BanGuiBaseModel):
|
||||||
class JailSummary(BaseModel):
|
|
||||||
"""Lightweight jail entry for the overview list."""
|
"""Lightweight jail entry for the overview list."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
enabled: bool
|
enabled: bool
|
||||||
running: bool
|
running: bool
|
||||||
@@ -61,36 +53,48 @@ class JailSummary(BaseModel):
|
|||||||
max_retry: int
|
max_retry: int
|
||||||
status: JailStatus | None = None
|
status: JailStatus | None = None
|
||||||
|
|
||||||
|
class JailListResponse(CollectionResponse[JailSummary]):
|
||||||
|
"""Response for ``GET /api/jails``.
|
||||||
|
|
||||||
class JailListResponse(BaseModel):
|
Returns a non-paginated collection of jail summaries with their current status.
|
||||||
"""Response for ``GET /api/jails``."""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
pass
|
||||||
|
|
||||||
jails: list[JailSummary] = Field(default_factory=list)
|
class IgnoreListResponse(CollectionResponse[str]):
|
||||||
total: int = Field(..., ge=0)
|
"""Response for ``GET /api/jails/{name}/ignoreip``.
|
||||||
|
|
||||||
|
Returns the jailed ignore list as a standard collection response.
|
||||||
|
"""
|
||||||
|
|
||||||
class JailDetailResponse(BaseModel):
|
pass
|
||||||
"""Response for ``GET /api/jails/{name}``."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
class JailDetailResponse(BanGuiBaseModel):
|
||||||
|
"""Response for ``GET /api/jails/{name}``.
|
||||||
|
|
||||||
|
Includes the primary jail object together with supplemental metadata
|
||||||
|
required by the UI.
|
||||||
|
"""
|
||||||
|
|
||||||
jail: Jail
|
jail: Jail
|
||||||
|
ignore_list: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="List of IP addresses and networks currently ignored by the jail.",
|
||||||
|
)
|
||||||
|
ignore_self: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Whether the jail ignores the server's own IP addresses.",
|
||||||
|
)
|
||||||
|
|
||||||
|
class JailCommandResponse(CommandResponse):
|
||||||
|
"""Generic response for jail control commands (start, stop, reload, idle).
|
||||||
|
|
||||||
class JailCommandResponse(BaseModel):
|
Extends the base CommandResponse with a jail field to identify the target.
|
||||||
"""Generic response for jail control commands (start, stop, reload, idle)."""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
jail: str = Field(..., description="Target jail name, or '*' for operations on all jails.")
|
||||||
|
|
||||||
message: str
|
class IgnoreIpRequest(BanGuiBaseModel):
|
||||||
jail: str
|
|
||||||
|
|
||||||
|
|
||||||
class IgnoreIpRequest(BaseModel):
|
|
||||||
"""Payload for adding an IP or network to a jail's ignore list."""
|
"""Payload for adding an IP or network to a jail's ignore list."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
ip: str = Field(..., description="IP address or CIDR network to ignore.")
|
ip: str = Field(..., description="IP address or CIDR network to ignore.")
|
||||||
|
|||||||
112
backend/app/models/jail_domain.py
Normal file
112
backend/app/models/jail_domain.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""Jail domain models.
|
||||||
|
|
||||||
|
Internal domain-focused models used by jail_service. These represent the
|
||||||
|
business domain layer and are independent of HTTP response shapes.
|
||||||
|
|
||||||
|
Response models are defined in `app.models.jail` and mappers convert domain
|
||||||
|
models to response models at the router boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainJailStatus:
|
||||||
|
"""Runtime metrics for a single jail (domain model)."""
|
||||||
|
|
||||||
|
currently_banned: int
|
||||||
|
total_banned: int
|
||||||
|
currently_failed: int
|
||||||
|
total_failed: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainBantimeEscalation:
|
||||||
|
"""Incremental ban-time escalation configuration (domain model)."""
|
||||||
|
|
||||||
|
increment: bool = False
|
||||||
|
factor: float | None = None
|
||||||
|
formula: str | None = None
|
||||||
|
multipliers: str | None = None
|
||||||
|
max_time: int | None = None
|
||||||
|
rnd_time: int | None = None
|
||||||
|
overall_jails: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainJailSummary:
|
||||||
|
"""Lightweight jail entry for the overview list (domain model)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
enabled: bool
|
||||||
|
running: bool
|
||||||
|
idle: bool
|
||||||
|
backend: str
|
||||||
|
find_time: int
|
||||||
|
ban_time: int
|
||||||
|
max_retry: int
|
||||||
|
status: DomainJailStatus | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainJailList:
|
||||||
|
"""List of active jails (domain model)."""
|
||||||
|
|
||||||
|
items: list[DomainJailSummary]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainJail:
|
||||||
|
"""Full jail configuration (domain model)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
enabled: bool
|
||||||
|
running: bool
|
||||||
|
idle: bool
|
||||||
|
backend: str
|
||||||
|
log_paths: list[str]
|
||||||
|
fail_regex: list[str]
|
||||||
|
ignore_regex: list[str]
|
||||||
|
ignore_ips: list[str]
|
||||||
|
find_time: int
|
||||||
|
ban_time: int
|
||||||
|
max_retry: int
|
||||||
|
actions: list[str]
|
||||||
|
date_pattern: str | None = None
|
||||||
|
log_encoding: str = "UTF-8"
|
||||||
|
bantime_escalation: DomainBantimeEscalation | None = None
|
||||||
|
status: DomainJailStatus | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainActiveBan:
|
||||||
|
"""A currently active ban entry from a jail (domain model)."""
|
||||||
|
|
||||||
|
ip: str
|
||||||
|
jail: str
|
||||||
|
banned_at: str | None = None
|
||||||
|
expires_at: str | None = None
|
||||||
|
ban_count: int = 1
|
||||||
|
country: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainJailBannedIps:
|
||||||
|
"""Paginated list of currently banned IPs for a jail (domain model)."""
|
||||||
|
|
||||||
|
items: list[DomainActiveBan]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainJailDetail:
|
||||||
|
"""Full jail with supplemental metadata (domain model)."""
|
||||||
|
|
||||||
|
jail: DomainJail
|
||||||
|
ignore_list: list[str]
|
||||||
|
ignore_self: bool
|
||||||
545
backend/app/models/response.py
Normal file
545
backend/app/models/response.py
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
"""Base response wrapper models for standardized API envelopes.
|
||||||
|
|
||||||
|
All API endpoints should wrap their responses using the base classes defined here.
|
||||||
|
This ensures a consistent response shape across the entire API, reducing frontend
|
||||||
|
branching logic and integration bugs.
|
||||||
|
|
||||||
|
Response Patterns:
|
||||||
|
|
||||||
|
1. **Paginated List** — Use `PaginatedListResponse[T]` for endpoints returning paginated items.
|
||||||
|
Example: GET /api/jails, GET /api/dashboard/bans
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyListResponse(PaginatedListResponse[MyItem]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Returns:
|
||||||
|
{
|
||||||
|
"items": [...],
|
||||||
|
"pagination": {
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20,
|
||||||
|
"total": 100,
|
||||||
|
"total_pages": 5,
|
||||||
|
"has_next_page": true,
|
||||||
|
"has_prev_page": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Simple Collection** — Use `CollectionResponse[T]` for non-paginated collections.
|
||||||
|
Example: GET /api/bans/active
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyCollectionResponse(CollectionResponse[MyItem]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Returns:
|
||||||
|
{
|
||||||
|
"items": [...],
|
||||||
|
"total": 50
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Single Item Detail** — Use domain model directly wrapped in a named field.
|
||||||
|
Example: GET /api/jails/{name}, GET /api/dashboard/status
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyDetailResponse(BaseModel):
|
||||||
|
jail: Jail # or: status: ServerStatus, settings: ServerSettings
|
||||||
|
# Optional extra fields (ignore_list, warnings, etc.)
|
||||||
|
|
||||||
|
# Returns:
|
||||||
|
{
|
||||||
|
"jail": {...},
|
||||||
|
"ignore_list": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Command/Action Result** — Use `CommandResponse` for success/acknowledgement.
|
||||||
|
Example: POST /api/jails/{name}/start, POST /api/bans
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyCommandResponse(CommandResponse):
|
||||||
|
jail: str # Optional: target identifier
|
||||||
|
|
||||||
|
# Returns:
|
||||||
|
{
|
||||||
|
"message": "Jail 'sshd' started.",
|
||||||
|
"success": true,
|
||||||
|
"jail": "sshd"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Aggregated Data** — Use domain-specific aggregation models with metadata.
|
||||||
|
Example: GET /api/dashboard/bans/by-jail
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyAggregationResponse(BaseModel):
|
||||||
|
jails: list[JailBanCount] # or: countries, buckets, etc.
|
||||||
|
total: int
|
||||||
|
# Optional: filters, time_range metadata
|
||||||
|
|
||||||
|
# Returns:
|
||||||
|
{
|
||||||
|
"jails": [...],
|
||||||
|
"total": 1234
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note on field naming:
|
||||||
|
- Paginated/collection responses always use "items" for the data array.
|
||||||
|
- Detail responses use domain-specific field names (jail, status, settings).
|
||||||
|
- Aggregation responses use domain-specific field names (jails, countries, buckets).
|
||||||
|
- All responses with multiple items include a "total" field.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Generic, Literal, TypeVar
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class BanGuiBaseModel(BaseModel):
|
||||||
|
"""Project-wide Pydantic base model.
|
||||||
|
|
||||||
|
Enforces the canonical **snake_case** API field naming policy:
|
||||||
|
all JSON wire-format field names use ``snake_case`` on both the backend
|
||||||
|
(Python) and the frontend (TypeScript interfaces). No ``alias_generator``
|
||||||
|
is applied — field names are serialized exactly as written.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Every model in ``app/models/`` must inherit from this class.
|
||||||
|
- Field names must be ``snake_case`` in Python *and* in the JSON payload.
|
||||||
|
- The corresponding TypeScript interface fields must also be ``snake_case``.
|
||||||
|
- Never add a ``camelCase`` alias generator to individual models — any
|
||||||
|
serialization change must go through this base class so all models
|
||||||
|
update at once.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(strict=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationMetadata(BanGuiBaseModel):
|
||||||
|
"""Pagination metadata embedded in paginated list responses.
|
||||||
|
|
||||||
|
Contains page information and computed fields to support frontend pagination controls.
|
||||||
|
Supports both offset-based and cursor-based pagination modes.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
page: Current page number (1-based). Set to 1 for cursor pagination.
|
||||||
|
page_size: Number of items per page.
|
||||||
|
total: Total number of items matching the query (across all pages).
|
||||||
|
For cursor pagination, this is -1 (unknown without full scan).
|
||||||
|
total_pages: Computed total number of pages.
|
||||||
|
For cursor pagination, this is -1 (unknown without full scan).
|
||||||
|
has_next_page: Whether there is a next page after this one.
|
||||||
|
has_prev_page: Whether there is a previous page before this one.
|
||||||
|
Always False for cursor pagination (cannot navigate backward without storing history).
|
||||||
|
cursor: Opaque cursor token for fetching the next page (cursor pagination only).
|
||||||
|
None for offset pagination or when there are no more pages.
|
||||||
|
pagination_mode: Pagination mode used by the endpoint. 'offset' uses page/page_size;
|
||||||
|
'cursor' uses cursor tokens for navigation.
|
||||||
|
|
||||||
|
Example (offset pagination):
|
||||||
|
```python
|
||||||
|
pagination = PaginationMetadata(
|
||||||
|
page=2,
|
||||||
|
page_size=50,
|
||||||
|
total=150,
|
||||||
|
total_pages=3,
|
||||||
|
has_next_page=True,
|
||||||
|
has_prev_page=True,
|
||||||
|
cursor=None,
|
||||||
|
pagination_mode="offset",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Example (cursor pagination):
|
||||||
|
```python
|
||||||
|
pagination = PaginationMetadata(
|
||||||
|
page=1,
|
||||||
|
page_size=50,
|
||||||
|
total=-1,
|
||||||
|
total_pages=-1,
|
||||||
|
has_next_page=True,
|
||||||
|
has_prev_page=False,
|
||||||
|
cursor="eyJpZCI6IDQyN30=",
|
||||||
|
pagination_mode="cursor",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
page: int = Field(..., ge=1, description="Current page number (1-based). Set to 1 for cursor pagination.")
|
||||||
|
page_size: int = Field(..., ge=1, description="Number of items per page.")
|
||||||
|
total: int = Field(..., description="Total number of items matching the query. -1 if unknown (cursor pagination).")
|
||||||
|
total_pages: int = Field(..., description="Computed total number of pages. -1 if unknown (cursor pagination).")
|
||||||
|
has_next_page: bool = Field(..., description="Whether there is a next page after this one.")
|
||||||
|
has_prev_page: bool = Field(..., description="Whether there is a previous page before this one.")
|
||||||
|
cursor: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Opaque cursor token for fetching the next page (cursor pagination only).",
|
||||||
|
)
|
||||||
|
pagination_mode: Literal["offset", "cursor"] = Field(
|
||||||
|
default="offset",
|
||||||
|
description="Pagination mode used by the endpoint. 'offset' uses page/page_size; 'cursor' uses cursor tokens.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedListResponse(BanGuiBaseModel, Generic[T]):
|
||||||
|
"""Standardized paginated list response.
|
||||||
|
|
||||||
|
Use this as a base for all endpoints that return paginated collections.
|
||||||
|
Automatically includes pagination metadata to support frontend paging UIs.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
items: The data items for the current page.
|
||||||
|
pagination: Pagination metadata with computed derived fields.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
class UserListResponse(PaginatedListResponse[User]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Returns:
|
||||||
|
{
|
||||||
|
"items": [...],
|
||||||
|
"pagination": {
|
||||||
|
"page": 2,
|
||||||
|
"page_size": 50,
|
||||||
|
"total": 150,
|
||||||
|
"total_pages": 3,
|
||||||
|
"has_next_page": true,
|
||||||
|
"has_prev_page": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
items: list[T] = Field(default_factory=list, description="Data items for the current page.")
|
||||||
|
pagination: PaginationMetadata = Field(..., description="Pagination metadata with computed derived fields.")
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionResponse(BanGuiBaseModel, Generic[T]):
|
||||||
|
"""Standardized non-paginated collection response.
|
||||||
|
|
||||||
|
Use this for endpoints that return a collection without pagination support.
|
||||||
|
Simpler than PaginatedListResponse, but still provides consistent wrapping.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
items: The data items in the collection.
|
||||||
|
total: Total number of items.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
class ActiveBansResponse(CollectionResponse[ActiveBan]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Returns:
|
||||||
|
{
|
||||||
|
"items": [...],
|
||||||
|
"total": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
items: list[T] = Field(default_factory=list, description="Collection items.")
|
||||||
|
total: int = Field(..., ge=0, description="Total number of items.")
|
||||||
|
|
||||||
|
|
||||||
|
class CommandResponse(BanGuiBaseModel):
|
||||||
|
"""Standardized command/action result response.
|
||||||
|
|
||||||
|
Use this for endpoints that execute commands (start, stop, reload, ban, unban, etc.).
|
||||||
|
Always includes a success indicator and human-readable message.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
message: Human-readable result message or error description.
|
||||||
|
success: Whether the command succeeded (default True).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
class StartJailResponse(CommandResponse):
|
||||||
|
jail: str # Optional: target identifier
|
||||||
|
|
||||||
|
# Returns:
|
||||||
|
{
|
||||||
|
"message": "Jail 'sshd' started.",
|
||||||
|
"success": true,
|
||||||
|
"jail": "sshd"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
message: str = Field(..., description="Human-readable result or error message.")
|
||||||
|
success: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Whether the command succeeded (false for errors in non-exception handlers).",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BanGuiBaseModel):
|
||||||
|
"""Standardized error response envelope for all API errors.
|
||||||
|
|
||||||
|
Use this for all error responses to ensure consistent client-side error handling.
|
||||||
|
The error code enables machine-readable branching, while detail provides
|
||||||
|
human-readable context. Metadata offers optional structured context.
|
||||||
|
|
||||||
|
The correlation_id field enables tracing this error back through logs on both
|
||||||
|
frontend and backend, enabling correlation across distributed systems.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
code: Machine-readable error code (e.g., "jail_not_found", "invalid_input").
|
||||||
|
detail: Human-readable error description for display to users.
|
||||||
|
metadata: Optional structured context (e.g., field names, constraint violations).
|
||||||
|
correlation_id: Unique ID for correlating this error with request logs.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
# 404 Not Found
|
||||||
|
{
|
||||||
|
"code": "jail_not_found",
|
||||||
|
"detail": "Jail 'sshd' not found",
|
||||||
|
"metadata": {"jail_name": "sshd"},
|
||||||
|
"correlation_id": "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 400 Bad Request - Validation Error
|
||||||
|
{
|
||||||
|
"code": "invalid_input",
|
||||||
|
"detail": "Invalid IP address format",
|
||||||
|
"metadata": {"field": "ip", "value": "999.999.999.999"},
|
||||||
|
"correlation_id": "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 409 Conflict
|
||||||
|
{
|
||||||
|
"code": "jail_already_active",
|
||||||
|
"detail": "Jail is already active: 'sshd'",
|
||||||
|
"metadata": {"jail_name": "sshd", "current_status": "active"},
|
||||||
|
"correlation_id": "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
code: str = Field(..., description="Machine-readable error code for client-side branching.")
|
||||||
|
detail: str = Field(..., description="Human-readable error description.")
|
||||||
|
metadata: "ErrorMetadata" = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Optional structured context for the error.",
|
||||||
|
)
|
||||||
|
correlation_id: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Unique ID for correlating this error with request logs on both frontend and backend.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ErrorMetadata must be defined after ErrorResponse due to Pydantic forward-ref resolution
|
||||||
|
# but before use at type-check time. This ordering is intentional.
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorMetadata(TypedDict, total=False):
|
||||||
|
"""Typed metadata fields for error responses.
|
||||||
|
|
||||||
|
Allows type-safe access to known metadata keys in exception handlers.
|
||||||
|
Keys are optional — exceptions return only relevant fields.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
jail_name: Name of the jail involved in the error.
|
||||||
|
filename: Config filename involved in the error.
|
||||||
|
filter_name: Name of the filter involved in the error.
|
||||||
|
action_name: Name of the action involved in the error.
|
||||||
|
source_id: ID of a blocklist source involved in the error.
|
||||||
|
url: URL involved in a blocklist error.
|
||||||
|
ip: IP address involved in the error.
|
||||||
|
pattern: Regex pattern that caused an error.
|
||||||
|
error: Regex compilation error message.
|
||||||
|
pattern_length: Actual length of an oversized pattern.
|
||||||
|
max_length: Maximum allowed length for a pattern.
|
||||||
|
timeout_seconds: Timeout value for regex compilation.
|
||||||
|
retry_after_seconds: Seconds to wait before retrying (rate limit errors).
|
||||||
|
socket_path: fail2ban socket path for connection errors.
|
||||||
|
current_status: Current jail status for conflict errors.
|
||||||
|
actual_length: Actual pattern length (alias for pattern_length).
|
||||||
|
message: Generic error message string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
jail_name: str
|
||||||
|
filename: str
|
||||||
|
filter_name: str
|
||||||
|
action_name: str
|
||||||
|
source_id: int
|
||||||
|
url: str
|
||||||
|
ip: str
|
||||||
|
pattern: str
|
||||||
|
error: str
|
||||||
|
pattern_length: int
|
||||||
|
max_length: int
|
||||||
|
timeout_seconds: int
|
||||||
|
retry_after_seconds: float
|
||||||
|
socket_path: str
|
||||||
|
current_status: str
|
||||||
|
actual_length: int
|
||||||
|
message: str
|
||||||
|
field_errors: int
|
||||||
|
first_field: str
|
||||||
|
|
||||||
|
|
||||||
|
class ComponentHealth(BanGuiBaseModel):
|
||||||
|
"""Health status of a single application component.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
name: Human-readable component name.
|
||||||
|
healthy: True when the component is operational.
|
||||||
|
message: Optional detail message (e.g., error description).
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = Field(..., description="Component name.")
|
||||||
|
healthy: bool = Field(..., description="True when the component is operational.")
|
||||||
|
message: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional detail message, e.g. error description.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BanGuiBaseModel):
|
||||||
|
"""Standardized response for the health check endpoint.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
status: Application health status — 'ok' when all components are healthy,
|
||||||
|
'degraded' when some components are unhealthy but the service can still
|
||||||
|
handle requests, 'unavailable' when fail2ban is offline.
|
||||||
|
fail2ban: fail2ban daemon status — 'online' or 'offline'.
|
||||||
|
database: Database connectivity — 'ok' or 'error'.
|
||||||
|
scheduler: Background scheduler status — 'running', 'stopped', or 'unknown'.
|
||||||
|
cache: Cache initialization status — 'initialised' or 'uninitialised'.
|
||||||
|
external_logging: External logging handler status — 'ok', 'error', or 'disabled'.
|
||||||
|
components: Per-component health detail list (empty when all healthy).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
# Healthy (HTTP 200)
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"fail2ban": "online",
|
||||||
|
"database": "ok",
|
||||||
|
"scheduler": "running",
|
||||||
|
"cache": "initialised",
|
||||||
|
"external_logging": "disabled",
|
||||||
|
"components": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Unhealthy (HTTP 503)
|
||||||
|
{
|
||||||
|
"status": "unavailable",
|
||||||
|
"fail2ban": "offline",
|
||||||
|
"database": "ok",
|
||||||
|
"scheduler": "running",
|
||||||
|
"cache": "initialised",
|
||||||
|
"external_logging": "ok",
|
||||||
|
"components": [{"name": "fail2ban", "healthy": false, "message": "Socket not reachable"}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
status: Literal["ok", "degraded", "unavailable"] = Field(
|
||||||
|
...,
|
||||||
|
description=(
|
||||||
|
"Application health status: 'ok' when healthy, 'degraded' when some "
|
||||||
|
"components are unhealthy, 'unavailable' when fail2ban is offline."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
fail2ban: Literal["online", "offline"] = Field(
|
||||||
|
...,
|
||||||
|
description="fail2ban daemon status: 'online' when reachable, 'offline' otherwise.",
|
||||||
|
)
|
||||||
|
database: Literal["ok", "error"] = Field(
|
||||||
|
...,
|
||||||
|
description="Database connectivity: 'ok' when accessible, 'error' when not.",
|
||||||
|
)
|
||||||
|
scheduler: Literal["running", "stopped", "unknown"] = Field(
|
||||||
|
...,
|
||||||
|
description="Background scheduler status: 'running', 'stopped', or 'unknown'.",
|
||||||
|
)
|
||||||
|
cache: Literal["initialised", "uninitialised"] = Field(
|
||||||
|
...,
|
||||||
|
description="Cache initialization status: 'initialised' when ready, 'uninitialised' when not.",
|
||||||
|
)
|
||||||
|
external_logging: Literal["ok", "error", "disabled"] = Field(
|
||||||
|
...,
|
||||||
|
description=(
|
||||||
|
"External logging handler status: 'ok' when operational, 'error' when "
|
||||||
|
"initialization failed, 'disabled' when external logging is not configured."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
components: list[ComponentHealth] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Per-component health detail list. Empty when status is 'ok'.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FlushLogsResponse(BanGuiBaseModel):
|
||||||
|
"""Standardized response for the flush-logs command endpoint.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
message: Human-readable result message from fail2ban.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
{"message": "Success: fail2ban log files were flushed."}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
message: str = Field(..., description="Human-readable result message from fail2ban.")
|
||||||
|
|
||||||
|
|
||||||
|
class ReadyCheck(BanGuiBaseModel):
|
||||||
|
"""Result of a single readiness subsystem check.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
name: Subsystem name (e.g., "database", "fail2ban", "config_dir").
|
||||||
|
healthy: True when the subsystem is reachable/operational.
|
||||||
|
message: Optional error message describing the failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = Field(..., description="Subsystem name.")
|
||||||
|
healthy: bool = Field(..., description="True when the subsystem is operational.")
|
||||||
|
message: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Error detail when the check fails.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReadyResponse(BanGuiBaseModel):
|
||||||
|
"""Structured readiness check response for the ``/health/ready`` endpoint.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
status: "ok" when all checks pass, "error" when at least one failed.
|
||||||
|
checks: Per-subsystem result list.
|
||||||
|
failed_count: Number of checks that returned healthy=False.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
# All healthy (HTTP 200)
|
||||||
|
{"status": "ok", "checks": [...], "failed_count": 0}
|
||||||
|
|
||||||
|
# Some failed (HTTP 503)
|
||||||
|
{"status": "error", "checks": [...], "failed_count": 2}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
status: Literal["ok", "error"] = Field(
|
||||||
|
...,
|
||||||
|
description="'ok' when all checks pass, 'error' when at least one fails.",
|
||||||
|
)
|
||||||
|
checks: list[ReadyCheck] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Per-subsystem check results.",
|
||||||
|
)
|
||||||
|
failed_count: int = Field(
|
||||||
|
...,
|
||||||
|
ge=0,
|
||||||
|
description="Number of checks that returned healthy=False.",
|
||||||
|
)
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
"""Server status and health-check Pydantic models.
|
"""Server status and health-check Pydantic models.
|
||||||
|
|
||||||
Used by the dashboard router, health service, and server settings router.
|
Used by the dashboard router, health service, and server settings router.
|
||||||
|
|
||||||
|
All models inherit from :class:`~app.models.response.BanGuiBaseModel` which
|
||||||
|
enforces the project-wide **snake_case** API field naming policy: field names
|
||||||
|
are identical in Python, JSON wire format, and the corresponding TypeScript
|
||||||
|
interfaces.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import Field
|
||||||
|
|
||||||
|
from app.models.response import BanGuiBaseModel
|
||||||
|
|
||||||
|
|
||||||
class ServerStatus(BaseModel):
|
class ServerStatus(BanGuiBaseModel):
|
||||||
"""Cached fail2ban server health snapshot."""
|
"""Cached fail2ban server health snapshot."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
online: bool = Field(..., description="Whether fail2ban is reachable via its socket.")
|
online: bool = Field(..., description="Whether fail2ban is reachable via its socket.")
|
||||||
version: str | None = Field(default=None, description="fail2ban version string.")
|
version: str | None = Field(default=None, description="fail2ban version string.")
|
||||||
active_jails: int = Field(default=0, ge=0, description="Number of currently active jails.")
|
active_jails: int = Field(default=0, ge=0, description="Number of currently active jails.")
|
||||||
@@ -18,19 +23,15 @@ class ServerStatus(BaseModel):
|
|||||||
total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.")
|
total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.")
|
||||||
|
|
||||||
|
|
||||||
class ServerStatusResponse(BaseModel):
|
class ServerStatusResponse(BanGuiBaseModel):
|
||||||
"""Response for ``GET /api/dashboard/status``."""
|
"""Response for ``GET /api/dashboard/status``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
status: ServerStatus
|
status: ServerStatus
|
||||||
|
|
||||||
|
|
||||||
class ServerSettings(BaseModel):
|
class ServerSettings(BanGuiBaseModel):
|
||||||
"""Domain model for fail2ban server-level settings."""
|
"""Domain model for fail2ban server-level settings."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
log_level: str = Field(..., description="fail2ban daemon log level.")
|
log_level: str = Field(..., description="fail2ban daemon log level.")
|
||||||
log_target: str = Field(..., description="Log destination: STDOUT, STDERR, SYSLOG, or a file path.")
|
log_target: str = Field(..., description="Log destination: STDOUT, STDERR, SYSLOG, or a file path.")
|
||||||
syslog_socket: str | None = Field(default=None)
|
syslog_socket: str | None = Field(default=None)
|
||||||
@@ -39,20 +40,20 @@ class ServerSettings(BaseModel):
|
|||||||
db_max_matches: int = Field(..., description="Maximum stored matches per ban record.")
|
db_max_matches: int = Field(..., description="Maximum stored matches per ban record.")
|
||||||
|
|
||||||
|
|
||||||
class ServerSettingsUpdate(BaseModel):
|
class ServerSettingsUpdate(BanGuiBaseModel):
|
||||||
"""Payload for ``PUT /api/server/settings``."""
|
"""Payload for ``PUT /api/server/settings``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
log_level: str | None = Field(default=None)
|
log_level: str | None = Field(default=None)
|
||||||
log_target: str | None = Field(default=None)
|
log_target: str | None = Field(default=None)
|
||||||
db_purge_age: int | None = Field(default=None, ge=0)
|
db_purge_age: int | None = Field(default=None, ge=0)
|
||||||
db_max_matches: int | None = Field(default=None, ge=0)
|
db_max_matches: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
class ServerSettingsResponse(BaseModel):
|
class ServerSettingsResponse(BanGuiBaseModel):
|
||||||
"""Response for ``GET /api/server/settings``."""
|
"""Response for ``GET /api/server/settings``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
settings: ServerSettings
|
settings: ServerSettings
|
||||||
|
warnings: dict[str, bool] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Warnings highlighting potentially unsafe settings.",
|
||||||
|
)
|
||||||
|
|||||||
32
backend/app/models/server_domain.py
Normal file
32
backend/app/models/server_domain.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Server domain models.
|
||||||
|
|
||||||
|
Internal domain-focused models used by server_service. These represent the
|
||||||
|
business domain layer and are independent of HTTP response shapes.
|
||||||
|
|
||||||
|
Response models are defined in `app.models.server` and mappers convert domain
|
||||||
|
models to response models at the router boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainServerSettings:
|
||||||
|
"""Fail2ban server-level settings (domain model)."""
|
||||||
|
|
||||||
|
log_level: str
|
||||||
|
log_target: str
|
||||||
|
db_path: str
|
||||||
|
db_purge_age: int
|
||||||
|
db_max_matches: int
|
||||||
|
syslog_socket: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DomainServerSettingsResult:
|
||||||
|
"""Server settings with warnings (domain model)."""
|
||||||
|
|
||||||
|
settings: DomainServerSettings
|
||||||
|
warnings: dict[str, bool]
|
||||||
@@ -3,19 +3,106 @@
|
|||||||
Request, response, and domain models for the first-run configuration wizard.
|
Request, response, and domain models for the first-run configuration wizard.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
|
from app.models.response import BanGuiBaseModel
|
||||||
|
|
||||||
|
# Top-50 most-common plaintext passwords (lower-case).
|
||||||
|
# Source: aggregated public breach compilations (Have I Been Pwned, Wikipedia).
|
||||||
|
# Covers passwords that pass structural checks (uppercase + digit + special char)
|
||||||
|
# but are trivial to guess.
|
||||||
|
_COMMON_PASSWORDS: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"password",
|
||||||
|
"password1",
|
||||||
|
"password123",
|
||||||
|
"password1234",
|
||||||
|
"password!",
|
||||||
|
"letmein",
|
||||||
|
"welcome",
|
||||||
|
"admin",
|
||||||
|
"admin123",
|
||||||
|
"administrator",
|
||||||
|
"qwerty",
|
||||||
|
"qwerty123",
|
||||||
|
"qwerty1234",
|
||||||
|
"abc123",
|
||||||
|
"abcdef",
|
||||||
|
"123456",
|
||||||
|
"1234567",
|
||||||
|
"12345678",
|
||||||
|
"123456789",
|
||||||
|
"1234567890",
|
||||||
|
"iloveyou",
|
||||||
|
"iloveyou1",
|
||||||
|
"monkey",
|
||||||
|
"dragon",
|
||||||
|
"master",
|
||||||
|
"login",
|
||||||
|
"login123",
|
||||||
|
"passw0rd",
|
||||||
|
"passw0rd!",
|
||||||
|
"changeme",
|
||||||
|
"default",
|
||||||
|
"guest",
|
||||||
|
"guest123",
|
||||||
|
"fuckyou",
|
||||||
|
"fuckyou1",
|
||||||
|
"shit",
|
||||||
|
"asshole",
|
||||||
|
"hello",
|
||||||
|
"hello123",
|
||||||
|
"hello!",
|
||||||
|
"world",
|
||||||
|
"pass",
|
||||||
|
"test",
|
||||||
|
"test123",
|
||||||
|
"test!",
|
||||||
|
"root",
|
||||||
|
"root123",
|
||||||
|
"p@ssword",
|
||||||
|
"p@ssword1",
|
||||||
|
"p@ssw0rd",
|
||||||
|
"p@ssw0rd!",
|
||||||
|
"sunshine",
|
||||||
|
"princess",
|
||||||
|
"shadow",
|
||||||
|
"shadow123",
|
||||||
|
"access",
|
||||||
|
"access123",
|
||||||
|
"mypass",
|
||||||
|
"mypass123",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SetupRequest(BaseModel):
|
class SetupRequest(BanGuiBaseModel):
|
||||||
"""Payload for ``POST /api/setup``."""
|
"""Payload for ``POST /api/setup``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
master_password: str = Field(
|
master_password: str = Field(
|
||||||
...,
|
...,
|
||||||
min_length=8,
|
min_length=8,
|
||||||
description="Master password that protects the BanGUI interface.",
|
max_length=72,
|
||||||
|
description="Master password that protects the BanGUI interface (max 72 bytes due to bcrypt truncation).",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@field_validator("master_password")
|
||||||
|
@classmethod
|
||||||
|
def validate_master_password(cls, value: str) -> str:
|
||||||
|
if len(value) < 8:
|
||||||
|
raise ValueError("Password must be at least 8 characters long.")
|
||||||
|
if len(value) > 72:
|
||||||
|
raise ValueError("Password must not exceed 72 bytes (bcrypt limitation).")
|
||||||
|
if not any(char.isupper() for char in value):
|
||||||
|
raise ValueError("Password must include at least one uppercase letter.")
|
||||||
|
if not any(char.isdigit() for char in value):
|
||||||
|
raise ValueError("Password must include at least one number.")
|
||||||
|
if not any(char in "!@#$%^&*()" for char in value):
|
||||||
|
raise ValueError("Password must include at least one special character (!@#$%^&*()).")
|
||||||
|
if value.lower() in _COMMON_PASSWORDS:
|
||||||
|
raise ValueError("Password is too common. Choose something more unique.")
|
||||||
|
return value
|
||||||
|
|
||||||
database_path: str = Field(
|
database_path: str = Field(
|
||||||
default="bangui.db",
|
default="bangui.db",
|
||||||
description="Filesystem path to the BanGUI SQLite application database.",
|
description="Filesystem path to the BanGUI SQLite application database.",
|
||||||
@@ -35,29 +122,23 @@ class SetupRequest(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SetupResponse(BaseModel):
|
class SetupResponse(BanGuiBaseModel):
|
||||||
"""Response returned after a successful initial setup."""
|
"""Response returned after a successful initial setup."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
message: str = Field(
|
message: str = Field(
|
||||||
default="Setup completed successfully. Please log in.",
|
default="Setup completed successfully. Please log in.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SetupTimezoneResponse(BaseModel):
|
class SetupTimezoneResponse(BanGuiBaseModel):
|
||||||
"""Response for ``GET /api/setup/timezone``."""
|
"""Response for ``GET /api/setup/timezone``."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
timezone: str = Field(..., description="Configured IANA timezone identifier.")
|
timezone: str = Field(..., description="Configured IANA timezone identifier.")
|
||||||
|
|
||||||
|
|
||||||
class SetupStatusResponse(BaseModel):
|
class SetupStatusResponse(BanGuiBaseModel):
|
||||||
"""Response indicating whether setup has been completed."""
|
"""Response indicating whether setup has been completed."""
|
||||||
|
|
||||||
model_config = ConfigDict(strict=True)
|
|
||||||
|
|
||||||
completed: bool = Field(
|
completed: bool = Field(
|
||||||
...,
|
...,
|
||||||
description="``True`` if the initial setup has already been performed.",
|
description="``True`` if the initial setup has already been performed.",
|
||||||
|
|||||||
@@ -13,6 +13,37 @@ if TYPE_CHECKING:
|
|||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
|
|
||||||
|
async def create_source_in_tx(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
name: str,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
enabled: bool = True,
|
||||||
|
) -> int:
|
||||||
|
"""Insert a new blocklist source without committing.
|
||||||
|
|
||||||
|
Caller is responsible for committing or rolling back the transaction.
|
||||||
|
Use this variant when validation must be atomic with insert.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Active aiosqlite connection with an open transaction.
|
||||||
|
name: Human-readable display name.
|
||||||
|
url: URL of the blocklist text file.
|
||||||
|
enabled: Whether the source is active. Defaults to ``True``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ``ROWID`` / primary key of the new row.
|
||||||
|
"""
|
||||||
|
cursor = await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO blocklist_sources (name, url, enabled)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(name, url, int(enabled)),
|
||||||
|
)
|
||||||
|
return int(cursor.lastrowid) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
async def create_source(
|
async def create_source(
|
||||||
db: aiosqlite.Connection,
|
db: aiosqlite.Connection,
|
||||||
name: str,
|
name: str,
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ service layers can focus on business logic and formatting.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
|
from app.utils.fail2ban_db_utils import escape_like
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from app.models.ban import BanOrigin
|
from app.models.ban import BanOrigin
|
||||||
@@ -70,6 +74,53 @@ def _make_db_uri(db_path: str) -> str:
|
|||||||
return f"file:{db_path}?mode=ro"
|
return f"file:{db_path}?mode=ro"
|
||||||
|
|
||||||
|
|
||||||
|
async def _acquire_readonly_connection(
|
||||||
|
db_path: str,
|
||||||
|
) -> aiosqlite.Connection:
|
||||||
|
"""Open a read-only connection to the fail2ban database.
|
||||||
|
|
||||||
|
Defense-in-depth: both the ``?mode=ro`` URI flag AND the SQLite-level
|
||||||
|
``PRAGMA query_only = ON`` are applied. The URI flag is a library-level hint
|
||||||
|
that can be bypassed by malformed URIs or version inconsistencies;
|
||||||
|
``query_only`` is a connection-level enforcement that makes all write
|
||||||
|
operations fail. We verify enforcement by reading back the PRAGMA value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to the fail2ban SQLite database.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An aiosqlite connection in guaranteed read-only mode.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AssertionError: If PRAGMA query_only is not confirmed as enabled.
|
||||||
|
"""
|
||||||
|
conn = await aiosqlite.connect(_make_db_uri(db_path), uri=True)
|
||||||
|
# Set connection-level read-only enforcement and verify in one statement.
|
||||||
|
# Even if the ?mode=ro URI flag is bypassed, this PRAGMA blocks writes.
|
||||||
|
cursor = await conn.execute("PRAGMA query_only = ON")
|
||||||
|
await cursor.close()
|
||||||
|
# Verify the PRAGMA took effect.
|
||||||
|
cursor = await conn.execute("PRAGMA query_only")
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
await cursor.close()
|
||||||
|
if not row or row[0] != 1:
|
||||||
|
await conn.close()
|
||||||
|
raise AssertionError(
|
||||||
|
"PRAGMA query_only is not enabled; connection may be writable"
|
||||||
|
)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _readonly_connection(db_path: str) -> AsyncIterator[aiosqlite.Connection]:
|
||||||
|
"""Async context manager that yields a read-only fail2ban DB connection."""
|
||||||
|
conn = await _acquire_readonly_connection(db_path)
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
def _origin_sql_filter(origin: BanOrigin | None) -> tuple[str, tuple[str, ...]]:
|
def _origin_sql_filter(origin: BanOrigin | None) -> tuple[str, tuple[str, ...]]:
|
||||||
"""Return a SQL fragment and parameters for the origin filter."""
|
"""Return a SQL fragment and parameters for the origin filter."""
|
||||||
|
|
||||||
@@ -114,7 +165,7 @@ def _rows_to_history_records(rows: Iterable[aiosqlite.Row]) -> list[HistoryRecor
|
|||||||
async def check_db_nonempty(db_path: str) -> bool:
|
async def check_db_nonempty(db_path: str) -> bool:
|
||||||
"""Return True if the fail2ban database contains at least one ban row."""
|
"""Return True if the fail2ban database contains at least one ban row."""
|
||||||
|
|
||||||
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db, db.execute(
|
async with _readonly_connection(db_path) as db, db.execute(
|
||||||
"SELECT 1 FROM bans LIMIT 1"
|
"SELECT 1 FROM bans LIMIT 1"
|
||||||
) as cur:
|
) as cur:
|
||||||
row = await cur.fetchone()
|
row = await cur.fetchone()
|
||||||
@@ -126,6 +177,7 @@ async def get_currently_banned(
|
|||||||
since: int,
|
since: int,
|
||||||
origin: BanOrigin | None = None,
|
origin: BanOrigin | None = None,
|
||||||
*,
|
*,
|
||||||
|
ip_filter: list[str] | None = None,
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
offset: int | None = None,
|
offset: int | None = None,
|
||||||
) -> tuple[list[BanRecord], int]:
|
) -> tuple[list[BanRecord], int]:
|
||||||
@@ -135,6 +187,7 @@ async def get_currently_banned(
|
|||||||
db_path: File path to the fail2ban SQLite database.
|
db_path: File path to the fail2ban SQLite database.
|
||||||
since: Unix timestamp to filter bans newer than or equal to.
|
since: Unix timestamp to filter bans newer than or equal to.
|
||||||
origin: Optional origin filter.
|
origin: Optional origin filter.
|
||||||
|
ip_filter: Optional list of IP addresses to restrict the result to.
|
||||||
limit: Optional maximum number of rows to return.
|
limit: Optional maximum number of rows to return.
|
||||||
offset: Optional offset for pagination.
|
offset: Optional offset for pagination.
|
||||||
|
|
||||||
@@ -142,14 +195,21 @@ async def get_currently_banned(
|
|||||||
A ``(records, total)`` tuple.
|
A ``(records, total)`` tuple.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
origin_clause, origin_params = _origin_sql_filter(origin)
|
if ip_filter is not None and len(ip_filter) == 0:
|
||||||
|
return [], 0
|
||||||
|
|
||||||
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db:
|
origin_clause, origin_params = _origin_sql_filter(origin)
|
||||||
|
ip_filter_clause = ""
|
||||||
|
if ip_filter is not None:
|
||||||
|
placeholder = ", ".join("?" for _ in ip_filter)
|
||||||
|
ip_filter_clause = f" AND ip IN ({placeholder})"
|
||||||
|
|
||||||
|
async with _readonly_connection(db_path) as db:
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
|
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
"SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause,
|
"SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause + ip_filter_clause,
|
||||||
(since, *origin_params),
|
(since, *origin_params, *(ip_filter or [])),
|
||||||
) as cur:
|
) as cur:
|
||||||
count_row = await cur.fetchone()
|
count_row = await cur.fetchone()
|
||||||
total: int = int(count_row[0]) if count_row else 0
|
total: int = int(count_row[0]) if count_row else 0
|
||||||
@@ -157,9 +217,9 @@ async def get_currently_banned(
|
|||||||
query = (
|
query = (
|
||||||
"SELECT jail, ip, timeofban, bancount, data "
|
"SELECT jail, ip, timeofban, bancount, data "
|
||||||
"FROM bans "
|
"FROM bans "
|
||||||
"WHERE timeofban >= ?" + origin_clause + " ORDER BY timeofban DESC"
|
"WHERE timeofban >= ?" + origin_clause + ip_filter_clause + " ORDER BY timeofban DESC"
|
||||||
)
|
)
|
||||||
params: list[object] = [since, *origin_params]
|
params: list[object] = [since, *origin_params, *(ip_filter or [])]
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
query += " LIMIT ?"
|
query += " LIMIT ?"
|
||||||
params.append(limit)
|
params.append(limit)
|
||||||
@@ -184,7 +244,7 @@ async def get_ban_counts_by_bucket(
|
|||||||
|
|
||||||
origin_clause, origin_params = _origin_sql_filter(origin)
|
origin_clause, origin_params = _origin_sql_filter(origin)
|
||||||
|
|
||||||
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db:
|
async with _readonly_connection(db_path) as db:
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
"SELECT CAST((timeofban - ?) / ? AS INTEGER) AS bucket_idx, "
|
"SELECT CAST((timeofban - ?) / ? AS INTEGER) AS bucket_idx, "
|
||||||
@@ -214,7 +274,7 @@ async def get_ban_event_counts(
|
|||||||
|
|
||||||
origin_clause, origin_params = _origin_sql_filter(origin)
|
origin_clause, origin_params = _origin_sql_filter(origin)
|
||||||
|
|
||||||
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db:
|
async with _readonly_connection(db_path) as db:
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
"SELECT ip, COUNT(*) AS event_count "
|
"SELECT ip, COUNT(*) AS event_count "
|
||||||
@@ -239,7 +299,7 @@ async def get_bans_by_jail(
|
|||||||
|
|
||||||
origin_clause, origin_params = _origin_sql_filter(origin)
|
origin_clause, origin_params = _origin_sql_filter(origin)
|
||||||
|
|
||||||
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db:
|
async with _readonly_connection(db_path) as db:
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
|
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
@@ -272,7 +332,7 @@ async def get_bans_table_summary(
|
|||||||
empty the min/max values will be ``None``.
|
empty the min/max values will be ``None``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db:
|
async with _readonly_connection(db_path) as db:
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
"SELECT COUNT(*), MIN(timeofban), MAX(timeofban) FROM bans"
|
"SELECT COUNT(*), MIN(timeofban), MAX(timeofban) FROM bans"
|
||||||
@@ -312,8 +372,8 @@ async def get_history_page(
|
|||||||
params.append(jail)
|
params.append(jail)
|
||||||
|
|
||||||
if ip_filter is not None:
|
if ip_filter is not None:
|
||||||
wheres.append("ip LIKE ?")
|
wheres.append("ip LIKE ? ESCAPE '\\'")
|
||||||
params.append(f"{ip_filter}%")
|
params.append(f"{escape_like(ip_filter)}%")
|
||||||
|
|
||||||
origin_clause, origin_params = _origin_sql_filter(origin)
|
origin_clause, origin_params = _origin_sql_filter(origin)
|
||||||
if origin_clause:
|
if origin_clause:
|
||||||
@@ -326,7 +386,7 @@ async def get_history_page(
|
|||||||
effective_page_size: int = page_size
|
effective_page_size: int = page_size
|
||||||
offset: int = (page - 1) * effective_page_size
|
offset: int = (page - 1) * effective_page_size
|
||||||
|
|
||||||
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db:
|
async with _readonly_connection(db_path) as db:
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
|
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
@@ -351,7 +411,7 @@ async def get_history_page(
|
|||||||
async def get_history_for_ip(db_path: str, ip: str) -> list[HistoryRecord]:
|
async def get_history_for_ip(db_path: str, ip: str) -> list[HistoryRecord]:
|
||||||
"""Return the full ban timeline for a specific IP."""
|
"""Return the full ban timeline for a specific IP."""
|
||||||
|
|
||||||
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db:
|
async with _readonly_connection(db_path) as db:
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
"SELECT jail, ip, timeofban, bancount, data "
|
"SELECT jail, ip, timeofban, bancount, data "
|
||||||
|
|||||||
@@ -9,22 +9,17 @@ connection lifetimes.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, TypedDict
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
|
from app.models.geo import GeoCacheEntry
|
||||||
|
|
||||||
class GeoCacheRow(TypedDict):
|
# Alias for backward compatibility with protocols
|
||||||
"""A single row from the ``geo_cache`` table."""
|
GeoCacheRow = GeoCacheEntry
|
||||||
|
|
||||||
ip: str
|
|
||||||
country_code: str | None
|
|
||||||
country_name: str | None
|
|
||||||
asn: str | None
|
|
||||||
org: str | None
|
|
||||||
|
|
||||||
|
|
||||||
async def load_all(db: aiosqlite.Connection) -> list[GeoCacheRow]:
|
async def load_all(db: aiosqlite.Connection) -> list[GeoCacheRow]:
|
||||||
@@ -98,20 +93,60 @@ async def upsert_entry(
|
|||||||
country_name = excluded.country_name,
|
country_name = excluded.country_name,
|
||||||
asn = excluded.asn,
|
asn = excluded.asn,
|
||||||
org = excluded.org,
|
org = excluded.org,
|
||||||
cached_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
cached_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||||
|
last_seen = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
""",
|
""",
|
||||||
(ip, country_code, country_name, asn, org),
|
(ip, country_code, country_name, asn, org),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_entry_and_commit(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
ip: str,
|
||||||
|
country_code: str | None,
|
||||||
|
country_name: str | None,
|
||||||
|
asn: str | None,
|
||||||
|
org: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""Insert or update a resolved geo cache entry and commit.
|
||||||
|
|
||||||
|
Wraps the upsert in an explicit transaction to ensure atomicity.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await db.execute("BEGIN IMMEDIATE")
|
||||||
|
await upsert_entry(db, ip, country_code, country_name, asn, org)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def upsert_neg_entry(db: aiosqlite.Connection, ip: str) -> None:
|
async def upsert_neg_entry(db: aiosqlite.Connection, ip: str) -> None:
|
||||||
"""Record a failed lookup attempt as a negative entry."""
|
"""Record a failed lookup attempt as a negative entry."""
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"INSERT OR IGNORE INTO geo_cache (ip) VALUES (?)",
|
"""
|
||||||
|
INSERT INTO geo_cache (ip) VALUES (?)
|
||||||
|
ON CONFLICT(ip) DO UPDATE SET
|
||||||
|
last_seen = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
|
""",
|
||||||
(ip,),
|
(ip,),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_neg_entry_and_commit(db: aiosqlite.Connection, ip: str) -> None:
|
||||||
|
"""Record a failed lookup attempt and commit the transaction.
|
||||||
|
|
||||||
|
Wraps the upsert in an explicit transaction to ensure atomicity.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await db.execute("BEGIN IMMEDIATE")
|
||||||
|
await upsert_neg_entry(db, ip)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def bulk_upsert_entries(
|
async def bulk_upsert_entries(
|
||||||
db: aiosqlite.Connection,
|
db: aiosqlite.Connection,
|
||||||
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
|
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
|
||||||
@@ -129,7 +164,8 @@ async def bulk_upsert_entries(
|
|||||||
country_name = excluded.country_name,
|
country_name = excluded.country_name,
|
||||||
asn = excluded.asn,
|
asn = excluded.asn,
|
||||||
org = excluded.org,
|
org = excluded.org,
|
||||||
cached_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
cached_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||||
|
last_seen = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
""",
|
""",
|
||||||
rows,
|
rows,
|
||||||
)
|
)
|
||||||
@@ -146,3 +182,91 @@ async def bulk_upsert_neg_entries(db: aiosqlite.Connection, ips: list[str]) -> i
|
|||||||
[(ip,) for ip in ips],
|
[(ip,) for ip in ips],
|
||||||
)
|
)
|
||||||
return len(ips)
|
return len(ips)
|
||||||
|
|
||||||
|
|
||||||
|
async def bulk_upsert_entries_and_commit(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
|
||||||
|
) -> int:
|
||||||
|
"""Bulk insert or update multiple geo cache entries and commit.
|
||||||
|
|
||||||
|
Wraps the bulk upsert in an explicit transaction to ensure atomicity.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await db.execute("BEGIN IMMEDIATE")
|
||||||
|
count = await bulk_upsert_entries(db, rows)
|
||||||
|
await db.commit()
|
||||||
|
return count
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def bulk_upsert_neg_entries_and_commit(db: aiosqlite.Connection, ips: list[str]) -> int:
|
||||||
|
"""Bulk insert negative lookup entries and commit.
|
||||||
|
|
||||||
|
Wraps the bulk upsert in an explicit transaction to ensure atomicity.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await db.execute("BEGIN IMMEDIATE")
|
||||||
|
count = await bulk_upsert_neg_entries(db, ips)
|
||||||
|
await db.commit()
|
||||||
|
return count
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def bulk_upsert_entries_and_neg_entries_and_commit(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
|
||||||
|
ips: list[str],
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Persist positive and negative geo cache rows together, then commit.
|
||||||
|
|
||||||
|
Wraps both upserts in a single transaction to ensure atomicity.
|
||||||
|
Either all rows are persisted or none are.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Active aiosqlite connection.
|
||||||
|
rows: Sequence of (ip, country_code, country_name, asn, org) tuples.
|
||||||
|
ips: List of IP strings for negative entries (failed lookups).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple (positive_count, negative_count) of rows persisted.
|
||||||
|
"""
|
||||||
|
positive_count = 0
|
||||||
|
negative_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db.execute("BEGIN IMMEDIATE")
|
||||||
|
if rows:
|
||||||
|
positive_count = await bulk_upsert_entries(db, rows)
|
||||||
|
if ips:
|
||||||
|
negative_count = await bulk_upsert_neg_entries(db, ips)
|
||||||
|
|
||||||
|
if rows or ips:
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
return positive_count, negative_count
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_stale_entries(db: aiosqlite.Connection, cutoff_iso: str) -> int:
|
||||||
|
"""Delete geo cache entries not referenced since the cutoff timestamp.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Open BanGUI application database connection.
|
||||||
|
cutoff_iso: ISO 8601 timestamp (e.g., '2024-01-01T00:00:00Z'). Entries with
|
||||||
|
``last_seen`` before this time will be deleted.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The number of rows deleted.
|
||||||
|
"""
|
||||||
|
async with db.execute(
|
||||||
|
"DELETE FROM geo_cache WHERE last_seen < ?",
|
||||||
|
(cutoff_iso,),
|
||||||
|
) as cur:
|
||||||
|
return cur.rowcount if cur.rowcount is not None else 0
|
||||||
|
|||||||
504
backend/app/repositories/history_archive_repo.py
Normal file
504
backend/app/repositories/history_archive_repo.py
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
"""Ban history archive repository.
|
||||||
|
|
||||||
|
Provides persistence APIs for the BanGUI archival history table in the
|
||||||
|
application database.
|
||||||
|
|
||||||
|
Supports both offset-based and cursor-based pagination:
|
||||||
|
|
||||||
|
- **Offset pagination** (legacy): ``get_archived_history(page=2, page_size=100)``
|
||||||
|
- convenient for small datasets but degrades on large offsets.
|
||||||
|
|
||||||
|
- **Cursor pagination** (recommended): ``get_archived_history_keyset(page_size=100, last_ban_id=None)``
|
||||||
|
- constant-time performance regardless of dataset size.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from app.models.ban import BLOCKLIST_JAIL, BanOrigin
|
||||||
|
from app.utils.fail2ban_db_utils import escape_like
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
|
||||||
|
async def archive_ban_event(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
jail: str,
|
||||||
|
ip: str,
|
||||||
|
timeofban: int,
|
||||||
|
bancount: int,
|
||||||
|
data: str,
|
||||||
|
action: str = "ban",
|
||||||
|
) -> bool:
|
||||||
|
"""Insert a new archived ban/unban event, ignoring duplicates."""
|
||||||
|
async with db.execute(
|
||||||
|
"""INSERT OR IGNORE INTO history_archive
|
||||||
|
(jail, ip, timeofban, bancount, data, action)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||||
|
(jail, ip, timeofban, bancount, data, action),
|
||||||
|
) as cursor:
|
||||||
|
inserted = cursor.rowcount == 1
|
||||||
|
await db.commit()
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
async def get_max_timeofban(db: aiosqlite.Connection) -> int | None:
|
||||||
|
"""Return the latest archived ban timestamp or ``None`` when empty."""
|
||||||
|
async with db.execute("SELECT MAX(timeofban) FROM history_archive") as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None or row[0] is None:
|
||||||
|
return None
|
||||||
|
return int(row[0])
|
||||||
|
|
||||||
|
|
||||||
|
async def get_archived_history(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
since: int | None = None,
|
||||||
|
jail: str | None = None,
|
||||||
|
ip_filter: str | list[str] | None = None,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
|
action: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 100,
|
||||||
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
|
"""Return a paginated archived history result set."""
|
||||||
|
if isinstance(ip_filter, list) and len(ip_filter) == 0:
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
wheres: list[str] = []
|
||||||
|
params: list[object] = []
|
||||||
|
|
||||||
|
if since is not None:
|
||||||
|
wheres.append("timeofban >= ?")
|
||||||
|
params.append(since)
|
||||||
|
|
||||||
|
if jail is not None:
|
||||||
|
wheres.append("jail = ?")
|
||||||
|
params.append(jail)
|
||||||
|
|
||||||
|
if ip_filter is not None:
|
||||||
|
if isinstance(ip_filter, list):
|
||||||
|
placeholder = ", ".join("?" for _ in ip_filter)
|
||||||
|
wheres.append(f"ip IN ({placeholder})")
|
||||||
|
params.extend(ip_filter)
|
||||||
|
else:
|
||||||
|
wheres.append("ip LIKE ? ESCAPE '\\'")
|
||||||
|
params.append(f"{escape_like(ip_filter)}%")
|
||||||
|
|
||||||
|
if origin == "blocklist":
|
||||||
|
wheres.append("jail = ?")
|
||||||
|
params.append(BLOCKLIST_JAIL)
|
||||||
|
elif origin == "selfblock":
|
||||||
|
wheres.append("jail != ?")
|
||||||
|
params.append(BLOCKLIST_JAIL)
|
||||||
|
|
||||||
|
if action is not None:
|
||||||
|
wheres.append("action = ?")
|
||||||
|
params.append(action)
|
||||||
|
|
||||||
|
where_sql = "WHERE " + " AND ".join(wheres) if wheres else ""
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
|
async with db.execute(f"SELECT COUNT(*) FROM history_archive {where_sql}", params) as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
total = int(row[0]) if row is not None and row[0] is not None else 0
|
||||||
|
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT id, jail, ip, timeofban, bancount, data, action "
|
||||||
|
"FROM history_archive "
|
||||||
|
f"{where_sql} "
|
||||||
|
"ORDER BY timeofban DESC LIMIT ? OFFSET ?",
|
||||||
|
[*params, page_size, offset],
|
||||||
|
) as cur:
|
||||||
|
rows = await cur.fetchall()
|
||||||
|
|
||||||
|
records = [
|
||||||
|
{
|
||||||
|
"id": int(r[0]),
|
||||||
|
"jail": str(r[1]),
|
||||||
|
"ip": str(r[2]),
|
||||||
|
"timeofban": int(r[3]),
|
||||||
|
"bancount": int(r[4]),
|
||||||
|
"data": str(r[5]),
|
||||||
|
"action": str(r[6]),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return records, total
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_archived_history(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
since: int | None = None,
|
||||||
|
jail: str | None = None,
|
||||||
|
ip_filter: str | list[str] | None = None,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
|
action: str | None = None,
|
||||||
|
page_size: int = 1000,
|
||||||
|
max_rows: int = 50_000,
|
||||||
|
last_ban_id: int | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return archived history rows for the given filters, bounded to *max_rows*.
|
||||||
|
|
||||||
|
Uses keyset pagination internally for constant-time performance regardless
|
||||||
|
of how deep into the result set we go. The caller must provide *last_ban_id*
|
||||||
|
from the previous call to continue pagination; ``None`` starts fresh.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page_size: Number of rows to fetch per internal batch (default 1000).
|
||||||
|
max_rows: Hard cap on total rows returned (default 50 000). When
|
||||||
|
reached the function returns even if more rows exist. Pass ``0``
|
||||||
|
to request zero rows (useful for count-only callers).
|
||||||
|
last_ban_id: Cursor from the previous call. ``None`` for the first
|
||||||
|
call — the result set will start from the newest row.
|
||||||
|
"""
|
||||||
|
if max_rows <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
all_rows: list[dict[str, Any]] = []
|
||||||
|
current_last_ban_id: int | None = last_ban_id
|
||||||
|
|
||||||
|
while True:
|
||||||
|
batch, has_more = await get_archived_history_keyset(
|
||||||
|
db=db,
|
||||||
|
since=since,
|
||||||
|
jail=jail,
|
||||||
|
ip_filter=ip_filter,
|
||||||
|
origin=origin,
|
||||||
|
action=action,
|
||||||
|
page_size=page_size,
|
||||||
|
last_ban_id=current_last_ban_id,
|
||||||
|
)
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
all_rows.extend(batch)
|
||||||
|
if len(all_rows) >= max_rows:
|
||||||
|
break
|
||||||
|
if not has_more:
|
||||||
|
break
|
||||||
|
# Use the id of the last row in the batch as the next cursor.
|
||||||
|
# Rows are ordered id DESC, so the last row has the smallest id
|
||||||
|
# seen in this batch and is the correct keyset anchor.
|
||||||
|
last_row = batch[-1]
|
||||||
|
current_last_ban_id = last_row.get("id")
|
||||||
|
if current_last_ban_id is None:
|
||||||
|
# Fallback: determine id from the WHERE clause of the previous query.
|
||||||
|
# If we somehow cannot determine the id, stop to avoid an infinite loop.
|
||||||
|
break
|
||||||
|
|
||||||
|
return all_rows[:max_rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def purge_archived_history(db: aiosqlite.Connection, age_seconds: int) -> int:
|
||||||
|
"""Purge archived entries older than *age_seconds*; return rows deleted."""
|
||||||
|
threshold = int(datetime.datetime.now(datetime.UTC).timestamp()) - age_seconds
|
||||||
|
async with db.execute(
|
||||||
|
"DELETE FROM history_archive WHERE timeofban < ?",
|
||||||
|
(threshold,),
|
||||||
|
) as cursor:
|
||||||
|
deleted = cursor.rowcount
|
||||||
|
await db.commit()
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
async def get_archived_history_keyset(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
since: int | None = None,
|
||||||
|
jail: str | None = None,
|
||||||
|
ip_filter: str | list[str] | None = None,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
|
action: str | None = None,
|
||||||
|
page_size: int = 100,
|
||||||
|
last_ban_id: int | None = None,
|
||||||
|
) -> tuple[list[dict[str, Any]], bool]:
|
||||||
|
"""Return cursor-paginated archived history using keyset pagination.
|
||||||
|
|
||||||
|
Uses keyset pagination (WHERE id < last_id) for constant-time performance
|
||||||
|
regardless of result set size. This is the recommended pagination method
|
||||||
|
for large result sets.
|
||||||
|
|
||||||
|
Ordering is by timeofban DESC (newest first), with id DESC as tiebreaker for
|
||||||
|
events with identical timestamps. This ensures stable, deterministic pagination.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Active aiosqlite connection.
|
||||||
|
since: If given, filter to events on or after this Unix timestamp.
|
||||||
|
jail: If given, filter to events for this jail.
|
||||||
|
ip_filter: If given, filter by IP (exact match list or LIKE prefix).
|
||||||
|
origin: If given, filter by ban origin ('blocklist' or 'selfblock').
|
||||||
|
action: If given, filter to this action type ('ban' or 'unban').
|
||||||
|
page_size: Number of items per page (max returned is page_size + 1 to detect overflow).
|
||||||
|
last_ban_id: The ID of the last item from the previous page (for cursor).
|
||||||
|
None for the first page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A 2-tuple ``(records, has_more)`` where:
|
||||||
|
- *records* is a list of up to page_size dicts with ban details
|
||||||
|
- *has_more* is True if there are additional pages beyond this one
|
||||||
|
"""
|
||||||
|
if isinstance(ip_filter, list) and len(ip_filter) == 0:
|
||||||
|
return [], False
|
||||||
|
|
||||||
|
wheres: list[str] = []
|
||||||
|
params: list[object] = []
|
||||||
|
|
||||||
|
if since is not None:
|
||||||
|
wheres.append("timeofban >= ?")
|
||||||
|
params.append(since)
|
||||||
|
|
||||||
|
if jail is not None:
|
||||||
|
wheres.append("jail = ?")
|
||||||
|
params.append(jail)
|
||||||
|
|
||||||
|
if ip_filter is not None:
|
||||||
|
if isinstance(ip_filter, list):
|
||||||
|
placeholder = ", ".join("?" for _ in ip_filter)
|
||||||
|
wheres.append(f"ip IN ({placeholder})")
|
||||||
|
params.extend(ip_filter)
|
||||||
|
else:
|
||||||
|
wheres.append("ip LIKE ? ESCAPE '\\'")
|
||||||
|
params.append(f"{escape_like(ip_filter)}%")
|
||||||
|
|
||||||
|
if origin == "blocklist":
|
||||||
|
wheres.append("jail = ?")
|
||||||
|
params.append(BLOCKLIST_JAIL)
|
||||||
|
elif origin == "selfblock":
|
||||||
|
wheres.append("jail != ?")
|
||||||
|
params.append(BLOCKLIST_JAIL)
|
||||||
|
|
||||||
|
if action is not None:
|
||||||
|
wheres.append("action = ?")
|
||||||
|
params.append(action)
|
||||||
|
|
||||||
|
if last_ban_id is not None:
|
||||||
|
wheres.append("id < ?")
|
||||||
|
params.append(last_ban_id)
|
||||||
|
|
||||||
|
where_sql = "WHERE " + " AND ".join(wheres) if wheres else ""
|
||||||
|
|
||||||
|
# Fetch page_size + 1 to detect if there are more pages
|
||||||
|
fetch_limit = page_size + 1
|
||||||
|
params.append(fetch_limit)
|
||||||
|
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT id, jail, ip, timeofban, bancount, data, action "
|
||||||
|
"FROM history_archive "
|
||||||
|
f"{where_sql} "
|
||||||
|
"ORDER BY id DESC "
|
||||||
|
"LIMIT ?", # noqa: S608
|
||||||
|
params,
|
||||||
|
) as cur:
|
||||||
|
rows_iterable = await cur.fetchall()
|
||||||
|
rows = list(rows_iterable)
|
||||||
|
|
||||||
|
records = [
|
||||||
|
{
|
||||||
|
"id": int(r[0]),
|
||||||
|
"jail": str(r[1]),
|
||||||
|
"ip": str(r[2]),
|
||||||
|
"timeofban": int(r[3]),
|
||||||
|
"bancount": int(r[4]),
|
||||||
|
"data": str(r[5]),
|
||||||
|
"action": str(r[6]),
|
||||||
|
}
|
||||||
|
for r in rows[:page_size]
|
||||||
|
]
|
||||||
|
has_more = len(rows) > page_size
|
||||||
|
|
||||||
|
return records, has_more
|
||||||
|
|
||||||
|
|
||||||
|
async def get_ip_ban_counts(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
since: int | None = None,
|
||||||
|
jail: str | None = None,
|
||||||
|
ip_filter: str | list[str] | None = None,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
|
action: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return ban event counts grouped by IP using SQL aggregation.
|
||||||
|
|
||||||
|
Uses SQL GROUP BY to aggregate in the database rather than loading
|
||||||
|
all rows into Python memory. Returns lightweight {ip, event_count} dicts
|
||||||
|
suitable for downstream aggregation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Active aiosqlite connection.
|
||||||
|
since: If given, filter to events on or after this Unix timestamp.
|
||||||
|
jail: If given, filter to events for this jail.
|
||||||
|
ip_filter: If given, filter by IP (exact match list or LIKE prefix).
|
||||||
|
origin: If given, filter by ban origin ('blocklist' or 'selfblock').
|
||||||
|
action: If given, filter to this action type ('ban' or 'unban').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of {ip: str, event_count: int} dicts.
|
||||||
|
"""
|
||||||
|
if isinstance(ip_filter, list) and len(ip_filter) == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
wheres: list[str] = []
|
||||||
|
params: list[object] = []
|
||||||
|
|
||||||
|
if since is not None:
|
||||||
|
wheres.append("timeofban >= ?")
|
||||||
|
params.append(since)
|
||||||
|
|
||||||
|
if jail is not None:
|
||||||
|
wheres.append("jail = ?")
|
||||||
|
params.append(jail)
|
||||||
|
|
||||||
|
if ip_filter is not None:
|
||||||
|
if isinstance(ip_filter, list):
|
||||||
|
placeholder = ", ".join("?" for _ in ip_filter)
|
||||||
|
wheres.append(f"ip IN ({placeholder})")
|
||||||
|
params.extend(ip_filter)
|
||||||
|
else:
|
||||||
|
wheres.append("ip LIKE ? ESCAPE '\\'")
|
||||||
|
params.append(f"{escape_like(ip_filter)}%")
|
||||||
|
|
||||||
|
if origin == "blocklist":
|
||||||
|
wheres.append("jail = ?")
|
||||||
|
params.append(BLOCKLIST_JAIL)
|
||||||
|
elif origin == "selfblock":
|
||||||
|
wheres.append("jail != ?")
|
||||||
|
params.append(BLOCKLIST_JAIL)
|
||||||
|
|
||||||
|
if action is not None:
|
||||||
|
wheres.append("action = ?")
|
||||||
|
params.append(action)
|
||||||
|
|
||||||
|
where_sql = "WHERE " + " AND ".join(wheres) if wheres else ""
|
||||||
|
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT ip, COUNT(*) AS event_count "
|
||||||
|
"FROM history_archive "
|
||||||
|
f"{where_sql} "
|
||||||
|
"GROUP BY ip",
|
||||||
|
params,
|
||||||
|
) as cur:
|
||||||
|
rows = await cur.fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"ip": str(r[0]), "event_count": int(r[1])}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_jail_ban_counts(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
since: int | None = None,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
|
action: str | None = None,
|
||||||
|
) -> tuple[int, list[dict[str, Any]]]:
|
||||||
|
"""Return per-jail ban counts and total using SQL aggregation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Active aiosqlite connection.
|
||||||
|
since: If given, filter to events on or after this Unix timestamp.
|
||||||
|
origin: If given, filter by ban origin ('blocklist' or 'selfblock').
|
||||||
|
action: If given, filter to this action type ('ban' or 'unban').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A 2-tuple (total_count, jail_counts) where jail_counts is a list
|
||||||
|
of {jail: str, event_count: int} dicts sorted descending by count.
|
||||||
|
"""
|
||||||
|
wheres: list[str] = []
|
||||||
|
params: list[object] = []
|
||||||
|
|
||||||
|
if since is not None:
|
||||||
|
wheres.append("timeofban >= ?")
|
||||||
|
params.append(since)
|
||||||
|
|
||||||
|
if origin == "blocklist":
|
||||||
|
wheres.append("jail = ?")
|
||||||
|
params.append(BLOCKLIST_JAIL)
|
||||||
|
elif origin == "selfblock":
|
||||||
|
wheres.append("jail != ?")
|
||||||
|
params.append(BLOCKLIST_JAIL)
|
||||||
|
|
||||||
|
if action is not None:
|
||||||
|
wheres.append("action = ?")
|
||||||
|
params.append(action)
|
||||||
|
|
||||||
|
where_sql = "WHERE " + " AND ".join(wheres) if wheres else ""
|
||||||
|
|
||||||
|
async with db.execute(
|
||||||
|
f"SELECT COUNT(*) FROM history_archive {where_sql}", params
|
||||||
|
) as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
total = int(row[0]) if row is not None and row[0] is not None else 0
|
||||||
|
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT jail, COUNT(*) AS event_count "
|
||||||
|
"FROM history_archive "
|
||||||
|
f"{where_sql} "
|
||||||
|
"GROUP BY jail "
|
||||||
|
"ORDER BY event_count DESC",
|
||||||
|
params,
|
||||||
|
) as cur:
|
||||||
|
rows = await cur.fetchall()
|
||||||
|
|
||||||
|
return total, [
|
||||||
|
{"jail": str(r[0]), "event_count": int(r[1])}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_ban_counts_by_bucket(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
since: int,
|
||||||
|
bucket_secs: int,
|
||||||
|
num_buckets: int,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
|
action: str | None = None,
|
||||||
|
) -> list[int]:
|
||||||
|
"""Return ban counts bucketed by time using SQL aggregation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Active aiosqlite connection.
|
||||||
|
since: Start of the time window (Unix timestamp).
|
||||||
|
bucket_secs: Width of each bucket in seconds.
|
||||||
|
num_buckets: Total number of buckets in the window.
|
||||||
|
origin: If given, filter by ban origin.
|
||||||
|
action: If given, filter to this action type ('ban' or 'unban').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of int counts, one per bucket, indexed by bucket index.
|
||||||
|
"""
|
||||||
|
wheres: list[str] = ["timeofban >= ?"]
|
||||||
|
params: list[object] = [since]
|
||||||
|
|
||||||
|
if origin == "blocklist":
|
||||||
|
wheres.append("jail = ?")
|
||||||
|
params.append(BLOCKLIST_JAIL)
|
||||||
|
elif origin == "selfblock":
|
||||||
|
wheres.append("jail != ?")
|
||||||
|
params.append(BLOCKLIST_JAIL)
|
||||||
|
|
||||||
|
if action is not None:
|
||||||
|
wheres.append("action = ?")
|
||||||
|
params.append(action)
|
||||||
|
|
||||||
|
where_sql = "WHERE " + " AND ".join(wheres)
|
||||||
|
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT CAST((timeofban - ?) / ? AS INTEGER) AS bucket_idx, "
|
||||||
|
"COUNT(*) AS cnt "
|
||||||
|
"FROM history_archive "
|
||||||
|
f"{where_sql} GROUP BY bucket_idx ORDER BY bucket_idx",
|
||||||
|
(since, bucket_secs, *params),
|
||||||
|
) as cur:
|
||||||
|
rows = await cur.fetchall()
|
||||||
|
|
||||||
|
counts: list[int] = [0] * num_buckets
|
||||||
|
for row in rows:
|
||||||
|
idx: int = int(row[0])
|
||||||
|
if 0 <= idx < num_buckets:
|
||||||
|
counts[idx] = int(row[1])
|
||||||
|
|
||||||
|
return counts
|
||||||
|
|
||||||
@@ -3,31 +3,30 @@
|
|||||||
Persists and queries blocklist import run records in the ``import_log``
|
Persists and queries blocklist import run records in the ``import_log``
|
||||||
table. All methods are plain async functions that accept a
|
table. All methods are plain async functions that accept a
|
||||||
:class:`aiosqlite.Connection`.
|
:class:`aiosqlite.Connection`.
|
||||||
|
|
||||||
|
Supports both offset-based and cursor-based pagination:
|
||||||
|
|
||||||
|
- **Offset pagination** (legacy): ``list_logs(page=2, page_size=50)`` - query-efficient
|
||||||
|
but degrades on large offsets.
|
||||||
|
|
||||||
|
- **Cursor pagination** (recommended): ``list_logs_keyset(page_size=50, last_log_id=None)``
|
||||||
|
- constant-time performance regardless of dataset size.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
from typing import TYPE_CHECKING, TypedDict, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
|
from app.models.blocklist import ImportLogEntry
|
||||||
|
|
||||||
class ImportLogRow(TypedDict):
|
# Alias for backward compatibility with protocols
|
||||||
"""Row shape returned by queries on the import_log table."""
|
ImportLogRow = ImportLogEntry
|
||||||
|
|
||||||
id: int
|
|
||||||
source_id: int | None
|
|
||||||
source_url: str
|
|
||||||
timestamp: str
|
|
||||||
ips_imported: int
|
|
||||||
ips_skipped: int
|
|
||||||
errors: str | None
|
|
||||||
|
|
||||||
|
|
||||||
async def add_log(
|
async def add_log(
|
||||||
db: aiosqlite.Connection,
|
db: aiosqlite.Connection,
|
||||||
*,
|
*,
|
||||||
@@ -51,12 +50,15 @@ async def add_log(
|
|||||||
Returns:
|
Returns:
|
||||||
Primary key of the inserted row.
|
Primary key of the inserted row.
|
||||||
"""
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
timestamp_unix: int = int(time.time())
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO import_log (source_id, source_url, ips_imported, ips_skipped, errors)
|
INSERT INTO import_log (source_id, source_url, timestamp, ips_imported, ips_skipped, errors)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(source_id, source_url, ips_imported, ips_skipped, errors),
|
(source_id, source_url, timestamp_unix, ips_imported, ips_skipped, errors),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return int(cursor.lastrowid) # type: ignore[arg-type]
|
return int(cursor.lastrowid) # type: ignore[arg-type]
|
||||||
@@ -152,19 +154,80 @@ def compute_total_pages(total: int, page_size: int) -> int:
|
|||||||
return math.ceil(total / page_size)
|
return math.ceil(total / page_size)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_logs_keyset(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
*,
|
||||||
|
source_id: int | None = None,
|
||||||
|
page_size: int = 50,
|
||||||
|
last_log_id: int | None = None,
|
||||||
|
) -> tuple[list[ImportLogRow], bool]:
|
||||||
|
"""Return a cursor-paginated list of import log entries.
|
||||||
|
|
||||||
|
Uses keyset pagination (WHERE id < last_id) for constant-time performance
|
||||||
|
regardless of result set size. This is the recommended pagination method
|
||||||
|
for large result sets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Active aiosqlite connection.
|
||||||
|
source_id: If given, filter to logs for this source only.
|
||||||
|
page_size: Number of items per page (max returned is page_size + 1 to detect overflow).
|
||||||
|
last_log_id: The ID of the last item from the previous page (for cursor).
|
||||||
|
None for the first page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A 2-tuple ``(items, has_more)`` where:
|
||||||
|
- *items* is a list of up to page_size ImportLogEntry objects
|
||||||
|
- *has_more* is True if there are additional pages beyond this one
|
||||||
|
"""
|
||||||
|
where = ""
|
||||||
|
params: list[object] = []
|
||||||
|
|
||||||
|
if source_id is not None:
|
||||||
|
where = " WHERE source_id = ?"
|
||||||
|
params.append(source_id)
|
||||||
|
|
||||||
|
if last_log_id is not None:
|
||||||
|
if where:
|
||||||
|
where += " AND id < ?"
|
||||||
|
else:
|
||||||
|
where = " WHERE id < ?"
|
||||||
|
params.append(last_log_id)
|
||||||
|
|
||||||
|
# Fetch page_size + 1 to detect if there are more pages
|
||||||
|
fetch_limit = page_size + 1
|
||||||
|
params.append(fetch_limit)
|
||||||
|
|
||||||
|
async with db.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, source_id, source_url, timestamp, ips_imported, ips_skipped, errors
|
||||||
|
FROM import_log{where}
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""", # noqa: S608
|
||||||
|
params,
|
||||||
|
) as cursor:
|
||||||
|
rows_iterable = await cursor.fetchall()
|
||||||
|
rows = list(rows_iterable)
|
||||||
|
items = [_row_to_dict(r) for r in rows[:page_size]]
|
||||||
|
has_more = len(rows) > page_size
|
||||||
|
|
||||||
|
return items, has_more
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Internal helpers
|
# Internal helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _row_to_dict(row: object) -> ImportLogRow:
|
def _row_to_dict(row: object) -> ImportLogRow:
|
||||||
"""Convert an aiosqlite row to a plain Python dict.
|
"""Convert an aiosqlite row to an ImportLogEntry Pydantic model.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
row: An :class:`aiosqlite.Row` or similar mapping returned by a cursor.
|
row: An :class:`aiosqlite.Row` or similar mapping returned by a cursor.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict mapping column names to Python values.
|
ImportLogEntry Pydantic model instance.
|
||||||
"""
|
"""
|
||||||
mapping = cast("Mapping[str, object]", row)
|
from typing import Any as AnyType
|
||||||
return cast("ImportLogRow", dict(mapping))
|
mapping = cast("Mapping[str, AnyType]", row)
|
||||||
|
return ImportLogEntry.model_validate(dict(mapping))
|
||||||
|
|||||||
163
backend/app/repositories/import_run_repo.py
Normal file
163
backend/app/repositories/import_run_repo.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"""Import run repository for blocklist import idempotency tracking.
|
||||||
|
|
||||||
|
Persists and queries import run records in the ``import_runs`` table.
|
||||||
|
Enables detection of duplicate import attempts and prevents re-running bans
|
||||||
|
on scheduler retry after a crash.
|
||||||
|
|
||||||
|
All methods are plain async functions that accept an :class:`aiosqlite.Connection`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
from app.models.blocklist import ImportRunEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def get_by_source_and_hash(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
source_id: int,
|
||||||
|
content_hash: str,
|
||||||
|
) -> ImportRunEntry | None:
|
||||||
|
"""Check if a specific import (by source and content hash) already exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Active aiosqlite connection.
|
||||||
|
source_id: FK to ``blocklist_sources.id``.
|
||||||
|
content_hash: SHA256 hash of the downloaded blocklist content.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ImportRunEntry if found, None otherwise.
|
||||||
|
"""
|
||||||
|
async with db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id, source_id, content_hash, status,
|
||||||
|
imported_count, skipped_count, error_message,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM import_runs
|
||||||
|
WHERE source_id = ? AND content_hash = ?
|
||||||
|
""",
|
||||||
|
(source_id, content_hash),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return ImportRunEntry(
|
||||||
|
id=row[0],
|
||||||
|
source_id=row[1],
|
||||||
|
content_hash=row[2],
|
||||||
|
status=row[3],
|
||||||
|
imported_count=row[4],
|
||||||
|
skipped_count=row[5],
|
||||||
|
error_message=row[6],
|
||||||
|
created_at=row[7],
|
||||||
|
updated_at=row[8],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_pending(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
source_id: int,
|
||||||
|
content_hash: str,
|
||||||
|
) -> int:
|
||||||
|
"""Atomically insert or reset a pending import run entry.
|
||||||
|
|
||||||
|
Uses ``INSERT ... ON CONFLICT`` to make the operation fully atomic —
|
||||||
|
no window between check and insert where a concurrent request can create
|
||||||
|
a duplicate row. If a row for ``(source_id, content_hash)`` already exists,
|
||||||
|
its status is reset to ``pending`` and its ID is returned.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Active aiosqlite connection.
|
||||||
|
source_id: FK to ``blocklist_sources.id``.
|
||||||
|
content_hash: SHA256 hash of the downloaded blocklist content.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Primary key of the inserted or updated row.
|
||||||
|
"""
|
||||||
|
cursor = await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO import_runs (source_id, content_hash, status)
|
||||||
|
VALUES (?, ?, 'pending')
|
||||||
|
ON CONFLICT(source_id, content_hash) DO UPDATE SET
|
||||||
|
status = 'pending',
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
|
RETURNING id;
|
||||||
|
""",
|
||||||
|
(source_id, content_hash),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return int(row[0]) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_completed(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
run_id: int,
|
||||||
|
imported_count: int,
|
||||||
|
skipped_count: int,
|
||||||
|
) -> None:
|
||||||
|
"""Mark an import run as completed with final counts.
|
||||||
|
|
||||||
|
Wraps the update in an explicit transaction to ensure atomicity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Active aiosqlite connection.
|
||||||
|
run_id: Primary key of the import run.
|
||||||
|
imported_count: Number of IPs successfully banned.
|
||||||
|
skipped_count: Number of entries skipped (invalid or CIDR).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await db.execute("BEGIN IMMEDIATE")
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE import_runs
|
||||||
|
SET status = 'completed',
|
||||||
|
imported_count = ?,
|
||||||
|
skipped_count = ?,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(imported_count, skipped_count, run_id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_failed(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
run_id: int,
|
||||||
|
error_message: str,
|
||||||
|
) -> None:
|
||||||
|
"""Mark an import run as failed with error details.
|
||||||
|
|
||||||
|
Wraps the update in an explicit transaction to ensure atomicity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Active aiosqlite connection.
|
||||||
|
run_id: Primary key of the import run.
|
||||||
|
error_message: Error description.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await db.execute("BEGIN IMMEDIATE")
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE import_runs
|
||||||
|
SET status = 'failed',
|
||||||
|
error_message = ?,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(error_message, run_id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
394
backend/app/repositories/protocols.py
Normal file
394
backend/app/repositories/protocols.py
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
"""Repository interface protocols for dependency injection.
|
||||||
|
|
||||||
|
Routers and services can depend on these abstractions instead of concrete
|
||||||
|
module implementations, making the backend easier to test and extend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
from app.models.auth import Session
|
||||||
|
from app.models.ban import BanOrigin
|
||||||
|
from app.repositories.fail2ban_db_repo import BanIpCount, BanRecord, HistoryRecord, JailBanCount
|
||||||
|
from app.repositories.geo_cache_repo import GeoCacheRow
|
||||||
|
from app.repositories.import_log_repo import ImportLogRow
|
||||||
|
from app.models.blocklist import ImportRunEntry
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRepository(Protocol):
|
||||||
|
"""Protocol for session persistence operations."""
|
||||||
|
|
||||||
|
async def create_session(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
token: str,
|
||||||
|
created_at: str,
|
||||||
|
expires_at: str,
|
||||||
|
) -> Session:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_session(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
token: str,
|
||||||
|
) -> Session | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def delete_session(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
token: str,
|
||||||
|
) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def delete_expired_sessions(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
now_iso: str,
|
||||||
|
) -> int:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsRepository(Protocol):
|
||||||
|
"""Protocol for application settings persistence operations."""
|
||||||
|
|
||||||
|
async def get_setting(self, db: aiosqlite.Connection, key: str) -> str | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def set_setting(self, db: aiosqlite.Connection, key: str, value: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def delete_setting(self, db: aiosqlite.Connection, key: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_all_settings(self, db: aiosqlite.Connection) -> dict[str, str]:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def set_settings_batch(self, db: aiosqlite.Connection, settings: dict[str, str]) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class BlocklistRepository(Protocol):
|
||||||
|
async def create_source(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
name: str,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
enabled: bool = True,
|
||||||
|
) -> int:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_source(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
source_id: int,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def list_sources(self, db: aiosqlite.Connection) -> list[dict[str, Any]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def list_enabled_sources(self, db: aiosqlite.Connection) -> list[dict[str, Any]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def update_source(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
source_id: int,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
url: str | None = None,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def delete_source(self, db: aiosqlite.Connection, source_id: int) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class ImportLogRepository(Protocol):
|
||||||
|
async def add_log(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
*,
|
||||||
|
source_id: int | None,
|
||||||
|
source_url: str,
|
||||||
|
ips_imported: int,
|
||||||
|
ips_skipped: int,
|
||||||
|
errors: str | None,
|
||||||
|
) -> int:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def list_logs(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
*,
|
||||||
|
source_id: int | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 50,
|
||||||
|
) -> tuple[list[ImportLogRow], int]:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_last_log(self, db: aiosqlite.Connection) -> ImportLogRow | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def compute_total_pages(self, total: int, page_size: int) -> int:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class ImportRunRepository(Protocol):
|
||||||
|
"""Protocol for tracking blocklist import runs for idempotency."""
|
||||||
|
|
||||||
|
async def get_by_source_and_hash(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
source_id: int,
|
||||||
|
content_hash: str,
|
||||||
|
) -> ImportRunEntry | None:
|
||||||
|
"""Check if a specific import (by source and content hash) has been completed."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def upsert_pending(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
source_id: int,
|
||||||
|
content_hash: str,
|
||||||
|
) -> int:
|
||||||
|
"""Atomically insert or reset a pending import run entry. Returns the id."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def mark_completed(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
run_id: int,
|
||||||
|
imported_count: int,
|
||||||
|
skipped_count: int,
|
||||||
|
) -> None:
|
||||||
|
"""Mark an import run as completed with final counts."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def mark_failed(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
run_id: int,
|
||||||
|
error_message: str,
|
||||||
|
) -> None:
|
||||||
|
"""Mark an import run as failed with error details."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class GeoCacheRepository(Protocol):
|
||||||
|
async def load_all(self, db: aiosqlite.Connection) -> list[GeoCacheRow]:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_unresolved_ips(self, db: aiosqlite.Connection) -> list[str]:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def count_unresolved(self, db: aiosqlite.Connection) -> int:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def upsert_entry(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
ip: str,
|
||||||
|
country_code: str | None,
|
||||||
|
country_name: str | None,
|
||||||
|
asn: str | None,
|
||||||
|
org: str | None,
|
||||||
|
) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def upsert_entry_and_commit(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
ip: str,
|
||||||
|
country_code: str | None,
|
||||||
|
country_name: str | None,
|
||||||
|
asn: str | None,
|
||||||
|
org: str | None,
|
||||||
|
) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def upsert_neg_entry(self, db: aiosqlite.Connection, ip: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def upsert_neg_entry_and_commit(self, db: aiosqlite.Connection, ip: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def bulk_upsert_entries(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
|
||||||
|
) -> int:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def bulk_upsert_entries_and_commit(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
|
||||||
|
) -> int:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def bulk_upsert_neg_entries(self, db: aiosqlite.Connection, ips: list[str]) -> int:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def bulk_upsert_neg_entries_and_commit(self, db: aiosqlite.Connection, ips: list[str]) -> int:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def bulk_upsert_entries_and_neg_entries_and_commit(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
|
||||||
|
ips: list[str],
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def delete_stale_entries(self, db: aiosqlite.Connection, cutoff_iso: str) -> int:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryArchiveRepository(Protocol):
|
||||||
|
"""Protocol for archived ban history persistence operations."""
|
||||||
|
|
||||||
|
async def archive_ban_event(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
jail: str,
|
||||||
|
ip: str,
|
||||||
|
timeofban: int,
|
||||||
|
bancount: int,
|
||||||
|
data: str,
|
||||||
|
action: str = "ban",
|
||||||
|
) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_max_timeofban(self, db: aiosqlite.Connection) -> int | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_archived_history(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
since: int | None = None,
|
||||||
|
jail: str | None = None,
|
||||||
|
ip_filter: str | list[str] | None = None,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
|
action: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 100,
|
||||||
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_all_archived_history(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
since: int | None = None,
|
||||||
|
jail: str | None = None,
|
||||||
|
ip_filter: str | list[str] | None = None,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
|
action: str | None = None,
|
||||||
|
page_size: int = 1000,
|
||||||
|
max_rows: int = 50_000,
|
||||||
|
last_ban_id: int | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def purge_archived_history(self, db: aiosqlite.Connection, age_seconds: int) -> int:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_ip_ban_counts(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
since: int | None = None,
|
||||||
|
jail: str | None = None,
|
||||||
|
ip_filter: str | list[str] | None = None,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
|
action: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_jail_ban_counts(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
since: int | None = None,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
|
action: str | None = None,
|
||||||
|
) -> tuple[int, list[dict[str, Any]]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_ban_counts_by_bucket(
|
||||||
|
self,
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
since: int,
|
||||||
|
bucket_secs: int,
|
||||||
|
num_buckets: int,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
|
action: str | None = None,
|
||||||
|
) -> list[int]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class Fail2BanDbRepository(Protocol):
|
||||||
|
async def check_db_nonempty(self, db_path: str) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_currently_banned(
|
||||||
|
self,
|
||||||
|
db_path: str,
|
||||||
|
since: int,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
|
*,
|
||||||
|
ip_filter: list[str] | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
offset: int | None = None,
|
||||||
|
) -> tuple[list[BanRecord], int]:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_ban_counts_by_bucket(
|
||||||
|
self,
|
||||||
|
db_path: str,
|
||||||
|
since: int,
|
||||||
|
bucket_secs: int,
|
||||||
|
num_buckets: int,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
|
) -> list[int]:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_ban_event_counts(
|
||||||
|
self,
|
||||||
|
db_path: str,
|
||||||
|
since: int,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
|
) -> list[BanIpCount]:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_bans_by_jail(
|
||||||
|
self,
|
||||||
|
db_path: str,
|
||||||
|
since: int,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
|
) -> tuple[int, list[JailBanCount]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_bans_table_summary(self, db_path: str) -> tuple[int, int | None, int | None]:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_history_page(
|
||||||
|
self,
|
||||||
|
db_path: str,
|
||||||
|
since: int | None = None,
|
||||||
|
jail: str | None = None,
|
||||||
|
ip_filter: str | None = None,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 100,
|
||||||
|
) -> tuple[list[HistoryRecord], int]:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_history_for_ip(self, db_path: str, ip: str) -> list[HistoryRecord]:
|
||||||
|
...
|
||||||
@@ -2,10 +2,15 @@
|
|||||||
|
|
||||||
Provides storage, retrieval, and deletion of session records in the
|
Provides storage, retrieval, and deletion of session records in the
|
||||||
``sessions`` table of the application SQLite database.
|
``sessions`` table of the application SQLite database.
|
||||||
|
|
||||||
|
Session tokens are stored as one-way SHA256 hashes to ensure that if the
|
||||||
|
database is exposed, the session tokens themselves cannot be directly used.
|
||||||
|
The hash is computed from the raw token before all database operations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -14,6 +19,18 @@ if TYPE_CHECKING:
|
|||||||
from app.models.auth import Session
|
from app.models.auth import Session
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_token(token: str) -> str:
|
||||||
|
"""Return the SHA256 hash of a session token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The raw session token to hash.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The hexadecimal SHA256 digest of the token.
|
||||||
|
"""
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
async def create_session(
|
async def create_session(
|
||||||
db: aiosqlite.Connection,
|
db: aiosqlite.Connection,
|
||||||
token: str,
|
token: str,
|
||||||
@@ -30,10 +47,15 @@ async def create_session(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The newly created :class:`~app.models.auth.Session`.
|
The newly created :class:`~app.models.auth.Session`.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
The token is hashed before storage. The returned Session object
|
||||||
|
contains the original raw token for use in signing and response.
|
||||||
"""
|
"""
|
||||||
|
token_hash = _hash_token(token)
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"INSERT INTO sessions (token, created_at, expires_at) VALUES (?, ?, ?)",
|
"INSERT INTO sessions (token_hash, created_at, expires_at) VALUES (?, ?, ?)",
|
||||||
(token, created_at, expires_at),
|
(token_hash, created_at, expires_at),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return Session(
|
return Session(
|
||||||
@@ -53,10 +75,14 @@ async def get_session(db: aiosqlite.Connection, token: str) -> Session | None:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The :class:`~app.models.auth.Session` if found, else ``None``.
|
The :class:`~app.models.auth.Session` if found, else ``None``.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
The token is hashed before the database lookup.
|
||||||
"""
|
"""
|
||||||
|
token_hash = _hash_token(token)
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
"SELECT id, token, created_at, expires_at FROM sessions WHERE token = ?",
|
"SELECT id, token_hash, created_at, expires_at FROM sessions WHERE token_hash = ?",
|
||||||
(token,),
|
(token_hash,),
|
||||||
) as cursor:
|
) as cursor:
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
|
|
||||||
@@ -65,7 +91,7 @@ async def get_session(db: aiosqlite.Connection, token: str) -> Session | None:
|
|||||||
|
|
||||||
return Session(
|
return Session(
|
||||||
id=int(row[0]),
|
id=int(row[0]),
|
||||||
token=str(row[1]),
|
token=token,
|
||||||
created_at=str(row[2]),
|
created_at=str(row[2]),
|
||||||
expires_at=str(row[3]),
|
expires_at=str(row[3]),
|
||||||
)
|
)
|
||||||
@@ -77,8 +103,12 @@ async def delete_session(db: aiosqlite.Connection, token: str) -> None:
|
|||||||
Args:
|
Args:
|
||||||
db: Active aiosqlite connection.
|
db: Active aiosqlite connection.
|
||||||
token: The session token to remove.
|
token: The session token to remove.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
The token is hashed before the database lookup.
|
||||||
"""
|
"""
|
||||||
await db.execute("DELETE FROM sessions WHERE token = ?", (token,))
|
token_hash = _hash_token(token)
|
||||||
|
await db.execute("DELETE FROM sessions WHERE token_hash = ?", (token_hash,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,36 @@ async def delete_setting(db: aiosqlite.Connection, key: str) -> None:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def set_settings_batch(db: aiosqlite.Connection, settings: dict[str, str]) -> None:
|
||||||
|
"""Insert or replace multiple settings atomically in a single transaction.
|
||||||
|
|
||||||
|
Wraps all writes in a single BEGIN IMMEDIATE ... COMMIT transaction to ensure
|
||||||
|
atomicity. Either all settings are persisted or none are. This is useful for
|
||||||
|
operations that must succeed as a logical unit (e.g., setup initialization).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Active aiosqlite connection.
|
||||||
|
settings: A dictionary mapping setting keys to their values.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Any exception from executing the SQL will cause a rollback.
|
||||||
|
"""
|
||||||
|
if not settings:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db.execute("BEGIN IMMEDIATE;")
|
||||||
|
for key, value in settings.items():
|
||||||
|
await db.execute(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
||||||
|
(key, value),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def get_all_settings(db: aiosqlite.Connection) -> dict[str, str]:
|
async def get_all_settings(db: aiosqlite.Connection) -> dict[str, str]:
|
||||||
"""Return all settings as a plain ``dict``.
|
"""Return all settings as a plain ``dict``.
|
||||||
|
|
||||||
|
|||||||
357
backend/app/routers/action_config.py
Normal file
357
backend/app/routers/action_config.py
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query, Request, status
|
||||||
|
|
||||||
|
from app.dependencies import AuthDep, Fail2BanConfigDirDep, Fail2BanSocketDep, GlobalRateLimiterDep
|
||||||
|
from app.models.config import (
|
||||||
|
ActionConfig,
|
||||||
|
ActionCreateRequest,
|
||||||
|
ActionListResponse,
|
||||||
|
ActionUpdateRequest,
|
||||||
|
)
|
||||||
|
from app.services import action_config_service
|
||||||
|
from app.utils.constants import (
|
||||||
|
RATE_LIMIT_ACTION_CREATE_REQUESTS,
|
||||||
|
RATE_LIMIT_ACTION_DELETE_REQUESTS,
|
||||||
|
RATE_LIMIT_ACTION_UPDATE_REQUESTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
router: APIRouter = APIRouter(prefix="/actions", tags=["Action Config"])
|
||||||
|
|
||||||
|
_MINUTE = 60
|
||||||
|
|
||||||
|
_ACTION_UPDATE_BUCKET = "action:update"
|
||||||
|
_ACTION_CREATE_BUCKET = "action:create"
|
||||||
|
_ACTION_DELETE_BUCKET = "action:delete"
|
||||||
|
|
||||||
|
|
||||||
|
def _check_action_update_rate_limit(
|
||||||
|
request: Request,
|
||||||
|
rate_limiter: GlobalRateLimiterDep,
|
||||||
|
) -> None:
|
||||||
|
"""Check rate limit for action update operations."""
|
||||||
|
from app.utils.client_ip import get_client_ip
|
||||||
|
|
||||||
|
settings = request.app.state.settings
|
||||||
|
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
|
||||||
|
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
|
||||||
|
_ACTION_UPDATE_BUCKET, client_ip, RATE_LIMIT_ACTION_UPDATE_REQUESTS, _MINUTE
|
||||||
|
)
|
||||||
|
if not is_allowed:
|
||||||
|
from app.exceptions import RateLimitError
|
||||||
|
from app.utils.logging_compat import get_logger
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
log.warning(
|
||||||
|
"action_update_rate_limit_exceeded",
|
||||||
|
client_ip=client_ip,
|
||||||
|
path=request.url.path,
|
||||||
|
retry_after=retry_after,
|
||||||
|
)
|
||||||
|
raise RateLimitError(
|
||||||
|
"Rate limit exceeded for action update operations. Please try again later.",
|
||||||
|
retry_after_seconds=retry_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_action_create_rate_limit(
|
||||||
|
request: Request,
|
||||||
|
rate_limiter: GlobalRateLimiterDep,
|
||||||
|
) -> None:
|
||||||
|
"""Check rate limit for action create operations."""
|
||||||
|
from app.utils.client_ip import get_client_ip
|
||||||
|
|
||||||
|
settings = request.app.state.settings
|
||||||
|
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
|
||||||
|
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
|
||||||
|
_ACTION_CREATE_BUCKET, client_ip, RATE_LIMIT_ACTION_CREATE_REQUESTS, _MINUTE
|
||||||
|
)
|
||||||
|
if not is_allowed:
|
||||||
|
from app.exceptions import RateLimitError
|
||||||
|
from app.utils.logging_compat import get_logger
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
log.warning(
|
||||||
|
"action_create_rate_limit_exceeded",
|
||||||
|
client_ip=client_ip,
|
||||||
|
path=request.url.path,
|
||||||
|
retry_after=retry_after,
|
||||||
|
)
|
||||||
|
raise RateLimitError(
|
||||||
|
"Rate limit exceeded for action create operations. Please try again later.",
|
||||||
|
retry_after_seconds=retry_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_action_delete_rate_limit(
|
||||||
|
request: Request,
|
||||||
|
rate_limiter: GlobalRateLimiterDep,
|
||||||
|
) -> None:
|
||||||
|
"""Check rate limit for action delete operations."""
|
||||||
|
from app.utils.client_ip import get_client_ip
|
||||||
|
|
||||||
|
settings = request.app.state.settings
|
||||||
|
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
|
||||||
|
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
|
||||||
|
_ACTION_DELETE_BUCKET, client_ip, RATE_LIMIT_ACTION_DELETE_REQUESTS, _MINUTE
|
||||||
|
)
|
||||||
|
if not is_allowed:
|
||||||
|
from app.exceptions import RateLimitError
|
||||||
|
from app.utils.logging_compat import get_logger
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
log.warning(
|
||||||
|
"action_delete_rate_limit_exceeded",
|
||||||
|
client_ip=client_ip,
|
||||||
|
path=request.url.path,
|
||||||
|
retry_after=retry_after,
|
||||||
|
)
|
||||||
|
raise RateLimitError(
|
||||||
|
"Rate limit exceeded for action delete operations. Please try again later.",
|
||||||
|
retry_after_seconds=retry_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_ActionNamePath = Annotated[
|
||||||
|
str,
|
||||||
|
Path(description='Action base name, e.g. ``iptables`` or ``iptables.conf``.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
_NamePath = Annotated[
|
||||||
|
str,
|
||||||
|
Path(description='Jail name as configured in fail2ban.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=ActionListResponse,
|
||||||
|
summary="List all available actions with active/inactive status",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Action list returned", "model": ActionListResponse},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
502: {"description": "fail2ban unreachable"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def list_actions(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
) -> ActionListResponse:
|
||||||
|
"""Return all actions discovered in ``action.d/`` with active/inactive status.
|
||||||
|
|
||||||
|
Scans ``{config_dir}/action.d/`` for ``.conf`` files, merges any
|
||||||
|
corresponding ``.local`` overrides, and cross-references each action's
|
||||||
|
name against the ``action`` fields of currently running jails to determine
|
||||||
|
whether it is active.
|
||||||
|
|
||||||
|
Active actions (those used by at least one running jail) are sorted to the
|
||||||
|
top of the list; inactive actions follow. Both groups are sorted
|
||||||
|
alphabetically within themselves.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session — enforces authentication.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.ActionListResponse` with all discovered
|
||||||
|
actions.
|
||||||
|
"""
|
||||||
|
result = await action_config_service.list_actions(config_dir, socket_path)
|
||||||
|
result.actions.sort(key=lambda a: (not a.active, a.name.lower()))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{name}",
|
||||||
|
response_model=ActionConfig,
|
||||||
|
summary="Return full parsed detail for a single action",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Action config returned", "model": ActionConfig},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
404: {"description": "Action not found in action.d/"},
|
||||||
|
502: {"description": "fail2ban unreachable"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def get_action(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
name: _ActionNamePath,
|
||||||
|
) -> ActionConfig:
|
||||||
|
"""Return the full parsed configuration and active/inactive status for one action.
|
||||||
|
|
||||||
|
Reads ``{config_dir}/action.d/{name}.conf``, merges any corresponding
|
||||||
|
``.local`` override, and annotates the result with ``active``,
|
||||||
|
``used_by_jails``, ``source_file``, and ``has_local_override``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session — enforces authentication.
|
||||||
|
name: Action base name (with or without ``.conf`` extension).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.ActionConfig`.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if the action is not found in ``action.d/``.
|
||||||
|
"""
|
||||||
|
return await action_config_service.get_action(config_dir, socket_path, name)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Action write endpoints (Task 3.2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{name}",
|
||||||
|
response_model=ActionConfig,
|
||||||
|
summary="Update an action's .local override with new lifecycle command values",
|
||||||
|
dependencies=[Depends(_check_action_update_rate_limit)],
|
||||||
|
responses={
|
||||||
|
200: {"description": "Action updated", "model": ActionConfig},
|
||||||
|
400: {"description": "Invalid action name"},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
404: {"description": "Action not found"},
|
||||||
|
429: {"description": "Rate limit exceeded for action update operations"},
|
||||||
|
500: {"description": "Failed to write .local file"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def update_action(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
name: _ActionNamePath,
|
||||||
|
body: ActionUpdateRequest,
|
||||||
|
reload: bool = Query(default=False, description="Reload fail2ban after writing."),
|
||||||
|
) -> ActionConfig:
|
||||||
|
"""Update an action's ``[Definition]`` fields by writing a ``.local`` override.
|
||||||
|
|
||||||
|
Only non-``null`` fields in the request body are written. The original
|
||||||
|
``.conf`` file is never modified.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session.
|
||||||
|
name: Action base name (with or without ``.conf`` extension).
|
||||||
|
body: Partial update — lifecycle commands and ``[Init]`` parameters.
|
||||||
|
reload: When ``true``, trigger a fail2ban reload after writing.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated :class:`~app.models.config.ActionConfig`.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if *name* contains invalid characters.
|
||||||
|
HTTPException: 404 if the action does not exist.
|
||||||
|
HTTPException: 500 if writing the ``.local`` file fails.
|
||||||
|
"""
|
||||||
|
return await action_config_service.update_action(config_dir, socket_path, name, body, do_reload=reload)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=ActionConfig,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create a new user-defined action",
|
||||||
|
dependencies=[Depends(_check_action_create_rate_limit)],
|
||||||
|
responses={
|
||||||
|
201: {"description": "Action created", "model": ActionConfig},
|
||||||
|
400: {"description": "Invalid action name"},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
409: {"description": "Action already exists"},
|
||||||
|
429: {"description": "Rate limit exceeded for action create operations"},
|
||||||
|
500: {"description": "Failed to write .local file"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def create_action(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
body: ActionCreateRequest,
|
||||||
|
reload: bool = Query(default=False, description="Reload fail2ban after creating."),
|
||||||
|
) -> ActionConfig:
|
||||||
|
"""Create a new user-defined action at ``action.d/{name}.local``.
|
||||||
|
|
||||||
|
Returns 409 if a ``.conf`` or ``.local`` for the requested name already
|
||||||
|
exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session.
|
||||||
|
body: Action name and ``[Definition]`` lifecycle fields.
|
||||||
|
reload: When ``true``, trigger a fail2ban reload after creating.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.ActionConfig` for the new action.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if the name contains invalid characters.
|
||||||
|
HTTPException: 409 if the action already exists.
|
||||||
|
HTTPException: 500 if writing fails.
|
||||||
|
"""
|
||||||
|
return await action_config_service.create_action(config_dir, socket_path, body, do_reload=reload)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{name}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Delete a user-created action's .local file",
|
||||||
|
dependencies=[Depends(_check_action_delete_rate_limit)],
|
||||||
|
responses={
|
||||||
|
204: {"description": "Action deleted successfully"},
|
||||||
|
400: {"description": "Invalid action name"},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
404: {"description": "Action not found"},
|
||||||
|
409: {"description": "Action is a shipped default (conf-only)"},
|
||||||
|
429: {"description": "Rate limit exceeded for action delete operations"},
|
||||||
|
500: {"description": "Failed to delete .local file"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def delete_action(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
name: _ActionNamePath,
|
||||||
|
) -> None:
|
||||||
|
"""Delete a user-created action's ``.local`` override file.
|
||||||
|
|
||||||
|
Shipped ``.conf``-only actions cannot be deleted (returns 409). When
|
||||||
|
both a ``.conf`` and a ``.local`` exist, only the ``.local`` is removed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session.
|
||||||
|
name: Action base name.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if *name* contains invalid characters.
|
||||||
|
HTTPException: 404 if the action does not exist.
|
||||||
|
HTTPException: 409 if the action is a shipped default (conf-only).
|
||||||
|
HTTPException: 500 if deletion fails.
|
||||||
|
"""
|
||||||
|
await action_config_service.delete_action(config_dir, name)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fail2ban log viewer endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -3,86 +3,155 @@
|
|||||||
``POST /api/auth/login`` — verify master password and issue a session.
|
``POST /api/auth/login`` — verify master password and issue a session.
|
||||||
``POST /api/auth/logout`` — revoke the current session.
|
``POST /api/auth/logout`` — revoke the current session.
|
||||||
|
|
||||||
The session token is returned both in the JSON body (for API-first
|
The session token is set as an ``HttpOnly`` ``SameSite=Lax`` cookie for
|
||||||
consumers) and as an ``HttpOnly`` cookie (for the browser SPA).
|
browser-based SPAs. The cookie is automatically included in all requests
|
||||||
|
and is inaccessible to JavaScript, protecting it from XSS attacks and
|
||||||
|
malicious scripts.
|
||||||
|
|
||||||
|
For programmatic API clients (non-browser), use ``POST /api/auth/token``
|
||||||
|
which returns a token in the response body for use in the ``Authorization``
|
||||||
|
header. This endpoint does not set a cookie.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import structlog
|
from app.utils.logging_compat import get_logger
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
from fastapi import APIRouter, Request, Response
|
||||||
|
|
||||||
from app.dependencies import DbDep, SettingsDep, invalidate_session_cache
|
from app.dependencies import (
|
||||||
from app.models.auth import LoginRequest, LoginResponse, LogoutResponse
|
AuthDep,
|
||||||
|
SessionCacheDep,
|
||||||
|
SessionServiceContextDep,
|
||||||
|
SettingsDep,
|
||||||
|
)
|
||||||
|
from app.exceptions import AuthenticationError
|
||||||
|
from app.models.auth import LoginRequest, LoginResponse, LogoutResponse, SessionValidResponse
|
||||||
from app.services import auth_service
|
from app.services import auth_service
|
||||||
|
from app.utils.client_ip import get_client_ip
|
||||||
|
from app.utils.constants import SESSION_COOKIE_NAME
|
||||||
|
|
||||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
log = get_logger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||||
|
|
||||||
_COOKIE_NAME = "bangui_session"
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/login",
|
"/login",
|
||||||
response_model=LoginResponse,
|
response_model=LoginResponse,
|
||||||
summary="Authenticate with the master password",
|
summary="Authenticate with the master password",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Login successful", "model": LoginResponse},
|
||||||
|
401: {"description": "Invalid password"},
|
||||||
|
422: {"description": "Validation error — invalid request body"},
|
||||||
|
503: {"description": "Setup not complete"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def login(
|
async def login(
|
||||||
body: LoginRequest,
|
body: LoginRequest,
|
||||||
response: Response,
|
response: Response,
|
||||||
db: DbDep,
|
request: Request,
|
||||||
|
session_ctx: SessionServiceContextDep,
|
||||||
settings: SettingsDep,
|
settings: SettingsDep,
|
||||||
|
session_cache: SessionCacheDep,
|
||||||
) -> LoginResponse:
|
) -> LoginResponse:
|
||||||
"""Verify the master password and return a session token.
|
"""Verify the master password and return a session token.
|
||||||
|
|
||||||
On success the token is also set as an ``HttpOnly`` ``SameSite=Lax``
|
On success the token is also set as an ``HttpOnly`` ``SameSite=Lax``
|
||||||
cookie so the browser SPA benefits from automatic credential handling.
|
cookie so the browser SPA benefits from automatic credential handling.
|
||||||
|
|
||||||
|
Cache invalidation: On successful login, any existing cached sessions for
|
||||||
|
the same user are invalidated so that stale tokens (e.g., from a stolen
|
||||||
|
device) cannot be reused beyond the cache TTL window.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
body: Login request validated by Pydantic.
|
body: Login request validated by Pydantic.
|
||||||
response: FastAPI response object used to set the cookie.
|
response: FastAPI response object used to set the cookie.
|
||||||
db: Injected aiosqlite connection.
|
request: The incoming HTTP request (used to extract client IP).
|
||||||
settings: Application settings (used for session duration).
|
session_ctx: Session service context containing db and repository.
|
||||||
|
settings: Application settings (used for session duration and trusted proxies).
|
||||||
|
session_cache: Session cache for invalidating old sessions on login.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`~app.models.auth.LoginResponse` containing the token.
|
:class:`~app.models.auth.LoginResponse` containing the token.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: 401 if the password is incorrect.
|
AuthenticationError: if the password is incorrect.
|
||||||
"""
|
"""
|
||||||
|
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session = await auth_service.login(
|
signed_token, expires_at, session = await auth_service.login(
|
||||||
db,
|
session_ctx.db,
|
||||||
password=body.password,
|
password=body.password,
|
||||||
session_duration_minutes=settings.session_duration_minutes,
|
session_duration_minutes=settings.session_duration_minutes,
|
||||||
|
session_secret=settings.session_secret,
|
||||||
|
session_repo=session_ctx.session_repo,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(
|
log.warning("login_failed", client_ip=client_ip, error=str(exc))
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
raise AuthenticationError(str(exc)) from exc
|
||||||
detail=str(exc),
|
|
||||||
) from exc
|
# Invalidate any cached sessions for the same user to prevent reuse of
|
||||||
|
# stale tokens (e.g., from a stolen device) beyond the cache TTL window.
|
||||||
|
session_cache.invalidate_by_user(session.id)
|
||||||
|
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=_COOKIE_NAME,
|
key=SESSION_COOKIE_NAME,
|
||||||
value=session.token,
|
value=signed_token,
|
||||||
httponly=True,
|
httponly=settings.session_cookie_httponly,
|
||||||
samesite="lax",
|
samesite=settings.session_cookie_samesite,
|
||||||
secure=False, # Set to True in production behind HTTPS
|
secure=settings.session_cookie_secure,
|
||||||
max_age=settings.session_duration_minutes * 60,
|
max_age=settings.session_duration_minutes * 60,
|
||||||
)
|
)
|
||||||
return LoginResponse(token=session.token, expires_at=session.expires_at)
|
log.info("login_success", client_ip=client_ip)
|
||||||
|
return LoginResponse(expires_at=expires_at)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/session",
|
||||||
|
response_model=SessionValidResponse,
|
||||||
|
summary="Validate the current session",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Session valid", "model": SessionValidResponse},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def validate_session(
|
||||||
|
_: AuthDep,
|
||||||
|
) -> SessionValidResponse:
|
||||||
|
"""Validate the current session.
|
||||||
|
|
||||||
|
This endpoint requires a valid session and returns 200 if the session is
|
||||||
|
valid and still active. If the session is invalid, expired, or missing,
|
||||||
|
FastAPI's ``require_auth`` dependency returns 401 automatically.
|
||||||
|
|
||||||
|
The frontend calls this on mount to bootstrap its authentication state
|
||||||
|
from the backend rather than relying solely on cached ``sessionStorage``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
_: The injected session object (unused, but its presence triggers validation).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.auth.SessionValidResponse` confirming the session state.
|
||||||
|
"""
|
||||||
|
return SessionValidResponse(valid=True)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/logout",
|
"/logout",
|
||||||
response_model=LogoutResponse,
|
response_model=LogoutResponse,
|
||||||
summary="Revoke the current session",
|
summary="Revoke the current session",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Logout successful", "model": LogoutResponse},
|
||||||
|
401: {"description": "Session missing or invalid (silently successful)"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def logout(
|
async def logout(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
db: DbDep,
|
session_ctx: SessionServiceContextDep,
|
||||||
|
settings: SettingsDep,
|
||||||
|
session_cache: SessionCacheDep,
|
||||||
) -> LogoutResponse:
|
) -> LogoutResponse:
|
||||||
"""Invalidate the active session.
|
"""Invalidate the active session.
|
||||||
|
|
||||||
@@ -93,16 +162,26 @@ async def logout(
|
|||||||
Args:
|
Args:
|
||||||
request: FastAPI request (used to extract the token).
|
request: FastAPI request (used to extract the token).
|
||||||
response: FastAPI response (used to clear the cookie).
|
response: FastAPI response (used to clear the cookie).
|
||||||
db: Injected aiosqlite connection.
|
session_ctx: Session service context containing db and repository.
|
||||||
|
settings: Application settings (used to unwrap signed tokens).
|
||||||
|
session_cache: Session cache for invalidation.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`~app.models.auth.LogoutResponse`.
|
:class:`~app.models.auth.LogoutResponse`.
|
||||||
"""
|
"""
|
||||||
token = _extract_token(request)
|
token = _extract_token(request)
|
||||||
if token:
|
if token:
|
||||||
await auth_service.logout(db, token)
|
raw_token = await auth_service.logout(
|
||||||
invalidate_session_cache(token)
|
session_ctx.db,
|
||||||
response.delete_cookie(key=_COOKIE_NAME)
|
token,
|
||||||
|
settings.session_secret,
|
||||||
|
settings.session_secret_previous,
|
||||||
|
session_repo=session_ctx.session_repo,
|
||||||
|
)
|
||||||
|
if raw_token:
|
||||||
|
session_cache.invalidate(raw_token)
|
||||||
|
session_cache.invalidate(token)
|
||||||
|
response.delete_cookie(key=SESSION_COOKIE_NAME)
|
||||||
return LogoutResponse()
|
return LogoutResponse()
|
||||||
|
|
||||||
|
|
||||||
@@ -120,7 +199,7 @@ def _extract_token(request: Request) -> str | None:
|
|||||||
Returns:
|
Returns:
|
||||||
The token string, or ``None`` if absent.
|
The token string, or ``None`` if absent.
|
||||||
"""
|
"""
|
||||||
token: str | None = request.cookies.get(_COOKIE_NAME)
|
token: str | None = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
if token:
|
if token:
|
||||||
return token
|
return token
|
||||||
auth_header: str = request.headers.get("Authorization", "")
|
auth_header: str = request.headers.get("Authorization", "")
|
||||||
|
|||||||
@@ -10,46 +10,110 @@ Manual ban and unban operations and the active-bans overview:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from fastapi import APIRouter, Depends, Request, status
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
from app.dependencies import (
|
||||||
import aiohttp
|
AuthDep,
|
||||||
|
BanServiceContextDep,
|
||||||
from fastapi import APIRouter, HTTPException, Request, status
|
Fail2BanSocketDep,
|
||||||
|
GeoCacheDep,
|
||||||
from app.dependencies import AuthDep
|
GlobalRateLimiterDep,
|
||||||
|
HttpSessionDep,
|
||||||
|
)
|
||||||
|
from app.mappers import map_domain_active_ban_list_to_response
|
||||||
from app.models.ban import ActiveBanListResponse, BanRequest, UnbanAllResponse, UnbanRequest
|
from app.models.ban import ActiveBanListResponse, BanRequest, UnbanAllResponse, UnbanRequest
|
||||||
from app.models.jail import JailCommandResponse
|
from app.models.jail import JailCommandResponse
|
||||||
from app.services import geo_service, jail_service
|
from app.services import ban_service, jail_service
|
||||||
from app.exceptions import JailNotFoundError, JailOperationError
|
from app.utils.constants import (
|
||||||
from app.utils.fail2ban_client import Fail2BanConnectionError
|
RATE_LIMIT_BANS_BAN_REQUESTS,
|
||||||
|
RATE_LIMIT_BANS_UNBAN_REQUESTS,
|
||||||
|
)
|
||||||
|
|
||||||
router: APIRouter = APIRouter(prefix="/api/bans", tags=["Bans"])
|
router: APIRouter = APIRouter(prefix="/api/v1/bans", tags=["Bans"])
|
||||||
|
|
||||||
|
# Rate limit bucket constants
|
||||||
|
_BANS_BAN_BUCKET = "bans:ban"
|
||||||
|
_BANS_UNBAN_BUCKET = "bans:unban"
|
||||||
|
|
||||||
|
# 60 seconds per minute
|
||||||
|
_MINUTE = 60
|
||||||
|
|
||||||
|
|
||||||
def _bad_gateway(exc: Exception) -> HTTPException:
|
def _check_ban_rate_limit(
|
||||||
"""Return a 502 response when fail2ban is unreachable.
|
request: Request,
|
||||||
|
rate_limiter: GlobalRateLimiterDep,
|
||||||
|
) -> None:
|
||||||
|
"""Check rate limit for ban operations."""
|
||||||
|
from app.utils.client_ip import get_client_ip
|
||||||
|
|
||||||
Args:
|
settings = request.app.state.settings
|
||||||
exc: The underlying connection error.
|
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
|
||||||
|
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
|
||||||
Returns:
|
_BANS_BAN_BUCKET, client_ip, RATE_LIMIT_BANS_BAN_REQUESTS, _MINUTE
|
||||||
:class:`fastapi.HTTPException` with status 502.
|
|
||||||
"""
|
|
||||||
return HTTPException(
|
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
||||||
detail=f"Cannot reach fail2ban: {exc}",
|
|
||||||
)
|
)
|
||||||
|
if not is_allowed:
|
||||||
|
from app.exceptions import RateLimitError
|
||||||
|
from app.utils.logging_compat import get_logger
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
log.warning(
|
||||||
|
"bans_ban_rate_limit_exceeded",
|
||||||
|
client_ip=client_ip,
|
||||||
|
path=request.url.path,
|
||||||
|
retry_after=retry_after,
|
||||||
|
)
|
||||||
|
raise RateLimitError(
|
||||||
|
"Rate limit exceeded for ban operations. Please try again later.",
|
||||||
|
retry_after_seconds=retry_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_unban_rate_limit(
|
||||||
|
request: Request,
|
||||||
|
rate_limiter: GlobalRateLimiterDep,
|
||||||
|
) -> None:
|
||||||
|
"""Check rate limit for unban operations."""
|
||||||
|
from app.utils.client_ip import get_client_ip
|
||||||
|
|
||||||
|
settings = request.app.state.settings
|
||||||
|
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
|
||||||
|
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
|
||||||
|
_BANS_UNBAN_BUCKET, client_ip, RATE_LIMIT_BANS_UNBAN_REQUESTS, _MINUTE
|
||||||
|
)
|
||||||
|
if not is_allowed:
|
||||||
|
from app.exceptions import RateLimitError
|
||||||
|
from app.utils.logging_compat import get_logger
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
log.warning(
|
||||||
|
"bans_unban_rate_limit_exceeded",
|
||||||
|
client_ip=client_ip,
|
||||||
|
path=request.url.path,
|
||||||
|
retry_after=retry_after,
|
||||||
|
)
|
||||||
|
raise RateLimitError(
|
||||||
|
"Rate limit exceeded for unban operations. Please try again later.",
|
||||||
|
retry_after_seconds=retry_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/active",
|
"/active",
|
||||||
response_model=ActiveBanListResponse,
|
response_model=ActiveBanListResponse,
|
||||||
summary="List all currently banned IPs across all jails",
|
summary="List all currently banned IPs across all jails",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Active ban list returned", "model": ActiveBanListResponse},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
502: {"description": "fail2ban unreachable"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def get_active_bans(
|
async def get_active_bans(
|
||||||
request: Request,
|
request: Request,
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
|
ban_ctx: BanServiceContextDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
http_session: HttpSessionDep,
|
||||||
|
geo_cache: GeoCacheDep,
|
||||||
) -> ActiveBanListResponse:
|
) -> ActiveBanListResponse:
|
||||||
"""Return every IP that is currently banned across all fail2ban jails.
|
"""Return every IP that is currently banned across all fail2ban jails.
|
||||||
|
|
||||||
@@ -59,6 +123,10 @@ async def get_active_bans(
|
|||||||
Args:
|
Args:
|
||||||
request: Incoming request (used to access ``app.state``).
|
request: Incoming request (used to access ``app.state``).
|
||||||
_auth: Validated session — enforces authentication.
|
_auth: Validated session — enforces authentication.
|
||||||
|
ban_ctx: Ban service context containing db and repository.
|
||||||
|
socket_path: Path to fail2ban Unix domain socket.
|
||||||
|
http_session: Shared HTTP session for geolocation.
|
||||||
|
geo_cache: Geolocation cache instance.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`~app.models.ban.ActiveBanListResponse` with all active bans.
|
:class:`~app.models.ban.ActiveBanListResponse` with all active bans.
|
||||||
@@ -66,19 +134,13 @@ async def get_active_bans(
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: 502 when fail2ban is unreachable.
|
HTTPException: 502 when fail2ban is unreachable.
|
||||||
"""
|
"""
|
||||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
domain_result = await ban_service.get_active_bans(
|
||||||
http_session: aiohttp.ClientSession = request.app.state.http_session
|
socket_path,
|
||||||
app_db = request.app.state.db
|
geo_cache=geo_cache,
|
||||||
|
http_session=http_session,
|
||||||
try:
|
app_db=ban_ctx.db,
|
||||||
return await jail_service.get_active_bans(
|
)
|
||||||
socket_path,
|
return map_domain_active_ban_list_to_response(domain_result)
|
||||||
geo_batch_lookup=geo_service.lookup_batch,
|
|
||||||
http_session=http_session,
|
|
||||||
app_db=app_db,
|
|
||||||
)
|
|
||||||
except Fail2BanConnectionError as exc:
|
|
||||||
raise _bad_gateway(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -86,11 +148,22 @@ async def get_active_bans(
|
|||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
response_model=JailCommandResponse,
|
response_model=JailCommandResponse,
|
||||||
summary="Ban an IP address in a specific jail",
|
summary="Ban an IP address in a specific jail",
|
||||||
|
dependencies=[Depends(_check_ban_rate_limit)],
|
||||||
|
responses={
|
||||||
|
201: {"description": "IP banned successfully", "model": JailCommandResponse},
|
||||||
|
400: {"description": "Invalid IP address"},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
404: {"description": "Jail not found"},
|
||||||
|
409: {"description": "Ban command failed in fail2ban"},
|
||||||
|
429: {"description": "Rate limit exceeded for ban operations"},
|
||||||
|
502: {"description": "fail2ban unreachable"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def ban_ip(
|
async def ban_ip(
|
||||||
request: Request,
|
request: Request,
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
body: BanRequest,
|
body: BanRequest,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
) -> JailCommandResponse:
|
) -> JailCommandResponse:
|
||||||
"""Ban an IP address in the specified fail2ban jail.
|
"""Ban an IP address in the specified fail2ban jail.
|
||||||
|
|
||||||
@@ -111,41 +184,33 @@ async def ban_ip(
|
|||||||
HTTPException: 409 when fail2ban reports the ban failed.
|
HTTPException: 409 when fail2ban reports the ban failed.
|
||||||
HTTPException: 502 when fail2ban is unreachable.
|
HTTPException: 502 when fail2ban is unreachable.
|
||||||
"""
|
"""
|
||||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
await ban_service.ban_ip(socket_path, body.jail, body.ip)
|
||||||
try:
|
return JailCommandResponse(
|
||||||
await jail_service.ban_ip(socket_path, body.jail, body.ip)
|
message=f"IP {body.ip!r} banned in jail {body.jail!r}.",
|
||||||
return JailCommandResponse(
|
jail=body.jail,
|
||||||
message=f"IP {body.ip!r} banned in jail {body.jail!r}.",
|
)
|
||||||
jail=body.jail,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=str(exc),
|
|
||||||
) from exc
|
|
||||||
except JailNotFoundError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Jail not found: {body.jail!r}",
|
|
||||||
) from None
|
|
||||||
except JailOperationError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=str(exc),
|
|
||||||
) from exc
|
|
||||||
except Fail2BanConnectionError as exc:
|
|
||||||
raise _bad_gateway(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"",
|
"",
|
||||||
response_model=JailCommandResponse,
|
response_model=JailCommandResponse,
|
||||||
summary="Unban an IP address from one or all jails",
|
summary="Unban an IP address from one or all jails",
|
||||||
|
dependencies=[Depends(_check_unban_rate_limit)],
|
||||||
|
responses={
|
||||||
|
200: {"description": "IP unbanned successfully", "model": JailCommandResponse},
|
||||||
|
400: {"description": "Invalid IP address"},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
404: {"description": "Jail not found"},
|
||||||
|
409: {"description": "Unban command failed in fail2ban"},
|
||||||
|
429: {"description": "Rate limit exceeded for unban operations"},
|
||||||
|
502: {"description": "fail2ban unreachable"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def unban_ip(
|
async def unban_ip(
|
||||||
request: Request,
|
request: Request,
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
body: UnbanRequest,
|
body: UnbanRequest,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
) -> JailCommandResponse:
|
) -> JailCommandResponse:
|
||||||
"""Unban an IP address from a specific jail or all jails.
|
"""Unban an IP address from a specific jail or all jails.
|
||||||
|
|
||||||
@@ -168,45 +233,31 @@ async def unban_ip(
|
|||||||
HTTPException: 409 when fail2ban reports the unban failed.
|
HTTPException: 409 when fail2ban reports the unban failed.
|
||||||
HTTPException: 502 when fail2ban is unreachable.
|
HTTPException: 502 when fail2ban is unreachable.
|
||||||
"""
|
"""
|
||||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
|
||||||
|
|
||||||
# Determine target jail (None means all jails).
|
# Determine target jail (None means all jails).
|
||||||
target_jail: str | None = None if (body.unban_all or body.jail is None) else body.jail
|
target_jail: str | None = None if (body.unban_all or body.jail is None) else body.jail
|
||||||
|
|
||||||
try:
|
await ban_service.unban_ip(socket_path, body.ip, jail=target_jail)
|
||||||
await jail_service.unban_ip(socket_path, body.ip, jail=target_jail)
|
scope = f"jail {target_jail!r}" if target_jail else "all jails"
|
||||||
scope = f"jail {target_jail!r}" if target_jail else "all jails"
|
return JailCommandResponse(
|
||||||
return JailCommandResponse(
|
message=f"IP {body.ip!r} unbanned from {scope}.",
|
||||||
message=f"IP {body.ip!r} unbanned from {scope}.",
|
jail=target_jail or "*",
|
||||||
jail=target_jail or "*",
|
)
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=str(exc),
|
|
||||||
) from exc
|
|
||||||
except JailNotFoundError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Jail not found: {target_jail!r}",
|
|
||||||
) from None
|
|
||||||
except JailOperationError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=str(exc),
|
|
||||||
) from exc
|
|
||||||
except Fail2BanConnectionError as exc:
|
|
||||||
raise _bad_gateway(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/all",
|
"/all",
|
||||||
response_model=UnbanAllResponse,
|
response_model=UnbanAllResponse,
|
||||||
summary="Unban every currently banned IP across all jails",
|
summary="Unban every currently banned IP across all jails",
|
||||||
|
responses={
|
||||||
|
200: {"description": "All bans cleared", "model": UnbanAllResponse},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
502: {"description": "fail2ban unreachable"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def unban_all(
|
async def unban_all(
|
||||||
request: Request,
|
request: Request,
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
) -> UnbanAllResponse:
|
) -> UnbanAllResponse:
|
||||||
"""Remove all active bans from every fail2ban jail in a single operation.
|
"""Remove all active bans from every fail2ban jail in a single operation.
|
||||||
|
|
||||||
@@ -224,12 +275,8 @@ async def unban_all(
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: 502 when fail2ban is unreachable.
|
HTTPException: 502 when fail2ban is unreachable.
|
||||||
"""
|
"""
|
||||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
count: int = await jail_service.unban_all_ips(socket_path)
|
||||||
try:
|
return UnbanAllResponse(
|
||||||
count: int = await jail_service.unban_all_ips(socket_path)
|
message=f"All bans cleared. {count} IP address{'es' if count != 1 else ''} unbanned.",
|
||||||
return UnbanAllResponse(
|
count=count,
|
||||||
message=f"All bans cleared. {count} IP address{'es' if count != 1 else ''} unbanned.",
|
)
|
||||||
count=count,
|
|
||||||
)
|
|
||||||
except Fail2BanConnectionError as exc:
|
|
||||||
raise _bad_gateway(exc) from exc
|
|
||||||
|
|||||||
@@ -22,15 +22,26 @@ registered *before* the ``/{id}`` routes so FastAPI resolves them correctly.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Annotated
|
from app.utils.logging_compat import get_logger
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request, status
|
||||||
|
|
||||||
import aiosqlite
|
from app.dependencies import (
|
||||||
|
AuthDep,
|
||||||
if TYPE_CHECKING:
|
BlocklistServiceContextDep,
|
||||||
import aiohttp
|
Fail2BanSocketDep,
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
GeoCacheDep,
|
||||||
|
GlobalRateLimiterDep,
|
||||||
from app.dependencies import AuthDep, get_db
|
HttpSessionDep,
|
||||||
|
SchedulerDep,
|
||||||
|
SettingsDep,
|
||||||
|
)
|
||||||
|
from app.exceptions import (
|
||||||
|
BadRequestError,
|
||||||
|
BlocklistSourceAlreadyExistsError,
|
||||||
|
BlocklistSourceNotFoundError,
|
||||||
|
RateLimitError,
|
||||||
|
)
|
||||||
|
from app.mappers import blocklist_mappers
|
||||||
from app.models.blocklist import (
|
from app.models.blocklist import (
|
||||||
BlocklistListResponse,
|
BlocklistListResponse,
|
||||||
BlocklistSource,
|
BlocklistSource,
|
||||||
@@ -42,12 +53,43 @@ from app.models.blocklist import (
|
|||||||
ScheduleConfig,
|
ScheduleConfig,
|
||||||
ScheduleInfo,
|
ScheduleInfo,
|
||||||
)
|
)
|
||||||
from app.services import blocklist_service, geo_service
|
from app.services import ban_service, blocklist_service
|
||||||
from app.tasks import blocklist_import as blocklist_import_task
|
from app.tasks.blocklist_import import run_import_with_resources
|
||||||
|
from app.utils.constants import DEFAULT_PAGE_SIZE, RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS
|
||||||
|
|
||||||
router: APIRouter = APIRouter(prefix="/api/blocklists", tags=["Blocklists"])
|
router: APIRouter = APIRouter(prefix="/api/v1/blocklists", tags=["Blocklists"])
|
||||||
|
|
||||||
DbDep = Annotated[aiosqlite.Connection, Depends(get_db)]
|
#: Rate limit bucket constants
|
||||||
|
_BLOCKLIST_IMPORT_BUCKET = "blocklist:import"
|
||||||
|
# 3600 seconds per hour
|
||||||
|
_HOUR = 3600
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_blocklist_import_rate_limit(
|
||||||
|
request: Request,
|
||||||
|
rate_limiter: GlobalRateLimiterDep,
|
||||||
|
) -> None:
|
||||||
|
"""Check rate limit for blocklist import operations."""
|
||||||
|
from app.utils.client_ip import get_client_ip
|
||||||
|
|
||||||
|
settings = request.app.state.settings
|
||||||
|
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
|
||||||
|
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
|
||||||
|
_BLOCKLIST_IMPORT_BUCKET, client_ip, RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS, _HOUR
|
||||||
|
)
|
||||||
|
if not is_allowed:
|
||||||
|
log.warning(
|
||||||
|
"blocklist_import_rate_limit_exceeded",
|
||||||
|
client_ip=client_ip,
|
||||||
|
path=request.url.path,
|
||||||
|
retry_after=retry_after,
|
||||||
|
)
|
||||||
|
raise RateLimitError(
|
||||||
|
"Rate limit exceeded for blocklist import. Please try again later.",
|
||||||
|
retry_after_seconds=retry_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -59,21 +101,25 @@ DbDep = Annotated[aiosqlite.Connection, Depends(get_db)]
|
|||||||
"",
|
"",
|
||||||
response_model=BlocklistListResponse,
|
response_model=BlocklistListResponse,
|
||||||
summary="List all blocklist sources",
|
summary="List all blocklist sources",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Blocklist sources returned", "model": BlocklistListResponse},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def list_blocklists(
|
async def list_blocklists(
|
||||||
db: DbDep,
|
blocklist_ctx: BlocklistServiceContextDep,
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
) -> BlocklistListResponse:
|
) -> BlocklistListResponse:
|
||||||
"""Return all configured blocklist source definitions.
|
"""Return all configured blocklist source definitions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Application database connection (injected).
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
||||||
_auth: Validated session — enforces authentication.
|
_auth: Validated session — enforces authentication.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`~app.models.blocklist.BlocklistListResponse` with all sources.
|
:class:`~app.models.blocklist.BlocklistListResponse` with all sources.
|
||||||
"""
|
"""
|
||||||
sources = await blocklist_service.list_sources(db)
|
sources = await blocklist_service.list_sources(blocklist_ctx.db)
|
||||||
return BlocklistListResponse(sources=sources)
|
return BlocklistListResponse(sources=sources)
|
||||||
|
|
||||||
|
|
||||||
@@ -82,25 +128,39 @@ async def list_blocklists(
|
|||||||
response_model=BlocklistSource,
|
response_model=BlocklistSource,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="Add a new blocklist source",
|
summary="Add a new blocklist source",
|
||||||
|
responses={
|
||||||
|
201: {"description": "Blocklist source created", "model": BlocklistSource},
|
||||||
|
400: {"description": "URL validation failed"},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
409: {"description": "A blocklist source with this URL already exists"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def create_blocklist(
|
async def create_blocklist(
|
||||||
payload: BlocklistSourceCreate,
|
payload: BlocklistSourceCreate,
|
||||||
db: DbDep,
|
blocklist_ctx: BlocklistServiceContextDep,
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
) -> BlocklistSource:
|
) -> BlocklistSource:
|
||||||
"""Create a new blocklist source definition.
|
"""Create a new blocklist source definition.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
payload: New source data (name, url, enabled).
|
payload: New source data (name, url, enabled).
|
||||||
db: Application database connection (injected).
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
||||||
_auth: Validated session — enforces authentication.
|
_auth: Validated session — enforces authentication.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The newly created :class:`~app.models.blocklist.BlocklistSource`.
|
The newly created :class:`~app.models.blocklist.BlocklistSource`.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if URL validation fails.
|
||||||
"""
|
"""
|
||||||
return await blocklist_service.create_source(
|
try:
|
||||||
db, payload.name, payload.url, enabled=payload.enabled
|
return await blocklist_service.create_source(
|
||||||
)
|
blocklist_ctx.db, payload.name, str(payload.url), enabled=payload.enabled
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise BadRequestError(str(exc)) from exc
|
||||||
|
except BlocklistSourceAlreadyExistsError as exc:
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -112,33 +172,41 @@ async def create_blocklist(
|
|||||||
"/import",
|
"/import",
|
||||||
response_model=ImportRunResult,
|
response_model=ImportRunResult,
|
||||||
summary="Trigger a manual blocklist import",
|
summary="Trigger a manual blocklist import",
|
||||||
|
dependencies=[Depends(_check_blocklist_import_rate_limit)],
|
||||||
|
responses={
|
||||||
|
200: {"description": "Import completed", "model": ImportRunResult},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
429: {"description": "Rate limit exceeded for blocklist import"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def run_import_now(
|
async def run_import_now(
|
||||||
request: Request,
|
http_session: HttpSessionDep,
|
||||||
db: DbDep,
|
blocklist_ctx: BlocklistServiceContextDep,
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
geo_cache: GeoCacheDep,
|
||||||
) -> ImportRunResult:
|
) -> ImportRunResult:
|
||||||
"""Download and apply all enabled blocklist sources immediately.
|
"""Download and apply all enabled blocklist sources immediately.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: Incoming request (used to access shared HTTP session).
|
http_session: Shared HTTP session (injected).
|
||||||
db: Application database connection (injected).
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
||||||
_auth: Validated session — enforces authentication.
|
_auth: Validated session — enforces authentication.
|
||||||
|
socket_path: Path to fail2ban Unix domain socket.
|
||||||
|
geo_cache: Geolocation cache instance.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`~app.models.blocklist.ImportRunResult` with per-source
|
:class:`~app.models.blocklist.ImportRunResult` with per-source
|
||||||
results and aggregated counters.
|
results and aggregated counters.
|
||||||
"""
|
"""
|
||||||
http_session: aiohttp.ClientSession = request.app.state.http_session
|
|
||||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
|
||||||
from app.services import jail_service
|
|
||||||
|
|
||||||
return await blocklist_service.import_all(
|
return await blocklist_service.import_all(
|
||||||
db,
|
blocklist_ctx.db,
|
||||||
http_session,
|
http_session,
|
||||||
socket_path,
|
socket_path,
|
||||||
geo_is_cached=geo_service.is_cached,
|
geo_is_cached=geo_cache.is_cached,
|
||||||
geo_batch_lookup=geo_service.lookup_batch,
|
geo_cache=geo_cache,
|
||||||
|
ban_ip=ban_service.ban_ip,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -146,84 +214,94 @@ async def run_import_now(
|
|||||||
"/schedule",
|
"/schedule",
|
||||||
response_model=ScheduleInfo,
|
response_model=ScheduleInfo,
|
||||||
summary="Get the current import schedule",
|
summary="Get the current import schedule",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Schedule info returned", "model": ScheduleInfo},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def get_schedule(
|
async def get_schedule(
|
||||||
request: Request,
|
blocklist_ctx: BlocklistServiceContextDep,
|
||||||
db: DbDep,
|
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
|
scheduler: SchedulerDep,
|
||||||
) -> ScheduleInfo:
|
) -> ScheduleInfo:
|
||||||
"""Return the current schedule configuration and runtime metadata.
|
"""Return the current schedule configuration and runtime metadata.
|
||||||
|
|
||||||
The ``next_run_at`` field is read from APScheduler if the job is active.
|
The ``next_run_at`` field is read from APScheduler if the job is active.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: Incoming request (used to query the scheduler).
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
||||||
db: Application database connection (injected).
|
|
||||||
_auth: Validated session — enforces authentication.
|
_auth: Validated session — enforces authentication.
|
||||||
|
scheduler: APScheduler instance.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`~app.models.blocklist.ScheduleInfo` with config and run
|
:class:`~app.models.blocklist.ScheduleInfo` with config and run
|
||||||
times.
|
times.
|
||||||
"""
|
"""
|
||||||
scheduler = request.app.state.scheduler
|
return await blocklist_service.get_schedule_info_with_runtime(blocklist_ctx.db, scheduler)
|
||||||
job = scheduler.get_job(blocklist_import_task.JOB_ID)
|
|
||||||
next_run_at: str | None = None
|
|
||||||
if job is not None and job.next_run_time is not None:
|
|
||||||
next_run_at = job.next_run_time.isoformat()
|
|
||||||
|
|
||||||
return await blocklist_service.get_schedule_info(db, next_run_at)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/schedule",
|
"/schedule",
|
||||||
response_model=ScheduleInfo,
|
response_model=ScheduleInfo,
|
||||||
summary="Update the import schedule",
|
summary="Update the import schedule",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Schedule updated", "model": ScheduleInfo},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def update_schedule(
|
async def update_schedule(
|
||||||
payload: ScheduleConfig,
|
payload: ScheduleConfig,
|
||||||
request: Request,
|
blocklist_ctx: BlocklistServiceContextDep,
|
||||||
db: DbDep,
|
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
|
scheduler: SchedulerDep,
|
||||||
|
http_session: HttpSessionDep,
|
||||||
|
settings: SettingsDep,
|
||||||
) -> ScheduleInfo:
|
) -> ScheduleInfo:
|
||||||
"""Persist a new schedule configuration and reschedule the import job.
|
"""Persist a new schedule configuration and reschedule the import job.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
payload: New :class:`~app.models.blocklist.ScheduleConfig`.
|
payload: New :class:`~app.models.blocklist.ScheduleConfig`.
|
||||||
request: Incoming request (used to access the scheduler).
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
||||||
db: Application database connection (injected).
|
|
||||||
_auth: Validated session — enforces authentication.
|
_auth: Validated session — enforces authentication.
|
||||||
|
scheduler: Shared APScheduler instance (injected).
|
||||||
|
http_session: Shared HTTP session used by the scheduler job.
|
||||||
|
settings: Current application settings used by the scheduler job.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Updated :class:`~app.models.blocklist.ScheduleInfo`.
|
Updated :class:`~app.models.blocklist.ScheduleInfo`.
|
||||||
"""
|
"""
|
||||||
await blocklist_service.set_schedule(db, payload)
|
return await blocklist_service.update_schedule(
|
||||||
# Reschedule the background job immediately.
|
blocklist_ctx.db,
|
||||||
blocklist_import_task.reschedule(request.app)
|
scheduler,
|
||||||
|
http_session,
|
||||||
job = request.app.state.scheduler.get_job(blocklist_import_task.JOB_ID)
|
settings,
|
||||||
next_run_at: str | None = None
|
payload,
|
||||||
if job is not None and job.next_run_time is not None:
|
run_import_with_resources,
|
||||||
next_run_at = job.next_run_time.isoformat()
|
)
|
||||||
|
|
||||||
return await blocklist_service.get_schedule_info(db, next_run_at)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/log",
|
"/log",
|
||||||
response_model=ImportLogListResponse,
|
response_model=ImportLogListResponse,
|
||||||
summary="Get the paginated import log",
|
summary="Get the paginated import log",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Import log returned", "model": ImportLogListResponse},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def get_import_log(
|
async def get_import_log(
|
||||||
db: DbDep,
|
blocklist_ctx: BlocklistServiceContextDep,
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
source_id: int | None = Query(default=None, description="Filter by source id"),
|
source_id: int | None = Query(default=None, description="Filter by source id"),
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1, description="1-based page number."),
|
||||||
page_size: int = Query(default=50, ge=1, le=200),
|
page_size: int = Query(
|
||||||
|
default=DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page (max 500)."
|
||||||
|
),
|
||||||
) -> ImportLogListResponse:
|
) -> ImportLogListResponse:
|
||||||
"""Return a paginated log of all import runs.
|
"""Return a paginated log of all import runs.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Application database connection (injected).
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
||||||
_auth: Validated session — enforces authentication.
|
_auth: Validated session — enforces authentication.
|
||||||
source_id: Optional filter — only show logs for this source.
|
source_id: Optional filter — only show logs for this source.
|
||||||
page: 1-based page number.
|
page: 1-based page number.
|
||||||
@@ -233,7 +311,7 @@ async def get_import_log(
|
|||||||
:class:`~app.models.blocklist.ImportLogListResponse`.
|
:class:`~app.models.blocklist.ImportLogListResponse`.
|
||||||
"""
|
"""
|
||||||
return await blocklist_service.list_import_logs(
|
return await blocklist_service.list_import_logs(
|
||||||
db, source_id=source_id, page=page, page_size=page_size
|
blocklist_ctx.db, source_id=source_id, page=page, page_size=page_size
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -246,25 +324,30 @@ async def get_import_log(
|
|||||||
"/{source_id}",
|
"/{source_id}",
|
||||||
response_model=BlocklistSource,
|
response_model=BlocklistSource,
|
||||||
summary="Get a single blocklist source",
|
summary="Get a single blocklist source",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Blocklist source returned", "model": BlocklistSource},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
404: {"description": "Blocklist source not found"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def get_blocklist(
|
async def get_blocklist(
|
||||||
source_id: int,
|
source_id: int,
|
||||||
db: DbDep,
|
blocklist_ctx: BlocklistServiceContextDep,
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
) -> BlocklistSource:
|
) -> BlocklistSource:
|
||||||
"""Return a single blocklist source by id.
|
"""Return a single blocklist source by id.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
source_id: Primary key of the source.
|
source_id: Primary key of the source.
|
||||||
db: Application database connection (injected).
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
||||||
_auth: Validated session — enforces authentication.
|
_auth: Validated session — enforces authentication.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: 404 if the source does not exist.
|
HTTPException: 404 if the source does not exist.
|
||||||
"""
|
"""
|
||||||
source = await blocklist_service.get_source(db, source_id)
|
source = await blocklist_service.get_source(blocklist_ctx.db, source_id)
|
||||||
if source is None:
|
if source is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
|
raise BlocklistSourceNotFoundError(source_id)
|
||||||
return source
|
return source
|
||||||
|
|
||||||
|
|
||||||
@@ -272,11 +355,17 @@ async def get_blocklist(
|
|||||||
"/{source_id}",
|
"/{source_id}",
|
||||||
response_model=BlocklistSource,
|
response_model=BlocklistSource,
|
||||||
summary="Update a blocklist source",
|
summary="Update a blocklist source",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Blocklist source updated", "model": BlocklistSource},
|
||||||
|
400: {"description": "URL validation failed"},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
404: {"description": "Blocklist source not found"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def update_blocklist(
|
async def update_blocklist(
|
||||||
source_id: int,
|
source_id: int,
|
||||||
payload: BlocklistSourceUpdate,
|
payload: BlocklistSourceUpdate,
|
||||||
db: DbDep,
|
blocklist_ctx: BlocklistServiceContextDep,
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
) -> BlocklistSource:
|
) -> BlocklistSource:
|
||||||
"""Update one or more fields on a blocklist source.
|
"""Update one or more fields on a blocklist source.
|
||||||
@@ -284,21 +373,25 @@ async def update_blocklist(
|
|||||||
Args:
|
Args:
|
||||||
source_id: Primary key of the source to update.
|
source_id: Primary key of the source to update.
|
||||||
payload: Fields to update (all optional).
|
payload: Fields to update (all optional).
|
||||||
db: Application database connection (injected).
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
||||||
_auth: Validated session — enforces authentication.
|
_auth: Validated session — enforces authentication.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
|
HTTPException: 400 if URL validation fails.
|
||||||
HTTPException: 404 if the source does not exist.
|
HTTPException: 404 if the source does not exist.
|
||||||
"""
|
"""
|
||||||
updated = await blocklist_service.update_source(
|
try:
|
||||||
db,
|
updated = await blocklist_service.update_source(
|
||||||
source_id,
|
blocklist_ctx.db,
|
||||||
name=payload.name,
|
source_id,
|
||||||
url=payload.url,
|
name=payload.name,
|
||||||
enabled=payload.enabled,
|
url=str(payload.url) if payload.url is not None else None,
|
||||||
)
|
enabled=payload.enabled,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise BadRequestError(str(exc)) from exc
|
||||||
if updated is None:
|
if updated is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
|
raise BlocklistSourceNotFoundError(source_id)
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
@@ -306,36 +399,48 @@ async def update_blocklist(
|
|||||||
"/{source_id}",
|
"/{source_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
summary="Delete a blocklist source",
|
summary="Delete a blocklist source",
|
||||||
|
responses={
|
||||||
|
204: {"description": "Blocklist source deleted successfully"},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
404: {"description": "Blocklist source not found"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def delete_blocklist(
|
async def delete_blocklist(
|
||||||
source_id: int,
|
source_id: int,
|
||||||
db: DbDep,
|
blocklist_ctx: BlocklistServiceContextDep,
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete a blocklist source by id.
|
"""Delete a blocklist source by id.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
source_id: Primary key of the source to remove.
|
source_id: Primary key of the source to remove.
|
||||||
db: Application database connection (injected).
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
||||||
_auth: Validated session — enforces authentication.
|
_auth: Validated session — enforces authentication.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: 404 if the source does not exist.
|
HTTPException: 404 if the source does not exist.
|
||||||
"""
|
"""
|
||||||
deleted = await blocklist_service.delete_source(db, source_id)
|
deleted = await blocklist_service.delete_source(blocklist_ctx.db, source_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
|
raise BlocklistSourceNotFoundError(source_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{source_id}/preview",
|
"/{source_id}/preview",
|
||||||
response_model=PreviewResponse,
|
response_model=PreviewResponse,
|
||||||
summary="Preview the contents of a blocklist source",
|
summary="Preview the contents of a blocklist source",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Blocklist preview returned", "model": PreviewResponse},
|
||||||
|
401: {"description": "Session missing, expired, or invalid"},
|
||||||
|
404: {"description": "Blocklist source not found"},
|
||||||
|
502: {"description": "URL could not be reached"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def preview_blocklist(
|
async def preview_blocklist(
|
||||||
source_id: int,
|
source_id: int,
|
||||||
request: Request,
|
http_session: HttpSessionDep,
|
||||||
db: DbDep,
|
blocklist_ctx: BlocklistServiceContextDep,
|
||||||
|
settings: SettingsDep,
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
) -> PreviewResponse:
|
) -> PreviewResponse:
|
||||||
"""Download and preview a sample of a blocklist source.
|
"""Download and preview a sample of a blocklist source.
|
||||||
@@ -345,23 +450,22 @@ async def preview_blocklist(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
source_id: Primary key of the source to preview.
|
source_id: Primary key of the source to preview.
|
||||||
request: Incoming request (used to access the HTTP session).
|
http_session: Shared HTTP session for downloading.
|
||||||
db: Application database connection (injected).
|
blocklist_ctx: Blocklist service context containing db and repositories.
|
||||||
_auth: Validated session — enforces authentication.
|
_auth: Validated session — enforces authentication.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: 404 if the source does not exist.
|
HTTPException: 404 if the source does not exist.
|
||||||
HTTPException: 502 if the URL cannot be reached.
|
HTTPException: 502 if the URL cannot be reached.
|
||||||
"""
|
"""
|
||||||
source = await blocklist_service.get_source(db, source_id)
|
source = await blocklist_service.get_source(blocklist_ctx.db, source_id)
|
||||||
if source is None:
|
if source is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
|
raise BlocklistSourceNotFoundError(source_id)
|
||||||
|
|
||||||
http_session: aiohttp.ClientSession = request.app.state.http_session
|
|
||||||
try:
|
try:
|
||||||
return await blocklist_service.preview_source(source.url, http_session)
|
domain_result = await blocklist_service.preview_source(
|
||||||
|
source.url, http_session, sample_lines=settings.blocklist_preview_max_lines
|
||||||
|
)
|
||||||
|
return blocklist_mappers.map_domain_preview_result_to_response(domain_result)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(
|
raise BadRequestError(f"Could not fetch blocklist: {exc}") from exc
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
||||||
detail=f"Could not fetch blocklist: {exc}",
|
|
||||||
) from exc
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user