feat: implement API versioning /api/v1/

- All backend routers moved to /api/v1/ prefix
- Frontend BASE_URL updated to /api/v1
- Setup redirect middleware updated to redirect to /api/v1/setup
- Health router path fixed: prefix=/api/v1/health, @router.get('')
- conftest.py: set server_status=online for test fixture
- Created Docs/API_VERSIONING.md with deprecation policy
- Updated Docs/Backend-Development.md with versioning section
- Updated Instructions.md curl examples

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-02 21:29:30 +02:00
parent 0d5882b32f
commit cc6dbcf3f0
51 changed files with 1886 additions and 671 deletions

View File

@@ -380,6 +380,22 @@ class Settings(BaseSettings):
"Larger batches are more efficient but introduce slight latency."
),
)
# Rate limit configuration (per IP)
rate_limit_bans_per_minute: int = Field(
default=100,
ge=1,
description="Max ban/unban requests per IP per minute.",
)
rate_limit_blocklist_import_per_hour: int = Field(
default=10,
ge=1,
description="Max blocklist import requests per IP per hour.",
)
rate_limit_config_update_per_minute: int = Field(
default=50,
ge=1,
description="Max config update requests per IP per minute.",
)
@field_validator("elasticsearch_hosts", mode="before")
@classmethod

View File

