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

@@ -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(