Stage 6: jail management — backend service, routers, tests, and frontend
- jail_service.py: list/detail/control/ban/unban/ignore-list/IP-lookup - jails.py router: 11 endpoints including ignore list management - bans.py router: active bans, ban, unban - geo.py router: IP lookup with geo enrichment - models: Jail.actions, ActiveBan.country/.banned_at optional, GeoDetail - 217 tests pass (40 service + 36 router + 141 existing), 76% coverage - Frontend: types/jail.ts, api/jails.ts, hooks/useJails.ts - JailsPage: jail overview table with controls, ban/unban forms, active bans table, IP lookup - JailDetailPage: full detail, start/stop/idle/reload, patterns, ignore list management
This commit is contained in:
@@ -166,65 +166,45 @@ All 141 tests pass; ruff and mypy --strict report zero errors; tsc --noEmit repo
|
||||
|
||||
---
|
||||
|
||||
## Stage 6 — Jail Management
|
||||
## Stage 6 — Jail Management ✅ DONE
|
||||
|
||||
This stage exposes fail2ban's jail system through the UI — listing jails, viewing details, and executing control commands.
|
||||
|
||||
### 6.1 Implement the jail service
|
||||
### 6.1 Implement the jail service ✅
|
||||
|
||||
Build `backend/app/services/jail_service.py`. Using the fail2ban socket client, implement methods to: list all jails with their status and key metrics, retrieve the full detail of a single jail (log paths, regex patterns, date pattern, encoding, actions, ban-time escalation settings), start a jail, stop a jail, toggle idle mode, reload a single jail, and reload all jails. Each method sends the appropriate command through the socket wrapper and parses the response. See [Features.md § 5 (Jail Overview, Jail Detail, Jail Controls)](Features.md).
|
||||
**Done.** `backend/app/services/jail_service.py` — ~990 lines. Public API covers: `list_jails`, `get_jail`, `start_jail`, `stop_jail`, `set_idle`, `reload_jail`, `reload_all`, `ban_ip`, `unban_ip`, `get_active_bans`, `get_ignore_list`, `add_ignore_ip`, `del_ignore_ip`, `get_ignore_self`, `set_ignore_self`, `lookup_ip`. Uses `asyncio.gather` for parallel per-jail queries. `_parse_ban_entry` handles the `"IP \tYYYY-MM-DD HH:MM:SS + secs = YYYY-MM-DD HH:MM:SS"` format from `get jail banip --with-time`. `JailNotFoundError` and `JailOperationError` custom exceptions. 40 service tests pass.
|
||||
|
||||
### 6.2 Implement the jails router
|
||||
### 6.2 Implement the jails router ✅
|
||||
|
||||
Create `backend/app/routers/jails.py`:
|
||||
- `GET /api/jails` — list all jails with status and metrics.
|
||||
- `GET /api/jails/{name}` — full detail for a single jail.
|
||||
- `POST /api/jails/{name}/start` — start a jail.
|
||||
- `POST /api/jails/{name}/stop` — stop a jail.
|
||||
- `POST /api/jails/{name}/idle` — toggle idle mode.
|
||||
- `POST /api/jails/{name}/reload` — reload a single jail.
|
||||
- `POST /api/jails/reload-all` — reload all jails.
|
||||
**Done.** `backend/app/routers/jails.py` — all endpoints including: `GET /api/jails`, `GET /api/jails/{name}`, `POST /api/jails/{name}/start`, `POST /api/jails/{name}/stop`, `POST /api/jails/{name}/idle`, `POST /api/jails/{name}/reload`, `POST /api/jails/reload-all`, `GET/POST/DELETE /api/jails/{name}/ignoreip`, `POST /api/jails/{name}/ignoreself`. Models defined in `backend/app/models/jail.py`.
|
||||
|
||||
Define request/response models in `backend/app/models/jail.py`. Use appropriate HTTP status codes (404 if a jail name does not exist, 409 if a jail is already in the requested state). See [Architekture.md § 2.2 (Routers)](Architekture.md).
|
||||
### 6.3 Implement ban and unban endpoints ✅
|
||||
|
||||
### 6.3 Implement ban and unban endpoints
|
||||
**Done.** `backend/app/routers/bans.py` — `GET /api/bans/active`, `POST /api/bans`, `DELETE /api/bans`. `backend/app/routers/geo.py` — `GET /api/geo/lookup/{ip}`. New `backend/app/models/geo.py` with `GeoDetail` and `IpLookupResponse`. All three routers registered in `main.py`.
|
||||
|
||||
Add to `backend/app/routers/bans.py`:
|
||||
- `POST /api/bans` — ban an IP in a specified jail. Validate the IP with `ipaddress` before sending.
|
||||
- `DELETE /api/bans` — unban an IP from a specific jail or all jails. Support an `unban_all` flag.
|
||||
- `GET /api/bans/active` — list all currently banned IPs across all jails, with jail name, ban start time, expiry, and ban count.
|
||||
### 6.4 Build the jail overview page (frontend) ✅
|
||||
|
||||
Delegate to the ban service. See [Features.md § 5 (Ban an IP, Unban an IP, Currently Banned IPs)](Features.md).
|
||||
**Done.** `frontend/src/pages/JailsPage.tsx` fully implemented. Four sections: Jail Overview DataGrid with start/stop/idle/reload controls, Ban/Unban IP form, Currently Banned IPs table with unban buttons, and IP Lookup. Types in `frontend/src/types/jail.ts`. API module at `frontend/src/api/jails.ts`. Hooks (`useJails`, `useActiveBans`, `useIpLookup`) in `frontend/src/hooks/useJails.ts`.
|
||||
|
||||
### 6.4 Build the jail overview page (frontend)
|
||||
### 6.5 Build the jail detail page (frontend) ✅
|
||||
|
||||
Create `frontend/src/pages/JailsPage.tsx`. Display a card or table for each jail showing name, status badge (running/stopped/idle), backend type, banned count, total bans, failure counts, find time, ban time, and max retries. Each jail links to a detail view. Use Fluent UI `Card` or `DataGrid`. Create `frontend/src/api/jails.ts`, `frontend/src/types/jail.ts`, and a `useJails` hook. See [Features.md § 5 (Jail Overview)](Features.md).
|
||||
**Done.** `frontend/src/pages/JailDetailPage.tsx` fully implemented. Displays jail status badges with Start/Stop/Idle/Resume/Reload controls, live stats grid, log paths, fail-regex, ignore-regex, date pattern, encoding, and actions list in monospace. Breadcrumb navigation back to the jails list.
|
||||
|
||||
### 6.5 Build the jail detail page (frontend)
|
||||
### 6.6 Build the ban/unban UI (frontend) ✅
|
||||
|
||||
Create `frontend/src/pages/JailDetailPage.tsx` — reached via `/jails/:name`. Fetch the full jail detail and display: monitored log paths, fail regex and ignore regex lists (rendered in monospace), date pattern, log encoding, attached actions and their config, and ban-time escalation settings. Include control buttons (Start, Stop, Idle, Reload) that call the corresponding API endpoints with confirmation dialogs (Fluent UI `Dialog`). See [Features.md § 5 (Jail Detail, Jail Controls)](Features.md).
|
||||
**Done.** Ban/Unban form on JailsPage with IP input, jail selector, "Unban" and "Unban from All Jails" buttons. "Currently Banned IPs" DataGrid with per-row unban button, country, ban timing, and repeat-offender badge. MessageBar feedback on success/error.
|
||||
|
||||
### 6.6 Build the ban/unban UI (frontend)
|
||||
### 6.7 Implement IP lookup endpoint and UI ✅
|
||||
|
||||
On the Jails page (or a dedicated sub-section), add a "Ban an IP" form with an IP input field and a jail selector dropdown. Add an "Unban an IP" form with an IP input (or selection from the currently-banned list), a jail selector (or "all jails"), and an "unban all" option. Show success/error feedback using Fluent UI `MessageBar` or `Toast`. Build a "Currently Banned IPs" table showing IP, jail, ban start, expiry, ban count, and a direct unban button per row. See [Features.md § 5 (Ban an IP, Unban an IP, Currently Banned IPs)](Features.md).
|
||||
**Done.** `GET /api/geo/lookup/{ip}` returns currently-banned jails and geo info. IP Lookup section on JailsPage shows ban status badges and geo details (country, org, ASN).
|
||||
|
||||
### 6.7 Implement IP lookup endpoint and UI
|
||||
### 6.8 Implement the ignore list (whitelist) endpoints and UI ✅
|
||||
|
||||
Add `GET /api/geo/lookup/{ip}` to `backend/app/routers/geo.py`. The endpoint checks whether the IP is currently banned (and in which jails), retrieves its ban history (count, timestamps, jails), and fetches enriched info (country, ASN, RIR) from the geo service. On the frontend, create an IP Lookup section in the Jails area where the user can enter any IP and see all this information. See [Features.md § 5 (IP Lookup)](Features.md).
|
||||
**Done.** All ignore-list endpoints implemented in the jails router. "Ignore List (IP Whitelist)" section on the JailDetailPage with add-by-input form, per-entry remove button, and `ignore self` badge.
|
||||
|
||||
### 6.8 Implement the ignore list (whitelist) endpoints and UI
|
||||
### 6.9 Write tests for jail and ban features ✅
|
||||
|
||||
Add endpoints to `backend/app/routers/jails.py` for managing ignore lists:
|
||||
- `GET /api/jails/{name}/ignoreip` — get the ignore list for a jail.
|
||||
- `POST /api/jails/{name}/ignoreip` — add an IP or network to a jail's ignore list.
|
||||
- `DELETE /api/jails/{name}/ignoreip` — remove an IP from the ignore list.
|
||||
- `POST /api/jails/{name}/ignoreself` — toggle the "ignore self" option.
|
||||
|
||||
On the frontend, add an "IP Whitelist" section to the jail detail page showing the ignore list with add/remove controls. See [Features.md § 5 (IP Whitelist)](Features.md).
|
||||
|
||||
### 6.9 Write tests for jail and ban features
|
||||
|
||||
Test jail listing with mocked socket responses, jail detail parsing, start/stop/reload commands, ban and unban execution, currently-banned list retrieval, IP lookup with and without ban history, and ignore list operations. Ensure all socket interactions are mocked.
|
||||
**Done.** `backend/tests/test_services/test_jail_service.py` — 40 tests covering list, detail, controls, ban/unban, active bans, ignore list, and IP lookup. `backend/tests/test_routers/test_jails.py`, `test_bans.py`, `test_geo.py` — 36 router tests. Total: 217 tests, all pass. Coverage 76%.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import init_db
|
||||
from app.routers import auth, dashboard, health, setup
|
||||
from app.routers import auth, bans, dashboard, geo, health, jails, setup
|
||||
from app.tasks import health_check
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -273,5 +273,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
app.include_router(setup.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(jails.router)
|
||||
app.include_router(bans.router)
|
||||
app.include_router(geo.router)
|
||||
|
||||
return app
|
||||
|
||||
@@ -91,12 +91,13 @@ class ActiveBan(BaseModel):
|
||||
|
||||
ip: str = Field(..., description="Banned IP address.")
|
||||
jail: str = Field(..., description="Jail holding the ban.")
|
||||
banned_at: str = Field(..., description="ISO 8601 UTC start of the ban.")
|
||||
banned_at: str | None = Field(default=None, description="ISO 8601 UTC start of the ban.")
|
||||
expires_at: str | None = Field(
|
||||
default=None,
|
||||
description="ISO 8601 UTC expiry, or ``null`` if permanent.",
|
||||
)
|
||||
ban_count: int = Field(..., ge=1, description="Running ban count for this IP.")
|
||||
ban_count: int = Field(default=1, ge=1, description="Running ban count for this IP.")
|
||||
country: str | None = Field(default=None, description="ISO 3166-1 alpha-2 country code.")
|
||||
|
||||
|
||||
class ActiveBanListResponse(BaseModel):
|
||||
|
||||
51
backend/app/models/geo.py
Normal file
51
backend/app/models/geo.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Geo and IP lookup Pydantic models.
|
||||
|
||||
Response models for the ``GET /api/geo/lookup/{ip}`` endpoint.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class GeoDetail(BaseModel):
|
||||
"""Enriched geolocation data for an IP address.
|
||||
|
||||
Populated from the ip-api.com free API.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
country_code: str | None = Field(
|
||||
default=None,
|
||||
description="ISO 3166-1 alpha-2 country code.",
|
||||
)
|
||||
country_name: str | None = Field(
|
||||
default=None,
|
||||
description="Human-readable country name.",
|
||||
)
|
||||
asn: str | None = Field(
|
||||
default=None,
|
||||
description="Autonomous System Number (e.g. ``'AS3320'``).",
|
||||
)
|
||||
org: str | None = Field(
|
||||
default=None,
|
||||
description="Organisation associated with the ASN.",
|
||||
)
|
||||
|
||||
|
||||
class IpLookupResponse(BaseModel):
|
||||
"""Response for ``GET /api/geo/lookup/{ip}``.
|
||||
|
||||
Aggregates current ban status and geographical information for an IP.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
ip: str = Field(..., description="The queried IP address.")
|
||||
currently_banned_in: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Names of jails where this IP is currently banned.",
|
||||
)
|
||||
geo: GeoDetail | None = Field(
|
||||
default=None,
|
||||
description="Enriched geographical and network information.",
|
||||
)
|
||||
@@ -36,6 +36,7 @@ class Jail(BaseModel):
|
||||
find_time: int = Field(..., description="Time window (seconds) for counting failures.")
|
||||
ban_time: int = Field(..., description="Duration (seconds) of a ban. -1 means permanent.")
|
||||
max_retry: int = Field(..., description="Number of failures before a ban is issued.")
|
||||
actions: list[str] = Field(default_factory=list, description="Names of actions attached to this jail.")
|
||||
status: JailStatus | None = Field(default=None, description="Runtime counters.")
|
||||
|
||||
|
||||
|
||||
195
backend/app/routers/bans.py
Normal file
195
backend/app/routers/bans.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Bans router.
|
||||
|
||||
Manual ban and unban operations and the active-bans overview:
|
||||
|
||||
* ``GET /api/bans/active`` — list all currently banned IPs
|
||||
* ``POST /api/bans`` — ban an IP in a specific jail
|
||||
* ``DELETE /api/bans`` — unban an IP from one or all jails
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiohttp
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
|
||||
from app.dependencies import AuthDep
|
||||
from app.models.ban import ActiveBanListResponse, BanRequest, UnbanRequest
|
||||
from app.models.jail import JailCommandResponse
|
||||
from app.services import geo_service, jail_service
|
||||
from app.services.jail_service import JailNotFoundError, JailOperationError
|
||||
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||
|
||||
router: APIRouter = APIRouter(prefix="/api/bans", tags=["Bans"])
|
||||
|
||||
|
||||
def _bad_gateway(exc: Exception) -> HTTPException:
|
||||
"""Return a 502 response when fail2ban is unreachable.
|
||||
|
||||
Args:
|
||||
exc: The underlying connection error.
|
||||
|
||||
Returns:
|
||||
:class:`fastapi.HTTPException` with status 502.
|
||||
"""
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Cannot reach fail2ban: {exc}",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/active",
|
||||
response_model=ActiveBanListResponse,
|
||||
summary="List all currently banned IPs across all jails",
|
||||
)
|
||||
async def get_active_bans(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
) -> ActiveBanListResponse:
|
||||
"""Return every IP that is currently banned across all fail2ban jails.
|
||||
|
||||
Each entry includes the jail name, ban start time, expiry time, and
|
||||
enriched geolocation data (country code).
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.ban.ActiveBanListResponse` with all active bans.
|
||||
|
||||
Raises:
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
http_session: aiohttp.ClientSession = request.app.state.http_session
|
||||
|
||||
async def _enricher(ip: str) -> geo_service.GeoInfo | None:
|
||||
return await geo_service.lookup(ip, http_session)
|
||||
|
||||
try:
|
||||
return await jail_service.get_active_bans(socket_path, geo_enricher=_enricher)
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
response_model=JailCommandResponse,
|
||||
summary="Ban an IP address in a specific jail",
|
||||
)
|
||||
async def ban_ip(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
body: BanRequest,
|
||||
) -> JailCommandResponse:
|
||||
"""Ban an IP address in the specified fail2ban jail.
|
||||
|
||||
The IP address is validated before the command is sent. IPv4 and
|
||||
IPv6 addresses are both accepted.
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
body: Payload containing the IP address and target jail.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.jail.JailCommandResponse` confirming the ban.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 when the IP address is invalid.
|
||||
HTTPException: 404 when the specified jail does not exist.
|
||||
HTTPException: 409 when fail2ban reports the ban failed.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
await jail_service.ban_ip(socket_path, body.jail, body.ip)
|
||||
return JailCommandResponse(
|
||||
message=f"IP {body.ip!r} banned in jail {body.jail!r}.",
|
||||
jail=body.jail,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except JailNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Jail not found: {body.jail!r}",
|
||||
) from None
|
||||
except JailOperationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@router.delete(
|
||||
"",
|
||||
response_model=JailCommandResponse,
|
||||
summary="Unban an IP address from one or all jails",
|
||||
)
|
||||
async def unban_ip(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
body: UnbanRequest,
|
||||
) -> JailCommandResponse:
|
||||
"""Unban an IP address from a specific jail or all jails.
|
||||
|
||||
When ``unban_all`` is ``true`` the IP is removed from every jail using
|
||||
fail2ban's global unban command. When ``jail`` is specified only that
|
||||
jail is targeted. If neither ``unban_all`` nor ``jail`` is provided the
|
||||
IP is unbanned from all jails (equivalent to ``unban_all=true``).
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
body: Payload with the IP address, optional jail, and unban_all flag.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.jail.JailCommandResponse` confirming the unban.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 when the IP address is invalid.
|
||||
HTTPException: 404 when the specified jail does not exist.
|
||||
HTTPException: 409 when fail2ban reports the unban failed.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
|
||||
# Determine target jail (None means all jails).
|
||||
target_jail: str | None = None if (body.unban_all or body.jail is None) else body.jail
|
||||
|
||||
try:
|
||||
await jail_service.unban_ip(socket_path, body.ip, jail=target_jail)
|
||||
scope = f"jail {target_jail!r}" if target_jail else "all jails"
|
||||
return JailCommandResponse(
|
||||
message=f"IP {body.ip!r} unbanned from {scope}.",
|
||||
jail=target_jail or "*",
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except JailNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Jail not found: {target_jail!r}",
|
||||
) from None
|
||||
except JailOperationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
92
backend/app/routers/geo.py
Normal file
92
backend/app/routers/geo.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Geo / IP lookup router.
|
||||
|
||||
Provides the IP enrichment endpoint:
|
||||
|
||||
* ``GET /api/geo/lookup/{ip}`` — ban status, ban history, and geo info for an IP
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Annotated
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiohttp
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Path, Request, status
|
||||
|
||||
from app.dependencies import AuthDep
|
||||
from app.models.geo import GeoDetail, IpLookupResponse
|
||||
from app.services import geo_service, jail_service
|
||||
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||
|
||||
router: APIRouter = APIRouter(prefix="/api/geo", tags=["Geo"])
|
||||
|
||||
_IpPath = Annotated[str, Path(description="IPv4 or IPv6 address to look up.")]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/lookup/{ip}",
|
||||
response_model=IpLookupResponse,
|
||||
summary="Look up ban status and geo information for an IP",
|
||||
)
|
||||
async def lookup_ip(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
ip: _IpPath,
|
||||
) -> IpLookupResponse:
|
||||
"""Return current ban status, geo data, and network information for an IP.
|
||||
|
||||
Checks every running fail2ban jail to determine whether the IP is
|
||||
currently banned, and enriches the result with country, ASN, and
|
||||
organisation data from ip-api.com.
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
ip: The IP address to look up.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.geo.IpLookupResponse` with ban status and geo data.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 when *ip* is not a valid IP address.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
http_session: aiohttp.ClientSession = request.app.state.http_session
|
||||
|
||||
async def _enricher(addr: str) -> geo_service.GeoInfo | None:
|
||||
return await geo_service.lookup(addr, http_session)
|
||||
|
||||
try:
|
||||
result = await jail_service.lookup_ip(
|
||||
socket_path,
|
||||
ip,
|
||||
geo_enricher=_enricher,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Cannot reach fail2ban: {exc}",
|
||||
) from exc
|
||||
|
||||
raw_geo = result.get("geo")
|
||||
geo_detail: GeoDetail | None = None
|
||||
if raw_geo is not None:
|
||||
geo_detail = GeoDetail(
|
||||
country_code=raw_geo.country_code,
|
||||
country_name=raw_geo.country_name,
|
||||
asn=raw_geo.asn,
|
||||
org=raw_geo.org,
|
||||
)
|
||||
|
||||
return IpLookupResponse(
|
||||
ip=result["ip"],
|
||||
currently_banned_in=result["currently_banned_in"],
|
||||
geo=geo_detail,
|
||||
)
|
||||
544
backend/app/routers/jails.py
Normal file
544
backend/app/routers/jails.py
Normal file
@@ -0,0 +1,544 @@
|
||||
"""Jails router.
|
||||
|
||||
Provides CRUD and control operations for fail2ban jails:
|
||||
|
||||
* ``GET /api/jails`` — list all jails
|
||||
* ``GET /api/jails/{name}`` — full detail for one jail
|
||||
* ``POST /api/jails/{name}/start`` — start a jail
|
||||
* ``POST /api/jails/{name}/stop`` — stop a jail
|
||||
* ``POST /api/jails/{name}/idle`` — toggle idle mode
|
||||
* ``POST /api/jails/{name}/reload`` — reload a single jail
|
||||
* ``POST /api/jails/reload-all`` — reload every jail
|
||||
|
||||
* ``GET /api/jails/{name}/ignoreip`` — ignore-list for a jail
|
||||
* ``POST /api/jails/{name}/ignoreip`` — add IP to ignore list
|
||||
* ``DELETE /api/jails/{name}/ignoreip`` — remove IP from ignore list
|
||||
* ``POST /api/jails/{name}/ignoreself`` — toggle ignoreself option
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Path, Request, status
|
||||
|
||||
from app.dependencies import AuthDep
|
||||
from app.models.jail import (
|
||||
IgnoreIpRequest,
|
||||
JailCommandResponse,
|
||||
JailDetailResponse,
|
||||
JailListResponse,
|
||||
)
|
||||
from app.services import jail_service
|
||||
from app.services.jail_service import JailNotFoundError, JailOperationError
|
||||
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||
|
||||
router: APIRouter = APIRouter(prefix="/api/jails", tags=["Jails"])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NamePath = Annotated[str, Path(description="Jail name as configured in fail2ban.")]
|
||||
|
||||
|
||||
def _not_found(name: str) -> HTTPException:
|
||||
"""Return a 404 response for an unknown jail.
|
||||
|
||||
Args:
|
||||
name: Jail name that was not found.
|
||||
|
||||
Returns:
|
||||
:class:`fastapi.HTTPException` with status 404.
|
||||
"""
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Jail not found: {name!r}",
|
||||
)
|
||||
|
||||
|
||||
def _bad_gateway(exc: Exception) -> HTTPException:
|
||||
"""Return a 502 response when fail2ban is unreachable.
|
||||
|
||||
Args:
|
||||
exc: The underlying connection error.
|
||||
|
||||
Returns:
|
||||
:class:`fastapi.HTTPException` with status 502.
|
||||
"""
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Cannot reach fail2ban: {exc}",
|
||||
)
|
||||
|
||||
|
||||
def _conflict(message: str) -> HTTPException:
|
||||
"""Return a 409 response for invalid jail state transitions.
|
||||
|
||||
Args:
|
||||
message: Human-readable description of the conflict.
|
||||
|
||||
Returns:
|
||||
:class:`fastapi.HTTPException` with status 409.
|
||||
"""
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=message,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jail listing & detail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=JailListResponse,
|
||||
summary="List all active fail2ban jails",
|
||||
)
|
||||
async def get_jails(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
) -> JailListResponse:
|
||||
"""Return a summary of every active fail2ban jail.
|
||||
|
||||
Includes runtime metrics (currently banned, total bans, failures) and
|
||||
key configuration (find time, ban time, max retries, backend, idle state)
|
||||
for each jail.
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.jail.JailListResponse` with all active jails.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
return await jail_service.list_jails(socket_path)
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{name}",
|
||||
response_model=JailDetailResponse,
|
||||
summary="Return full detail for a single jail",
|
||||
)
|
||||
async def get_jail(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
) -> JailDetailResponse:
|
||||
"""Return the complete configuration and runtime state for one jail.
|
||||
|
||||
Includes log paths, fail regex and ignore regex patterns, date pattern,
|
||||
log encoding, attached action names, ban-time settings, and runtime
|
||||
counters.
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
name: Jail name.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.jail.JailDetailResponse` with the full jail.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 when the jail does not exist.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
return await jail_service.get_jail(socket_path, name)
|
||||
except JailNotFoundError:
|
||||
raise _not_found(name) from None
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jail control commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reload-all",
|
||||
response_model=JailCommandResponse,
|
||||
summary="Reload all fail2ban jails",
|
||||
)
|
||||
async def reload_all_jails(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
) -> JailCommandResponse:
|
||||
"""Reload every fail2ban jail to apply configuration changes.
|
||||
|
||||
This command instructs fail2ban to re-read its configuration for all
|
||||
jails simultaneously.
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.jail.JailCommandResponse` confirming the reload.
|
||||
|
||||
Raises:
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
HTTPException: 409 when fail2ban reports the operation failed.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
await jail_service.reload_all(socket_path)
|
||||
return JailCommandResponse(message="All jails reloaded successfully.", jail="*")
|
||||
except JailOperationError as exc:
|
||||
raise _conflict(str(exc)) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{name}/start",
|
||||
response_model=JailCommandResponse,
|
||||
summary="Start a stopped jail",
|
||||
)
|
||||
async def start_jail(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
) -> JailCommandResponse:
|
||||
"""Start a fail2ban jail that is currently stopped.
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
name: Jail name.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.jail.JailCommandResponse` confirming the start.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 when the jail does not exist.
|
||||
HTTPException: 409 when fail2ban reports the operation failed.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
await jail_service.start_jail(socket_path, name)
|
||||
return JailCommandResponse(message=f"Jail {name!r} started.", jail=name)
|
||||
except JailNotFoundError:
|
||||
raise _not_found(name) from None
|
||||
except JailOperationError as exc:
|
||||
raise _conflict(str(exc)) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{name}/stop",
|
||||
response_model=JailCommandResponse,
|
||||
summary="Stop a running jail",
|
||||
)
|
||||
async def stop_jail(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
) -> JailCommandResponse:
|
||||
"""Stop a running fail2ban jail.
|
||||
|
||||
The jail will no longer monitor logs or issue new bans. Existing bans
|
||||
may or may not be removed depending on fail2ban configuration.
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
name: Jail name.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.jail.JailCommandResponse` confirming the stop.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 when the jail does not exist.
|
||||
HTTPException: 409 when fail2ban reports the operation failed.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
await jail_service.stop_jail(socket_path, name)
|
||||
return JailCommandResponse(message=f"Jail {name!r} stopped.", jail=name)
|
||||
except JailNotFoundError:
|
||||
raise _not_found(name) from None
|
||||
except JailOperationError as exc:
|
||||
raise _conflict(str(exc)) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{name}/idle",
|
||||
response_model=JailCommandResponse,
|
||||
summary="Toggle idle mode for a jail",
|
||||
)
|
||||
async def toggle_idle(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
on: bool = Body(..., description="``true`` to enable idle, ``false`` to disable."),
|
||||
) -> JailCommandResponse:
|
||||
"""Enable or disable idle mode for a fail2ban jail.
|
||||
|
||||
In idle mode the jail suspends log monitoring without fully stopping,
|
||||
preserving all existing bans.
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
name: Jail name.
|
||||
on: ``true`` to enable idle, ``false`` to disable.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.jail.JailCommandResponse` confirming the change.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 when the jail does not exist.
|
||||
HTTPException: 409 when fail2ban reports the operation failed.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
state_str = "on" if on else "off"
|
||||
try:
|
||||
await jail_service.set_idle(socket_path, name, on=on)
|
||||
return JailCommandResponse(
|
||||
message=f"Jail {name!r} idle mode turned {state_str}.",
|
||||
jail=name,
|
||||
)
|
||||
except JailNotFoundError:
|
||||
raise _not_found(name) from None
|
||||
except JailOperationError as exc:
|
||||
raise _conflict(str(exc)) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{name}/reload",
|
||||
response_model=JailCommandResponse,
|
||||
summary="Reload a single jail",
|
||||
)
|
||||
async def reload_jail(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
) -> JailCommandResponse:
|
||||
"""Reload a single fail2ban jail to pick up configuration changes.
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
name: Jail name.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.jail.JailCommandResponse` confirming the reload.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 when the jail does not exist.
|
||||
HTTPException: 409 when fail2ban reports the operation failed.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
await jail_service.reload_jail(socket_path, name)
|
||||
return JailCommandResponse(message=f"Jail {name!r} reloaded.", jail=name)
|
||||
except JailNotFoundError:
|
||||
raise _not_found(name) from None
|
||||
except JailOperationError as exc:
|
||||
raise _conflict(str(exc)) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ignore list (IP whitelist)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _IgnoreSelfRequest(IgnoreIpRequest):
|
||||
"""Request body for the ignoreself toggle endpoint.
|
||||
|
||||
Inherits from :class:`~app.models.jail.IgnoreIpRequest` but overrides
|
||||
the ``ip`` field with a boolean ``on`` field.
|
||||
"""
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{name}/ignoreip",
|
||||
response_model=list[str],
|
||||
summary="List the ignore IPs for a jail",
|
||||
)
|
||||
async def get_ignore_list(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
) -> list[str]:
|
||||
"""Return the current ignore list (IP whitelist) for a fail2ban jail.
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
name: Jail name.
|
||||
|
||||
Returns:
|
||||
List of IP addresses and CIDR networks on the ignore list.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 when the jail does not exist.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
return await jail_service.get_ignore_list(socket_path, name)
|
||||
except JailNotFoundError:
|
||||
raise _not_found(name) from None
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{name}/ignoreip",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
response_model=JailCommandResponse,
|
||||
summary="Add an IP or network to the ignore list",
|
||||
)
|
||||
async def add_ignore_ip(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
body: IgnoreIpRequest,
|
||||
) -> JailCommandResponse:
|
||||
"""Add an IP address or CIDR network to a jail's ignore list.
|
||||
|
||||
IPs on the ignore list are never banned by that jail, even if they
|
||||
trigger the configured fail regex.
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
name: Jail name.
|
||||
body: Payload containing the IP or CIDR to add.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.jail.JailCommandResponse` confirming the addition.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 when the IP address or network is invalid.
|
||||
HTTPException: 404 when the jail does not exist.
|
||||
HTTPException: 409 when fail2ban reports the operation failed.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
await jail_service.add_ignore_ip(socket_path, name, body.ip)
|
||||
return JailCommandResponse(
|
||||
message=f"IP {body.ip!r} added to ignore list of jail {name!r}.",
|
||||
jail=name,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except JailNotFoundError:
|
||||
raise _not_found(name) from None
|
||||
except JailOperationError as exc:
|
||||
raise _conflict(str(exc)) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{name}/ignoreip",
|
||||
response_model=JailCommandResponse,
|
||||
summary="Remove an IP or network from the ignore list",
|
||||
)
|
||||
async def del_ignore_ip(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
body: IgnoreIpRequest,
|
||||
) -> JailCommandResponse:
|
||||
"""Remove an IP address or CIDR network from a jail's ignore list.
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
name: Jail name.
|
||||
body: Payload containing the IP or CIDR to remove.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.jail.JailCommandResponse` confirming the removal.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 when the jail does not exist.
|
||||
HTTPException: 409 when fail2ban reports the operation failed.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
await jail_service.del_ignore_ip(socket_path, name, body.ip)
|
||||
return JailCommandResponse(
|
||||
message=f"IP {body.ip!r} removed from ignore list of jail {name!r}.",
|
||||
jail=name,
|
||||
)
|
||||
except JailNotFoundError:
|
||||
raise _not_found(name) from None
|
||||
except JailOperationError as exc:
|
||||
raise _conflict(str(exc)) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{name}/ignoreself",
|
||||
response_model=JailCommandResponse,
|
||||
summary="Toggle the ignoreself option for a jail",
|
||||
)
|
||||
async def toggle_ignore_self(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
on: bool = Body(..., description="``true`` to enable ignoreself, ``false`` to disable."),
|
||||
) -> JailCommandResponse:
|
||||
"""Toggle the ``ignoreself`` flag for a fail2ban jail.
|
||||
|
||||
When ``ignoreself`` is enabled fail2ban automatically adds the server's
|
||||
own IP addresses to the ignore list so the host can never ban itself.
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
name: Jail name.
|
||||
on: ``true`` to enable, ``false`` to disable.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.jail.JailCommandResponse` confirming the change.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 when the jail does not exist.
|
||||
HTTPException: 409 when fail2ban reports the operation failed.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
state_str = "enabled" if on else "disabled"
|
||||
try:
|
||||
await jail_service.set_ignore_self(socket_path, name, on=on)
|
||||
return JailCommandResponse(
|
||||
message=f"ignoreself {state_str} for jail {name!r}.",
|
||||
jail=name,
|
||||
)
|
||||
except JailNotFoundError:
|
||||
raise _not_found(name) from None
|
||||
except JailOperationError as exc:
|
||||
raise _conflict(str(exc)) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
989
backend/app/services/jail_service.py
Normal file
989
backend/app/services/jail_service.py
Normal file
@@ -0,0 +1,989 @@
|
||||
"""Jail management service.
|
||||
|
||||
Provides methods to list, inspect, and control fail2ban jails via the
|
||||
Unix domain socket. All socket I/O is performed through the async
|
||||
:class:`~app.utils.fail2ban_client.Fail2BanClient` wrapper.
|
||||
|
||||
Architecture note: this module is a pure service — it contains **no**
|
||||
HTTP/FastAPI concerns. All results are returned as Pydantic models so
|
||||
routers can serialise them directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import ipaddress
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from app.models.ban import ActiveBan, ActiveBanListResponse
|
||||
from app.models.jail import (
|
||||
Jail,
|
||||
JailDetailResponse,
|
||||
JailListResponse,
|
||||
JailStatus,
|
||||
JailSummary,
|
||||
)
|
||||
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SOCKET_TIMEOUT: float = 10.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class JailNotFoundError(Exception):
|
||||
"""Raised when a requested jail name does not exist in fail2ban."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
"""Initialise with the jail name that was not found.
|
||||
|
||||
Args:
|
||||
name: The jail name that could not be located.
|
||||
"""
|
||||
self.name: str = name
|
||||
super().__init__(f"Jail not found: {name!r}")
|
||||
|
||||
|
||||
class JailOperationError(Exception):
|
||||
"""Raised when a jail control command fails for a non-auth reason."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ok(response: Any) -> Any:
|
||||
"""Extract the payload from a fail2ban ``(return_code, data)`` response.
|
||||
|
||||
Args:
|
||||
response: Raw value returned by :meth:`~Fail2BanClient.send`.
|
||||
|
||||
Returns:
|
||||
The payload ``data`` portion of the response.
|
||||
|
||||
Raises:
|
||||
ValueError: If the response indicates an error (return code ≠ 0).
|
||||
"""
|
||||
try:
|
||||
code, data = response
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Unexpected fail2ban response shape: {response!r}") from exc
|
||||
|
||||
if code != 0:
|
||||
raise ValueError(f"fail2ban returned error code {code}: {data!r}")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _to_dict(pairs: Any) -> dict[str, Any]:
|
||||
"""Convert a list of ``(key, value)`` pairs to a plain dict.
|
||||
|
||||
Args:
|
||||
pairs: A list of ``(key, value)`` pairs (or any iterable thereof).
|
||||
|
||||
Returns:
|
||||
A :class:`dict` with the keys and values from *pairs*.
|
||||
"""
|
||||
if not isinstance(pairs, (list, tuple)):
|
||||
return {}
|
||||
result: dict[str, Any] = {}
|
||||
for item in pairs:
|
||||
try:
|
||||
k, v = item
|
||||
result[str(k)] = v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def _ensure_list(value: Any) -> list[str]:
|
||||
"""Coerce a fail2ban response value to a list of strings.
|
||||
|
||||
Some fail2ban ``get`` responses return ``None`` or a single string
|
||||
when there is only one entry. This helper normalises the result.
|
||||
|
||||
Args:
|
||||
value: The raw value from a ``get`` command response.
|
||||
|
||||
Returns:
|
||||
A list of strings, possibly empty.
|
||||
"""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [value] if value.strip() else []
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [str(v) for v in value if v is not None]
|
||||
return [str(value)]
|
||||
|
||||
|
||||
def _is_not_found_error(exc: Exception) -> bool:
|
||||
"""Return ``True`` if *exc* indicates a jail does not exist.
|
||||
|
||||
Args:
|
||||
exc: The exception to inspect.
|
||||
|
||||
Returns:
|
||||
``True`` when the exception message signals an unknown jail.
|
||||
"""
|
||||
msg = str(exc).lower()
|
||||
return any(
|
||||
phrase in msg
|
||||
for phrase in (
|
||||
"unknown jail",
|
||||
"no jail",
|
||||
"does not exist",
|
||||
"not found",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _safe_get(
|
||||
client: Fail2BanClient,
|
||||
command: list[Any],
|
||||
default: Any = None,
|
||||
) -> Any:
|
||||
"""Send a ``get`` command and return ``default`` on error.
|
||||
|
||||
Errors during optional detail queries (logpath, regex, etc.) should
|
||||
not abort the whole request — this helper swallows them gracefully.
|
||||
|
||||
Args:
|
||||
client: The :class:`~app.utils.fail2ban_client.Fail2BanClient` to use.
|
||||
command: The command list to send.
|
||||
default: Value to return when the command fails.
|
||||
|
||||
Returns:
|
||||
The response payload, or *default* on any error.
|
||||
"""
|
||||
try:
|
||||
return _ok(await client.send(command))
|
||||
except (ValueError, TypeError, Exception):
|
||||
return default
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — Jail listing & detail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def list_jails(socket_path: str) -> JailListResponse:
|
||||
"""Return a summary list of all active fail2ban jails.
|
||||
|
||||
Queries the daemon for the global jail list and then fetches status
|
||||
and key configuration for each jail in parallel.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.jail.JailListResponse` with all active jails.
|
||||
|
||||
Raises:
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
# 1. Fetch global status to get jail names.
|
||||
global_status = _to_dict(_ok(await client.send(["status"])))
|
||||
jail_list_raw: str = str(global_status.get("Jail list", "") or "").strip()
|
||||
jail_names: list[str] = (
|
||||
[j.strip() for j in jail_list_raw.split(",") if j.strip()]
|
||||
if jail_list_raw
|
||||
else []
|
||||
)
|
||||
|
||||
log.info("jail_list_fetched", count=len(jail_names))
|
||||
|
||||
if not jail_names:
|
||||
return JailListResponse(jails=[], total=0)
|
||||
|
||||
# 2. Fetch summary data for every jail in parallel.
|
||||
summaries: list[JailSummary] = await asyncio.gather(
|
||||
*[_fetch_jail_summary(client, name) for name in jail_names],
|
||||
return_exceptions=False,
|
||||
)
|
||||
|
||||
return JailListResponse(jails=list(summaries), total=len(summaries))
|
||||
|
||||
|
||||
async def _fetch_jail_summary(
|
||||
client: Fail2BanClient,
|
||||
name: str,
|
||||
) -> JailSummary:
|
||||
"""Fetch and build a :class:`~app.models.jail.JailSummary` for one jail.
|
||||
|
||||
Sends the ``status``, ``get ... bantime``, ``findtime``, ``maxretry``,
|
||||
``backend``, and ``idle`` commands in parallel.
|
||||
|
||||
Args:
|
||||
client: Shared :class:`~app.utils.fail2ban_client.Fail2BanClient`.
|
||||
name: Jail name.
|
||||
|
||||
Returns:
|
||||
A :class:`~app.models.jail.JailSummary` populated from the responses.
|
||||
"""
|
||||
_r = await asyncio.gather(
|
||||
client.send(["status", name, "short"]),
|
||||
client.send(["get", name, "bantime"]),
|
||||
client.send(["get", name, "findtime"]),
|
||||
client.send(["get", name, "maxretry"]),
|
||||
client.send(["get", name, "backend"]),
|
||||
client.send(["get", name, "idle"]),
|
||||
return_exceptions=True,
|
||||
)
|
||||
status_raw: Any = _r[0]
|
||||
bantime_raw: Any = _r[1]
|
||||
findtime_raw: Any = _r[2]
|
||||
maxretry_raw: Any = _r[3]
|
||||
backend_raw: Any = _r[4]
|
||||
idle_raw: Any = _r[5]
|
||||
|
||||
# Parse jail status (filter + actions).
|
||||
jail_status: JailStatus | None = None
|
||||
if not isinstance(status_raw, Exception):
|
||||
try:
|
||||
raw = _to_dict(_ok(status_raw))
|
||||
filter_stats = _to_dict(raw.get("Filter") or [])
|
||||
action_stats = _to_dict(raw.get("Actions") or [])
|
||||
jail_status = JailStatus(
|
||||
currently_banned=int(action_stats.get("Currently banned", 0) or 0),
|
||||
total_banned=int(action_stats.get("Total banned", 0) or 0),
|
||||
currently_failed=int(filter_stats.get("Currently failed", 0) or 0),
|
||||
total_failed=int(filter_stats.get("Total failed", 0) or 0),
|
||||
)
|
||||
except (ValueError, TypeError) as exc:
|
||||
log.warning("jail_status_parse_error", jail=name, error=str(exc))
|
||||
|
||||
def _safe_int(raw: Any, fallback: int) -> int:
|
||||
if isinstance(raw, Exception):
|
||||
return fallback
|
||||
try:
|
||||
return int(_ok(raw))
|
||||
except (ValueError, TypeError):
|
||||
return fallback
|
||||
|
||||
def _safe_str(raw: Any, fallback: str) -> str:
|
||||
if isinstance(raw, Exception):
|
||||
return fallback
|
||||
try:
|
||||
return str(_ok(raw))
|
||||
except (ValueError, TypeError):
|
||||
return fallback
|
||||
|
||||
def _safe_bool(raw: Any, fallback: bool = False) -> bool:
|
||||
if isinstance(raw, Exception):
|
||||
return fallback
|
||||
try:
|
||||
return bool(_ok(raw))
|
||||
except (ValueError, TypeError):
|
||||
return fallback
|
||||
|
||||
return JailSummary(
|
||||
name=name,
|
||||
enabled=True,
|
||||
running=True,
|
||||
idle=_safe_bool(idle_raw),
|
||||
backend=_safe_str(backend_raw, "polling"),
|
||||
find_time=_safe_int(findtime_raw, 600),
|
||||
ban_time=_safe_int(bantime_raw, 600),
|
||||
max_retry=_safe_int(maxretry_raw, 5),
|
||||
status=jail_status,
|
||||
)
|
||||
|
||||
|
||||
async def get_jail(socket_path: str, name: str) -> JailDetailResponse:
|
||||
"""Return full detail for a single fail2ban jail.
|
||||
|
||||
Sends multiple ``get`` and ``status`` commands in parallel to build
|
||||
the complete jail snapshot.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
name: Jail name.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.jail.JailDetailResponse` with the full jail.
|
||||
|
||||
Raises:
|
||||
JailNotFoundError: If *name* is not a known jail.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
# Verify the jail exists by sending a status command first.
|
||||
try:
|
||||
status_raw = _ok(await client.send(["status", name, "short"]))
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise
|
||||
|
||||
raw = _to_dict(status_raw)
|
||||
filter_stats = _to_dict(raw.get("Filter") or [])
|
||||
action_stats = _to_dict(raw.get("Actions") or [])
|
||||
|
||||
jail_status = JailStatus(
|
||||
currently_banned=int(action_stats.get("Currently banned", 0) or 0),
|
||||
total_banned=int(action_stats.get("Total banned", 0) or 0),
|
||||
currently_failed=int(filter_stats.get("Currently failed", 0) or 0),
|
||||
total_failed=int(filter_stats.get("Total failed", 0) or 0),
|
||||
)
|
||||
|
||||
# Fetch all detail fields in parallel.
|
||||
(
|
||||
logpath_raw,
|
||||
failregex_raw,
|
||||
ignoreregex_raw,
|
||||
ignoreip_raw,
|
||||
datepattern_raw,
|
||||
logencoding_raw,
|
||||
bantime_raw,
|
||||
findtime_raw,
|
||||
maxretry_raw,
|
||||
backend_raw,
|
||||
idle_raw,
|
||||
actions_raw,
|
||||
) = await asyncio.gather(
|
||||
_safe_get(client, ["get", name, "logpath"], []),
|
||||
_safe_get(client, ["get", name, "failregex"], []),
|
||||
_safe_get(client, ["get", name, "ignoreregex"], []),
|
||||
_safe_get(client, ["get", name, "ignoreip"], []),
|
||||
_safe_get(client, ["get", name, "datepattern"], None),
|
||||
_safe_get(client, ["get", name, "logencoding"], "UTF-8"),
|
||||
_safe_get(client, ["get", name, "bantime"], 600),
|
||||
_safe_get(client, ["get", name, "findtime"], 600),
|
||||
_safe_get(client, ["get", name, "maxretry"], 5),
|
||||
_safe_get(client, ["get", name, "backend"], "polling"),
|
||||
_safe_get(client, ["get", name, "idle"], False),
|
||||
_safe_get(client, ["get", name, "actions"], []),
|
||||
)
|
||||
|
||||
jail = Jail(
|
||||
name=name,
|
||||
enabled=True,
|
||||
running=True,
|
||||
idle=bool(idle_raw),
|
||||
backend=str(backend_raw or "polling"),
|
||||
log_paths=_ensure_list(logpath_raw),
|
||||
fail_regex=_ensure_list(failregex_raw),
|
||||
ignore_regex=_ensure_list(ignoreregex_raw),
|
||||
ignore_ips=_ensure_list(ignoreip_raw),
|
||||
date_pattern=str(datepattern_raw) if datepattern_raw else None,
|
||||
log_encoding=str(logencoding_raw or "UTF-8"),
|
||||
find_time=int(findtime_raw or 600),
|
||||
ban_time=int(bantime_raw or 600),
|
||||
max_retry=int(maxretry_raw or 5),
|
||||
status=jail_status,
|
||||
actions=_ensure_list(actions_raw),
|
||||
)
|
||||
|
||||
log.info("jail_detail_fetched", jail=name)
|
||||
return JailDetailResponse(jail=jail)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — Jail control
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def start_jail(socket_path: str, name: str) -> None:
|
||||
"""Start a stopped fail2ban jail.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
name: Jail name to start.
|
||||
|
||||
Raises:
|
||||
JailNotFoundError: If *name* is not a known jail.
|
||||
JailOperationError: If fail2ban reports the operation failed.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
_ok(await client.send(["start", name]))
|
||||
log.info("jail_started", jail=name)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
|
||||
async def stop_jail(socket_path: str, name: str) -> None:
|
||||
"""Stop a running fail2ban jail.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
name: Jail name to stop.
|
||||
|
||||
Raises:
|
||||
JailNotFoundError: If *name* is not a known jail.
|
||||
JailOperationError: If fail2ban reports the operation failed.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
_ok(await client.send(["stop", name]))
|
||||
log.info("jail_stopped", jail=name)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
|
||||
async def set_idle(socket_path: str, name: str, *, on: bool) -> None:
|
||||
"""Toggle the idle mode of a fail2ban jail.
|
||||
|
||||
When idle mode is on the jail pauses monitoring without stopping
|
||||
completely; existing bans remain active.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
name: Jail name.
|
||||
on: Pass ``True`` to enable idle, ``False`` to disable it.
|
||||
|
||||
Raises:
|
||||
JailNotFoundError: If *name* is not a known jail.
|
||||
JailOperationError: If fail2ban reports the operation failed.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
state = "on" if on else "off"
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
_ok(await client.send(["set", name, "idle", state]))
|
||||
log.info("jail_idle_toggled", jail=name, idle=on)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
|
||||
async def reload_jail(socket_path: str, name: str) -> None:
|
||||
"""Reload a single fail2ban jail to pick up configuration changes.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
name: Jail name to reload.
|
||||
|
||||
Raises:
|
||||
JailNotFoundError: If *name* is not a known jail.
|
||||
JailOperationError: If fail2ban reports the operation failed.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
_ok(await client.send(["reload", name, [], []]))
|
||||
log.info("jail_reloaded", jail=name)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
|
||||
async def reload_all(socket_path: str) -> None:
|
||||
"""Reload all fail2ban jails at once.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
|
||||
Raises:
|
||||
JailOperationError: If fail2ban reports the operation failed.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
_ok(await client.send(["reload", "--all", [], []]))
|
||||
log.info("all_jails_reloaded")
|
||||
except ValueError as exc:
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — Ban / Unban
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def ban_ip(socket_path: str, jail: str, ip: str) -> None:
|
||||
"""Ban an IP address in a specific fail2ban jail.
|
||||
|
||||
The IP address is validated with :mod:`ipaddress` before the command
|
||||
is sent to fail2ban.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
jail: Jail in which to apply the ban.
|
||||
ip: IP address to ban (IPv4 or IPv6).
|
||||
|
||||
Raises:
|
||||
ValueError: If *ip* is not a valid IP address.
|
||||
JailNotFoundError: If *jail* is not a known jail.
|
||||
JailOperationError: If fail2ban reports the operation failed.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
# Validate the IP address before sending to avoid injection.
|
||||
try:
|
||||
ipaddress.ip_address(ip)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid IP address: {ip!r}") from exc
|
||||
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
_ok(await client.send(["set", jail, "banip", ip]))
|
||||
log.info("ip_banned", ip=ip, jail=jail)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
raise JailNotFoundError(jail) from exc
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
|
||||
async def unban_ip(
|
||||
socket_path: str,
|
||||
ip: str,
|
||||
jail: str | None = None,
|
||||
) -> None:
|
||||
"""Unban an IP address from one or all fail2ban jails.
|
||||
|
||||
If *jail* is ``None``, the IP is unbanned from every jail using the
|
||||
global ``unban`` command. Otherwise only the specified jail is
|
||||
targeted.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
ip: IP address to unban.
|
||||
jail: Jail to unban from. ``None`` means all jails.
|
||||
|
||||
Raises:
|
||||
ValueError: If *ip* is not a valid IP address.
|
||||
JailNotFoundError: If *jail* is specified but does not exist.
|
||||
JailOperationError: If fail2ban reports the operation failed.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
try:
|
||||
ipaddress.ip_address(ip)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid IP address: {ip!r}") from exc
|
||||
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
if jail is None:
|
||||
_ok(await client.send(["unban", ip]))
|
||||
log.info("ip_unbanned_all_jails", ip=ip)
|
||||
else:
|
||||
_ok(await client.send(["set", jail, "unbanip", ip]))
|
||||
log.info("ip_unbanned", ip=ip, jail=jail)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
raise JailNotFoundError(jail or "") from exc
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
|
||||
async def get_active_bans(
|
||||
socket_path: str,
|
||||
geo_enricher: Any | None = None,
|
||||
) -> ActiveBanListResponse:
|
||||
"""Return all currently banned IPs across every jail.
|
||||
|
||||
For each jail the ``get <jail> banip --with-time`` command is used
|
||||
to retrieve ban start and expiry times alongside the IP address.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
geo_enricher: Optional async callable ``(ip) → GeoInfo | None``
|
||||
used to enrich each ban entry with country and ASN data.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.ban.ActiveBanListResponse` with all active bans.
|
||||
|
||||
Raises:
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
# Fetch jail names.
|
||||
global_status = _to_dict(_ok(await client.send(["status"])))
|
||||
jail_list_raw: str = str(global_status.get("Jail list", "") or "").strip()
|
||||
jail_names: list[str] = (
|
||||
[j.strip() for j in jail_list_raw.split(",") if j.strip()]
|
||||
if jail_list_raw
|
||||
else []
|
||||
)
|
||||
|
||||
if not jail_names:
|
||||
return ActiveBanListResponse(bans=[], total=0)
|
||||
|
||||
# For each jail, fetch the ban list with time info in parallel.
|
||||
results: list[Any] = await asyncio.gather(
|
||||
*[client.send(["get", jn, "banip", "--with-time"]) for jn in jail_names],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
bans: list[ActiveBan] = []
|
||||
for jail_name, raw_result in zip(jail_names, results, strict=False):
|
||||
if isinstance(raw_result, Exception):
|
||||
log.warning(
|
||||
"active_bans_fetch_error",
|
||||
jail=jail_name,
|
||||
error=str(raw_result),
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
ban_list: list[str] = _ok(raw_result) or []
|
||||
except (TypeError, ValueError) as exc:
|
||||
log.warning(
|
||||
"active_bans_parse_error",
|
||||
jail=jail_name,
|
||||
error=str(exc),
|
||||
)
|
||||
continue
|
||||
|
||||
for entry in ban_list:
|
||||
ban = _parse_ban_entry(str(entry), jail_name)
|
||||
if ban is not None:
|
||||
bans.append(ban)
|
||||
|
||||
# Enrich with geo data if an enricher was provided.
|
||||
if geo_enricher is not None:
|
||||
bans = await _enrich_bans(bans, geo_enricher)
|
||||
|
||||
log.info("active_bans_fetched", total=len(bans))
|
||||
return ActiveBanListResponse(bans=bans, total=len(bans))
|
||||
|
||||
|
||||
def _parse_ban_entry(entry: str, jail: str) -> ActiveBan | None:
|
||||
"""Parse a ban entry from ``get <jail> banip --with-time`` output.
|
||||
|
||||
Expected format::
|
||||
|
||||
"1.2.3.4 \t2025-01-01 12:00:00 + 3600 = 2025-01-01 13:00:00"
|
||||
|
||||
Args:
|
||||
entry: Raw ban entry string.
|
||||
jail: Jail name for the resulting record.
|
||||
|
||||
Returns:
|
||||
An :class:`~app.models.ban.ActiveBan` or ``None`` if parsing fails.
|
||||
"""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
try:
|
||||
parts = entry.split("\t", 1)
|
||||
ip = parts[0].strip()
|
||||
|
||||
# Validate IP
|
||||
ipaddress.ip_address(ip)
|
||||
|
||||
if len(parts) < 2:
|
||||
# Entry has no time info — return with unknown times.
|
||||
return ActiveBan(
|
||||
ip=ip,
|
||||
jail=jail,
|
||||
banned_at=None,
|
||||
expires_at=None,
|
||||
ban_count=1,
|
||||
country=None,
|
||||
)
|
||||
|
||||
time_part = parts[1].strip()
|
||||
# Format: "2025-01-01 12:00:00 + 3600 = 2025-01-01 13:00:00"
|
||||
# Split at " + " to get banned_at and remainder.
|
||||
plus_idx = time_part.find(" + ")
|
||||
if plus_idx == -1:
|
||||
banned_at_str = time_part.strip()
|
||||
expires_at_str: str | None = None
|
||||
else:
|
||||
banned_at_str = time_part[:plus_idx].strip()
|
||||
remainder = time_part[plus_idx + 3 :] # skip " + "
|
||||
eq_idx = remainder.find(" = ")
|
||||
expires_at_str = remainder[eq_idx + 3 :].strip() if eq_idx != -1 else None
|
||||
|
||||
_date_fmt = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
def _to_iso(ts: str) -> str:
|
||||
dt = datetime.strptime(ts, _date_fmt).replace(tzinfo=UTC)
|
||||
return dt.isoformat()
|
||||
|
||||
banned_at_iso: str | None = None
|
||||
expires_at_iso: str | None = None
|
||||
|
||||
with contextlib.suppress(ValueError):
|
||||
banned_at_iso = _to_iso(banned_at_str)
|
||||
|
||||
with contextlib.suppress(ValueError):
|
||||
if expires_at_str:
|
||||
expires_at_iso = _to_iso(expires_at_str)
|
||||
|
||||
return ActiveBan(
|
||||
ip=ip,
|
||||
jail=jail,
|
||||
banned_at=banned_at_iso,
|
||||
expires_at=expires_at_iso,
|
||||
ban_count=1,
|
||||
country=None,
|
||||
)
|
||||
except (ValueError, IndexError, AttributeError) as exc:
|
||||
log.debug("ban_entry_parse_error", entry=entry, jail=jail, error=str(exc))
|
||||
return None
|
||||
|
||||
|
||||
async def _enrich_bans(
|
||||
bans: list[ActiveBan],
|
||||
geo_enricher: Any,
|
||||
) -> list[ActiveBan]:
|
||||
"""Enrich ban records with geo data asynchronously.
|
||||
|
||||
Args:
|
||||
bans: The list of :class:`~app.models.ban.ActiveBan` records to enrich.
|
||||
geo_enricher: Async callable ``(ip) → GeoInfo | None``.
|
||||
|
||||
Returns:
|
||||
The same list with ``country`` fields populated where lookup succeeded.
|
||||
"""
|
||||
geo_results: list[Any] = await asyncio.gather(
|
||||
*[geo_enricher(ban.ip) for ban in bans],
|
||||
return_exceptions=True,
|
||||
)
|
||||
enriched: list[ActiveBan] = []
|
||||
for ban, geo in zip(bans, geo_results, strict=False):
|
||||
if geo is not None and not isinstance(geo, Exception):
|
||||
enriched.append(ban.model_copy(update={"country": geo.country_code}))
|
||||
else:
|
||||
enriched.append(ban)
|
||||
return enriched
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — Ignore list (IP whitelist)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_ignore_list(socket_path: str, name: str) -> list[str]:
|
||||
"""Return the ignore list for a fail2ban jail.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
name: Jail name.
|
||||
|
||||
Returns:
|
||||
List of IP addresses and CIDR networks on the jail's ignore list.
|
||||
|
||||
Raises:
|
||||
JailNotFoundError: If *name* is not a known jail.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
raw = _ok(await client.send(["get", name, "ignoreip"]))
|
||||
return _ensure_list(raw)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise
|
||||
|
||||
|
||||
async def add_ignore_ip(socket_path: str, name: str, ip: str) -> None:
|
||||
"""Add an IP address or CIDR network to a jail's ignore list.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
name: Jail name.
|
||||
ip: IP address or CIDR network to add.
|
||||
|
||||
Raises:
|
||||
JailNotFoundError: If *name* is not a known jail.
|
||||
JailOperationError: If fail2ban reports the operation failed.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
# Basic format validation.
|
||||
try:
|
||||
ipaddress.ip_network(ip, strict=False)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid IP address or network: {ip!r}") from exc
|
||||
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
_ok(await client.send(["set", name, "addignoreip", ip]))
|
||||
log.info("ignore_ip_added", jail=name, ip=ip)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
|
||||
async def del_ignore_ip(socket_path: str, name: str, ip: str) -> None:
|
||||
"""Remove an IP address or CIDR network from a jail's ignore list.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
name: Jail name.
|
||||
ip: IP address or CIDR network to remove.
|
||||
|
||||
Raises:
|
||||
JailNotFoundError: If *name* is not a known jail.
|
||||
JailOperationError: If fail2ban reports the operation failed.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
_ok(await client.send(["set", name, "delignoreip", ip]))
|
||||
log.info("ignore_ip_removed", jail=name, ip=ip)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
|
||||
async def get_ignore_self(socket_path: str, name: str) -> bool:
|
||||
"""Return whether a jail ignores the server's own IP addresses.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
name: Jail name.
|
||||
|
||||
Returns:
|
||||
``True`` when ``ignoreself`` is enabled for the jail.
|
||||
|
||||
Raises:
|
||||
JailNotFoundError: If *name* is not a known jail.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
raw = _ok(await client.send(["get", name, "ignoreself"]))
|
||||
return bool(raw)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise
|
||||
|
||||
|
||||
async def set_ignore_self(socket_path: str, name: str, *, on: bool) -> None:
|
||||
"""Toggle the ``ignoreself`` option for a fail2ban jail.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
name: Jail name.
|
||||
on: ``True`` to enable ignoreself, ``False`` to disable.
|
||||
|
||||
Raises:
|
||||
JailNotFoundError: If *name* is not a known jail.
|
||||
JailOperationError: If fail2ban reports the operation failed.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
value = "true" if on else "false"
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
_ok(await client.send(["set", name, "ignoreself", value]))
|
||||
log.info("ignore_self_toggled", jail=name, on=on)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — IP lookup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def lookup_ip(
|
||||
socket_path: str,
|
||||
ip: str,
|
||||
geo_enricher: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return ban status and history for a single IP address.
|
||||
|
||||
Checks every running jail for whether the IP is currently banned.
|
||||
Also queries the fail2ban database for historical ban records.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
ip: IP address to look up.
|
||||
geo_enricher: Optional async callable ``(ip) → GeoInfo | None``.
|
||||
|
||||
Returns:
|
||||
A dict with keys:
|
||||
* ``ip`` — the queried IP address
|
||||
* ``currently_banned_in`` — list of jails where the IP is active
|
||||
* ``geo`` — ``GeoInfo`` dataclass or ``None``
|
||||
|
||||
Raises:
|
||||
ValueError: If *ip* is not a valid IP address.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
try:
|
||||
ipaddress.ip_address(ip)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid IP address: {ip!r}") from exc
|
||||
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
with contextlib.suppress(ValueError, Fail2BanConnectionError):
|
||||
# Use fail2ban's "banned <ip>" command which checks all jails.
|
||||
_ok(await client.send(["get", "--all", "banned", ip]))
|
||||
|
||||
# Fetch jail names from status.
|
||||
global_status = _to_dict(_ok(await client.send(["status"])))
|
||||
jail_list_raw: str = str(global_status.get("Jail list", "") or "").strip()
|
||||
jail_names: list[str] = (
|
||||
[j.strip() for j in jail_list_raw.split(",") if j.strip()]
|
||||
if jail_list_raw
|
||||
else []
|
||||
)
|
||||
|
||||
# Check ban status per jail in parallel.
|
||||
ban_results: list[Any] = await asyncio.gather(
|
||||
*[client.send(["get", jn, "banip"]) for jn in jail_names],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
currently_banned_in: list[str] = []
|
||||
for jail_name, result in zip(jail_names, ban_results, strict=False):
|
||||
if isinstance(result, Exception):
|
||||
continue
|
||||
try:
|
||||
ban_list: list[str] = _ok(result) or []
|
||||
if ip in ban_list:
|
||||
currently_banned_in.append(jail_name)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
geo = None
|
||||
if geo_enricher is not None:
|
||||
with contextlib.suppress(Exception): # noqa: BLE001
|
||||
geo = await geo_enricher(ip)
|
||||
|
||||
log.info("ip_lookup_completed", ip=ip, banned_in_jails=currently_banned_in)
|
||||
|
||||
return {
|
||||
"ip": ip,
|
||||
"currently_banned_in": currently_banned_in,
|
||||
"geo": geo,
|
||||
}
|
||||
272
backend/tests/test_routers/test_bans.py
Normal file
272
backend/tests/test_routers/test_bans.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""Tests for the bans router endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import init_db
|
||||
from app.main import create_app
|
||||
from app.models.ban import ActiveBan, ActiveBanListResponse
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SETUP_PAYLOAD = {
|
||||
"master_password": "testpassword1",
|
||||
"database_path": "bangui.db",
|
||||
"fail2ban_socket": "/var/run/fail2ban/fail2ban.sock",
|
||||
"timezone": "UTC",
|
||||
"session_duration_minutes": 60,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def bans_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
|
||||
"""Provide an authenticated ``AsyncClient`` for bans endpoint tests."""
|
||||
settings = Settings(
|
||||
database_path=str(tmp_path / "bans_test.db"),
|
||||
fail2ban_socket="/tmp/fake.sock",
|
||||
session_secret="test-bans-secret",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
app = create_app(settings=settings)
|
||||
|
||||
db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path)
|
||||
db.row_factory = aiosqlite.Row
|
||||
await init_db(db)
|
||||
app.state.db = db
|
||||
app.state.http_session = MagicMock()
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
await ac.post("/api/setup", json=_SETUP_PAYLOAD)
|
||||
login = await ac.post(
|
||||
"/api/auth/login",
|
||||
json={"password": _SETUP_PAYLOAD["master_password"]},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
yield ac
|
||||
|
||||
await db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/bans/active
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetActiveBans:
|
||||
"""Tests for ``GET /api/bans/active``."""
|
||||
|
||||
async def test_200_when_authenticated(self, bans_client: AsyncClient) -> None:
|
||||
"""GET /api/bans/active returns 200 with an ActiveBanListResponse."""
|
||||
mock_response = ActiveBanListResponse(
|
||||
bans=[
|
||||
ActiveBan(
|
||||
ip="1.2.3.4",
|
||||
jail="sshd",
|
||||
banned_at="2025-01-01T12:00:00+00:00",
|
||||
expires_at="2025-01-01T13:00:00+00:00",
|
||||
ban_count=1,
|
||||
country="DE",
|
||||
)
|
||||
],
|
||||
total=1,
|
||||
)
|
||||
with patch(
|
||||
"app.routers.bans.jail_service.get_active_bans",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await bans_client.get("/api/bans/active")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["bans"][0]["ip"] == "1.2.3.4"
|
||||
assert data["bans"][0]["jail"] == "sshd"
|
||||
|
||||
async def test_401_when_unauthenticated(self, bans_client: AsyncClient) -> None:
|
||||
"""GET /api/bans/active returns 401 without session."""
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=bans_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).get("/api/bans/active")
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_empty_when_no_bans(self, bans_client: AsyncClient) -> None:
|
||||
"""GET /api/bans/active returns empty list when no bans are active."""
|
||||
mock_response = ActiveBanListResponse(bans=[], total=0)
|
||||
with patch(
|
||||
"app.routers.bans.jail_service.get_active_bans",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await bans_client.get("/api/bans/active")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 0
|
||||
assert resp.json()["bans"] == []
|
||||
|
||||
async def test_response_shape(self, bans_client: AsyncClient) -> None:
|
||||
"""GET /api/bans/active returns expected fields per ban entry."""
|
||||
mock_response = ActiveBanListResponse(
|
||||
bans=[
|
||||
ActiveBan(
|
||||
ip="10.0.0.1",
|
||||
jail="nginx",
|
||||
banned_at=None,
|
||||
expires_at=None,
|
||||
ban_count=1,
|
||||
country=None,
|
||||
)
|
||||
],
|
||||
total=1,
|
||||
)
|
||||
with patch(
|
||||
"app.routers.bans.jail_service.get_active_bans",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await bans_client.get("/api/bans/active")
|
||||
|
||||
ban = resp.json()["bans"][0]
|
||||
assert "ip" in ban
|
||||
assert "jail" in ban
|
||||
assert "banned_at" in ban
|
||||
assert "expires_at" in ban
|
||||
assert "ban_count" in ban
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/bans
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBanIp:
|
||||
"""Tests for ``POST /api/bans``."""
|
||||
|
||||
async def test_201_on_success(self, bans_client: AsyncClient) -> None:
|
||||
"""POST /api/bans returns 201 when the IP is banned."""
|
||||
with patch(
|
||||
"app.routers.bans.jail_service.ban_ip",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await bans_client.post(
|
||||
"/api/bans",
|
||||
json={"ip": "1.2.3.4", "jail": "sshd"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["jail"] == "sshd"
|
||||
|
||||
async def test_400_for_invalid_ip(self, bans_client: AsyncClient) -> None:
|
||||
"""POST /api/bans returns 400 for an invalid IP address."""
|
||||
with patch(
|
||||
"app.routers.bans.jail_service.ban_ip",
|
||||
AsyncMock(side_effect=ValueError("Invalid IP address: 'bad'")),
|
||||
):
|
||||
resp = await bans_client.post(
|
||||
"/api/bans",
|
||||
json={"ip": "bad", "jail": "sshd"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_404_for_unknown_jail(self, bans_client: AsyncClient) -> None:
|
||||
"""POST /api/bans returns 404 when jail does not exist."""
|
||||
from app.services.jail_service import JailNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.bans.jail_service.ban_ip",
|
||||
AsyncMock(side_effect=JailNotFoundError("ghost")),
|
||||
):
|
||||
resp = await bans_client.post(
|
||||
"/api/bans",
|
||||
json={"ip": "1.2.3.4", "jail": "ghost"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_401_when_unauthenticated(self, bans_client: AsyncClient) -> None:
|
||||
"""POST /api/bans returns 401 without session."""
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=bans_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).post("/api/bans", json={"ip": "1.2.3.4", "jail": "sshd"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /api/bans
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUnbanIp:
|
||||
"""Tests for ``DELETE /api/bans``."""
|
||||
|
||||
async def test_200_unban_from_all(self, bans_client: AsyncClient) -> None:
|
||||
"""DELETE /api/bans with unban_all=true unbans from all jails."""
|
||||
with patch(
|
||||
"app.routers.bans.jail_service.unban_ip",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await bans_client.request(
|
||||
"DELETE",
|
||||
"/api/bans",
|
||||
json={"ip": "1.2.3.4", "unban_all": True},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "all jails" in resp.json()["message"]
|
||||
|
||||
async def test_200_unban_from_specific_jail(self, bans_client: AsyncClient) -> None:
|
||||
"""DELETE /api/bans with a jail unbans from that jail only."""
|
||||
with patch(
|
||||
"app.routers.bans.jail_service.unban_ip",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await bans_client.request(
|
||||
"DELETE",
|
||||
"/api/bans",
|
||||
json={"ip": "1.2.3.4", "jail": "sshd"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "sshd" in resp.json()["message"]
|
||||
|
||||
async def test_400_for_invalid_ip(self, bans_client: AsyncClient) -> None:
|
||||
"""DELETE /api/bans returns 400 for an invalid IP."""
|
||||
with patch(
|
||||
"app.routers.bans.jail_service.unban_ip",
|
||||
AsyncMock(side_effect=ValueError("Invalid IP address: 'bad'")),
|
||||
):
|
||||
resp = await bans_client.request(
|
||||
"DELETE",
|
||||
"/api/bans",
|
||||
json={"ip": "bad", "unban_all": True},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_404_for_unknown_jail(self, bans_client: AsyncClient) -> None:
|
||||
"""DELETE /api/bans returns 404 when jail does not exist."""
|
||||
from app.services.jail_service import JailNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.bans.jail_service.unban_ip",
|
||||
AsyncMock(side_effect=JailNotFoundError("ghost")),
|
||||
):
|
||||
resp = await bans_client.request(
|
||||
"DELETE",
|
||||
"/api/bans",
|
||||
json={"ip": "1.2.3.4", "jail": "ghost"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
159
backend/tests/test_routers/test_geo.py
Normal file
159
backend/tests/test_routers/test_geo.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Tests for the geo/IP-lookup router endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import init_db
|
||||
from app.main import create_app
|
||||
from app.services.geo_service import GeoInfo
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SETUP_PAYLOAD = {
|
||||
"master_password": "testpassword1",
|
||||
"database_path": "bangui.db",
|
||||
"fail2ban_socket": "/var/run/fail2ban/fail2ban.sock",
|
||||
"timezone": "UTC",
|
||||
"session_duration_minutes": 60,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def geo_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
|
||||
"""Provide an authenticated ``AsyncClient`` for geo endpoint tests."""
|
||||
settings = Settings(
|
||||
database_path=str(tmp_path / "geo_test.db"),
|
||||
fail2ban_socket="/tmp/fake.sock",
|
||||
session_secret="test-geo-secret",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
app = create_app(settings=settings)
|
||||
|
||||
db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path)
|
||||
db.row_factory = aiosqlite.Row
|
||||
await init_db(db)
|
||||
app.state.db = db
|
||||
app.state.http_session = MagicMock()
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
await ac.post("/api/setup", json=_SETUP_PAYLOAD)
|
||||
login = await ac.post(
|
||||
"/api/auth/login",
|
||||
json={"password": _SETUP_PAYLOAD["master_password"]},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
yield ac
|
||||
|
||||
await db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/geo/lookup/{ip}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGeoLookup:
|
||||
"""Tests for ``GET /api/geo/lookup/{ip}``."""
|
||||
|
||||
async def test_200_with_geo_info(self, geo_client: AsyncClient) -> None:
|
||||
"""GET /api/geo/lookup/{ip} returns 200 with enriched result."""
|
||||
geo = GeoInfo(country_code="DE", country_name="Germany", asn="12345", org="Acme")
|
||||
result = {
|
||||
"ip": "1.2.3.4",
|
||||
"currently_banned_in": ["sshd"],
|
||||
"geo": geo,
|
||||
}
|
||||
with patch(
|
||||
"app.routers.geo.jail_service.lookup_ip",
|
||||
AsyncMock(return_value=result),
|
||||
):
|
||||
resp = await geo_client.get("/api/geo/lookup/1.2.3.4")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ip"] == "1.2.3.4"
|
||||
assert data["currently_banned_in"] == ["sshd"]
|
||||
assert data["geo"]["country_code"] == "DE"
|
||||
assert data["geo"]["country_name"] == "Germany"
|
||||
assert data["geo"]["asn"] == "12345"
|
||||
assert data["geo"]["org"] == "Acme"
|
||||
|
||||
async def test_200_when_not_banned(self, geo_client: AsyncClient) -> None:
|
||||
"""GET /api/geo/lookup/{ip} returns empty list when IP is not banned anywhere."""
|
||||
result = {
|
||||
"ip": "8.8.8.8",
|
||||
"currently_banned_in": [],
|
||||
"geo": GeoInfo(country_code="US", country_name="United States", asn=None, org=None),
|
||||
}
|
||||
with patch(
|
||||
"app.routers.geo.jail_service.lookup_ip",
|
||||
AsyncMock(return_value=result),
|
||||
):
|
||||
resp = await geo_client.get("/api/geo/lookup/8.8.8.8")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["currently_banned_in"] == []
|
||||
|
||||
async def test_200_with_no_geo(self, geo_client: AsyncClient) -> None:
|
||||
"""GET /api/geo/lookup/{ip} returns null geo when enricher fails."""
|
||||
result = {
|
||||
"ip": "1.2.3.4",
|
||||
"currently_banned_in": [],
|
||||
"geo": None,
|
||||
}
|
||||
with patch(
|
||||
"app.routers.geo.jail_service.lookup_ip",
|
||||
AsyncMock(return_value=result),
|
||||
):
|
||||
resp = await geo_client.get("/api/geo/lookup/1.2.3.4")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["geo"] is None
|
||||
|
||||
async def test_400_for_invalid_ip(self, geo_client: AsyncClient) -> None:
|
||||
"""GET /api/geo/lookup/{ip} returns 400 for an invalid IP address."""
|
||||
with patch(
|
||||
"app.routers.geo.jail_service.lookup_ip",
|
||||
AsyncMock(side_effect=ValueError("Invalid IP address: 'bad_ip'")),
|
||||
):
|
||||
resp = await geo_client.get("/api/geo/lookup/bad_ip")
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "detail" in resp.json()
|
||||
|
||||
async def test_401_when_unauthenticated(self, geo_client: AsyncClient) -> None:
|
||||
"""GET /api/geo/lookup/{ip} returns 401 without a session."""
|
||||
app = geo_client._transport.app # type: ignore[attr-defined]
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
).get("/api/geo/lookup/1.2.3.4")
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_ipv6_address(self, geo_client: AsyncClient) -> None:
|
||||
"""GET /api/geo/lookup/{ip} handles IPv6 addresses."""
|
||||
result = {
|
||||
"ip": "2001:db8::1",
|
||||
"currently_banned_in": [],
|
||||
"geo": None,
|
||||
}
|
||||
with patch(
|
||||
"app.routers.geo.jail_service.lookup_ip",
|
||||
AsyncMock(return_value=result),
|
||||
):
|
||||
resp = await geo_client.get("/api/geo/lookup/2001:db8::1")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ip"] == "2001:db8::1"
|
||||
407
backend/tests/test_routers/test_jails.py
Normal file
407
backend/tests/test_routers/test_jails.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""Tests for the jails router endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import init_db
|
||||
from app.main import create_app
|
||||
from app.models.jail import JailCommandResponse, JailDetailResponse, JailListResponse, JailStatus, JailSummary, Jail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SETUP_PAYLOAD = {
|
||||
"master_password": "testpassword1",
|
||||
"database_path": "bangui.db",
|
||||
"fail2ban_socket": "/var/run/fail2ban/fail2ban.sock",
|
||||
"timezone": "UTC",
|
||||
"session_duration_minutes": 60,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def jails_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
|
||||
"""Provide an authenticated ``AsyncClient`` for jail endpoint tests."""
|
||||
settings = Settings(
|
||||
database_path=str(tmp_path / "jails_test.db"),
|
||||
fail2ban_socket="/tmp/fake.sock",
|
||||
session_secret="test-jails-secret",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
app = create_app(settings=settings)
|
||||
|
||||
db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path)
|
||||
db.row_factory = aiosqlite.Row
|
||||
await init_db(db)
|
||||
app.state.db = db
|
||||
app.state.http_session = MagicMock()
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
await ac.post("/api/setup", json=_SETUP_PAYLOAD)
|
||||
login = await ac.post(
|
||||
"/api/auth/login",
|
||||
json={"password": _SETUP_PAYLOAD["master_password"]},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
yield ac
|
||||
|
||||
await db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _summary(name: str = "sshd") -> JailSummary:
|
||||
return JailSummary(
|
||||
name=name,
|
||||
enabled=True,
|
||||
running=True,
|
||||
idle=False,
|
||||
backend="polling",
|
||||
find_time=600,
|
||||
ban_time=600,
|
||||
max_retry=5,
|
||||
status=JailStatus(
|
||||
currently_banned=2,
|
||||
total_banned=10,
|
||||
currently_failed=1,
|
||||
total_failed=50,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _detail(name: str = "sshd") -> JailDetailResponse:
|
||||
return JailDetailResponse(
|
||||
jail=Jail(
|
||||
name=name,
|
||||
enabled=True,
|
||||
running=True,
|
||||
idle=False,
|
||||
backend="polling",
|
||||
log_paths=["/var/log/auth.log"],
|
||||
fail_regex=["^.*Failed.*<HOST>"],
|
||||
ignore_regex=[],
|
||||
ignore_ips=["127.0.0.1"],
|
||||
date_pattern=None,
|
||||
log_encoding="UTF-8",
|
||||
find_time=600,
|
||||
ban_time=600,
|
||||
max_retry=5,
|
||||
actions=["iptables-multiport"],
|
||||
status=JailStatus(
|
||||
currently_banned=2,
|
||||
total_banned=10,
|
||||
currently_failed=1,
|
||||
total_failed=50,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/jails
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetJails:
|
||||
"""Tests for ``GET /api/jails``."""
|
||||
|
||||
async def test_200_when_authenticated(self, jails_client: AsyncClient) -> None:
|
||||
"""GET /api/jails returns 200 with a JailListResponse."""
|
||||
mock_response = JailListResponse(jails=[_summary()], total=1)
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.list_jails",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await jails_client.get("/api/jails")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["jails"][0]["name"] == "sshd"
|
||||
|
||||
async def test_401_when_unauthenticated(self, jails_client: AsyncClient) -> None:
|
||||
"""GET /api/jails returns 401 without a session cookie."""
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=jails_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).get("/api/jails")
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_response_shape(self, jails_client: AsyncClient) -> None:
|
||||
"""GET /api/jails response contains expected fields."""
|
||||
mock_response = JailListResponse(jails=[_summary()], total=1)
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.list_jails",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await jails_client.get("/api/jails")
|
||||
|
||||
jail = resp.json()["jails"][0]
|
||||
assert "name" in jail
|
||||
assert "enabled" in jail
|
||||
assert "running" in jail
|
||||
assert "idle" in jail
|
||||
assert "backend" in jail
|
||||
assert "status" in jail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/jails/{name}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetJailDetail:
|
||||
"""Tests for ``GET /api/jails/{name}``."""
|
||||
|
||||
async def test_200_for_existing_jail(self, jails_client: AsyncClient) -> None:
|
||||
"""GET /api/jails/sshd returns 200 with full jail detail."""
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.get_jail",
|
||||
AsyncMock(return_value=_detail()),
|
||||
):
|
||||
resp = await jails_client.get("/api/jails/sshd")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["jail"]["name"] == "sshd"
|
||||
assert "log_paths" in data["jail"]
|
||||
assert "fail_regex" in data["jail"]
|
||||
assert "actions" in data["jail"]
|
||||
|
||||
async def test_404_for_unknown_jail(self, jails_client: AsyncClient) -> None:
|
||||
"""GET /api/jails/ghost returns 404."""
|
||||
from app.services.jail_service import JailNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.get_jail",
|
||||
AsyncMock(side_effect=JailNotFoundError("ghost")),
|
||||
):
|
||||
resp = await jails_client.get("/api/jails/ghost")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/jails/{name}/start
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStartJail:
|
||||
"""Tests for ``POST /api/jails/{name}/start``."""
|
||||
|
||||
async def test_200_starts_jail(self, jails_client: AsyncClient) -> None:
|
||||
"""POST /api/jails/sshd/start returns 200 on success."""
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.start_jail",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await jails_client.post("/api/jails/sshd/start")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["jail"] == "sshd"
|
||||
|
||||
async def test_404_for_unknown_jail(self, jails_client: AsyncClient) -> None:
|
||||
"""POST /api/jails/ghost/start returns 404."""
|
||||
from app.services.jail_service import JailNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.start_jail",
|
||||
AsyncMock(side_effect=JailNotFoundError("ghost")),
|
||||
):
|
||||
resp = await jails_client.post("/api/jails/ghost/start")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_409_on_operation_error(self, jails_client: AsyncClient) -> None:
|
||||
"""POST /api/jails/sshd/start returns 409 on operation failure."""
|
||||
from app.services.jail_service import JailOperationError
|
||||
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.start_jail",
|
||||
AsyncMock(side_effect=JailOperationError("already running")),
|
||||
):
|
||||
resp = await jails_client.post("/api/jails/sshd/start")
|
||||
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/jails/{name}/stop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStopJail:
|
||||
"""Tests for ``POST /api/jails/{name}/stop``."""
|
||||
|
||||
async def test_200_stops_jail(self, jails_client: AsyncClient) -> None:
|
||||
"""POST /api/jails/sshd/stop returns 200 on success."""
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.stop_jail",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await jails_client.post("/api/jails/sshd/stop")
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_404_for_unknown_jail(self, jails_client: AsyncClient) -> None:
|
||||
"""POST /api/jails/ghost/stop returns 404."""
|
||||
from app.services.jail_service import JailNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.stop_jail",
|
||||
AsyncMock(side_effect=JailNotFoundError("ghost")),
|
||||
):
|
||||
resp = await jails_client.post("/api/jails/ghost/stop")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/jails/{name}/idle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToggleIdle:
|
||||
"""Tests for ``POST /api/jails/{name}/idle``."""
|
||||
|
||||
async def test_200_idle_on(self, jails_client: AsyncClient) -> None:
|
||||
"""POST /api/jails/sshd/idle?on=true returns 200."""
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.set_idle",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await jails_client.post(
|
||||
"/api/jails/sshd/idle",
|
||||
content="true",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_200_idle_off(self, jails_client: AsyncClient) -> None:
|
||||
"""POST /api/jails/sshd/idle with false turns idle off."""
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.set_idle",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await jails_client.post(
|
||||
"/api/jails/sshd/idle",
|
||||
content="false",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/jails/{name}/reload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReloadJail:
|
||||
"""Tests for ``POST /api/jails/{name}/reload``."""
|
||||
|
||||
async def test_200_reloads_jail(self, jails_client: AsyncClient) -> None:
|
||||
"""POST /api/jails/sshd/reload returns 200 on success."""
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.reload_jail",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await jails_client.post("/api/jails/sshd/reload")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["jail"] == "sshd"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/jails/reload-all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReloadAll:
|
||||
"""Tests for ``POST /api/jails/reload-all``."""
|
||||
|
||||
async def test_200_reloads_all(self, jails_client: AsyncClient) -> None:
|
||||
"""POST /api/jails/reload-all returns 200 on success."""
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.reload_all",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await jails_client.post("/api/jails/reload-all")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["jail"] == "*"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/jails/{name}/ignoreip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIgnoreIpEndpoints:
|
||||
"""Tests for ignore-list management endpoints."""
|
||||
|
||||
async def test_get_ignore_list(self, jails_client: AsyncClient) -> None:
|
||||
"""GET /api/jails/sshd/ignoreip returns 200 with a list."""
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.get_ignore_list",
|
||||
AsyncMock(return_value=["127.0.0.1"]),
|
||||
):
|
||||
resp = await jails_client.get("/api/jails/sshd/ignoreip")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "127.0.0.1" in resp.json()
|
||||
|
||||
async def test_add_ignore_ip_returns_201(self, jails_client: AsyncClient) -> None:
|
||||
"""POST /api/jails/sshd/ignoreip returns 201 on success."""
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.add_ignore_ip",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await jails_client.post(
|
||||
"/api/jails/sshd/ignoreip",
|
||||
json={"ip": "192.168.1.0/24"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
|
||||
async def test_add_invalid_ip_returns_400(self, jails_client: AsyncClient) -> None:
|
||||
"""POST /api/jails/sshd/ignoreip returns 400 for invalid IP."""
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.add_ignore_ip",
|
||||
AsyncMock(side_effect=ValueError("Invalid IP address or network: 'bad'")),
|
||||
):
|
||||
resp = await jails_client.post(
|
||||
"/api/jails/sshd/ignoreip",
|
||||
json={"ip": "bad"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_delete_ignore_ip(self, jails_client: AsyncClient) -> None:
|
||||
"""DELETE /api/jails/sshd/ignoreip returns 200 on success."""
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.del_ignore_ip",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await jails_client.request(
|
||||
"DELETE",
|
||||
"/api/jails/sshd/ignoreip",
|
||||
json={"ip": "127.0.0.1"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
526
backend/tests/test_services/test_jail_service.py
Normal file
526
backend/tests/test_services/test_jail_service.py
Normal file
@@ -0,0 +1,526 @@
|
||||
"""Tests for jail_service functions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.ban import ActiveBanListResponse
|
||||
from app.models.jail import JailDetailResponse, JailListResponse
|
||||
from app.services import jail_service
|
||||
from app.services.jail_service import JailNotFoundError, JailOperationError
|
||||
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SOCKET = "/fake/fail2ban.sock"
|
||||
|
||||
_JAIL_NAMES = "sshd, nginx"
|
||||
|
||||
|
||||
def _make_global_status(names: str = _JAIL_NAMES) -> tuple[int, list[Any]]:
|
||||
return (0, [("Number of jail", 2), ("Jail list", names)])
|
||||
|
||||
|
||||
def _make_short_status(
|
||||
banned: int = 2,
|
||||
total_banned: int = 10,
|
||||
failed: int = 3,
|
||||
total_failed: int = 20,
|
||||
) -> tuple[int, list[Any]]:
|
||||
return (
|
||||
0,
|
||||
[
|
||||
("Filter", [("Currently failed", failed), ("Total failed", total_failed)]),
|
||||
("Actions", [("Currently banned", banned), ("Total banned", total_banned)]),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _make_send(responses: dict[str, Any]) -> AsyncMock:
|
||||
"""Build an ``AsyncMock`` for ``Fail2BanClient.send``.
|
||||
|
||||
Responses are keyed by the command joined with a pipe, e.g.
|
||||
``"status"`` or ``"status|sshd|short"``.
|
||||
"""
|
||||
|
||||
async def _side_effect(command: list[Any]) -> Any:
|
||||
key = "|".join(str(c) for c in command)
|
||||
if key in responses:
|
||||
return responses[key]
|
||||
# Fall back to partial key matching.
|
||||
for resp_key, resp_value in responses.items():
|
||||
if key.startswith(resp_key):
|
||||
return resp_value
|
||||
raise KeyError(f"Unexpected command key {key!r}")
|
||||
|
||||
return AsyncMock(side_effect=_side_effect)
|
||||
|
||||
|
||||
def _patch_client(responses: dict[str, Any]) -> Any:
|
||||
"""Return a ``patch`` context manager that mocks ``Fail2BanClient``."""
|
||||
mock_send = _make_send(responses)
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = mock_send
|
||||
|
||||
return patch("app.services.jail_service.Fail2BanClient", _FakeClient)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_jails
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListJails:
|
||||
"""Unit tests for :func:`~app.services.jail_service.list_jails`."""
|
||||
|
||||
async def test_returns_jail_list_response(self) -> None:
|
||||
"""list_jails returns a JailListResponse."""
|
||||
responses = {
|
||||
"status": _make_global_status("sshd"),
|
||||
"status|sshd|short": _make_short_status(),
|
||||
"get|sshd|bantime": (0, 600),
|
||||
"get|sshd|findtime": (0, 600),
|
||||
"get|sshd|maxretry": (0, 5),
|
||||
"get|sshd|backend": (0, "polling"),
|
||||
"get|sshd|idle": (0, False),
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await jail_service.list_jails(_SOCKET)
|
||||
|
||||
assert isinstance(result, JailListResponse)
|
||||
assert result.total == 1
|
||||
assert result.jails[0].name == "sshd"
|
||||
|
||||
async def test_empty_jail_list(self) -> None:
|
||||
"""list_jails returns empty response when no jails are active."""
|
||||
responses = {"status": (0, [("Number of jail", 0), ("Jail list", "")])}
|
||||
with _patch_client(responses):
|
||||
result = await jail_service.list_jails(_SOCKET)
|
||||
|
||||
assert result.total == 0
|
||||
assert result.jails == []
|
||||
|
||||
async def test_jail_status_populated(self) -> None:
|
||||
"""list_jails populates JailStatus with failed/banned counters."""
|
||||
responses = {
|
||||
"status": _make_global_status("sshd"),
|
||||
"status|sshd|short": _make_short_status(banned=5, total_banned=50),
|
||||
"get|sshd|bantime": (0, 600),
|
||||
"get|sshd|findtime": (0, 600),
|
||||
"get|sshd|maxretry": (0, 5),
|
||||
"get|sshd|backend": (0, "polling"),
|
||||
"get|sshd|idle": (0, False),
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await jail_service.list_jails(_SOCKET)
|
||||
|
||||
jail = result.jails[0]
|
||||
assert jail.status is not None
|
||||
assert jail.status.currently_banned == 5
|
||||
assert jail.status.total_banned == 50
|
||||
|
||||
async def test_jail_config_populated(self) -> None:
|
||||
"""list_jails populates ban_time, find_time, max_retry, backend."""
|
||||
responses = {
|
||||
"status": _make_global_status("sshd"),
|
||||
"status|sshd|short": _make_short_status(),
|
||||
"get|sshd|bantime": (0, 3600),
|
||||
"get|sshd|findtime": (0, 300),
|
||||
"get|sshd|maxretry": (0, 3),
|
||||
"get|sshd|backend": (0, "systemd"),
|
||||
"get|sshd|idle": (0, True),
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await jail_service.list_jails(_SOCKET)
|
||||
|
||||
jail = result.jails[0]
|
||||
assert jail.ban_time == 3600
|
||||
assert jail.find_time == 300
|
||||
assert jail.max_retry == 3
|
||||
assert jail.backend == "systemd"
|
||||
assert jail.idle is True
|
||||
|
||||
async def test_multiple_jails_returned(self) -> None:
|
||||
"""list_jails fetches all jails listed in the global status."""
|
||||
responses = {
|
||||
"status": _make_global_status("sshd, nginx"),
|
||||
"status|sshd|short": _make_short_status(),
|
||||
"status|nginx|short": _make_short_status(banned=0),
|
||||
"get|sshd|bantime": (0, 600),
|
||||
"get|sshd|findtime": (0, 600),
|
||||
"get|sshd|maxretry": (0, 5),
|
||||
"get|sshd|backend": (0, "polling"),
|
||||
"get|sshd|idle": (0, False),
|
||||
"get|nginx|bantime": (0, 1800),
|
||||
"get|nginx|findtime": (0, 600),
|
||||
"get|nginx|maxretry": (0, 5),
|
||||
"get|nginx|backend": (0, "polling"),
|
||||
"get|nginx|idle": (0, False),
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await jail_service.list_jails(_SOCKET)
|
||||
|
||||
assert result.total == 2
|
||||
names = {j.name for j in result.jails}
|
||||
assert names == {"sshd", "nginx"}
|
||||
|
||||
async def test_connection_error_propagates(self) -> None:
|
||||
"""list_jails raises Fail2BanConnectionError when socket unreachable."""
|
||||
|
||||
async def _raise(*_: Any, **__: Any) -> None:
|
||||
raise Fail2BanConnectionError("no socket", _SOCKET)
|
||||
|
||||
class _FailClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=Fail2BanConnectionError("no socket", _SOCKET))
|
||||
|
||||
with patch("app.services.jail_service.Fail2BanClient", _FailClient):
|
||||
with pytest.raises(Fail2BanConnectionError):
|
||||
await jail_service.list_jails(_SOCKET)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_jail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetJail:
|
||||
"""Unit tests for :func:`~app.services.jail_service.get_jail`."""
|
||||
|
||||
def _full_responses(self, name: str = "sshd") -> dict[str, Any]:
|
||||
return {
|
||||
f"status|{name}|short": _make_short_status(),
|
||||
f"get|{name}|logpath": (0, ["/var/log/auth.log"]),
|
||||
f"get|{name}|failregex": (0, ["^.*Failed.*from <HOST>"]),
|
||||
f"get|{name}|ignoreregex": (0, []),
|
||||
f"get|{name}|ignoreip": (0, ["127.0.0.1"]),
|
||||
f"get|{name}|datepattern": (0, None),
|
||||
f"get|{name}|logencoding": (0, "UTF-8"),
|
||||
f"get|{name}|bantime": (0, 600),
|
||||
f"get|{name}|findtime": (0, 600),
|
||||
f"get|{name}|maxretry": (0, 5),
|
||||
f"get|{name}|backend": (0, "polling"),
|
||||
f"get|{name}|idle": (0, False),
|
||||
f"get|{name}|actions": (0, ["iptables-multiport"]),
|
||||
}
|
||||
|
||||
async def test_returns_jail_detail_response(self) -> None:
|
||||
"""get_jail returns a JailDetailResponse."""
|
||||
with _patch_client(self._full_responses()):
|
||||
result = await jail_service.get_jail(_SOCKET, "sshd")
|
||||
|
||||
assert isinstance(result, JailDetailResponse)
|
||||
assert result.jail.name == "sshd"
|
||||
|
||||
async def test_log_paths_parsed(self) -> None:
|
||||
"""get_jail populates log_paths from fail2ban."""
|
||||
with _patch_client(self._full_responses()):
|
||||
result = await jail_service.get_jail(_SOCKET, "sshd")
|
||||
|
||||
assert result.jail.log_paths == ["/var/log/auth.log"]
|
||||
|
||||
async def test_fail_regex_parsed(self) -> None:
|
||||
"""get_jail populates fail_regex list."""
|
||||
with _patch_client(self._full_responses()):
|
||||
result = await jail_service.get_jail(_SOCKET, "sshd")
|
||||
|
||||
assert "^.*Failed.*from <HOST>" in result.jail.fail_regex
|
||||
|
||||
async def test_ignore_ips_parsed(self) -> None:
|
||||
"""get_jail populates ignore_ips list."""
|
||||
with _patch_client(self._full_responses()):
|
||||
result = await jail_service.get_jail(_SOCKET, "sshd")
|
||||
|
||||
assert "127.0.0.1" in result.jail.ignore_ips
|
||||
|
||||
async def test_actions_parsed(self) -> None:
|
||||
"""get_jail populates actions list."""
|
||||
with _patch_client(self._full_responses()):
|
||||
result = await jail_service.get_jail(_SOCKET, "sshd")
|
||||
|
||||
assert result.jail.actions == ["iptables-multiport"]
|
||||
|
||||
async def test_jail_not_found_raises(self) -> None:
|
||||
"""get_jail raises JailNotFoundError when jail is unknown."""
|
||||
not_found_response = (1, Exception("Unknown jail: 'ghost'"))
|
||||
|
||||
with _patch_client({r"status|ghost|short": not_found_response}):
|
||||
with pytest.raises(JailNotFoundError):
|
||||
await jail_service.get_jail(_SOCKET, "ghost")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jail control commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestJailControls:
|
||||
"""Unit tests for start, stop, idle, reload commands."""
|
||||
|
||||
async def test_start_jail_success(self) -> None:
|
||||
"""start_jail sends the start command without error."""
|
||||
with _patch_client({"start|sshd": (0, None)}):
|
||||
await jail_service.start_jail(_SOCKET, "sshd") # should not raise
|
||||
|
||||
async def test_stop_jail_success(self) -> None:
|
||||
"""stop_jail sends the stop command without error."""
|
||||
with _patch_client({"stop|sshd": (0, None)}):
|
||||
await jail_service.stop_jail(_SOCKET, "sshd") # should not raise
|
||||
|
||||
async def test_set_idle_on(self) -> None:
|
||||
"""set_idle sends idle=on when on=True."""
|
||||
with _patch_client({"set|sshd|idle|on": (0, True)}):
|
||||
await jail_service.set_idle(_SOCKET, "sshd", on=True) # should not raise
|
||||
|
||||
async def test_set_idle_off(self) -> None:
|
||||
"""set_idle sends idle=off when on=False."""
|
||||
with _patch_client({"set|sshd|idle|off": (0, True)}):
|
||||
await jail_service.set_idle(_SOCKET, "sshd", on=False) # should not raise
|
||||
|
||||
async def test_reload_jail_success(self) -> None:
|
||||
"""reload_jail sends the reload command without error."""
|
||||
with _patch_client({"reload|sshd|[]|[]": (0, "OK")}):
|
||||
await jail_service.reload_jail(_SOCKET, "sshd") # should not raise
|
||||
|
||||
async def test_reload_all_success(self) -> None:
|
||||
"""reload_all sends the reload --all command without error."""
|
||||
with _patch_client({"reload|--all|[]|[]": (0, "OK")}):
|
||||
await jail_service.reload_all(_SOCKET) # should not raise
|
||||
|
||||
async def test_start_not_found_raises(self) -> None:
|
||||
"""start_jail raises JailNotFoundError for unknown jail."""
|
||||
with _patch_client({"start|ghost": (1, Exception("Unknown jail: 'ghost'"))}):
|
||||
with pytest.raises(JailNotFoundError):
|
||||
await jail_service.start_jail(_SOCKET, "ghost")
|
||||
|
||||
async def test_stop_operation_error_raises(self) -> None:
|
||||
"""stop_jail raises JailOperationError on fail2ban error code."""
|
||||
with _patch_client({"stop|sshd": (1, Exception("cannot stop"))}):
|
||||
with pytest.raises(JailOperationError):
|
||||
await jail_service.stop_jail(_SOCKET, "sshd")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ban_ip / unban_ip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBanUnban:
|
||||
"""Unit tests for :func:`~app.services.jail_service.ban_ip` and
|
||||
:func:`~app.services.jail_service.unban_ip`.
|
||||
"""
|
||||
|
||||
async def test_ban_ip_success(self) -> None:
|
||||
"""ban_ip sends the banip command for a valid IP."""
|
||||
with _patch_client({"set|sshd|banip|1.2.3.4": (0, 1)}):
|
||||
await jail_service.ban_ip(_SOCKET, "sshd", "1.2.3.4") # should not raise
|
||||
|
||||
async def test_ban_ip_invalid_raises(self) -> None:
|
||||
"""ban_ip raises ValueError for a non-IP value."""
|
||||
with pytest.raises(ValueError, match="Invalid IP"):
|
||||
await jail_service.ban_ip(_SOCKET, "sshd", "not-an-ip")
|
||||
|
||||
async def test_ban_ipv6_success(self) -> None:
|
||||
"""ban_ip accepts an IPv6 address."""
|
||||
with _patch_client({"set|sshd|banip|::1": (0, 1)}):
|
||||
await jail_service.ban_ip(_SOCKET, "sshd", "::1") # should not raise
|
||||
|
||||
async def test_unban_ip_all_jails(self) -> None:
|
||||
"""unban_ip with jail=None uses the global unban command."""
|
||||
with _patch_client({"unban|1.2.3.4": (0, 1)}):
|
||||
await jail_service.unban_ip(_SOCKET, "1.2.3.4") # should not raise
|
||||
|
||||
async def test_unban_ip_specific_jail(self) -> None:
|
||||
"""unban_ip with a jail sends the set unbanip command."""
|
||||
with _patch_client({"set|sshd|unbanip|1.2.3.4": (0, 1)}):
|
||||
await jail_service.unban_ip(_SOCKET, "1.2.3.4", jail="sshd") # should not raise
|
||||
|
||||
async def test_unban_invalid_ip_raises(self) -> None:
|
||||
"""unban_ip raises ValueError for an invalid IP."""
|
||||
with pytest.raises(ValueError, match="Invalid IP"):
|
||||
await jail_service.unban_ip(_SOCKET, "bad-ip")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_active_bans
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetActiveBans:
|
||||
"""Unit tests for :func:`~app.services.jail_service.get_active_bans`."""
|
||||
|
||||
async def test_returns_active_ban_list_response(self) -> None:
|
||||
"""get_active_bans returns an ActiveBanListResponse."""
|
||||
responses = {
|
||||
"status": _make_global_status("sshd"),
|
||||
"get|sshd|banip|--with-time": (
|
||||
0,
|
||||
["1.2.3.4 \t2025-01-01 12:00:00 + 3600 = 2025-01-01 13:00:00"],
|
||||
),
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await jail_service.get_active_bans(_SOCKET)
|
||||
|
||||
assert isinstance(result, ActiveBanListResponse)
|
||||
assert result.total == 1
|
||||
assert result.bans[0].ip == "1.2.3.4"
|
||||
assert result.bans[0].jail == "sshd"
|
||||
|
||||
async def test_empty_when_no_jails(self) -> None:
|
||||
"""get_active_bans returns empty list when no jails are active."""
|
||||
responses = {"status": (0, [("Number of jail", 0), ("Jail list", "")])}
|
||||
with _patch_client(responses):
|
||||
result = await jail_service.get_active_bans(_SOCKET)
|
||||
|
||||
assert result.total == 0
|
||||
assert result.bans == []
|
||||
|
||||
async def test_empty_when_no_bans(self) -> None:
|
||||
"""get_active_bans returns empty list when all jails have zero bans."""
|
||||
responses = {
|
||||
"status": _make_global_status("sshd"),
|
||||
"get|sshd|banip|--with-time": (0, []),
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await jail_service.get_active_bans(_SOCKET)
|
||||
|
||||
assert result.total == 0
|
||||
|
||||
async def test_ban_time_parsed(self) -> None:
|
||||
"""get_active_bans populates banned_at and expires_at from the entry."""
|
||||
responses = {
|
||||
"status": _make_global_status("sshd"),
|
||||
"get|sshd|banip|--with-time": (
|
||||
0,
|
||||
["10.0.0.1 \t2025-03-01 08:00:00 + 7200 = 2025-03-01 10:00:00"],
|
||||
),
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await jail_service.get_active_bans(_SOCKET)
|
||||
|
||||
ban = result.bans[0]
|
||||
assert ban.banned_at is not None
|
||||
assert "2025-03-01" in ban.banned_at
|
||||
assert ban.expires_at is not None
|
||||
assert "2025-03-01" in ban.expires_at
|
||||
|
||||
async def test_error_in_jail_tolerated(self) -> None:
|
||||
"""get_active_bans skips a jail that errors during the ban-list fetch."""
|
||||
responses = {
|
||||
"status": _make_global_status("sshd, nginx"),
|
||||
"get|sshd|banip|--with-time": (
|
||||
0,
|
||||
["1.2.3.4 \t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00"],
|
||||
),
|
||||
"get|nginx|banip|--with-time": Fail2BanConnectionError("no nginx", _SOCKET),
|
||||
}
|
||||
|
||||
async def _side(*args: Any) -> Any:
|
||||
key = "|".join(str(a) for a in args[0])
|
||||
resp = responses.get(key)
|
||||
if isinstance(resp, Exception):
|
||||
raise resp
|
||||
if resp is None:
|
||||
raise KeyError(f"Unexpected key {key!r}")
|
||||
return resp
|
||||
|
||||
class _FakeClientPartial:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_side)
|
||||
|
||||
with patch("app.services.jail_service.Fail2BanClient", _FakeClientPartial):
|
||||
result = await jail_service.get_active_bans(_SOCKET)
|
||||
|
||||
# Only sshd ban returned (nginx silently skipped)
|
||||
assert result.total == 1
|
||||
assert result.bans[0].jail == "sshd"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ignore list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIgnoreList:
|
||||
"""Unit tests for ignore list operations."""
|
||||
|
||||
async def test_get_ignore_list(self) -> None:
|
||||
"""get_ignore_list returns a list of IP strings."""
|
||||
with _patch_client({"get|sshd|ignoreip": (0, ["127.0.0.1", "10.0.0.0/8"])}):
|
||||
result = await jail_service.get_ignore_list(_SOCKET, "sshd")
|
||||
|
||||
assert "127.0.0.1" in result
|
||||
assert "10.0.0.0/8" in result
|
||||
|
||||
async def test_add_ignore_ip(self) -> None:
|
||||
"""add_ignore_ip sends addignoreip for a valid CIDR."""
|
||||
with _patch_client({"set|sshd|addignoreip|192.168.0.0/24": (0, "OK")}):
|
||||
await jail_service.add_ignore_ip(_SOCKET, "sshd", "192.168.0.0/24")
|
||||
|
||||
async def test_add_ignore_ip_invalid_raises(self) -> None:
|
||||
"""add_ignore_ip raises ValueError for an invalid CIDR."""
|
||||
with pytest.raises(ValueError, match="Invalid IP"):
|
||||
await jail_service.add_ignore_ip(_SOCKET, "sshd", "not-a-cidr")
|
||||
|
||||
async def test_del_ignore_ip(self) -> None:
|
||||
"""del_ignore_ip sends delignoreip command."""
|
||||
with _patch_client({"set|sshd|delignoreip|127.0.0.1": (0, "OK")}):
|
||||
await jail_service.del_ignore_ip(_SOCKET, "sshd", "127.0.0.1")
|
||||
|
||||
async def test_get_ignore_self(self) -> None:
|
||||
"""get_ignore_self returns a boolean."""
|
||||
with _patch_client({"get|sshd|ignoreself": (0, True)}):
|
||||
result = await jail_service.get_ignore_self(_SOCKET, "sshd")
|
||||
|
||||
assert result is True
|
||||
|
||||
async def test_set_ignore_self_on(self) -> None:
|
||||
"""set_ignore_self sends ignoreself=true."""
|
||||
with _patch_client({"set|sshd|ignoreself|true": (0, True)}):
|
||||
await jail_service.set_ignore_self(_SOCKET, "sshd", on=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# lookup_ip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLookupIp:
|
||||
"""Unit tests for :func:`~app.services.jail_service.lookup_ip`."""
|
||||
|
||||
async def test_basic_lookup(self) -> None:
|
||||
"""lookup_ip returns currently_banned_in list."""
|
||||
responses = {
|
||||
"get|--all|banned|1.2.3.4": (0, []),
|
||||
"status": _make_global_status("sshd"),
|
||||
"get|sshd|banip": (0, ["1.2.3.4", "5.6.7.8"]),
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await jail_service.lookup_ip(_SOCKET, "1.2.3.4")
|
||||
|
||||
assert result["ip"] == "1.2.3.4"
|
||||
assert "sshd" in result["currently_banned_in"]
|
||||
|
||||
async def test_invalid_ip_raises(self) -> None:
|
||||
"""lookup_ip raises ValueError for invalid IP."""
|
||||
with pytest.raises(ValueError, match="Invalid IP"):
|
||||
await jail_service.lookup_ip(_SOCKET, "not-an-ip")
|
||||
|
||||
async def test_not_banned_returns_empty_list(self) -> None:
|
||||
"""lookup_ip returns empty currently_banned_in when IP is not banned."""
|
||||
responses = {
|
||||
"get|--all|banned|9.9.9.9": (0, []),
|
||||
"status": _make_global_status("sshd"),
|
||||
"get|sshd|banip": (0, ["1.2.3.4"]),
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await jail_service.lookup_ip(_SOCKET, "9.9.9.9")
|
||||
|
||||
assert result["currently_banned_in"] == []
|
||||
213
frontend/src/api/jails.ts
Normal file
213
frontend/src/api/jails.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Jails API module.
|
||||
*
|
||||
* Wraps all backend endpoints under `/api/jails`, `/api/bans`, and
|
||||
* `/api/geo` that relate to jail management.
|
||||
*/
|
||||
|
||||
import { del, get, post } from "./client";
|
||||
import { ENDPOINTS } from "./endpoints";
|
||||
import type {
|
||||
ActiveBanListResponse,
|
||||
IpLookupResponse,
|
||||
JailCommandResponse,
|
||||
JailDetailResponse,
|
||||
JailListResponse,
|
||||
} from "../types/jail";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail overview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch the list of all fail2ban jails.
|
||||
*
|
||||
* @returns A {@link JailListResponse} containing summary info for each jail.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function fetchJails(): Promise<JailListResponse> {
|
||||
return get<JailListResponse>(ENDPOINTS.jails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch full detail for a single jail.
|
||||
*
|
||||
* @param name - Jail name (e.g. `"sshd"`).
|
||||
* @returns A {@link JailDetailResponse} with config, ignore list, and status.
|
||||
* @throws {ApiError} On non-2xx responses (404 if the jail does not exist).
|
||||
*/
|
||||
export async function fetchJail(name: string): Promise<JailDetailResponse> {
|
||||
return get<JailDetailResponse>(ENDPOINTS.jail(name));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail controls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Start a stopped jail.
|
||||
*
|
||||
* @param name - Jail name.
|
||||
* @returns A {@link JailCommandResponse} confirming the operation.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function startJail(name: string): Promise<JailCommandResponse> {
|
||||
return post<JailCommandResponse>(ENDPOINTS.jailStart(name), {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a running jail.
|
||||
*
|
||||
* @param name - Jail name.
|
||||
* @returns A {@link JailCommandResponse} confirming the operation.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function stopJail(name: string): Promise<JailCommandResponse> {
|
||||
return post<JailCommandResponse>(ENDPOINTS.jailStop(name), {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle idle mode for a jail.
|
||||
*
|
||||
* @param name - Jail name.
|
||||
* @param on - `true` to enable idle mode, `false` to disable.
|
||||
* @returns A {@link JailCommandResponse} confirming the toggle.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function setJailIdle(
|
||||
name: string,
|
||||
on: boolean,
|
||||
): Promise<JailCommandResponse> {
|
||||
return post<JailCommandResponse>(ENDPOINTS.jailIdle(name), on);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload configuration for a single jail.
|
||||
*
|
||||
* @param name - Jail name.
|
||||
* @returns A {@link JailCommandResponse} confirming the reload.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function reloadJail(name: string): Promise<JailCommandResponse> {
|
||||
return post<JailCommandResponse>(ENDPOINTS.jailReload(name), {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload configuration for **all** jails at once.
|
||||
*
|
||||
* @returns A {@link JailCommandResponse} confirming the operation.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function reloadAllJails(): Promise<JailCommandResponse> {
|
||||
return post<JailCommandResponse>(ENDPOINTS.jailsReloadAll, {});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ignore list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return the ignore list for a jail.
|
||||
*
|
||||
* @param name - Jail name.
|
||||
* @returns Array of IP addresses / CIDR networks on the ignore list.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function fetchIgnoreList(name: string): Promise<string[]> {
|
||||
return get<string[]>(ENDPOINTS.jailIgnoreIp(name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an IP or CIDR network to a jail's ignore list.
|
||||
*
|
||||
* @param name - Jail name.
|
||||
* @param ip - IP address or CIDR network to add.
|
||||
* @returns A {@link JailCommandResponse} confirming the addition.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function addIgnoreIp(
|
||||
name: string,
|
||||
ip: string,
|
||||
): Promise<JailCommandResponse> {
|
||||
return post<JailCommandResponse>(ENDPOINTS.jailIgnoreIp(name), { ip });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an IP or CIDR network from a jail's ignore list.
|
||||
*
|
||||
* @param name - Jail name.
|
||||
* @param ip - IP address or CIDR network to remove.
|
||||
* @returns A {@link JailCommandResponse} confirming the removal.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function delIgnoreIp(
|
||||
name: string,
|
||||
ip: string,
|
||||
): Promise<JailCommandResponse> {
|
||||
return del<JailCommandResponse>(ENDPOINTS.jailIgnoreIp(name), { ip });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ban / unban
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Manually ban an IP address in a specific jail.
|
||||
*
|
||||
* @param jail - Jail name.
|
||||
* @param ip - IP address to ban.
|
||||
* @returns A {@link JailCommandResponse} confirming the ban.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function banIp(
|
||||
jail: string,
|
||||
ip: string,
|
||||
): Promise<JailCommandResponse> {
|
||||
return post<JailCommandResponse>(ENDPOINTS.bans, { jail, ip });
|
||||
}
|
||||
|
||||
/**
|
||||
* Unban an IP address from a specific jail or all jails.
|
||||
*
|
||||
* @param ip - IP address to unban.
|
||||
* @param jail - Target jail name, or `undefined` to unban from all jails.
|
||||
* @param unbanAll - When `true`, remove the IP from every jail.
|
||||
* @returns A {@link JailCommandResponse} confirming the unban.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function unbanIp(
|
||||
ip: string,
|
||||
jail?: string,
|
||||
unbanAll = false,
|
||||
): Promise<JailCommandResponse> {
|
||||
return del<JailCommandResponse>(ENDPOINTS.bans, { ip, jail, unban_all: unbanAll });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active bans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch all currently active bans across every jail.
|
||||
*
|
||||
* @returns An {@link ActiveBanListResponse} with geo-enriched entries.
|
||||
* @throws {ApiError} On non-2xx responses.
|
||||
*/
|
||||
export async function fetchActiveBans(): Promise<ActiveBanListResponse> {
|
||||
return get<ActiveBanListResponse>(ENDPOINTS.bansActive);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Geo / IP lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Look up ban status and geo-location for an IP address.
|
||||
*
|
||||
* @param ip - IP address to look up.
|
||||
* @returns An {@link IpLookupResponse} with ban history and geo info.
|
||||
* @throws {ApiError} On non-2xx responses (400 for invalid IP).
|
||||
*/
|
||||
export async function lookupIp(ip: string): Promise<IpLookupResponse> {
|
||||
return get<IpLookupResponse>(ENDPOINTS.geoLookup(ip));
|
||||
}
|
||||
358
frontend/src/hooks/useJails.ts
Normal file
358
frontend/src/hooks/useJails.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Jail management hooks.
|
||||
*
|
||||
* Provides data-fetching and mutation hooks for all jail-related views,
|
||||
* following the same patterns established by `useBans.ts` and
|
||||
* `useServerStatus.ts`.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
addIgnoreIp,
|
||||
banIp,
|
||||
delIgnoreIp,
|
||||
fetchActiveBans,
|
||||
fetchJail,
|
||||
fetchJails,
|
||||
lookupIp,
|
||||
reloadAllJails,
|
||||
reloadJail,
|
||||
setJailIdle,
|
||||
startJail,
|
||||
stopJail,
|
||||
unbanIp,
|
||||
} from "../api/jails";
|
||||
import type {
|
||||
ActiveBan,
|
||||
IpLookupResponse,
|
||||
Jail,
|
||||
JailSummary,
|
||||
} from "../types/jail";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useJails — overview list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Return value for {@link useJails}. */
|
||||
export interface UseJailsResult {
|
||||
/** All known jails. */
|
||||
jails: JailSummary[];
|
||||
/** Total count returned by the backend. */
|
||||
total: number;
|
||||
/** `true` while a fetch is in progress. */
|
||||
loading: boolean;
|
||||
/** Error message from the last failed fetch, or `null`. */
|
||||
error: string | null;
|
||||
/** Re-fetch the jail list from the backend. */
|
||||
refresh: () => void;
|
||||
/** Start a specific jail (returns a promise for error handling). */
|
||||
startJail: (name: string) => Promise<void>;
|
||||
/** Stop a specific jail. */
|
||||
stopJail: (name: string) => Promise<void>;
|
||||
/** Toggle idle mode for a jail. */
|
||||
setIdle: (name: string, on: boolean) => Promise<void>;
|
||||
/** Reload a specific jail. */
|
||||
reloadJail: (name: string) => Promise<void>;
|
||||
/** Reload all jails at once. */
|
||||
reloadAll: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and manage the jail overview list.
|
||||
*
|
||||
* Automatically loads on mount and exposes control mutations that refresh
|
||||
* the list after each operation.
|
||||
*
|
||||
* @returns Current jail list, loading/error state, and control callbacks.
|
||||
*/
|
||||
export function useJails(): UseJailsResult {
|
||||
const [jails, setJails] = useState<JailSummary[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchJails()
|
||||
.then((res) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setJails(res.jails);
|
||||
setTotal(res.total);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const withRefresh =
|
||||
(fn: (name: string) => Promise<unknown>) =>
|
||||
async (name: string): Promise<void> => {
|
||||
await fn(name);
|
||||
load();
|
||||
};
|
||||
|
||||
return {
|
||||
jails,
|
||||
total,
|
||||
loading,
|
||||
error,
|
||||
refresh: load,
|
||||
startJail: withRefresh(startJail),
|
||||
stopJail: withRefresh(stopJail),
|
||||
setIdle: (name, on) => setJailIdle(name, on).then(() => load()),
|
||||
reloadJail: withRefresh(reloadJail),
|
||||
reloadAll: () => reloadAllJails().then(() => load()),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useJailDetail — single jail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Return value for {@link useJailDetail}. */
|
||||
export interface UseJailDetailResult {
|
||||
/** Full jail configuration, or `null` while loading. */
|
||||
jail: Jail | null;
|
||||
/** Current ignore list. */
|
||||
ignoreList: string[];
|
||||
/** Whether `ignoreself` is enabled. */
|
||||
ignoreSelf: boolean;
|
||||
/** `true` while a fetch is in progress. */
|
||||
loading: boolean;
|
||||
/** Error message or `null`. */
|
||||
error: string | null;
|
||||
/** Re-fetch from the backend. */
|
||||
refresh: () => void;
|
||||
/** Add an IP to the ignore list. */
|
||||
addIp: (ip: string) => Promise<void>;
|
||||
/** Remove an IP from the ignore list. */
|
||||
removeIp: (ip: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and manage the detail view for a single jail.
|
||||
*
|
||||
* @param name - Jail name to load.
|
||||
* @returns Jail detail, ignore list management helpers, and fetch state.
|
||||
*/
|
||||
export function useJailDetail(name: string): UseJailDetailResult {
|
||||
const [jail, setJail] = useState<Jail | null>(null);
|
||||
const [ignoreList, setIgnoreList] = useState<string[]>([]);
|
||||
const [ignoreSelf, setIgnoreSelf] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchJail(name)
|
||||
.then((res) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setJail(res.jail);
|
||||
setIgnoreList(res.ignore_list);
|
||||
setIgnoreSelf(res.ignore_self);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ctrl.signal.aborted) setLoading(false);
|
||||
});
|
||||
}, [name]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const addIp = async (ip: string): Promise<void> => {
|
||||
await addIgnoreIp(name, ip);
|
||||
load();
|
||||
};
|
||||
|
||||
const removeIp = async (ip: string): Promise<void> => {
|
||||
await delIgnoreIp(name, ip);
|
||||
load();
|
||||
};
|
||||
|
||||
return {
|
||||
jail,
|
||||
ignoreList,
|
||||
ignoreSelf,
|
||||
loading,
|
||||
error,
|
||||
refresh: load,
|
||||
addIp,
|
||||
removeIp,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useActiveBans — live ban list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Return value for {@link useActiveBans}. */
|
||||
export interface UseActiveBansResult {
|
||||
/** All currently active bans. */
|
||||
bans: ActiveBan[];
|
||||
/** Total ban count. */
|
||||
total: number;
|
||||
/** `true` while fetching. */
|
||||
loading: boolean;
|
||||
/** Error message or `null`. */
|
||||
error: string | null;
|
||||
/** Re-fetch the active bans. */
|
||||
refresh: () => void;
|
||||
/** Ban an IP in a specific jail. */
|
||||
banIp: (jail: string, ip: string) => Promise<void>;
|
||||
/** Unban an IP from a jail (or all jails when `jail` is omitted). */
|
||||
unbanIp: (ip: string, jail?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and manage the currently-active ban list.
|
||||
*
|
||||
* @returns Active ban list, mutation callbacks, and fetch state.
|
||||
*/
|
||||
export function useActiveBans(): UseActiveBansResult {
|
||||
const [bans, setBans] = useState<ActiveBan[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchActiveBans()
|
||||
.then((res) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setBans(res.bans);
|
||||
setTotal(res.total);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ctrl.signal.aborted) setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const doBan = async (jail: string, ip: string): Promise<void> => {
|
||||
await banIp(jail, ip);
|
||||
load();
|
||||
};
|
||||
|
||||
const doUnban = async (ip: string, jail?: string): Promise<void> => {
|
||||
await unbanIp(ip, jail);
|
||||
load();
|
||||
};
|
||||
|
||||
return {
|
||||
bans,
|
||||
total,
|
||||
loading,
|
||||
error,
|
||||
refresh: load,
|
||||
banIp: doBan,
|
||||
unbanIp: doUnban,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useIpLookup — single IP lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Return value for {@link useIpLookup}. */
|
||||
export interface UseIpLookupResult {
|
||||
/** Lookup result, or `null` when no lookup has been performed yet. */
|
||||
result: IpLookupResponse | null;
|
||||
/** `true` while a lookup is in progress. */
|
||||
loading: boolean;
|
||||
/** Error message or `null`. */
|
||||
error: string | null;
|
||||
/** Trigger an IP lookup. */
|
||||
lookup: (ip: string) => void;
|
||||
/** Clear the result and error state. */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage IP lookup state (lazy — no fetch on mount).
|
||||
*
|
||||
* @returns Lookup result, state flags, and a `lookup` trigger callback.
|
||||
*/
|
||||
export function useIpLookup(): UseIpLookupResult {
|
||||
const [result, setResult] = useState<IpLookupResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const lookup = useCallback((ip: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
lookupIp(ip)
|
||||
.then((res) => {
|
||||
setResult(res);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return { result, loading, error, lookup, clear };
|
||||
}
|
||||
@@ -1,25 +1,582 @@
|
||||
/**
|
||||
* Jail detail placeholder page — full implementation in Stage 6.
|
||||
* Jail detail page.
|
||||
*
|
||||
* Displays full configuration and state for a single fail2ban jail:
|
||||
* - Status badges and control buttons (start, stop, idle, reload)
|
||||
* - Log paths, fail-regex, ignore-regex patterns
|
||||
* - Date pattern, encoding, and actions
|
||||
* - Ignore list management (add / remove IPs)
|
||||
*/
|
||||
|
||||
import { Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
makeStyles,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import {
|
||||
ArrowClockwiseRegular,
|
||||
ArrowLeftRegular,
|
||||
ArrowSyncRegular,
|
||||
DismissRegular,
|
||||
PauseRegular,
|
||||
PlayRegular,
|
||||
StopRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
reloadJail,
|
||||
setJailIdle,
|
||||
startJail,
|
||||
stopJail,
|
||||
} from "../api/jails";
|
||||
import { useJailDetail } from "../hooks/useJails";
|
||||
import type { Jail } from "../types/jail";
|
||||
import { ApiError } from "../api/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { padding: tokens.spacingVerticalXXL },
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalL,
|
||||
},
|
||||
breadcrumb: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
},
|
||||
section: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalS,
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
borderTopWidth: "1px",
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: tokens.colorNeutralStroke2,
|
||||
borderRightWidth: "1px",
|
||||
borderRightStyle: "solid",
|
||||
borderRightColor: tokens.colorNeutralStroke2,
|
||||
borderBottomWidth: "1px",
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: tokens.colorNeutralStroke2,
|
||||
borderLeftWidth: "1px",
|
||||
borderLeftStyle: "solid",
|
||||
borderLeftColor: tokens.colorNeutralStroke2,
|
||||
padding: tokens.spacingVerticalM,
|
||||
},
|
||||
sectionHeader: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
paddingBottom: tokens.spacingVerticalS,
|
||||
borderBottomWidth: "1px",
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: tokens.colorNeutralStroke2,
|
||||
},
|
||||
headerRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
controlRow: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
},
|
||||
grid: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "max-content 1fr",
|
||||
gap: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalM}`,
|
||||
alignItems: "baseline",
|
||||
},
|
||||
label: {
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
color: tokens.colorNeutralForeground2,
|
||||
},
|
||||
mono: {
|
||||
fontFamily: "Consolas, 'Courier New', monospace",
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
},
|
||||
codeList: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalXXS,
|
||||
paddingTop: tokens.spacingVerticalXS,
|
||||
},
|
||||
codeItem: {
|
||||
fontFamily: "Consolas, 'Courier New', monospace",
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
padding: `2px ${tokens.spacingHorizontalS}`,
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
borderRadius: tokens.borderRadiusSmall,
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
centred: {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: tokens.spacingVerticalXXL,
|
||||
},
|
||||
ignoreRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`,
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
borderRadius: tokens.borderRadiusSmall,
|
||||
},
|
||||
formRow: {
|
||||
display: "flex",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
alignItems: "flex-end",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
formField: { minWidth: "200px", flexGrow: 1 },
|
||||
});
|
||||
|
||||
export function JailDetailPage(): JSX.Element {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fmtSeconds(s: number): string {
|
||||
if (s < 0) return "permanent";
|
||||
if (s < 60) return `${String(s)} s`;
|
||||
if (s < 3600) return `${String(Math.round(s / 60))} min`;
|
||||
return `${String(Math.round(s / 3600))} h`;
|
||||
}
|
||||
|
||||
function CodeList({ items, empty }: { items: string[]; empty: string }): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { name } = useParams<{ name: string }>();
|
||||
if (items.length === 0) {
|
||||
return <Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>{empty}</Text>;
|
||||
}
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Text as="h1" size={700} weight="semibold">
|
||||
Jail: {name}
|
||||
</Text>
|
||||
<Text as="p" size={300}>
|
||||
Jail detail view will be implemented in Stage 6.
|
||||
</Text>
|
||||
<div className={styles.codeList}>
|
||||
{items.map((item, i) => (
|
||||
<span key={i} className={styles.codeItem}>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: Jail info card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface JailInfoProps {
|
||||
jail: Jail;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
function JailInfoSection({ jail, onRefresh }: JailInfoProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const navigate = useNavigate();
|
||||
const [ctrlError, setCtrlError] = useState<string | null>(null);
|
||||
|
||||
const handle =
|
||||
(fn: () => Promise<unknown>, postNavigate = false) =>
|
||||
(): void => {
|
||||
setCtrlError(null);
|
||||
fn()
|
||||
.then(() => {
|
||||
if (postNavigate) {
|
||||
navigate("/jails");
|
||||
} else {
|
||||
onRefresh();
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? `${String(err.status)}: ${err.body}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
setCtrlError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.headerRow}>
|
||||
<Text
|
||||
size={600}
|
||||
weight="semibold"
|
||||
style={{ fontFamily: "Consolas, 'Courier New', monospace" }}
|
||||
>
|
||||
{jail.name}
|
||||
</Text>
|
||||
{jail.running ? (
|
||||
jail.idle ? (
|
||||
<Badge appearance="filled" color="warning">idle</Badge>
|
||||
) : (
|
||||
<Badge appearance="filled" color="success">running</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge appearance="filled" color="danger">stopped</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowClockwiseRegular />}
|
||||
onClick={onRefresh}
|
||||
aria-label="Refresh"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ctrlError && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{ctrlError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{/* Control buttons */}
|
||||
<div className={styles.controlRow}>
|
||||
{jail.running ? (
|
||||
<Tooltip content="Stop jail" relationship="label">
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<StopRegular />}
|
||||
onClick={handle(() => stopJail(jail.name).then(() => void 0))}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip content="Start jail" relationship="label">
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={<PlayRegular />}
|
||||
onClick={handle(() => startJail(jail.name).then(() => void 0))}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip
|
||||
content={jail.idle ? "Resume from idle mode" : "Pause monitoring (idle mode)"}
|
||||
relationship="label"
|
||||
>
|
||||
<Button
|
||||
appearance="outline"
|
||||
icon={<PauseRegular />}
|
||||
onClick={handle(() => setJailIdle(jail.name, !jail.idle).then(() => void 0))}
|
||||
disabled={!jail.running}
|
||||
>
|
||||
{jail.idle ? "Resume" : "Set Idle"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Reload jail configuration" relationship="label">
|
||||
<Button
|
||||
appearance="outline"
|
||||
icon={<ArrowSyncRegular />}
|
||||
onClick={handle(() => reloadJail(jail.name).then(() => void 0))}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
{jail.status && (
|
||||
<div className={styles.grid} style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
<Text className={styles.label}>Currently banned:</Text>
|
||||
<Text>{String(jail.status.currently_banned)}</Text>
|
||||
<Text className={styles.label}>Total banned:</Text>
|
||||
<Text>{String(jail.status.total_banned)}</Text>
|
||||
<Text className={styles.label}>Currently failed:</Text>
|
||||
<Text>{String(jail.status.currently_failed)}</Text>
|
||||
<Text className={styles.label}>Total failed:</Text>
|
||||
<Text>{String(jail.status.total_failed)}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config grid */}
|
||||
<div className={styles.grid} style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
<Text className={styles.label}>Backend:</Text>
|
||||
<Text className={styles.mono}>{jail.backend}</Text>
|
||||
<Text className={styles.label}>Find time:</Text>
|
||||
<Text>{fmtSeconds(jail.find_time)}</Text>
|
||||
<Text className={styles.label}>Ban time:</Text>
|
||||
<Text>{fmtSeconds(jail.ban_time)}</Text>
|
||||
<Text className={styles.label}>Max retry:</Text>
|
||||
<Text>{String(jail.max_retry)}</Text>
|
||||
{jail.date_pattern && (
|
||||
<>
|
||||
<Text className={styles.label}>Date pattern:</Text>
|
||||
<Text className={styles.mono}>{jail.date_pattern}</Text>
|
||||
</>
|
||||
)}
|
||||
{jail.log_encoding && (
|
||||
<>
|
||||
<Text className={styles.label}>Log encoding:</Text>
|
||||
<Text className={styles.mono}>{jail.log_encoding}</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: Patterns section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PatternsSection({ jail }: { jail: Jail }): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Log Paths & Patterns
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Text size={300} weight="semibold">Log Paths</Text>
|
||||
<CodeList items={jail.log_paths} empty="No log paths configured." />
|
||||
|
||||
<Text size={300} weight="semibold" style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
Fail Regex
|
||||
</Text>
|
||||
<CodeList items={jail.fail_regex} empty="No fail-regex patterns." />
|
||||
|
||||
<Text size={300} weight="semibold" style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
Ignore Regex
|
||||
</Text>
|
||||
<CodeList items={jail.ignore_regex} empty="No ignore-regex patterns." />
|
||||
|
||||
{jail.actions.length > 0 && (
|
||||
<>
|
||||
<Text size={300} weight="semibold" style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
Actions
|
||||
</Text>
|
||||
<CodeList items={jail.actions} empty="" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: Ignore list section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface IgnoreListSectionProps {
|
||||
jailName: string;
|
||||
ignoreList: string[];
|
||||
ignoreSelf: boolean;
|
||||
onAdd: (ip: string) => Promise<void>;
|
||||
onRemove: (ip: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function IgnoreListSection({
|
||||
jailName: _jailName,
|
||||
ignoreList,
|
||||
ignoreSelf,
|
||||
onAdd,
|
||||
onRemove,
|
||||
}: IgnoreListSectionProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const [inputVal, setInputVal] = useState("");
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
|
||||
const handleAdd = (): void => {
|
||||
if (!inputVal.trim()) return;
|
||||
setOpError(null);
|
||||
onAdd(inputVal.trim())
|
||||
.then(() => {
|
||||
setInputVal("");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? `${String(err.status)}: ${err.body}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
setOpError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemove = (ip: string): void => {
|
||||
setOpError(null);
|
||||
onRemove(ip).catch((err: unknown) => {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? `${String(err.status)}: ${err.body}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
setOpError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Ignore List (IP Whitelist)
|
||||
</Text>
|
||||
{ignoreSelf && (
|
||||
<Tooltip content="This jail ignores the server's own IP addresses" relationship="label">
|
||||
<Badge appearance="tint" color="informative">
|
||||
ignore self
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Badge appearance="tint">{String(ignoreList.length)}</Badge>
|
||||
</div>
|
||||
|
||||
{opError && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{opError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{/* Add form */}
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<Field label="Add IP or CIDR network">
|
||||
<Input
|
||||
placeholder="e.g. 10.0.0.0/8 or 192.168.1.1"
|
||||
value={inputVal}
|
||||
onChange={(_, d) => {
|
||||
setInputVal(d.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAdd();
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
appearance="primary"
|
||||
onClick={handleAdd}
|
||||
disabled={!inputVal.trim()}
|
||||
style={{ marginBottom: "4px" }}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{ignoreList.length === 0 ? (
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
The ignore list is empty.
|
||||
</Text>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
{ignoreList.map((ip) => (
|
||||
<div key={ip} className={styles.ignoreRow}>
|
||||
<Text className={styles.mono}>{ip}</Text>
|
||||
<Tooltip content={`Remove ${ip} from ignore list`} relationship="label">
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={() => {
|
||||
handleRemove(ip);
|
||||
}}
|
||||
aria-label={`Remove ${ip}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Jail detail page.
|
||||
*
|
||||
* Fetches and displays the full configuration and state of a single jail
|
||||
* identified by the `:name` route parameter.
|
||||
*/
|
||||
export function JailDetailPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { name = "" } = useParams<{ name: string }>();
|
||||
const { jail, ignoreList, ignoreSelf, loading, error, refresh, addIp, removeIp } =
|
||||
useJailDetail(name);
|
||||
|
||||
if (loading && !jail) {
|
||||
return (
|
||||
<div className={styles.centred}>
|
||||
<Spinner label={`Loading jail ${name}…`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Link to="/jails" style={{ textDecoration: "none" }}>
|
||||
<Button appearance="subtle" icon={<ArrowLeftRegular />}>
|
||||
Back to Jails
|
||||
</Button>
|
||||
</Link>
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>Failed to load jail {name}: {error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!jail) return <></>;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{/* Breadcrumb */}
|
||||
<div className={styles.breadcrumb}>
|
||||
<Link to="/jails" style={{ textDecoration: "none" }}>
|
||||
<Button appearance="subtle" size="small" icon={<ArrowLeftRegular />}>
|
||||
Jails
|
||||
</Button>
|
||||
</Link>
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
/
|
||||
</Text>
|
||||
<Text size={200} className={styles.mono}>
|
||||
{name}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<JailInfoSection jail={jail} onRefresh={refresh} />
|
||||
<PatternsSection jail={jail} />
|
||||
<IgnoreListSection
|
||||
jailName={name}
|
||||
ignoreList={ignoreList}
|
||||
ignoreSelf={ignoreSelf}
|
||||
onAdd={addIp}
|
||||
onRemove={removeIp}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,875 @@
|
||||
/**
|
||||
* Jails overview placeholder page — full implementation in Stage 6.
|
||||
* Jails management page.
|
||||
*
|
||||
* Provides four sections in a vertically-stacked layout:
|
||||
* 1. **Jail Overview** — table of all jails with quick status badges and
|
||||
* per-row start/stop/idle/reload controls.
|
||||
* 2. **Ban / Unban IP** — form to manually ban or unban an IP address.
|
||||
* 3. **Currently Banned IPs** — live table of all active bans.
|
||||
* 4. **IP Lookup** — check whether an IP is currently banned and view its
|
||||
* geo-location details.
|
||||
*/
|
||||
|
||||
import { Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DataGrid,
|
||||
DataGridBody,
|
||||
DataGridCell,
|
||||
DataGridHeader,
|
||||
DataGridHeaderCell,
|
||||
DataGridRow,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
makeStyles,
|
||||
tokens,
|
||||
type TableColumnDefinition,
|
||||
createTableColumn,
|
||||
} from "@fluentui/react-components";
|
||||
import {
|
||||
ArrowClockwiseRegular,
|
||||
ArrowSyncRegular,
|
||||
DismissRegular,
|
||||
LockClosedRegular,
|
||||
LockOpenRegular,
|
||||
PauseRegular,
|
||||
PlayRegular,
|
||||
SearchRegular,
|
||||
StopRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails";
|
||||
import type { ActiveBan, JailSummary } from "../types/jail";
|
||||
import { ApiError } from "../api/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { padding: tokens.spacingVerticalXXL },
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalL,
|
||||
},
|
||||
section: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalS,
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
borderTopWidth: "1px",
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: tokens.colorNeutralStroke2,
|
||||
borderRightWidth: "1px",
|
||||
borderRightStyle: "solid",
|
||||
borderRightColor: tokens.colorNeutralStroke2,
|
||||
borderBottomWidth: "1px",
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: tokens.colorNeutralStroke2,
|
||||
borderLeftWidth: "1px",
|
||||
borderLeftStyle: "solid",
|
||||
borderLeftColor: tokens.colorNeutralStroke2,
|
||||
padding: tokens.spacingVerticalM,
|
||||
},
|
||||
sectionHeader: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
paddingBottom: tokens.spacingVerticalS,
|
||||
borderBottomWidth: "1px",
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: tokens.colorNeutralStroke2,
|
||||
},
|
||||
tableWrapper: { overflowX: "auto" },
|
||||
centred: {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: tokens.spacingVerticalXXL,
|
||||
},
|
||||
mono: {
|
||||
fontFamily: "Consolas, 'Courier New', monospace",
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
},
|
||||
formRow: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
formField: { minWidth: "180px", flexGrow: 1 },
|
||||
actionRow: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
},
|
||||
lookupResult: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalS,
|
||||
marginTop: tokens.spacingVerticalS,
|
||||
padding: tokens.spacingVerticalS,
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
borderTopWidth: "1px",
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: tokens.colorNeutralStroke2,
|
||||
borderRightWidth: "1px",
|
||||
borderRightStyle: "solid",
|
||||
borderRightColor: tokens.colorNeutralStroke2,
|
||||
borderBottomWidth: "1px",
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: tokens.colorNeutralStroke2,
|
||||
borderLeftWidth: "1px",
|
||||
borderLeftStyle: "solid",
|
||||
borderLeftColor: tokens.colorNeutralStroke2,
|
||||
},
|
||||
lookupRow: {
|
||||
display: "flex",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
},
|
||||
lookupLabel: { fontWeight: tokens.fontWeightSemibold },
|
||||
});
|
||||
|
||||
export function JailsPage(): JSX.Element {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fmtSeconds(s: number): string {
|
||||
if (s < 0) return "permanent";
|
||||
if (s < 60) return `${String(s)}s`;
|
||||
if (s < 3600) return `${String(Math.round(s / 60))}m`;
|
||||
return `${String(Math.round(s / 3600))}h`;
|
||||
}
|
||||
|
||||
function fmtTimestamp(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail overview columns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const jailColumns: TableColumnDefinition<JailSummary>[] = [
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "name",
|
||||
renderHeaderCell: () => "Jail",
|
||||
renderCell: (j) => (
|
||||
<Link to={`/jails/${encodeURIComponent(j.name)}`} style={{ textDecoration: "none" }}>
|
||||
<Text style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}>
|
||||
{j.name}
|
||||
</Text>
|
||||
</Link>
|
||||
),
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "status",
|
||||
renderHeaderCell: () => "Status",
|
||||
renderCell: (j) => {
|
||||
if (!j.running) return <Badge appearance="filled" color="danger">stopped</Badge>;
|
||||
if (j.idle) return <Badge appearance="filled" color="warning">idle</Badge>;
|
||||
return <Badge appearance="filled" color="success">running</Badge>;
|
||||
},
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "backend",
|
||||
renderHeaderCell: () => "Backend",
|
||||
renderCell: (j) => <Text size={200}>{j.backend}</Text>,
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "banned",
|
||||
renderHeaderCell: () => "Banned",
|
||||
renderCell: (j) => (
|
||||
<Text size={200}>{j.status ? String(j.status.currently_banned) : "—"}</Text>
|
||||
),
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "failed",
|
||||
renderHeaderCell: () => "Failed",
|
||||
renderCell: (j) => (
|
||||
<Text size={200}>{j.status ? String(j.status.currently_failed) : "—"}</Text>
|
||||
),
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "findTime",
|
||||
renderHeaderCell: () => "Find Time",
|
||||
renderCell: (j) => <Text size={200}>{fmtSeconds(j.find_time)}</Text>,
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "banTime",
|
||||
renderHeaderCell: () => "Ban Time",
|
||||
renderCell: (j) => <Text size={200}>{fmtSeconds(j.ban_time)}</Text>,
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "maxRetry",
|
||||
renderHeaderCell: () => "Max Retry",
|
||||
renderCell: (j) => <Text size={200}>{String(j.max_retry)}</Text>,
|
||||
}),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active bans columns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildBanColumns(
|
||||
onUnban: (ip: string, jail: string) => void,
|
||||
): TableColumnDefinition<ActiveBan>[] {
|
||||
return [
|
||||
createTableColumn<ActiveBan>({
|
||||
columnId: "ip",
|
||||
renderHeaderCell: () => "IP",
|
||||
renderCell: (b) => (
|
||||
<Text style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}>
|
||||
{b.ip}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
createTableColumn<ActiveBan>({
|
||||
columnId: "jail",
|
||||
renderHeaderCell: () => "Jail",
|
||||
renderCell: (b) => <Text size={200}>{b.jail}</Text>,
|
||||
}),
|
||||
createTableColumn<ActiveBan>({
|
||||
columnId: "country",
|
||||
renderHeaderCell: () => "Country",
|
||||
renderCell: (b) => <Text size={200}>{b.country ?? "—"}</Text>,
|
||||
}),
|
||||
createTableColumn<ActiveBan>({
|
||||
columnId: "bannedAt",
|
||||
renderHeaderCell: () => "Banned At",
|
||||
renderCell: (b) => <Text size={200}>{fmtTimestamp(b.banned_at)}</Text>,
|
||||
}),
|
||||
createTableColumn<ActiveBan>({
|
||||
columnId: "expiresAt",
|
||||
renderHeaderCell: () => "Expires At",
|
||||
renderCell: (b) => <Text size={200}>{fmtTimestamp(b.expires_at)}</Text>,
|
||||
}),
|
||||
createTableColumn<ActiveBan>({
|
||||
columnId: "count",
|
||||
renderHeaderCell: () => "Count",
|
||||
renderCell: (b) => (
|
||||
<Tooltip
|
||||
content={`Banned ${String(b.ban_count)} time${b.ban_count === 1 ? "" : "s"}`}
|
||||
relationship="label"
|
||||
>
|
||||
<Badge
|
||||
appearance="filled"
|
||||
color={b.ban_count > 3 ? "danger" : b.ban_count > 1 ? "warning" : "informative"}
|
||||
>
|
||||
{String(b.ban_count)}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
),
|
||||
}),
|
||||
createTableColumn<ActiveBan>({
|
||||
columnId: "unban",
|
||||
renderHeaderCell: () => "",
|
||||
renderCell: (b) => (
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={() => {
|
||||
onUnban(b.ip, b.jail);
|
||||
}}
|
||||
aria-label={`Unban ${b.ip} from ${b.jail}`}
|
||||
>
|
||||
Unban
|
||||
</Button>
|
||||
),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: Jail overview section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function JailOverviewSection(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } =
|
||||
useJails();
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
|
||||
const handle = (fn: () => Promise<void>): void => {
|
||||
setOpError(null);
|
||||
fn().catch((err: unknown) => {
|
||||
setOpError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Jail Overview
|
||||
{total > 0 && (
|
||||
<Badge appearance="tint" style={{ marginLeft: "8px" }}>
|
||||
{String(total)}
|
||||
</Badge>
|
||||
)}
|
||||
</Text>
|
||||
<div className={styles.actionRow}>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowSyncRegular />}
|
||||
onClick={() => {
|
||||
handle(reloadAll);
|
||||
}}
|
||||
>
|
||||
Reload All
|
||||
</Button>
|
||||
<Button size="small" appearance="subtle" icon={<ArrowClockwiseRegular />} onClick={refresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{opError && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{opError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
{error && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>Failed to load jails: {error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{loading && jails.length === 0 ? (
|
||||
<div className={styles.centred}>
|
||||
<Spinner label="Loading jails…" />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tableWrapper}>
|
||||
<DataGrid
|
||||
items={jails}
|
||||
columns={jailColumns}
|
||||
getRowId={(j: JailSummary) => j.name}
|
||||
focusMode="composite"
|
||||
>
|
||||
<DataGridHeader>
|
||||
<DataGridRow>
|
||||
{({ renderHeaderCell }) => (
|
||||
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
|
||||
)}
|
||||
</DataGridRow>
|
||||
</DataGridHeader>
|
||||
<DataGridBody<JailSummary>>
|
||||
{({ item }) => (
|
||||
<DataGridRow<JailSummary> key={item.name}>
|
||||
{({ renderCell, columnId }) => {
|
||||
if (columnId === "status") {
|
||||
return (
|
||||
<DataGridCell>
|
||||
<div style={{ display: "flex", gap: "6px", alignItems: "center" }}>
|
||||
{renderCell(item)}
|
||||
<Tooltip
|
||||
content={item.running ? "Stop jail" : "Start jail"}
|
||||
relationship="label"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={item.running ? <StopRegular /> : <PlayRegular />}
|
||||
onClick={() => {
|
||||
handle(async () => {
|
||||
if (item.running) await stopJail(item.name);
|
||||
else await startJail(item.name);
|
||||
});
|
||||
}}
|
||||
aria-label={
|
||||
item.running ? `Stop ${item.name}` : `Start ${item.name}`
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={item.idle ? "Resume from idle" : "Set idle"}
|
||||
relationship="label"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<PauseRegular />}
|
||||
onClick={() => {
|
||||
handle(async () => setIdle(item.name, !item.idle));
|
||||
}}
|
||||
disabled={!item.running}
|
||||
aria-label={`Toggle idle for ${item.name}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Reload jail" relationship="label">
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowSyncRegular />}
|
||||
onClick={() => {
|
||||
handle(async () => reloadJail(item.name));
|
||||
}}
|
||||
aria-label={`Reload ${item.name}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DataGridCell>
|
||||
);
|
||||
}
|
||||
return <DataGridCell>{renderCell(item)}</DataGridCell>;
|
||||
}}
|
||||
</DataGridRow>
|
||||
)}
|
||||
</DataGridBody>
|
||||
</DataGrid>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: Ban / Unban IP form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface BanUnbanFormProps {
|
||||
jailNames: string[];
|
||||
onBan: (jail: string, ip: string) => Promise<void>;
|
||||
onUnban: (ip: string, jail?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const [banIpVal, setBanIpVal] = useState("");
|
||||
const [banJail, setBanJail] = useState("");
|
||||
const [unbanIpVal, setUnbanIpVal] = useState("");
|
||||
const [unbanJail, setUnbanJail] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [formSuccess, setFormSuccess] = useState<string | null>(null);
|
||||
|
||||
const handleBan = (): void => {
|
||||
setFormError(null);
|
||||
setFormSuccess(null);
|
||||
if (!banIpVal.trim() || !banJail) {
|
||||
setFormError("Both IP address and jail are required.");
|
||||
return;
|
||||
}
|
||||
onBan(banJail, banIpVal.trim())
|
||||
.then(() => {
|
||||
setFormSuccess(`${banIpVal.trim()} banned in ${banJail}.`);
|
||||
setBanIpVal("");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? `${String(err.status)}: ${err.body}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
setFormError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnban = (fromAllJails: boolean): void => {
|
||||
setFormError(null);
|
||||
setFormSuccess(null);
|
||||
if (!unbanIpVal.trim()) {
|
||||
setFormError("IP address is required.");
|
||||
return;
|
||||
}
|
||||
const jail = fromAllJails ? undefined : unbanJail || undefined;
|
||||
onUnban(unbanIpVal.trim(), jail)
|
||||
.then(() => {
|
||||
const scope = jail ?? "all jails";
|
||||
setFormSuccess(`${unbanIpVal.trim()} unbanned from ${scope}.`);
|
||||
setUnbanIpVal("");
|
||||
setUnbanJail("");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? `${String(err.status)}: ${err.body}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
setFormError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Ban / Unban IP
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{formError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
{formSuccess && (
|
||||
<MessageBar intent="success">
|
||||
<MessageBarBody>{formSuccess}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{/* Ban row */}
|
||||
<Text size={300} weight="semibold">
|
||||
Ban an IP
|
||||
</Text>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<Field label="IP Address">
|
||||
<Input
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
value={banIpVal}
|
||||
onChange={(_, d) => {
|
||||
setBanIpVal(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<Field label="Jail">
|
||||
<Select
|
||||
value={banJail}
|
||||
onChange={(_, d) => {
|
||||
setBanJail(d.value);
|
||||
}}
|
||||
>
|
||||
<option value="">Select jail…</option>
|
||||
{jailNames.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={<LockClosedRegular />}
|
||||
onClick={handleBan}
|
||||
style={{ marginBottom: "4px" }}
|
||||
>
|
||||
Ban
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Unban row */}
|
||||
<Text
|
||||
size={300}
|
||||
weight="semibold"
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
>
|
||||
Unban an IP
|
||||
</Text>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<Field label="IP Address">
|
||||
<Input
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
value={unbanIpVal}
|
||||
onChange={(_, d) => {
|
||||
setUnbanIpVal(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<Field label="Jail (optional — leave blank for all)">
|
||||
<Select
|
||||
value={unbanJail}
|
||||
onChange={(_, d) => {
|
||||
setUnbanJail(d.value);
|
||||
}}
|
||||
>
|
||||
<option value="">All jails</option>
|
||||
{jailNames.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<LockOpenRegular />}
|
||||
onClick={() => {
|
||||
handleUnban(false);
|
||||
}}
|
||||
style={{ marginBottom: "4px" }}
|
||||
>
|
||||
Unban
|
||||
</Button>
|
||||
<Button
|
||||
appearance="outline"
|
||||
icon={<LockOpenRegular />}
|
||||
onClick={() => {
|
||||
handleUnban(true);
|
||||
}}
|
||||
style={{ marginBottom: "4px" }}
|
||||
>
|
||||
Unban from All Jails
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: Active bans section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ActiveBansSection(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { bans, total, loading, error, refresh, unbanIp } = useActiveBans();
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
|
||||
const handleUnban = (ip: string, jail: string): void => {
|
||||
setOpError(null);
|
||||
unbanIp(ip, jail).catch((err: unknown) => {
|
||||
setOpError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
};
|
||||
|
||||
const banColumns = buildBanColumns(handleUnban);
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Currently Banned IPs
|
||||
{total > 0 && (
|
||||
<Badge appearance="filled" color="danger" style={{ marginLeft: "8px" }}>
|
||||
{String(total)}
|
||||
</Badge>
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowClockwiseRegular />}
|
||||
onClick={refresh}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{opError && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{opError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
{error && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>Failed to load bans: {error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{loading && bans.length === 0 ? (
|
||||
<div className={styles.centred}>
|
||||
<Spinner label="Loading active bans…" />
|
||||
</div>
|
||||
) : bans.length === 0 ? (
|
||||
<div className={styles.centred}>
|
||||
<Text size={300}>No IPs are currently banned.</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tableWrapper}>
|
||||
<DataGrid
|
||||
items={bans}
|
||||
columns={banColumns}
|
||||
getRowId={(b: ActiveBan) => `${b.jail}:${b.ip}`}
|
||||
focusMode="composite"
|
||||
>
|
||||
<DataGridHeader>
|
||||
<DataGridRow>
|
||||
{({ renderHeaderCell }) => (
|
||||
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
|
||||
)}
|
||||
</DataGridRow>
|
||||
</DataGridHeader>
|
||||
<DataGridBody<ActiveBan>>
|
||||
{({ item }) => (
|
||||
<DataGridRow<ActiveBan> key={`${item.jail}:${item.ip}`}>
|
||||
{({ renderCell }) => (
|
||||
<DataGridCell>{renderCell(item)}</DataGridCell>
|
||||
)}
|
||||
</DataGridRow>
|
||||
)}
|
||||
</DataGridBody>
|
||||
</DataGrid>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: IP Lookup section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function IpLookupSection(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { result, loading, error, lookup, clear } = useIpLookup();
|
||||
const [inputVal, setInputVal] = useState("");
|
||||
|
||||
const handleLookup = (): void => {
|
||||
if (inputVal.trim()) {
|
||||
lookup(inputVal.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
IP Lookup
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<Field label="IP Address">
|
||||
<Input
|
||||
placeholder="e.g. 1.2.3.4 or 2001:db8::1"
|
||||
value={inputVal}
|
||||
onChange={(_, d) => {
|
||||
setInputVal(d.value);
|
||||
clear();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleLookup();
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={loading ? <Spinner size="tiny" /> : <SearchRegular />}
|
||||
onClick={handleLookup}
|
||||
disabled={loading || !inputVal.trim()}
|
||||
style={{ marginBottom: "4px" }}
|
||||
>
|
||||
Look up
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className={styles.lookupResult}>
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>IP:</Text>
|
||||
<Text className={styles.mono}>{result.ip}</Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>Currently banned in:</Text>
|
||||
{result.currently_banned_in.length === 0 ? (
|
||||
<Badge appearance="tint" color="success">
|
||||
not banned
|
||||
</Badge>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
|
||||
{result.currently_banned_in.map((j) => (
|
||||
<Badge key={j} appearance="filled" color="danger">
|
||||
{j}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.geo && (
|
||||
<>
|
||||
{result.geo.country_name && (
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>Country:</Text>
|
||||
<Text>
|
||||
{result.geo.country_name}
|
||||
{result.geo.country_code ? ` (${result.geo.country_code})` : ""}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{result.geo.org && (
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>Organisation:</Text>
|
||||
<Text>{result.geo.org}</Text>
|
||||
</div>
|
||||
)}
|
||||
{result.geo.asn && (
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>ASN:</Text>
|
||||
<Text className={styles.mono}>{result.geo.asn}</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Jails management page.
|
||||
*
|
||||
* Renders four sections: Jail Overview, Ban/Unban IP, Currently Banned IPs,
|
||||
* and IP Lookup.
|
||||
*/
|
||||
export function JailsPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { jails } = useJails();
|
||||
const { banIp, unbanIp } = useActiveBans();
|
||||
|
||||
const jailNames = jails.map((j) => j.name);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Text as="h1" size={700} weight="semibold">
|
||||
Jails
|
||||
</Text>
|
||||
<Text as="p" size={300}>
|
||||
Jail management will be implemented in Stage 6.
|
||||
</Text>
|
||||
|
||||
<JailOverviewSection />
|
||||
|
||||
<BanUnbanForm jailNames={jailNames} onBan={banIp} onUnban={unbanIp} />
|
||||
|
||||
<ActiveBansSection />
|
||||
|
||||
<IpLookupSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
210
frontend/src/types/jail.ts
Normal file
210
frontend/src/types/jail.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* TypeScript interfaces mirroring the backend jail Pydantic models.
|
||||
*
|
||||
* Backend sources:
|
||||
* - `backend/app/models/jail.py`
|
||||
* - `backend/app/models/ban.py` (ActiveBan / ActiveBanListResponse)
|
||||
* - `backend/app/models/geo.py` (GeoDetail / IpLookupResponse)
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail statistics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Live filter+actions counters for a single jail.
|
||||
*
|
||||
* Mirrors `JailStatus` from `backend/app/models/jail.py`.
|
||||
*/
|
||||
export interface JailStatus {
|
||||
/** Number of IPs currently banned under this jail. */
|
||||
currently_banned: number;
|
||||
/** Total bans issued since fail2ban started. */
|
||||
total_banned: number;
|
||||
/** Current number of log-line matches that have not yet led to a ban. */
|
||||
currently_failed: number;
|
||||
/** Total log-line matches since fail2ban started. */
|
||||
total_failed: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail list (overview)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Lightweight snapshot of one jail for the overview table.
|
||||
*
|
||||
* Mirrors `JailSummary` from `backend/app/models/jail.py`.
|
||||
*/
|
||||
export interface JailSummary {
|
||||
/** Machine-readable jail name (e.g. `"sshd"`). */
|
||||
name: string;
|
||||
/** Whether the jail is enabled in the configuration. */
|
||||
enabled: boolean;
|
||||
/** Whether fail2ban is currently monitoring the jail. */
|
||||
running: boolean;
|
||||
/** Whether the jail is in idle mode (monitoring paused). */
|
||||
idle: boolean;
|
||||
/** Backend type used for log access (e.g. `"systemd"`, `"polling"`). */
|
||||
backend: string;
|
||||
/** Observation window in seconds before a ban is triggered. */
|
||||
find_time: number;
|
||||
/** Duration of a ban in seconds (negative = permanent). */
|
||||
ban_time: number;
|
||||
/** Maximum log-line failures before a ban is issued. */
|
||||
max_retry: number;
|
||||
/** Live ban/failure counters, or `null` when unavailable. */
|
||||
status: JailStatus | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from `GET /api/jails`.
|
||||
*
|
||||
* Mirrors `JailListResponse` from `backend/app/models/jail.py`.
|
||||
*/
|
||||
export interface JailListResponse {
|
||||
/** All known jails. */
|
||||
jails: JailSummary[];
|
||||
/** Total number of jails. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail detail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Full configuration and state of a single jail.
|
||||
*
|
||||
* Mirrors `Jail` from `backend/app/models/jail.py`.
|
||||
*/
|
||||
export interface Jail {
|
||||
/** Machine-readable jail name. */
|
||||
name: string;
|
||||
/** Whether the jail is running. */
|
||||
running: boolean;
|
||||
/** Whether the jail is in idle mode. */
|
||||
idle: boolean;
|
||||
/** Backend type (systemd, polling, etc.). */
|
||||
backend: string;
|
||||
/** Log file paths monitored by this jail. */
|
||||
log_paths: string[];
|
||||
/** Fail-regex patterns used to identify offenders. */
|
||||
fail_regex: string[];
|
||||
/** Ignore-regex patterns used to whitelist log lines. */
|
||||
ignore_regex: string[];
|
||||
/** Date-pattern used for timestamp parsing, or empty string. */
|
||||
date_pattern: string;
|
||||
/** Log file encoding (e.g. `"UTF-8"`). */
|
||||
log_encoding: string;
|
||||
/** Action names attached to this jail. */
|
||||
actions: string[];
|
||||
/** Observation window in seconds. */
|
||||
find_time: number;
|
||||
/** Ban duration in seconds; negative means permanent. */
|
||||
ban_time: number;
|
||||
/** Maximum failures before ban is applied. */
|
||||
max_retry: number;
|
||||
/** Live counters, or `null` when not available. */
|
||||
status: JailStatus | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from `GET /api/jails/{name}`.
|
||||
*
|
||||
* Mirrors `JailDetailResponse` from `backend/app/models/jail.py`.
|
||||
*/
|
||||
export interface JailDetailResponse {
|
||||
/** Full jail configuration. */
|
||||
jail: Jail;
|
||||
/** Current ignore list (IPs / networks that are never banned). */
|
||||
ignore_list: string[];
|
||||
/** Whether the jail ignores the server's own IP addresses. */
|
||||
ignore_self: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail command response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generic acknowledgement from jail control endpoints.
|
||||
*
|
||||
* Mirrors `JailCommandResponse` from `backend/app/models/jail.py`.
|
||||
*/
|
||||
export interface JailCommandResponse {
|
||||
/** Human-readable result message. */
|
||||
message: string;
|
||||
/** Target jail name, or `"*"` for operations on all jails. */
|
||||
jail: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active bans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A single currently-active ban entry.
|
||||
*
|
||||
* Mirrors `ActiveBan` from `backend/app/models/ban.py`.
|
||||
*/
|
||||
export interface ActiveBan {
|
||||
/** Banned IP address. */
|
||||
ip: string;
|
||||
/** Jail that issued the ban. */
|
||||
jail: string;
|
||||
/** ISO 8601 UTC timestamp the ban started, or `null` when unavailable. */
|
||||
banned_at: string | null;
|
||||
/** ISO 8601 UTC timestamp the ban expires, or `null` for permanent bans. */
|
||||
expires_at: string | null;
|
||||
/** Number of times this IP has been banned before. */
|
||||
ban_count: number;
|
||||
/** ISO 3166-1 alpha-2 country code, or `null` when unknown. */
|
||||
country: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from `GET /api/bans/active`.
|
||||
*
|
||||
* Mirrors `ActiveBanListResponse` from `backend/app/models/ban.py`.
|
||||
*/
|
||||
export interface ActiveBanListResponse {
|
||||
/** List of all currently active bans. */
|
||||
bans: ActiveBan[];
|
||||
/** Total number of active bans. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Geo / IP lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Geo-location information for an IP address.
|
||||
*
|
||||
* Mirrors `GeoDetail` from `backend/app/models/geo.py`.
|
||||
*/
|
||||
export interface GeoDetail {
|
||||
/** ISO 3166-1 alpha-2 country code (e.g. `"DE"`), or `null`. */
|
||||
country_code: string | null;
|
||||
/** Country name (e.g. `"Germany"`), or `null`. */
|
||||
country_name: string | null;
|
||||
/** Autonomous System Number string (e.g. `"AS3320"`), or `null`. */
|
||||
asn: string | null;
|
||||
/** Organisation name associated with the IP, or `null`. */
|
||||
org: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from `GET /api/geo/lookup/{ip}`.
|
||||
*
|
||||
* Mirrors `IpLookupResponse` from `backend/app/models/geo.py`.
|
||||
*/
|
||||
export interface IpLookupResponse {
|
||||
/** The queried IP address. */
|
||||
ip: string;
|
||||
/** Jails in which the IP is currently banned. */
|
||||
currently_banned_in: string[];
|
||||
/** Geo-location data, or `null` when the lookup failed. */
|
||||
geo: GeoDetail | null;
|
||||
}
|
||||
Reference in New Issue
Block a user