Add better jail configuration: file CRUD, enable/disable, log paths
Task 4 (Better Jail Configuration) implementation:
- Add fail2ban_config_dir setting to app/config.py
- New file_config_service: list/view/edit/create jail.d, filter.d, action.d files
with path-traversal prevention and 512 KB content size limit
- New file_config router: GET/PUT/POST endpoints for jail files, filter files,
and action files; PUT .../enabled for toggle on/off
- Extend config_service with delete_log_path() and add_log_path()
- Add DELETE /api/config/jails/{name}/logpath and POST /api/config/jails/{name}/logpath
- Extend geo router with re-resolve endpoint; add geo_re_resolve background task
- Update blocklist_service with revised scheduling helpers
- Update Docker compose files with BANGUI_FAIL2BAN_CONFIG_DIR env var and
rw volume mount for the fail2ban config directory
- Frontend: new Jail Files, Filters, Actions tabs in ConfigPage; file editor
with accordion-per-file, editable textarea, save/create; add/delete log paths
- Frontend: types in types/config.ts; API calls in api/config.ts and api/endpoints.ts
- 63 new backend tests (test_file_config_service, test_file_config, test_geo_re_resolve)
- 6 new frontend tests in ConfigPageLogPath.test.tsx
- ruff, mypy --strict, tsc --noEmit, eslint: all clean; 617 backend tests pass
This commit is contained in:
@@ -18,7 +18,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Path, Request, status
|
||||
from fastapi import APIRouter, HTTPException, Path, Query, Request, status
|
||||
|
||||
from app.dependencies import AuthDep
|
||||
from app.models.config import (
|
||||
@@ -354,9 +354,42 @@ async def add_log_path(
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log preview
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.delete(
|
||||
"/jails/{name}/logpath",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Remove a monitored log path from a jail",
|
||||
)
|
||||
async def delete_log_path(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
log_path: str = Query(..., description="Absolute path of the log file to stop monitoring."),
|
||||
) -> None:
|
||||
"""Stop a jail from monitoring the specified log file.
|
||||
|
||||
Uses ``set <jail> dellogpath <path>`` to remove the log path at runtime
|
||||
without requiring a daemon restart.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
name: Jail name.
|
||||
log_path: Absolute path to the log file to remove (query parameter).
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 when the jail does not exist.
|
||||
HTTPException: 400 when the command is rejected.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
await config_service.delete_log_path(socket_path, name, log_path)
|
||||
except JailNotFoundError:
|
||||
raise _not_found(name) from None
|
||||
except ConfigOperationError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
495
backend/app/routers/file_config.py
Normal file
495
backend/app/routers/file_config.py
Normal file
@@ -0,0 +1,495 @@
|
||||
"""File-based fail2ban configuration router.
|
||||
|
||||
Provides endpoints to list, view, edit, and create fail2ban configuration
|
||||
files directly on the filesystem (``jail.d/``, ``filter.d/``, ``action.d/``).
|
||||
|
||||
Endpoints:
|
||||
* ``GET /api/config/jail-files`` — list all jail config files
|
||||
* ``GET /api/config/jail-files/{filename}`` — get one jail config file (with content)
|
||||
* ``PUT /api/config/jail-files/{filename}/enabled`` — enable/disable a jail config
|
||||
* ``GET /api/config/filters`` — list all filter files
|
||||
* ``GET /api/config/filters/{name}`` — get one filter file (with content)
|
||||
* ``PUT /api/config/filters/{name}`` — update a filter file
|
||||
* ``POST /api/config/filters`` — create a new filter file
|
||||
* ``GET /api/config/actions`` — list all action files
|
||||
* ``GET /api/config/actions/{name}`` — get one action file (with content)
|
||||
* ``PUT /api/config/actions/{name}`` — update an action file
|
||||
* ``POST /api/config/actions`` — create a new action file
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Path, Request, status
|
||||
|
||||
from app.dependencies import AuthDep
|
||||
from app.models.file_config import (
|
||||
ConfFileContent,
|
||||
ConfFileCreateRequest,
|
||||
ConfFilesResponse,
|
||||
ConfFileUpdateRequest,
|
||||
JailConfigFileContent,
|
||||
JailConfigFileEnabledUpdate,
|
||||
JailConfigFilesResponse,
|
||||
)
|
||||
from app.services import file_config_service
|
||||
from app.services.file_config_service import (
|
||||
ConfigDirError,
|
||||
ConfigFileExistsError,
|
||||
ConfigFileNameError,
|
||||
ConfigFileNotFoundError,
|
||||
ConfigFileWriteError,
|
||||
)
|
||||
|
||||
router: APIRouter = APIRouter(prefix="/api/config", tags=["Config"])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path type aliases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FilenamePath = Annotated[
|
||||
str, Path(description="Config filename including extension (e.g. ``sshd.conf``).")
|
||||
]
|
||||
_NamePath = Annotated[
|
||||
str, Path(description="Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).")
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _not_found(filename: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Config file not found: {filename!r}",
|
||||
)
|
||||
|
||||
|
||||
def _bad_request(message: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=message,
|
||||
)
|
||||
|
||||
|
||||
def _conflict(filename: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Config file already exists: {filename!r}",
|
||||
)
|
||||
|
||||
|
||||
def _service_unavailable(message: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=message,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jail config file endpoints (Task 4a)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/jail-files",
|
||||
response_model=JailConfigFilesResponse,
|
||||
summary="List all jail config files",
|
||||
)
|
||||
async def list_jail_config_files(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
) -> JailConfigFilesResponse:
|
||||
"""Return metadata for every ``.conf`` and ``.local`` file in ``jail.d/``.
|
||||
|
||||
The ``enabled`` field reflects the value of the ``enabled`` key inside the
|
||||
file (defaulting to ``true`` when the key is absent).
|
||||
|
||||
Args:
|
||||
request: Incoming request (used for ``app.state.settings``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.file_config.JailConfigFilesResponse`.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
try:
|
||||
return await file_config_service.list_jail_config_files(config_dir)
|
||||
except ConfigDirError as exc:
|
||||
raise _service_unavailable(str(exc)) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/jail-files/{filename}",
|
||||
response_model=JailConfigFileContent,
|
||||
summary="Return a single jail config file with its content",
|
||||
)
|
||||
async def get_jail_config_file(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
filename: _FilenamePath,
|
||||
) -> JailConfigFileContent:
|
||||
"""Return the metadata and raw content of one jail config file.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
filename: Filename including extension (e.g. ``sshd.conf``).
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.file_config.JailConfigFileContent`.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if *filename* is unsafe.
|
||||
HTTPException: 404 if the file does not exist.
|
||||
HTTPException: 503 if the config directory is unavailable.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
try:
|
||||
return await file_config_service.get_jail_config_file(config_dir, filename)
|
||||
except ConfigFileNameError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except ConfigFileNotFoundError:
|
||||
raise _not_found(filename) from None
|
||||
except ConfigDirError as exc:
|
||||
raise _service_unavailable(str(exc)) from exc
|
||||
|
||||
|
||||
@router.put(
|
||||
"/jail-files/{filename}/enabled",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Enable or disable a jail configuration file",
|
||||
)
|
||||
async def set_jail_config_file_enabled(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
filename: _FilenamePath,
|
||||
body: JailConfigFileEnabledUpdate,
|
||||
) -> None:
|
||||
"""Set the ``enabled = true/false`` key inside a jail config file.
|
||||
|
||||
The change modifies the file on disk. You must reload fail2ban
|
||||
(``POST /api/config/reload``) separately for the change to take effect.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
filename: Filename of the jail config file (e.g. ``sshd.conf``).
|
||||
body: New enabled state.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if *filename* is unsafe or the operation fails.
|
||||
HTTPException: 404 if the file does not exist.
|
||||
HTTPException: 503 if the config directory is unavailable.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
try:
|
||||
await file_config_service.set_jail_config_enabled(
|
||||
config_dir, filename, body.enabled
|
||||
)
|
||||
except ConfigFileNameError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except ConfigFileNotFoundError:
|
||||
raise _not_found(filename) from None
|
||||
except ConfigFileWriteError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except ConfigDirError as exc:
|
||||
raise _service_unavailable(str(exc)) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filter file endpoints (Task 4d)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/filters",
|
||||
response_model=ConfFilesResponse,
|
||||
summary="List all filter definition files",
|
||||
)
|
||||
async def list_filter_files(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
) -> ConfFilesResponse:
|
||||
"""Return a list of every ``.conf`` and ``.local`` file in ``filter.d/``.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.file_config.ConfFilesResponse`.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
try:
|
||||
return await file_config_service.list_filter_files(config_dir)
|
||||
except ConfigDirError as exc:
|
||||
raise _service_unavailable(str(exc)) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/filters/{name}",
|
||||
response_model=ConfFileContent,
|
||||
summary="Return a filter definition file with its content",
|
||||
)
|
||||
async def get_filter_file(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
) -> ConfFileContent:
|
||||
"""Return the content of a filter definition file.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
name: Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.file_config.ConfFileContent`.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if *name* is unsafe.
|
||||
HTTPException: 404 if the file does not exist.
|
||||
HTTPException: 503 if the config directory is unavailable.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
try:
|
||||
return await file_config_service.get_filter_file(config_dir, name)
|
||||
except ConfigFileNameError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except ConfigFileNotFoundError:
|
||||
raise _not_found(name) from None
|
||||
except ConfigDirError as exc:
|
||||
raise _service_unavailable(str(exc)) from exc
|
||||
|
||||
|
||||
@router.put(
|
||||
"/filters/{name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Update a filter definition file",
|
||||
)
|
||||
async def write_filter_file(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
body: ConfFileUpdateRequest,
|
||||
) -> None:
|
||||
"""Overwrite the content of an existing filter definition file.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
name: Base name with or without extension.
|
||||
body: New file content.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if *name* is unsafe or content exceeds the size limit.
|
||||
HTTPException: 404 if the file does not exist.
|
||||
HTTPException: 503 if the config directory is unavailable.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
try:
|
||||
await file_config_service.write_filter_file(config_dir, name, body)
|
||||
except ConfigFileNameError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except ConfigFileNotFoundError:
|
||||
raise _not_found(name) from None
|
||||
except ConfigFileWriteError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except ConfigDirError as exc:
|
||||
raise _service_unavailable(str(exc)) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/filters",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
response_model=ConfFileContent,
|
||||
summary="Create a new filter definition file",
|
||||
)
|
||||
async def create_filter_file(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
body: ConfFileCreateRequest,
|
||||
) -> ConfFileContent:
|
||||
"""Create a new ``.conf`` file in ``filter.d/``.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
body: Name and initial content for the new file.
|
||||
|
||||
Returns:
|
||||
The created :class:`~app.models.file_config.ConfFileContent`.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if *name* is invalid or content exceeds limit.
|
||||
HTTPException: 409 if a file with that name already exists.
|
||||
HTTPException: 503 if the config directory is unavailable.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
try:
|
||||
filename = await file_config_service.create_filter_file(config_dir, body)
|
||||
except ConfigFileNameError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except ConfigFileExistsError:
|
||||
raise _conflict(body.name) from None
|
||||
except ConfigFileWriteError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except ConfigDirError as exc:
|
||||
raise _service_unavailable(str(exc)) from exc
|
||||
|
||||
return ConfFileContent(
|
||||
name=body.name,
|
||||
filename=filename,
|
||||
content=body.content,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action file endpoints (Task 4e)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/actions",
|
||||
response_model=ConfFilesResponse,
|
||||
summary="List all action definition files",
|
||||
)
|
||||
async def list_action_files(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
) -> ConfFilesResponse:
|
||||
"""Return a list of every ``.conf`` and ``.local`` file in ``action.d/``.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.file_config.ConfFilesResponse`.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
try:
|
||||
return await file_config_service.list_action_files(config_dir)
|
||||
except ConfigDirError as exc:
|
||||
raise _service_unavailable(str(exc)) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/actions/{name}",
|
||||
response_model=ConfFileContent,
|
||||
summary="Return an action definition file with its content",
|
||||
)
|
||||
async def get_action_file(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
) -> ConfFileContent:
|
||||
"""Return the content of an action definition file.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
name: Base name with or without extension.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.file_config.ConfFileContent`.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if *name* is unsafe.
|
||||
HTTPException: 404 if the file does not exist.
|
||||
HTTPException: 503 if the config directory is unavailable.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
try:
|
||||
return await file_config_service.get_action_file(config_dir, name)
|
||||
except ConfigFileNameError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except ConfigFileNotFoundError:
|
||||
raise _not_found(name) from None
|
||||
except ConfigDirError as exc:
|
||||
raise _service_unavailable(str(exc)) from exc
|
||||
|
||||
|
||||
@router.put(
|
||||
"/actions/{name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Update an action definition file",
|
||||
)
|
||||
async def write_action_file(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
body: ConfFileUpdateRequest,
|
||||
) -> None:
|
||||
"""Overwrite the content of an existing action definition file.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
name: Base name with or without extension.
|
||||
body: New file content.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if *name* is unsafe or content exceeds the size limit.
|
||||
HTTPException: 404 if the file does not exist.
|
||||
HTTPException: 503 if the config directory is unavailable.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
try:
|
||||
await file_config_service.write_action_file(config_dir, name, body)
|
||||
except ConfigFileNameError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except ConfigFileNotFoundError:
|
||||
raise _not_found(name) from None
|
||||
except ConfigFileWriteError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except ConfigDirError as exc:
|
||||
raise _service_unavailable(str(exc)) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/actions",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
response_model=ConfFileContent,
|
||||
summary="Create a new action definition file",
|
||||
)
|
||||
async def create_action_file(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
body: ConfFileCreateRequest,
|
||||
) -> ConfFileContent:
|
||||
"""Create a new ``.conf`` file in ``action.d/``.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
body: Name and initial content for the new file.
|
||||
|
||||
Returns:
|
||||
The created :class:`~app.models.file_config.ConfFileContent`.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if *name* is invalid or content exceeds limit.
|
||||
HTTPException: 409 if a file with that name already exists.
|
||||
HTTPException: 503 if the config directory is unavailable.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
try:
|
||||
filename = await file_config_service.create_action_file(config_dir, body)
|
||||
except ConfigFileNameError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except ConfigFileExistsError:
|
||||
raise _conflict(body.name) from None
|
||||
except ConfigFileWriteError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except ConfigDirError as exc:
|
||||
raise _service_unavailable(str(exc)) from exc
|
||||
|
||||
return ConfFileContent(
|
||||
name=body.name,
|
||||
filename=filename,
|
||||
content=body.content,
|
||||
)
|
||||
@@ -17,7 +17,7 @@ import aiosqlite
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Request, status
|
||||
|
||||
from app.dependencies import AuthDep, get_db
|
||||
from app.models.geo import GeoDetail, IpLookupResponse
|
||||
from app.models.geo import GeoCacheStatsResponse, GeoDetail, IpLookupResponse
|
||||
from app.services import geo_service, jail_service
|
||||
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||
|
||||
@@ -99,6 +99,35 @@ async def lookup_ip(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/geo/stats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=GeoCacheStatsResponse,
|
||||
summary="Geo cache diagnostic counters",
|
||||
)
|
||||
async def geo_stats(
|
||||
_auth: AuthDep,
|
||||
db: Annotated[aiosqlite.Connection, Depends(get_db)],
|
||||
) -> GeoCacheStatsResponse:
|
||||
"""Return diagnostic counters for the geo cache subsystem.
|
||||
|
||||
Useful for operators and the UI to gauge geo-resolution health.
|
||||
|
||||
Args:
|
||||
_auth: Validated session — enforces authentication.
|
||||
db: BanGUI application database connection.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.geo.GeoCacheStatsResponse` with current counters.
|
||||
"""
|
||||
stats: dict[str, int] = await geo_service.cache_stats(db)
|
||||
return GeoCacheStatsResponse(**stats)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/re-resolve",
|
||||
summary="Re-resolve all IPs whose country could not be determined",
|
||||
|
||||
Reference in New Issue
Block a user