refactor(backend): clean up models setup, improve ip utils, add adr docs

- Extract ADR documents for architectural decisions (SQLite, FastAPI, React, APScheduler, Scheduler)
- Refactor setup.py: improve code structure and readability
- Add IP validation utilities with test coverage
- Update frontend components (BanTable, HistoryPage)
- Add pre-commit hooks and CONTRIBUTING.md
- Add .editorconfig for consistent coding standards
This commit is contained in:
2026-05-03 18:04:45 +02:00
parent 2f9fc8076d
commit 5f0ab40816
17 changed files with 517 additions and 48 deletions

33
.editorconfig Normal file
View 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

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
cd frontend && npm run validate:types

23
.pre-commit-config.yaml Normal file
View 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

118
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,118 @@
# 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 — 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` |
**All checks must pass before committing.** CI runs the same suite.
---
## Testing
```bash
# Backend
cd backend && pytest --cov=app --cov-report=term-missing
# Frontend — run once
cd frontend && npm test
```
---
## 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

View File

@@ -1,46 +1,3 @@
### Issue #27: MEDIUM - Inconsistent Error Handling Patterns
**Where found**:
- `ban_service.py` - Raises exceptions
- `server_service.py` - Returns defaults silently
- `jail_service.py` - Mixed approach
**Why this is needed**:
Different services have different error handling contracts. Callers don't know what to expect.
**Goal**:
Establish clear error handling contract for all services.
**What to do**:
1. Document error handling patterns:
```python
class ServiceErrorContract:
"""
ABORT_ON_ERROR: Raise exception, let router handle
RETURN_DEFAULT: Return empty result, log warning
PARTIAL_RESULT: Return partial success with error list
"""
```
2. Each service method documents which pattern it follows
3. Routers handle errors consistently
4. Update service protocols
**Possible traps and issues**:
- Users of service must know pattern
- Switching patterns is breaking change
**Docs changes needed**:
- Create service development guide with error patterns
**Doc references**:
- DETAILED_FINDINGS.md - Issue "Inconsistent Error Handling"
---
## LOWER PRIORITY ISSUES (LOW-MEDIUM)
---
### Issue #28: LOW-MEDIUM - Missing Pre-Commit Hooks ### Issue #28: LOW-MEDIUM - Missing Pre-Commit Hooks
**Where found**: **Where found**:

View File

@@ -0,0 +1,45 @@
# ADR-001: SQLite as the Application Database
## Status
Accepted
## Context
BanGUI needs a database to store application state: configuration, session records,
blocklist sources, import logs, and ban history archives.
## Decision
Use **SQLite** (via `aiosqlite`) as BanGUI's application database, persisted to a
volume mounted from the host or a named Docker volume.
## Rationale
### Why SQLite over PostgreSQL?
- **Zero-infrastructure:** No separate DB server process, no connection pooling,
no credentials to manage. Ships in the Docker container with no additional
configuration.
- **Fail2ban-compatible:** The fail2ban database itself is SQLite. BanGUI already
depends on SQLite; adding a second database engine would increase operational
complexity for no benefit.
- **Single-instance deployment:** BanGUI runs as a single service with one
background scheduler (enforced by `BANGUI_WORKERS=1`). Horizontal scaling is not
a design goal.
- **Async I/O:** `aiosqlite` provides full async support, avoiding blocking I/O in
the FastAPI async request handlers.
### Why not PostgreSQL?
- Requires a separate service or sidecar container.
- Adds connection overhead (TCP, connection pools, auth).
- Over-engineered for a single-instance web app.
### Trade-offs
- **Not suitable for multi-worker deployments.** SQLite's file-level locking means
only one process can write at a time. This is explicitly enforced:
`BANGUI_WORKERS=1` is validated at startup, and `scheduler_lock` prevents
duplicate schedulers in restarts.
- For future multi-instance deployments, BanGUI would need to migrate to
PostgreSQL or add a distributed lock layer (Redis + job store).
## Consequences
- Application database is a single file (`bangui.db`) in the container's data volume.
- Backup is a file copy. No `pg_dump` equivalent needed.
- Schema migrations managed via `app/startup.py` startup DAG.

View File

