Files
BanGUI/backend/app/routers/bans.py
Lukas 507f153ab9 Enforce repository boundary: Remove DbDep from routers
This commit enforces the repository boundary by eliminating direct database connection
dependencies (DbDep) from all routers. Routers now depend on service context dependencies
that combine the database connection with the related repositories.

Changes:
- Add 5 service context dependencies in dependencies.py:
  * SessionServiceContext: db + session_repo
  * BlocklistServiceContext: db + blocklist_repo + import_log_repo + settings_repo
  * SettingsServiceContext: db + settings_repo
  * BanServiceContext: db + fail2ban_db_repo
  * HistoryServiceContext: db + fail2ban_db_repo + history_archive_repo

- Refactor all 9 routers (auth, bans, blocklist, config_misc, dashboard, geo,
  history, jails, setup) to use service contexts instead of DbDep.

- Update Backend-Development.md with clear examples of the new pattern and
  documentation of available service contexts.

Rationale:
- Enforces the repository boundary through the dependency system
- Makes database operations explicit and auditable
- Improves testability by allowing service contexts to be mocked
- Prevents accidental direct database access from routers

The deprecated DbDep remains available for backward compatibility with
services that have not yet been refactored, but routers can no longer import it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 07:35:23 +02:00

181 lines
5.8 KiB
Python

"""Bans router.
Manual ban and unban operations and the active-bans overview:
* ``GET /api/bans/active`` — list all currently banned IPs
* ``POST /api/bans`` — ban an IP in a specific jail
* ``DELETE /api/bans`` — unban an IP from one or all jails
* ``DELETE /api/bans/all`` — unban every currently banned IP across all jails
"""
from __future__ import annotations
from fastapi import APIRouter, Request, status
from app.dependencies import (
AuthDep,
BanServiceContextDep,
Fail2BanSocketDep,
GeoCacheDep,
HttpSessionDep,
)
from app.models.ban import ActiveBanListResponse, BanRequest, UnbanAllResponse, UnbanRequest
from app.models.jail import JailCommandResponse
from app.services import ban_service, jail_service
router: APIRouter = APIRouter(prefix="/api/bans", tags=["Bans"])
@router.get(
"/active",
response_model=ActiveBanListResponse,
summary="List all currently banned IPs across all jails",
)
async def get_active_bans(
request: Request,
_auth: AuthDep,
ban_ctx: BanServiceContextDep,
socket_path: Fail2BanSocketDep,
http_session: HttpSessionDep,
geo_cache: GeoCacheDep,
) -> ActiveBanListResponse:
"""Return every IP that is currently banned across all fail2ban jails.
Each entry includes the jail name, ban start time, expiry time, and
enriched geolocation data (country code).
Args:
request: Incoming request (used to access ``app.state``).
_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:
:class:`~app.models.ban.ActiveBanListResponse` with all active bans.
Raises:
HTTPException: 502 when fail2ban is unreachable.
"""
return await ban_service.get_active_bans(
socket_path,
geo_cache=geo_cache,
http_session=http_session,
app_db=ban_ctx.db,
)
@router.post(
"",
status_code=status.HTTP_201_CREATED,
response_model=JailCommandResponse,
summary="Ban an IP address in a specific jail",
)
async def ban_ip(
request: Request,
_auth: AuthDep,
body: BanRequest,
socket_path: Fail2BanSocketDep,
) -> JailCommandResponse:
"""Ban an IP address in the specified fail2ban jail.
The IP address is validated before the command is sent. IPv4 and
IPv6 addresses are both accepted.
Args:
request: Incoming request (used to access ``app.state``).
_auth: Validated session — enforces authentication.
body: Payload containing the IP address and target jail.
Returns:
:class:`~app.models.jail.JailCommandResponse` confirming the ban.
Raises:
HTTPException: 400 when the IP address is invalid.
HTTPException: 404 when the specified jail does not exist.
HTTPException: 409 when fail2ban reports the ban failed.
HTTPException: 502 when fail2ban is unreachable.
"""
await ban_service.ban_ip(socket_path, body.jail, body.ip)
return JailCommandResponse(
message=f"IP {body.ip!r} banned in jail {body.jail!r}.",
jail=body.jail,
)
@router.delete(
"",
response_model=JailCommandResponse,
summary="Unban an IP address from one or all jails",
)
async def unban_ip(
request: Request,
_auth: AuthDep,
body: UnbanRequest,
socket_path: Fail2BanSocketDep,
) -> JailCommandResponse:
"""Unban an IP address from a specific jail or all jails.
When ``unban_all`` is ``true`` the IP is removed from every jail using
fail2ban's global unban command. When ``jail`` is specified only that
jail is targeted. If neither ``unban_all`` nor ``jail`` is provided the
IP is unbanned from all jails (equivalent to ``unban_all=true``).
Args:
request: Incoming request (used to access ``app.state``).
_auth: Validated session — enforces authentication.
body: Payload with the IP address, optional jail, and unban_all flag.
Returns:
:class:`~app.models.jail.JailCommandResponse` confirming the unban.
Raises:
HTTPException: 400 when the IP address is invalid.
HTTPException: 404 when the specified jail does not exist.
HTTPException: 409 when fail2ban reports the unban failed.
HTTPException: 502 when fail2ban is unreachable.
"""
# Determine target jail (None means all jails).
target_jail: str | None = None if (body.unban_all or body.jail is None) else body.jail
await ban_service.unban_ip(socket_path, body.ip, jail=target_jail)
scope = f"jail {target_jail!r}" if target_jail else "all jails"
return JailCommandResponse(
message=f"IP {body.ip!r} unbanned from {scope}.",
jail=target_jail or "*",
)
@router.delete(
"/all",
response_model=UnbanAllResponse,
summary="Unban every currently banned IP across all jails",
)
async def unban_all(
request: Request,
_auth: AuthDep,
socket_path: Fail2BanSocketDep,
) -> UnbanAllResponse:
"""Remove all active bans from every fail2ban jail in a single operation.
Uses fail2ban's ``unban --all`` command to atomically clear every active
ban across all jails. Returns the number of IPs that were unbanned.
Args:
request: Incoming request (used to access ``app.state``).
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.ban.UnbanAllResponse` with the count of
unbanned IPs.
Raises:
HTTPException: 502 when fail2ban is unreachable.
"""
count: int = await jail_service.unban_all_ips(socket_path)
return UnbanAllResponse(
message=f"All bans cleared. {count} IP address{'es' if count != 1 else ''} unbanned.",
count=count,
)