"""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, Depends, Request, status from app.dependencies import ( AuthDep, BanServiceContextDep, Fail2BanSocketDep, GeoCacheDep, 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.jail import JailCommandResponse from app.services import ban_service, jail_service from app.utils.constants import ( RATE_LIMIT_BANS_BAN_REQUESTS, RATE_LIMIT_BANS_UNBAN_REQUESTS, ) 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 _check_ban_rate_limit( request: Request, rate_limiter: GlobalRateLimiterDep, ) -> None: """Check rate limit for ban 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_BAN_BUCKET, client_ip, RATE_LIMIT_BANS_BAN_REQUESTS, _MINUTE ) if not is_allowed: from app.exceptions import RateLimitError import structlog log = structlog.get_logger() 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 import structlog log = structlog.get_logger() 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( "/active", response_model=ActiveBanListResponse, 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( 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. """ domain_result = await ban_service.get_active_bans( socket_path, geo_cache=geo_cache, http_session=http_session, app_db=ban_ctx.db, ) return map_domain_active_ban_list_to_response(domain_result) @router.post( "", status_code=status.HTTP_201_CREATED, response_model=JailCommandResponse, 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( 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", 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( 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", responses={ 200: {"description": "All bans cleared", "model": UnbanAllResponse}, 401: {"description": "Session missing, expired, or invalid"}, 502: {"description": "fail2ban unreachable"}, }, ) 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, )