@@ -41,6 +41,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[impo
from fastapi import Depends, FastAPI, HTTPException, Request, status
from app.config import Settings
from app.exceptions import RateLimitError
from app.models.auth import Session
from app.models.config import PendingRecovery
from app.models.server import ServerStatus
@@ -57,7 +58,7 @@ from app.repositories.protocols import (
from app.services.geo_cache import GeoCache
from app.services.protocols import Fail2BanMetadataService
from app.utils.constants import SESSION_COOKIE_NAME
from app.utils.rate_limiter import RateLimiter
from app.utils.rate_limiter import GlobalRateLimiter, RateLimiter
from app.utils.runtime_state import ApplicationState, JailServiceState, RuntimeState
from app.utils.session_cache import NoOpSessionCache, SessionCache
@@ -93,6 +94,7 @@ class ApplicationContext:
runtime_state: RuntimeState
session_cache: SessionCache | None
login_rate_limiter: RateLimiter
global_rate_limiter: GlobalRateLimiter
# ---------------------------------------------------------------------------
@@ -122,6 +124,10 @@ def _build_app_context(request: Request) -> ApplicationContext:
if login_rate_limiter is None:
login_rate_limiter = RateLimiter()
global_rate_limiter: GlobalRateLimiter = getattr(state, "global_rate_limiter", None)
if global_rate_limiter is None:
global_rate_limiter = GlobalRateLimiter()
return ApplicationContext(
settings=state.settings,
http_session=getattr(state, "http_session", None),
@@ -133,6 +139,7 @@ def _build_app_context(request: Request) -> ApplicationContext:
runtime_state=state.runtime_state,
session_cache=session_cache,
login_rate_limiter=login_rate_limiter,
global_rate_limiter=global_rate_limiter,
)
@@ -264,6 +271,62 @@ async def get_login_rate_limiter(
return app_context.login_rate_limiter
async def get_global_rate_limiter(
app_context: Annotated[ApplicationContext, Depends(get_app_context)],
) -> GlobalRateLimiter:
"""Provide the global rate limiter from application context."""
return app_context.global_rate_limiter
def rate_limit_dependency(
bucket: str,
max_requests: int,
window_seconds: int,
) -> Callable[[Request, "GlobalRateLimiter"], None]:
"""Create a rate limit dependency for a specific bucket and limit.
Use this factory to create per-endpoint rate limit dependencies.
Each call returns a configured dependency that enforces the
specified rate limit before the endpoint handler runs.
Args:
bucket: Bucket name (e.g., "bans:ban", "blocklist:import").
max_requests: Maximum requests allowed within the window.
window_seconds: Time window for this bucket.
Returns:
A callable that can be used as a FastAPI Depends() dependency.
"""
async def check_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
from app.utils.client_ip import get_client_ip
settings: 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(
bucket, client_ip, max_requests, window_seconds
)
if not is_allowed:
log.warning(
"operation_rate_limit_exceeded",
client_ip=client_ip,
bucket=bucket,
path=request.url.path,
method=request.method,
retry_after=retry_after,
)
raise RateLimitError(
f"Rate limit exceeded for {bucket}. Please try again later.",
retry_after_seconds=retry_after,
)
return check_rate_limit
async def get_session_repo() -> SessionRepository:
"""Provide the concrete session repository implementation.
@@ -668,6 +731,7 @@ AppStateDep = Annotated[ApplicationContext, Depends(get_app_state)]
AppDep = Annotated[FastAPI, Depends(get_app)]
AuthDep = Annotated[Session, Depends(require_auth)]
LoginRateLimiterDep = Annotated[RateLimiter, Depends(get_login_rate_limiter)]
GlobalRateLimiterDep = Annotated[GlobalRateLimiter, Depends(get_global_rate_limiter)]
Fail2BanMetadataServiceDep = Annotated[Fail2BanMetadataService, Depends(get_fail2ban_metadata_service)]
# Service context dependencies (db + repositories combined for routers)

View File

@@ -749,20 +749,20 @@ async def _request_validation_error_handler(
# the guard without being explicitly allowed.
_EXACT_ALLOWED: frozenset[str] = frozenset(
{
"/api/setup", # GET/POST /api/setup
"/api/health", # Health check endpoint
"/api/docs", # Swagger UI
"/api/redoc", # ReDoc
"/api/openapi.json", # OpenAPI schema
"/api/v1/setup", # GET/POST /api/v1/setup
"/api/v1/health", # Health check endpoint
"/api/docs", # Swagger UI
"/api/redoc", # ReDoc
"/api/openapi.json", # OpenAPI schema
},
)
# Prefix paths that are always reachable. These MUST end with "/" to prevent
# matching paths like "/api/setup-debug" while still matching nested routes
# like "/api/setup/timezone".
# matching paths like "/api/v1/setup-debug" while still matching nested routes
# like "/api/v1/setup/timezone".
_PREFIX_ALLOWED: frozenset[str] = frozenset(
{
"/api/setup/", # Nested setup routes (e.g., /api/setup/timezone)
"/api/v1/setup/", # Nested setup routes (e.g., /api/v1/setup/timezone)
},
)
@@ -857,13 +857,18 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
if path == prefix.rstrip("/") or path.startswith(prefix):
return await call_next(request)
# Health endpoint is always reachable (needed for Docker/health checks
# and load balancer probes before setup is complete).
if path == "/api/v1/health":
return await call_next(request)
# If setup is not complete, block all other API requests.
# The setup completion state is resolved at startup and stored in
# ``app.state.setup_complete_cached`` so this middleware does not
# perform any database queries during normal request handling.
if path.startswith("/api") and not is_setup_complete_cached(request.app):
if path.startswith("/api/v1") and not is_setup_complete_cached(request.app):
return RedirectResponse(
url="/api/setup",
url="/api/v1/setup",
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
@@ -998,7 +1003,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.add_exception_handler(Exception, _unhandled_exception_handler)
# --- Routers ---
app.include_router(metrics.router)
app.include_router(metrics.router, prefix="/api/v1")
app.include_router(health.router)
app.include_router(setup.router)
app.include_router(auth.router)

View File

@@ -311,3 +311,194 @@ async def get_archived_history_keyset(
return records, has_more
async def get_ip_ban_counts(
db: aiosqlite.Connection,
since: int | None = None,
jail: str | None = None,
ip_filter: str | list[str] | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
) -> list[dict[str, Any]]:
"""Return ban event counts grouped by IP using SQL aggregation.
Uses SQL GROUP BY to aggregate in the database rather than loading
all rows into Python memory. Returns lightweight {ip, event_count} dicts
suitable for downstream aggregation.
Args:
db: Active aiosqlite connection.
since: If given, filter to events on or after this Unix timestamp.
jail: If given, filter to events for this jail.
ip_filter: If given, filter by IP (exact match list or LIKE prefix).
origin: If given, filter by ban origin ('blocklist' or 'selfblock').
action: If given, filter to this action type ('ban' or 'unban').
Returns:
List of {ip: str, event_count: int} dicts.
"""
if isinstance(ip_filter, list) and len(ip_filter) == 0:
return []
wheres: list[str] = []
params: list[object] = []
if since is not None:
wheres.append("timeofban >= ?")
params.append(since)
if jail is not None:
wheres.append("jail = ?")
params.append(jail)
if ip_filter is not None:
if isinstance(ip_filter, list):
placeholder = ", ".join("?" for _ in ip_filter)
wheres.append(f"ip IN ({placeholder})")
params.extend(ip_filter)
else:
wheres.append("ip LIKE ? ESCAPE '\\'")
params.append(f"{escape_like(ip_filter)}%")
if origin == "blocklist":
wheres.append("jail = ?")
params.append(BLOCKLIST_JAIL)
elif origin == "selfblock":
wheres.append("jail != ?")
params.append(BLOCKLIST_JAIL)
if action is not None:
wheres.append("action = ?")
params.append(action)
where_sql = "WHERE " + " AND ".join(wheres) if wheres else ""
async with db.execute(
"SELECT ip, COUNT(*) AS event_count "
"FROM history_archive "
f"{where_sql} "
"GROUP BY ip",
params,
) as cur:
rows = await cur.fetchall()
return [
{"ip": str(r[0]), "event_count": int(r[1])}
for r in rows
]
async def get_jail_ban_counts(
db: aiosqlite.Connection,
since: int | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
) -> tuple[int, list[dict[str, Any]]]:
"""Return per-jail ban counts and total using SQL aggregation.
Args:
db: Active aiosqlite connection.
since: If given, filter to events on or after this Unix timestamp.
origin: If given, filter by ban origin ('blocklist' or 'selfblock').
action: If given, filter to this action type ('ban' or 'unban').
Returns:
A 2-tuple (total_count, jail_counts) where jail_counts is a list
of {jail: str, event_count: int} dicts sorted descending by count.
"""
wheres: list[str] = []
params: list[object] = []
if since is not None:
wheres.append("timeofban >= ?")
params.append(since)
if origin == "blocklist":
wheres.append("jail = ?")
params.append(BLOCKLIST_JAIL)
elif origin == "selfblock":
wheres.append("jail != ?")
params.append(BLOCKLIST_JAIL)
if action is not None:
wheres.append("action = ?")
params.append(action)
where_sql = "WHERE " + " AND ".join(wheres) if wheres else ""
async with db.execute(
f"SELECT COUNT(*) FROM history_archive {where_sql}", params
) as cur:
row = await cur.fetchone()
total = int(row[0]) if row is not None and row[0] is not None else 0
async with db.execute(
"SELECT jail, COUNT(*) AS event_count "
"FROM history_archive "
f"{where_sql} "
"GROUP BY jail "
"ORDER BY event_count DESC",
params,
) as cur:
rows = await cur.fetchall()
return total, [
{"jail": str(r[0]), "event_count": int(r[1])}
for r in rows
]
async def get_ban_counts_by_bucket(
db: aiosqlite.Connection,
since: int,
bucket_secs: int,
num_buckets: int,
origin: BanOrigin | None = None,
action: str | None = None,
) -> list[int]:
"""Return ban counts bucketed by time using SQL aggregation.
Args:
db: Active aiosqlite connection.
since: Start of the time window (Unix timestamp).
bucket_secs: Width of each bucket in seconds.
num_buckets: Total number of buckets in the window.
origin: If given, filter by ban origin.
action: If given, filter to this action type ('ban' or 'unban').
Returns:
List of int counts, one per bucket, indexed by bucket index.
"""
wheres: list[str] = ["timeofban >= ?"]
params: list[object] = [since]
if origin == "blocklist":
wheres.append("jail = ?")
params.append(BLOCKLIST_JAIL)
elif origin == "selfblock":
wheres.append("jail != ?")
params.append(BLOCKLIST_JAIL)
if action is not None:
wheres.append("action = ?")
params.append(action)
where_sql = "WHERE " + " AND ".join(wheres)
async with db.execute(
"SELECT CAST((timeofban - ?) / ? AS INTEGER) AS bucket_idx, "
"COUNT(*) AS cnt "
"FROM history_archive "
f"{where_sql} GROUP BY bucket_idx ORDER BY bucket_idx",
(since, bucket_secs, *params),
) as cur:
rows = await cur.fetchall()
counts: list[int] = [0] * num_buckets
for row in rows:
idx: int = int(row[0])
if 0 <= idx < num_buckets:
counts[idx] = int(row[1])
return counts

View File

@@ -301,6 +301,37 @@ class HistoryArchiveRepository(Protocol):
async def purge_archived_history(self, db: aiosqlite.Connection, age_seconds: int) -> int:
...
async def get_ip_ban_counts(
self,
db: aiosqlite.Connection,
since: int | None = None,
jail: str | None = None,
ip_filter: str | list[str] | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
) -> list[dict[str, Any]]:
...
async def get_jail_ban_counts(
self,
db: aiosqlite.Connection,
since: int | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
) -> tuple[int, list[dict[str, Any]]]:
...
async def get_ban_counts_by_bucket(
self,
db: aiosqlite.Connection,
since: int,
bucket_secs: int,
num_buckets: int,
origin: BanOrigin | None = None,
action: str | None = None,
) -> list[int]:
...
class Fail2BanDbRepository(Protocol):
async def check_db_nonempty(self, db_path: str) -> bool:

View File

@@ -2,9 +2,9 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Path, Query, Request, status
from fastapi import APIRouter, Depends, Path, Query, Request, status
from app.dependencies import AuthDep, Fail2BanConfigDirDep, Fail2BanSocketDep
from app.dependencies import AuthDep, Fail2BanConfigDirDep, Fail2BanSocketDep, GlobalRateLimiterDep
from app.models.config import (
ActionConfig,
ActionCreateRequest,
@@ -12,9 +12,108 @@ from app.models.config import (
ActionUpdateRequest,
)
from app.services import action_config_service
from app.utils.constants import (
RATE_LIMIT_ACTION_CREATE_REQUESTS,
RATE_LIMIT_ACTION_DELETE_REQUESTS,
RATE_LIMIT_ACTION_UPDATE_REQUESTS,
)
router: APIRouter = APIRouter(prefix="/actions", tags=["Action Config"])
_MINUTE = 60
_ACTION_UPDATE_BUCKET = "action:update"
_ACTION_CREATE_BUCKET = "action:create"
_ACTION_DELETE_BUCKET = "action:delete"
def _check_action_update_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for action update 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(
_ACTION_UPDATE_BUCKET, client_ip, RATE_LIMIT_ACTION_UPDATE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
import structlog
log = structlog.get_logger()
log.warning(
"action_update_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for action update operations. Please try again later.",
retry_after_seconds=retry_after,
)
def _check_action_create_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for action create 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(
_ACTION_CREATE_BUCKET, client_ip, RATE_LIMIT_ACTION_CREATE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
import structlog
log = structlog.get_logger()
log.warning(
"action_create_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for action create operations. Please try again later.",
retry_after_seconds=retry_after,
)
def _check_action_delete_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for action delete 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(
_ACTION_DELETE_BUCKET, client_ip, RATE_LIMIT_ACTION_DELETE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
import structlog
log = structlog.get_logger()
log.warning(
"action_delete_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for action delete operations. Please try again later.",
retry_after_seconds=retry_after,
)
_ActionNamePath = Annotated[
str,
Path(description='Action base name, e.g. ``iptables`` or ``iptables.conf``.'),
@@ -105,6 +204,7 @@ async def get_action(
"/{name}",
response_model=ActionConfig,
summary="Update an action's .local override with new lifecycle command values",
dependencies=[Depends(_check_action_update_rate_limit)],
)
async def update_action(
request: Request,
@@ -145,6 +245,7 @@ async def update_action(
response_model=ActionConfig,
status_code=status.HTTP_201_CREATED,
summary="Create a new user-defined action",
dependencies=[Depends(_check_action_create_rate_limit)],
)
async def create_action(
request: Request,
@@ -182,6 +283,7 @@ async def create_action(
"/{name}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a user-created action's .local file",
dependencies=[Depends(_check_action_delete_rate_limit)],
)
async def delete_action(
request: Request,

View File

@@ -38,7 +38,7 @@ from app.utils.constants import SESSION_COOKIE_NAME
log: structlog.stdlib.BoundLogger = structlog.get_logger()
router = APIRouter(prefix="/api/auth", tags=["auth"])
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
@router.post(

View File

@@ -10,21 +10,91 @@ Manual ban and unban operations and the active-bans overview:
from __future__ import annotations
from fastapi import APIRouter, Request, status
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/bans", tags=["Bans"])
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(
@@ -73,6 +143,7 @@ async def get_active_bans(
status_code=status.HTTP_201_CREATED,
response_model=JailCommandResponse,
summary="Ban an IP address in a specific jail",
dependencies=[Depends(_check_ban_rate_limit)],
)
async def ban_ip(
request: Request,
@@ -110,6 +181,7 @@ async def ban_ip(
"",
response_model=JailCommandResponse,
summary="Unban an IP address from one or all jails",
dependencies=[Depends(_check_unban_rate_limit)],
)
async def unban_ip(
request: Request,

View File

@@ -22,13 +22,14 @@ registered *before* the ``/{id}`` routes so FastAPI resolves them correctly.
from __future__ import annotations
from fastapi import APIRouter, Query, status
from fastapi import APIRouter, Depends, Query, Request, status
from app.dependencies import (
AuthDep,
BlocklistServiceContextDep,
Fail2BanSocketDep,
GeoCacheDep,
GlobalRateLimiterDep,
HttpSessionDep,
SchedulerDep,
SettingsDep,
@@ -48,9 +49,43 @@ from app.models.blocklist import (
)
from app.services import ban_service, blocklist_service
from app.tasks.blocklist_import import run_import_with_resources
from app.utils.constants import DEFAULT_PAGE_SIZE
from app.utils.constants import DEFAULT_PAGE_SIZE, RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS
router: APIRouter = APIRouter(prefix="/api/blocklists", tags=["Blocklists"])
router: APIRouter = APIRouter(prefix="/api/v1/blocklists", tags=["Blocklists"])
# Rate limit bucket constants
_BLOCKLIST_IMPORT_BUCKET = "blocklist:import"
# 3600 seconds per hour
_HOUR = 3600
def _check_blocklist_import_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for blocklist import 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(
_BLOCKLIST_IMPORT_BUCKET, client_ip, RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS, _HOUR
)
if not is_allowed:
from app.exceptions import RateLimitError
import structlog
log = structlog.get_logger()
log.warning(
"blocklist_import_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for blocklist import. Please try again later.",
retry_after_seconds=retry_after,
)
# ---------------------------------------------------------------------------
@@ -121,6 +156,7 @@ async def create_blocklist(
"/import",
response_model=ImportRunResult,
summary="Trigger a manual blocklist import",
dependencies=[Depends(_check_blocklist_import_rate_limit)],
)
async def run_import_now(
http_session: HttpSessionDep,

View File

@@ -4,7 +4,7 @@ from fastapi import APIRouter
from app.routers import action_config, config_misc, filter_config, jail_config
router: APIRouter = APIRouter(prefix="/api/config", tags=["Config"])
router: APIRouter = APIRouter(prefix="/api/v1/config", tags=["Config"])
router.include_router(jail_config.router)
router.include_router(filter_config.router)

View File

@@ -5,13 +5,14 @@ from pathlib import Path
from typing import Annotated
import structlog
from fastapi import APIRouter, Query, Request, status
from fastapi import APIRouter, Depends, Query, Request, status
from app.config import get_settings
from app.dependencies import (
AuthDep,
Fail2BanSocketDep,
Fail2BanStartCommandDep,
GlobalRateLimiterDep,
SettingsServiceContextDep,
)
from app.exceptions import OperationError
@@ -33,11 +34,46 @@ from app.services import (
jail_service,
log_service,
)
from app.utils.constants import RATE_LIMIT_CONFIG_UPDATE_REQUESTS
log: structlog.stdlib.BoundLogger = structlog.get_logger()
router: APIRouter = APIRouter(tags=["Config Misc"])
# Rate limit bucket constants
_CONFIG_UPDATE_BUCKET = "config:update"
# 60 seconds per minute
_MINUTE = 60
def _check_config_update_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for config update 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(
_CONFIG_UPDATE_BUCKET, client_ip, RATE_LIMIT_CONFIG_UPDATE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
import structlog
log = structlog.get_logger()
log.warning(
"config_update_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for config updates. Please try again later.",
retry_after_seconds=retry_after,
)
def _validate_log_target(value: str) -> None:
"""Validate that log_target is either a special value or a valid file path.
@@ -103,6 +139,7 @@ async def get_global_config(
"/global",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update global fail2ban settings",
dependencies=[Depends(_check_config_update_rate_limit)],
)
async def update_global_config(
_request: Request,
@@ -296,6 +333,7 @@ async def get_map_color_thresholds(
"/map-color-thresholds",
response_model=MapColorThresholdsResponse,
summary="Update map color threshold configuration",
dependencies=[Depends(_check_config_update_rate_limit)],
)
async def update_map_color_thresholds(
_request: Request,

View File

@@ -43,7 +43,7 @@ from app.models.server import ServerStatus, ServerStatusResponse
from app.services import ban_service
from app.utils.constants import DEFAULT_PAGE_SIZE
router: APIRouter = APIRouter(prefix="/api/dashboard", tags=["Dashboard"])
router: APIRouter = APIRouter(prefix="/api/v1/dashboard", tags=["Dashboard"])
# ---------------------------------------------------------------------------
# Default pagination constants

View File

@@ -53,7 +53,7 @@ from app.models.file_config import (
)
from app.services import raw_config_io_service
router: APIRouter = APIRouter(prefix="/api/config", tags=["Config"])
router: APIRouter = APIRouter(prefix="/api/v1/config", tags=["Config"])
# ---------------------------------------------------------------------------
# Path type aliases

View File

@@ -2,9 +2,9 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Path, Query, Request, status
from fastapi import APIRouter, Depends, Path, Query, Request, status
from app.dependencies import AuthDep, Fail2BanConfigDirDep, Fail2BanSocketDep
from app.dependencies import AuthDep, Fail2BanConfigDirDep, Fail2BanSocketDep, GlobalRateLimiterDep
from app.mappers import config_mappers
from app.models.config import (
FilterConfig,
@@ -13,14 +13,114 @@ from app.models.config import (
FilterUpdateRequest,
)
from app.services import filter_config_service
from app.utils.constants import (
RATE_LIMIT_FILTER_CREATE_REQUESTS,
RATE_LIMIT_FILTER_DELETE_REQUESTS,
RATE_LIMIT_FILTER_UPDATE_REQUESTS,
)
router: APIRouter = APIRouter(prefix="/filters", tags=["Filter Config"])
_MINUTE = 60
_FILTER_UPDATE_BUCKET = "filter:update"
_FILTER_CREATE_BUCKET = "filter:create"
_FILTER_DELETE_BUCKET = "filter:delete"
def _check_filter_update_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for filter update 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(
_FILTER_UPDATE_BUCKET, client_ip, RATE_LIMIT_FILTER_UPDATE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
import structlog
log = structlog.get_logger()
log.warning(
"filter_update_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for filter update operations. Please try again later.",
retry_after_seconds=retry_after,
)
def _check_filter_create_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for filter create 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(
_FILTER_CREATE_BUCKET, client_ip, RATE_LIMIT_FILTER_CREATE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
import structlog
log = structlog.get_logger()
log.warning(
"filter_create_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for filter create operations. Please try again later.",
retry_after_seconds=retry_after,
)
def _check_filter_delete_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for filter delete 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(
_FILTER_DELETE_BUCKET, client_ip, RATE_LIMIT_FILTER_DELETE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
import structlog
log = structlog.get_logger()
log.warning(
"filter_delete_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for filter delete operations. Please try again later.",
retry_after_seconds=retry_after,
)
_FilterNamePath = Annotated[
str,
Path(description='Filter base name, e.g. ``sshd`` or ``sshd.conf``.'),
]
@router.get(
"",
response_model=FilterListResponse,
@@ -107,6 +207,7 @@ _FilterNamePath = Annotated[
"/{name}",
response_model=FilterConfig,
summary="Update a filter's .local override with new regex/pattern values",
dependencies=[Depends(_check_filter_update_rate_limit)],
)
async def update_filter(
request: Request,
@@ -158,6 +259,7 @@ async def update_filter(
response_model=FilterConfig,
status_code=status.HTTP_201_CREATED,
summary="Create a new user-defined filter",
dependencies=[Depends(_check_filter_create_rate_limit)],
)
async def create_filter(
request: Request,
@@ -206,6 +308,7 @@ async def create_filter(
"/{name}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a user-created filter's .local file",
dependencies=[Depends(_check_filter_delete_rate_limit)],
)
async def delete_filter(
request: Request,

View File

@@ -21,7 +21,7 @@ from app.dependencies import (
from app.models.geo import GeoCacheStatsResponse, GeoReResolveResponse, IpLookupResponse
from app.services import geo_service, jail_service
router: APIRouter = APIRouter(prefix="/api/geo", tags=["Geo"])
router: APIRouter = APIRouter(prefix="/api/v1/geo", tags=["Geo"])
_IpPath = Annotated[str, Path(description="IPv4 or IPv6 address to look up.")]

View File

@@ -11,10 +11,10 @@ from fastapi.responses import JSONResponse
from app.dependencies import ServerStatusDep
router: APIRouter = APIRouter(prefix="/api", tags=["Health"])
router: APIRouter = APIRouter(prefix="/api/v1/health", tags=["Health"])
@router.get("/health", summary="Application health check")
@router.get("", summary="Application health check")
async def health_check(server_status: ServerStatusDep) -> JSONResponse:
"""Return application and fail2ban status.

View File

@@ -34,7 +34,7 @@ from app.models.history import HistoryListResponse, IpDetailResponse
from app.services import history_service
from app.utils.constants import DEFAULT_PAGE_SIZE
router: APIRouter = APIRouter(prefix="/api/history", tags=["History"])
router: APIRouter = APIRouter(prefix="/api/v1/history", tags=["History"])
@router.get(

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import shlex
from typing import Annotated
from fastapi import APIRouter, Path, Query, Request, status
from fastapi import APIRouter, Depends, Path, Query, Request, status
from app.dependencies import (
AppDep,
@@ -11,6 +11,7 @@ from app.dependencies import (
Fail2BanConfigDirDep,
Fail2BanSocketDep,
Fail2BanStartCommandDep,
GlobalRateLimiterDep,
HealthProbeDep,
PendingRecoveryDep,
)
@@ -37,6 +38,13 @@ from app.services import (
jail_config_service,
)
from app.utils.path_utils import validate_log_path
from app.utils.constants import (
RATE_LIMIT_JAIL_ACTIVATE_REQUESTS,
RATE_LIMIT_JAIL_CREATE_REQUESTS,
RATE_LIMIT_JAIL_DEACTIVATE_REQUESTS,
RATE_LIMIT_JAIL_DELETE_REQUESTS,
RATE_LIMIT_JAIL_UPDATE_REQUESTS,
)
from app.utils.runtime_state import (
clear_activation_record,
clear_pending_recovery,
@@ -45,6 +53,160 @@ from app.utils.runtime_state import (
router: APIRouter = APIRouter(prefix="/jails", tags=["Jail Config"])
_MINUTE = 60
_JAIL_UPDATE_BUCKET = "jail:update"
_JAIL_CREATE_BUCKET = "jail:create"
_JAIL_DELETE_BUCKET = "jail:delete"
_JAIL_ACTIVATE_BUCKET = "jail:activate"
_JAIL_DEACTIVATE_BUCKET = "jail:deactivate"
def _check_jail_update_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for jail update 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(
_JAIL_UPDATE_BUCKET, client_ip, RATE_LIMIT_JAIL_UPDATE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
import structlog
log = structlog.get_logger()
log.warning(
"jail_update_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for jail update operations. Please try again later.",
retry_after_seconds=retry_after,
)
def _check_jail_create_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for jail create 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(
_JAIL_CREATE_BUCKET, client_ip, RATE_LIMIT_JAIL_CREATE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
import structlog
log = structlog.get_logger()
log.warning(
"jail_create_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for jail create operations. Please try again later.",
retry_after_seconds=retry_after,
)
def _check_jail_delete_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for jail delete 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(
_JAIL_DELETE_BUCKET, client_ip, RATE_LIMIT_JAIL_DELETE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
import structlog
log = structlog.get_logger()
log.warning(
"jail_delete_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for jail delete operations. Please try again later.",
retry_after_seconds=retry_after,
)
def _check_jail_activate_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for jail activate 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(
_JAIL_ACTIVATE_BUCKET, client_ip, RATE_LIMIT_JAIL_ACTIVATE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
import structlog
log = structlog.get_logger()
log.warning(
"jail_activate_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for jail activate operations. Please try again later.",
retry_after_seconds=retry_after,
)
def _check_jail_deactivate_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for jail deactivate 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(
_JAIL_DEACTIVATE_BUCKET, client_ip, RATE_LIMIT_JAIL_DEACTIVATE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
import structlog
log = structlog.get_logger()
log.warning(
"jail_deactivate_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for jail deactivate operations. Please try again later.",
retry_after_seconds=retry_after,
)
_NamePath = Annotated[str, Path(description='Jail name as configured in fail2ban.')]
@router.get(
@@ -162,6 +324,7 @@ async def get_jail_config(
"/{name}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update jail configuration",
dependencies=[Depends(_check_jail_update_rate_limit)],
)
async def update_jail_config(
request: Request,
@@ -201,6 +364,7 @@ async def update_jail_config(
"/{name}/logpath",
status_code=status.HTTP_204_NO_CONTENT,
summary="Add a log file path to an existing jail",
dependencies=[Depends(_check_jail_create_rate_limit)],
)
async def add_log_path(
request: Request,
@@ -235,6 +399,7 @@ async def add_log_path(
"/{name}/logpath",
status_code=status.HTTP_204_NO_CONTENT,
summary="Remove a monitored log path from a jail",
dependencies=[Depends(_check_jail_delete_rate_limit)],
)
async def delete_log_path(
request: Request,
@@ -274,6 +439,7 @@ async def delete_log_path(
"/{name}/activate",
response_model=JailActivationResponse,
summary="Activate an inactive jail",
dependencies=[Depends(_check_jail_activate_rate_limit)],
)
async def activate_jail(
app: AppDep,
@@ -327,6 +493,7 @@ async def activate_jail(
"/{name}/deactivate",
response_model=JailActivationResponse,
summary="Deactivate an active jail",
dependencies=[Depends(_check_jail_deactivate_rate_limit)],
)
async def deactivate_jail(
_auth: AuthDep,
@@ -486,6 +653,7 @@ async def rollback_jail(
"/{name}/filter",
status_code=status.HTTP_204_NO_CONTENT,
summary="Assign a filter to a jail",
dependencies=[Depends(_check_jail_create_rate_limit)],
)
async def assign_filter_to_jail(
request: Request,
@@ -520,6 +688,7 @@ async def assign_filter_to_jail(
"/{name}/action",
status_code=status.HTTP_204_NO_CONTENT,
summary="Add an action to a jail",
dependencies=[Depends(_check_jail_create_rate_limit)],
)
async def assign_action_to_jail(
request: Request,
@@ -555,6 +724,7 @@ async def assign_action_to_jail(
"/{name}/action/{action_name}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Remove an action from a jail",
dependencies=[Depends(_check_jail_delete_rate_limit)],
)
async def remove_action_from_jail(
request: Request,

View File

@@ -44,7 +44,7 @@ from app.models.jail import (
)
from app.services import jail_service
router: APIRouter = APIRouter(prefix="/api/jails", tags=["Jails"])
router: APIRouter = APIRouter(prefix="/api/v1/jails", tags=["Jails"])
_NamePath = Annotated[str, Path(description="Jail name as configured in fail2ban.")]

View File

@@ -17,7 +17,7 @@ from app.mappers import server_mappers
from app.models.server import ServerSettingsResponse, ServerSettingsUpdate
from app.services import server_service
router: APIRouter = APIRouter(prefix="/api/server", tags=["Server"])
router: APIRouter = APIRouter(prefix="/api/v1/server", tags=["Server"])
# ---------------------------------------------------------------------------

View File

@@ -19,7 +19,7 @@ from app.utils.setup_state import is_setup_complete_cached, set_setup_complete_c
log: structlog.stdlib.BoundLogger = structlog.get_logger()
router = APIRouter(prefix="/api/setup", tags=["setup"])
router = APIRouter(prefix="/api/v1/setup", tags=["setup"])
@router.get(

View File

@@ -572,19 +572,19 @@ async def bans_by_country(
if app_db is None:
raise ValueError("app_db must be provided when source is 'archive'")
all_rows = await history_archive_repo.get_all_archived_history(
# SQL aggregation — no row materialisation into Python memory.
ip_counts = await history_archive_repo.get_ip_ban_counts(
db=app_db,
since=since,
origin=origin,
action="ban",
)
total = len(all_rows)
# Total = sum of all event counts.
total = sum(int(row["event_count"]) for row in ip_counts)
agg_rows = {}
for row in all_rows:
ip = str(row["ip"])
agg_rows[ip] = agg_rows.get(ip, 0) + 1
# {ip: event_count} for downstream geo aggregation.
agg_rows = {row["ip"]: int(row["event_count"]) for row in ip_counts}
unique_ips = list(agg_rows.keys())
else:
@@ -653,7 +653,7 @@ async def bans_by_country(
results = await asyncio.gather(*(_safe_lookup(ip) for ip in unique_ips))
geo_map = {ip: geo for ip, geo in results if geo is not None}
companion_rows: list[dict[str, object] | fail2ban_db_repo.BanRecord]
companion_rows: list[dict[str, Any] | fail2ban_db_repo.BanRecord]
if country_code is None:
if source == "archive":
companion_rows, _ = await history_archive_repo.get_archived_history(
@@ -681,12 +681,15 @@ async def bans_by_country(
if source == "archive":
if matched_ips:
companion_rows = await history_archive_repo.get_all_archived_history(
# Use keyset pagination instead of loading all matched IPs at once.
companion_rows, _ = await history_archive_repo.get_archived_history(
db=app_db,
since=since,
origin=origin,
action="ban",
ip_filter=matched_ips,
page=1,
page_size=_MAX_COMPANION_BANS,
)
else:
companion_rows = []
@@ -830,20 +833,16 @@ async def ban_trend(
if app_db is None:
raise ValueError("app_db must be provided when source is 'archive'")
all_rows = await history_archive_repo.get_all_archived_history(
# SQL aggregation — no row materialisation into Python memory.
counts = await history_archive_repo.get_ban_counts_by_bucket(
db=app_db,
since=since,
bucket_secs=bucket_secs,
num_buckets=num_buckets,
origin=origin,
action="ban",
)
counts: list[int] = [0] * num_buckets
for row in all_rows:
timeofban = int(row["timeofban"])
bucket_index = int((timeofban - since) / bucket_secs)
if 0 <= bucket_index < num_buckets:
counts[bucket_index] += 1
log.info(
"ban_service_ban_trend",
source=source,
@@ -928,22 +927,17 @@ async def bans_by_jail(
if app_db is None:
raise ValueError("app_db must be provided when source is 'archive'")
all_rows = await history_archive_repo.get_all_archived_history(
# SQL aggregation — no row materialisation into Python memory.
total, jail_rows = await history_archive_repo.get_jail_ban_counts(
db=app_db,
since=since,
origin=origin,
action="ban",
)
jail_counter: dict[str, int] = {}
for row in all_rows:
jail_name = str(row["jail"])
jail_counter[jail_name] = jail_counter.get(jail_name, 0) + 1
total = sum(jail_counter.values())
jail_counts = [
DomainJailBanCount(jail=jail_name, count=count)
for jail_name, count in sorted(jail_counter.items(), key=lambda x: x[1], reverse=True)
DomainJailBanCount(jail=str(row["jail"]), count=int(row["event_count"]))
for row in jail_rows
]
log.debug(

View File

@@ -104,3 +104,52 @@ BLOCKLIST_PREVIEW_MAX_LINES: Final[int] = 100
HEALTH_CHECK_INTERVAL_SECONDS: Final[int] = 30
"""How often the background health-check task polls fail2ban."""
# ---------------------------------------------------------------------------
# Rate limits (per IP)
# ---------------------------------------------------------------------------
RATE_LIMIT_BANS_BAN_REQUESTS: Final[int] = 100
"""Max ban requests per IP per minute."""
RATE_LIMIT_BANS_UNBAN_REQUESTS: Final[int] = 100
"""Max unban requests per IP per minute."""
RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS: Final[int] = 10
"""Max blocklist import requests per IP per hour."""
RATE_LIMIT_CONFIG_UPDATE_REQUESTS: Final[int] = 50
"""Max config update requests per IP per minute."""
RATE_LIMIT_FILTER_UPDATE_REQUESTS: Final[int] = 50
"""Max filter config update requests per IP per minute."""
RATE_LIMIT_FILTER_CREATE_REQUESTS: Final[int] = 50
"""Max filter config create requests per IP per minute."""
RATE_LIMIT_FILTER_DELETE_REQUESTS: Final[int] = 50
"""Max filter config delete requests per IP per minute."""
RATE_LIMIT_ACTION_UPDATE_REQUESTS: Final[int] = 50
"""Max action config update requests per IP per minute."""
RATE_LIMIT_ACTION_CREATE_REQUESTS: Final[int] = 50
"""Max action config create requests per IP per minute."""
RATE_LIMIT_ACTION_DELETE_REQUESTS: Final[int] = 50
"""Max action config delete requests per IP per minute."""
RATE_LIMIT_JAIL_UPDATE_REQUESTS: Final[int] = 100
"""Max jail config update requests per IP per minute."""
RATE_LIMIT_JAIL_CREATE_REQUESTS: Final[int] = 100
"""Max jail config create requests per IP per minute."""
RATE_LIMIT_JAIL_DELETE_REQUESTS: Final[int] = 100
"""Max jail config delete requests per IP per minute."""
RATE_LIMIT_JAIL_ACTIVATE_REQUESTS: Final[int] = 100
"""Max jail activation requests per IP per minute."""
RATE_LIMIT_JAIL_DEACTIVATE_REQUESTS: Final[int] = 100
"""Max jail deactivation requests per IP per minute."""

View File

@@ -228,12 +228,16 @@ class GlobalRateLimiter:
blocked until the oldest request expires.
4. A background cleanup task removes dormant IPs from memory periodically.
**Per-Endpoint Configuration:**
**Per-Bucket Configuration:**
Different endpoints can have different limits. For example:
- Login endpoint: 5 requests per 60 seconds
- Dashboard read: 100 requests per 60 seconds
- Config write: 20 requests per 60 seconds
Different endpoints can have different limits via named buckets:
- `bans:ban` — 100/minute per IP (ban operations)
- `bans:unban` — 100/minute per IP (unban operations)
- `blocklist:import` — 10/hour per IP (import operations)
- `config:update` — 50/minute per IP (config write operations)
Each bucket tracks its own requests independently, so hitting the
blocklist:import limit does not affect the bans:ban limit.
"""
def __init__(
@@ -250,6 +254,32 @@ class GlobalRateLimiter:
self.max_requests: int = max_requests
self.window_seconds: int = window_seconds
self._requests: dict[str, deque[float]] = {}
self._buckets: dict[str, dict[str, deque[float]]] = {}
def _get_bucket_deque(
self,
bucket: str,
ip_address: str,
max_requests: int,
window_seconds: int,
) -> deque[float]:
"""Get or create the deque for a specific bucket and IP.
Args:
bucket: Bucket name (e.g., "bans:ban").
ip_address: Client IP address.
max_requests: Maximum requests for this bucket (unused, for future).
window_seconds: Window in seconds (unused, for future).
Returns:
The deque of timestamps for this bucket+IP.
"""
if bucket not in self._buckets:
self._buckets[bucket] = {}
bucket_dict = self._buckets[bucket]
if ip_address not in bucket_dict:
bucket_dict[ip_address] = deque()
return bucket_dict[ip_address]
def check_allowed(self, ip_address: str) -> tuple[bool, float]:
"""Check if a request from *ip_address* is allowed.
@@ -292,6 +322,53 @@ class GlobalRateLimiter:
return False, retry_after
def check_allowed_for_bucket(
self,
bucket: str,
ip_address: str,
max_requests: int,
window_seconds: int,
) -> tuple[bool, float]:
"""Check if a request for a specific bucket is allowed.
Each bucket has independent rate limiting. This allows different
endpoints to have different limits (e.g., blocklist import is more
restrictive than ban operations).
Args:
bucket: Bucket name (e.g., "bans:ban", "blocklist:import").
ip_address: The client IP address to rate-limit.
max_requests: Maximum requests allowed within the window.
window_seconds: Time window (seconds) for this bucket.
Returns:
A tuple of (is_allowed, retry_after_seconds). If is_allowed is True,
retry_after_seconds is 0. If False, it's the estimated time to wait.
"""
now = time()
requests = self._get_bucket_deque(bucket, ip_address, max_requests, window_seconds)
cutoff = now - window_seconds
# Remove old requests outside the window
while requests and requests[0] < cutoff:
requests.popleft()
# If under the limit, allow the request
if len(requests) < max_requests:
requests.append(now)
return True, 0.0
# Over the limit: calculate how long to wait
oldest_request = requests[0]
age = now - oldest_request
retry_after = window_seconds - age
# Ensure retry_after is at least 1 second
retry_after = max(retry_after, 1.0)
return False, retry_after
def cleanup_expired(self) -> None:
"""Remove all IPs with no recent requests (cleanup task).
@@ -316,6 +393,21 @@ class GlobalRateLimiter:
if ips_to_remove:
log.debug("global_rate_limiter_cleanup", removed_ips=len(ips_to_remove))
# Cleanup per-bucket dictionaries
for bucket, bucket_dict in list(self._buckets.items()):
bucket_ips_to_remove = []
bucket_window = 60 # Use a reasonable window for bucket cleanup
bucket_cutoff = now - bucket_window
for ip_address, requests in bucket_dict.items():
while requests and requests[0] < bucket_cutoff:
requests.popleft()
if not requests:
bucket_ips_to_remove.append(ip_address)
for ip_address in bucket_ips_to_remove:
del bucket_dict[ip_address]
if not bucket_dict:
del self._buckets[bucket]
def get_state(self) -> Mapping[str, int]:
"""Return a read-only view of current request counts per IP.
@@ -334,6 +426,30 @@ class GlobalRateLimiter:
result[ip_address] = count
return result
def get_bucket_state(self, bucket: str) -> Mapping[str, int]:
"""Return a read-only view of current request counts per IP for a bucket.
For debugging and monitoring.
Args:
bucket: Bucket name to get state for.
Returns:
A mapping of IP addresses to their request counts in this bucket.
"""
if bucket not in self._buckets:
return {}
now = time()
result = {}
for ip_address, requests in self._buckets[bucket].items():
# Count non-expired requests (use max window of 3600s for hourly buckets)
cutoff = now - 3600
count = sum(1 for ts in requests if ts >= cutoff)
if count > 0:
result[ip_address] = count
return result
def reset(self) -> None:
"""Clear all tracked requests (for testing)."""
self._requests.clear()
self._buckets.clear()