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>
181 lines
5.8 KiB
Python
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,
|
|
)
|