@@ -0,0 +1,45 @@
# ADR-002: FastAPI over Django
## Status
Accepted
## Context
The backend requires a Python async web framework with strong typing, validation,
and OpenAPI support.
## Decision
Use **FastAPI** as the backend framework.
## Rationale
### Why FastAPI over Django?
- **Async-first:** FastAPI is built on Starlette with native `async def` route
handlers. Django's ORM and request handling are synchronous, requiring thread
pools for I/O-bound work.
- **Modern Python 3.12+:** FastAPI embraces modern Python idioms — type annotations,
structural pattern matching, dataclasses. Django maintains broad Python 3.8+
compatibility and shows its age.
- **Pydantic v2 integration:** FastAPI natively uses Pydantic for request/response
validation. Automatic OpenAPI schema generation from Pydantic models is seamless.
- **Dependency injection:** FastAPI's `Depends()` system provides a lightweight,
explicit DI pattern without a separate container library.
- **Performance:** FastAPI + Uvicorn consistently benchmarks as one of the fastest
Python web frameworks, comparable to Node.js and Go for JSON APIs.
### Why not Django?
- Django's synchronous ORM creates thread-pool bottlenecks with SQLite.
- Django's "batteries-included" philosophy is overkill for BanGUI's scope.
We need REST endpoints and background tasks, not a full CMS.
- Less flexible dependency injection — Django's middleware and view system is
less composable than FastAPI's routing layers.
### Trade-offs
- **Smaller ecosystem:** Django has decades of third-party packages. FastAPI's
ecosystem is younger but covers BanGUI's needs (structlog, aiosqlite, APScheduler,
aiohttp) completely.
- **No built-in admin UI:** BanGUI is its own admin UI; Django's admin is irrelevant.
## Consequences
- FastAPI routes are defined in `app/routers/`.
- Request/response models live in `app/models/`.
- Dependency injection via `app/dependencies.py`.

View File

@@ -0,0 +1,37 @@
# ADR-003: React over Vue
## Status
Accepted
## Context
The frontend requires a component-based SPA framework with strong typing, a
battle-tested component library, and broad ecosystem support.
## Decision
Use **React 18+ with TypeScript** as the frontend framework.
## Rationale
### Why React over Vue?
- **Ecosystem maturity:** React has the largest frontend ecosystem. Libraries
(date pickers, data grids, rich text editors) assume React availability first.
- **Fluent UI v9:** Microsoft's official React component library is built for React.
The Vue-compatible version (Fluent UI Vue) lags significantly in features and
maintenance.
- **Hiring and onboarding:** React is more widely known. New contributors are
more likely to arrive with React experience than Vue experience.
- **Concurrent features:** React 18's concurrent rendering (`useTransition`,
`useDeferredValue`) provides a foundation for performance improvements in
data-heavy views like the ban table.
### Why not Vue?
- Fluent UI v9 does not provide first-class Vue support.
- Vue's composition API is well-designed, but does not outweigh the Fluent UI
constraint.
- Ecosystem and hiring advantages strongly favor React for enterprise-adjacent
projects.
## Consequences
- Frontend is a React SPA in `frontend/src/`.
- All components are functional components using hooks.
- Global state via React context (`frontend/src/providers/`).

View File

