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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user