refactoring-backend #3

Merged
lukas.pupkalipinski merged 403 commits from refactoring-backend into main 2026-05-20 20:23:46 +02:00
17 changed files with 517 additions and 48 deletions
Showing only changes of commit 5f0ab40816 - Show all commits

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
**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
# 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):
"""Payload for ``POST /api/setup``."""
@@ -29,9 +98,9 @@ class SetupRequest(BanGuiBaseModel):
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 (!@#$%^&*())."
)
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(
@@ -52,6 +121,7 @@ class SetupRequest(BanGuiBaseModel):
description="Number of minutes a user session remains valid.",
)
class SetupResponse(BanGuiBaseModel):
"""Response returned after a successful initial setup."""
@@ -59,11 +129,13 @@ class SetupResponse(BanGuiBaseModel):
default="Setup completed successfully. Please log in.",
)
class SetupTimezoneResponse(BanGuiBaseModel):
"""Response for ``GET /api/setup/timezone``."""
timezone: str = Field(..., description="Configured IANA timezone identifier.")
class SetupStatusResponse(BanGuiBaseModel):
"""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.
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:
address: A valid IP address string.
@@ -69,7 +71,10 @@ def normalise_ip(address: str) -> str:
Raises:
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:

View File

@@ -77,6 +77,9 @@ class TestNormaliseIp:
def test_normalise_ip_ipv6_compressed(self) -> None:
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:
with pytest.raises(ValueError):
normalise_ip("not-an-ip")

View File

@@ -37,6 +37,7 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.0.0",
"husky": "^9.1.7",
"jiti": "^2.6.1",
"jsdom": "^28.1.0",
"openapi-typescript": "^7.13.0",
@@ -6152,6 +6153,22 @@
"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": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",

View File

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

View File

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

View File

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