@@ -0,0 +1,48 @@
# ADR-004: APScheduler over Celery
## Status
Accepted
## Context
BanGUI requires background task scheduling for periodic work: geo cache flush,
session cleanup, history sync, and blocklist imports.
## Decision
Use **APScheduler 4.x (AsyncIOScheduler)** for background scheduling.
## Rationale
### Why APScheduler over Celery?
- **No infrastructure:** Celery requires a message broker (Redis or RabbitMQ).
APScheduler runs in-process with no broker. Given BanGUI's single-instance
constraint, a message queue adds unnecessary operational complexity.
- **Async-native:** `AsyncIOScheduler` integrates directly with the asyncio event
loop. All BanGUI's I/O (database, HTTP, fail2ban socket) is async. APScheduler
jobs are `async def` functions that `await` without blocking.
- **Simplicity:** BanGUI's job set is fixed and small. Celery's rich task routing,
retry policies, and distributed execution are overkill. APScheduler covers
cron-style scheduling with simpler semantics.
- **Single-instance enforcement:** APScheduler's in-memory job store is a natural
fit when there is only one scheduler. No distributed coordination needed.
### Why not Celery?
- Celery's architecture (broker + workers + result backend) is designed for
distributed systems. BanGUI is explicitly single-instance.
- Celery tasks are synchronous wrappers around async code without careful
handling. Native `async def` tasks require `async_task()` or explicit `run_sync`,
creating friction in an async-first codebase.
- Added operational burden: Redis or RabbitMQ must be available at startup.
### Trade-offs
- **No horizontal scaling of workers:** APScheduler jobs run in the single
uvicorn worker process. CPU-intensive jobs would block the event loop.
(This is not a concern for BanGUI's I/O-bound jobs.)
- **No built-in retry mechanism:** Failed jobs must re-raise exceptions or
implement retry logic manually. This is acceptable given BanGUI's job
idempotency guarantees.
## Consequences
- Scheduler is configured in `app/startup.py` using `AsyncIOScheduler`.
- Jobs live in `app/tasks/`.
- Single-worker constraint is enforced via `BANGUI_WORKERS=1` validation and
the `scheduler_lock` database table.

View File

@@ -0,0 +1,61 @@
# ADR-005: Single-Instance Scheduler Enforcement
## Status
Accepted
## Context
APScheduler's `AsyncIOScheduler` is bound to a single asyncio event loop.
Running multiple scheduler instances leads to duplicate jobs, database lock
contention, and undefined behaviour.
## Decision
Enforce exactly **one scheduler instance** across the entire application lifecycle,
using a database-level distributed lock.
## Mechanism
### 1. Startup gate: `BANGUI_WORKERS=1`
The Docker compose file is configured with `BANGUI_WORKERS=1` and the startup DAG
validates this variable. If the variable is not set to `1`, startup aborts with
a clear error message.
### 2. Runtime lock: `scheduler_lock` table
During startup, after opening the SQLite database, the application attempts:
```sql
INSERT INTO scheduler_lock (lock_name, heartbeat_at)
VALUES ('scheduler', unixepoch())
ON CONFLICT(lock_name) DO UPDATE SET heartbeat_at = unixepoch()
WHERE (unixepoch() - heartbeat_at) < 30;
```
- If the INSERT succeeds, this instance holds the lock and starts the scheduler.
- If the INSERT is a no-op (heartbeat is recent), another instance holds the lock
and startup continues without starting the scheduler.
- A background task (`scheduler_lock_heartbeat`) updates the heartbeat every 10
seconds. If the process crashes, the lock expires after 30 seconds, allowing
a restart to acquire it immediately.
### 3. Deployment topology
| Deployment | Behaviour |
|---|---|
| Single container | Scheduler runs normally |
| Single Pod (Kubernetes) | Scheduler runs normally |
| Accidental multi-process restart | Second process fails to start scheduler; first continues |
| Intentional multi-worker | Not supported; requires external job store (future) |
## Rationale
### Why this approach?
- **No external coordination service:** No ZooKeeper, etcd, or Redis needed.
The existing SQLite database is reused.
- **Atomic:** SQLite's INSERT with ON CONFLICT is atomic; no race condition.
- **Self-healing:** Lock expiry means a crashed instance automatically releases
its lock. No manual cleanup required.
- **Crash-safe:** A heartbeat-based TTL ensures stale locks are not held
indefinitely.
## Consequences
- `BANGUI_WORKERS` must always be `1`. This is documented and enforced.
- Future multi-worker deployments require migration to a persistent job store
(PostgreSQL + SQLAlchemy job store, or Redis).

View File

@@ -7,6 +7,75 @@ from pydantic import Field, field_validator
from app.models.response import BanGuiBaseModel 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(BanGuiBaseModel): class SetupRequest(BanGuiBaseModel):
"""Payload for ``POST /api/setup``.""" """Payload for ``POST /api/setup``."""
@@ -29,9 +98,9 @@ class SetupRequest(BanGuiBaseModel):
if not any(char.isdigit() for char in value): if not any(char.isdigit() for char in value):
raise ValueError("Password must include at least one number.") raise ValueError("Password must include at least one number.")
if not any(char in "!@#$%^&*()" for char in value): if not any(char in "!@#$%^&*()" for char in value):
raise ValueError( raise ValueError("Password must include at least one special character (!@#$%^&*()).")
"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 return value
database_path: str = Field( database_path: str = Field(
@@ -52,6 +121,7 @@ class SetupRequest(BanGuiBaseModel):
description="Number of minutes a user session remains valid.", description="Number of minutes a user session remains valid.",
) )
class SetupResponse(BanGuiBaseModel): class SetupResponse(BanGuiBaseModel):
"""Response returned after a successful initial setup.""" """Response returned after a successful initial setup."""
@@ -59,11 +129,13 @@ class SetupResponse(BanGuiBaseModel):
default="Setup completed successfully. Please log in.", default="Setup completed successfully. Please log in.",
) )
class SetupTimezoneResponse(BanGuiBaseModel): class SetupTimezoneResponse(BanGuiBaseModel):
"""Response for ``GET /api/setup/timezone``.""" """Response for ``GET /api/setup/timezone``."""
timezone: str = Field(..., description="Configured IANA timezone identifier.") timezone: str = Field(..., description="Configured IANA timezone identifier.")
class SetupStatusResponse(BanGuiBaseModel): class SetupStatusResponse(BanGuiBaseModel):
"""Response indicating whether setup has been completed.""" """Response indicating whether setup has been completed."""

