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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.")]
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.")]
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user