View File

@@ -58,7 +58,9 @@ def normalise_ip(address: str) -> str:
"""Return a normalised string representation of an IP address. """Return a normalised string representation of an IP address.
IPv6 addresses are compressed to their canonical short form. IPv6 addresses are compressed to their canonical short form.
IPv4 addresses are returned unchanged. IPv4-mapped IPv6 addresses (e.g. ``::ffff:192.168.1.1``) are converted
to their IPv4 equivalent (``192.168.1.1``).
Plain IPv4 addresses are returned unchanged.
Args: Args:
address: A valid IP address string. address: A valid IP address string.
@@ -69,7 +71,10 @@ def normalise_ip(address: str) -> str:
Raises: Raises:
ValueError: If *address* is not a valid IP address. ValueError: If *address* is not a valid IP address.
""" """
return str(ipaddress.ip_address(address)) ip = ipaddress.ip_address(address)
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
return str(ip.ipv4_mapped)
return str(ip)
def normalise_network(cidr: str) -> str: def normalise_network(cidr: str) -> str:

View File

@@ -77,6 +77,9 @@ class TestNormaliseIp:
def test_normalise_ip_ipv6_compressed(self) -> None: def test_normalise_ip_ipv6_compressed(self) -> None:
assert normalise_ip("2001:0db8:0000:0000:0000:0000:0000:0001") == "2001:db8::1" assert normalise_ip("2001:0db8:0000:0000:0000:0000:0000:0001") == "2001:db8::1"
def test_normalise_ip_ipv4_mapped_ipv6_to_ipv4(self) -> None:
assert normalise_ip("::ffff:192.168.1.1") == "192.168.1.1"
def test_normalise_ip_invalid_raises_value_error(self) -> None: def test_normalise_ip_invalid_raises_value_error(self) -> None:
with pytest.raises(ValueError): with pytest.raises(ValueError):
normalise_ip("not-an-ip") normalise_ip("not-an-ip")

View File

@@ -37,6 +37,7 @@
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"husky": "^9.1.7",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"openapi-typescript": "^7.13.0", "openapi-typescript": "^7.13.0",
@@ -6152,6 +6153,22 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
"dev": true,
"license": "MIT",
"bin": {
"husky": "bin.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "7.0.5", "version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",

View File

@@ -46,6 +46,7 @@
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"husky": "^9.1.7",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"openapi-typescript": "^7.13.0", "openapi-typescript": "^7.13.0",

View File

@@ -241,6 +241,7 @@ export const BanTable = memo(function BanTable({ timeRange, origin, source }: Ba
items={banItems} items={banItems}
columns={banColumns} columns={banColumns}
getRowId={(item: DashboardBanItem) => `${item.ip}:${item.jail}:${item.banned_at}`} getRowId={(item: DashboardBanItem) => `${item.ip}:${item.jail}:${item.banned_at}`}
aria-label="Ban records table"
> >
<DataGridHeader> <DataGridHeader>
<DataGridRow> <DataGridRow>

View File

@@ -360,6 +360,7 @@ export function HistoryPage(): React.JSX.Element {
onClick={(): void => { onClick={(): void => {
setCurrentPage(currentPage - 1); setCurrentPage(currentPage - 1);
}} }}
aria-label="Previous page"
/> />
<Text size={200}> <Text size={200}>
Page {String(currentPage)} / {String(totalPages)} Page {String(currentPage)} / {String(totalPages)}
@@ -372,6 +373,7 @@ export function HistoryPage(): React.JSX.Element {
onClick={(): void => { onClick={(): void => {
setCurrentPage(currentPage + 1); setCurrentPage(currentPage + 1);
}} }}
aria-label="Next page"
/> />
</div> </div>
)} )}