Add ban management features and update documentation

- Implement ban model, service, and router endpoints in backend
- Add ban table component and dashboard integration in frontend
- Update ban-related types and API endpoints
- Add comprehensive tests for ban service and dashboard router
- Update documentation (Features, Tasks, Architecture, Web-Design)
- Clean up old fail2ban configuration files
- Update Makefile with new commands
This commit is contained in:
2026-03-06 20:33:42 +01:00
parent 06738dbfa5
commit cbad4ea706
20 changed files with 58 additions and 760 deletions

View File

@@ -67,50 +67,6 @@ Chains steps 13 automatically with appropriate sleep intervals.
---
## Testing the Access List Feature
The **Access List** tab in BanGUI displays each individual matched log line
stored in fail2ban's database. A second jail — `bangui-access` — monitors
`Docker/logs/access.log` for simulated HTTP bot-scan entries.
### 1 — Run the access-scan simulation
```bash
bash Docker/simulate_accesses.sh
```
Default: writes **5** HTTP-scan lines for IP `203.0.113.7` to
`Docker/logs/access.log`.
Optional overrides:
```bash
bash Docker/simulate_accesses.sh <COUNT> <SOURCE_IP> <LOG_FILE>
# e.g. bash Docker/simulate_accesses.sh 6 198.51.100.5
```
Log line format:
```
YYYY-MM-DD HH:MM:SS bangui-access: http scan from <IP> "GET /.env HTTP/1.1" 404
```
### 2 — Verify the IP was banned
```bash
bash Docker/check_ban_status.sh
```
The `bangui-access` jail should appear alongside `bangui-sim`, showing the
banned IP and matched line count.
### 3 — Unban and re-test
```bash
bash Docker/check_ban_status.sh --unban 203.0.113.7
```
---
## Configuration Reference
| File | Purpose |
@@ -118,9 +74,6 @@ bash Docker/check_ban_status.sh --unban 203.0.113.7
| `fail2ban/filter.d/bangui-sim.conf` | Defines the `failregex` that matches simulation log lines |
| `fail2ban/jail.d/bangui-sim.conf` | Jail settings: `maxretry=3`, `bantime=60s`, `findtime=120s` |
| `Docker/logs/auth.log` | Log file written by the simulation script (host path) |
| `fail2ban/filter.d/bangui-access.conf` | Defines the `failregex` that matches access-scan log lines |
| `fail2ban/jail.d/bangui-access.conf` | Access jail settings: `maxretry=3`, `bantime=60s`, `findtime=120s` |
| `Docker/logs/access.log` | Log file written by `simulate_accesses.sh` (host path) |
Inside the container the log file is mounted at `/remotelogs/bangui/auth.log`
(see `fail2ban/paths-lsio.conf``remote_logs_path = /remotelogs`).
@@ -156,9 +109,6 @@ Test the regex manually:
```bash
docker exec bangui-fail2ban-dev \
fail2ban-regex /remotelogs/bangui/auth.log bangui-sim
docker exec bangui-fail2ban-dev \
fail2ban-regex /remotelogs/bangui/access.log bangui-access
```
The output should show matched lines. If nothing matches, check that the log
@@ -167,9 +117,6 @@ lines match the corresponding `failregex` pattern:
```
# bangui-sim (auth log):
YYYY-MM-DD HH:MM:SS bangui-auth: authentication failure from <IP>
# bangui-access (access log):
YYYY-MM-DD HH:MM:SS bangui-access: http scan from <IP> "<METHOD> <path> HTTP/1.1" <STATUS>
```
### iptables / permission errors

View File

@@ -1,13 +0,0 @@
# ──────────────────────────────────────────────────────────────
# BanGUI — Simulated HTTP access scan failure filter
#
# Matches lines written by Docker/simulate_accesses.sh.
# Format:
# YYYY-MM-DD HH:MM:SS bangui-access: http scan from <IP> "<METHOD> <path> HTTP/1.1" <STATUS>
# ──────────────────────────────────────────────────────────────
[Definition]
failregex = ^.* bangui-access: http scan from <HOST> ".*" [45]\d\d\s*$
ignoreregex =

View File

@@ -1,20 +0,0 @@
# ──────────────────────────────────────────────────────────────
# BanGUI — Simulated HTTP access scan jail
#
# Watches Docker/logs/access.log (mounted at /remotelogs/bangui)
# for lines produced by Docker/simulate_accesses.sh.
# ──────────────────────────────────────────────────────────────
[bangui-access]
enabled = true
filter = bangui-access
logpath = /remotelogs/bangui/access.log
backend = polling
maxretry = 3
findtime = 120
bantime = 60
banaction = iptables-allports
# Never ban localhost, the Docker bridge network, or the host machine.
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12

View File

@@ -1,82 +0,0 @@
#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────
# simulate_accesses.sh
#
# Writes synthetic HTTP-scan log lines to a file that matches
# the bangui-access fail2ban filter. Use this to populate the
# Access List tab in BanGUI without a real web server.
#
# Usage:
# bash Docker/simulate_accesses.sh [COUNT] [SOURCE_IP] [LOG_FILE]
#
# Defaults:
# COUNT : 5
# SOURCE_IP: 203.0.113.7
# LOG_FILE : Docker/logs/access.log (relative to repo root)
#
# Log line format (must match bangui-access failregex exactly):
# YYYY-MM-DD HH:MM:SS bangui-access: http scan from <IP> "<METHOD> <path> HTTP/1.1" <STATUS>
#
# fail2ban bans the IP after maxretry (default 3) matching lines.
# ──────────────────────────────────────────────────────────────
set -euo pipefail
# ── Defaults ──────────────────────────────────────────────────
readonly DEFAULT_COUNT=5
readonly DEFAULT_IP="203.0.113.7"
# Resolve script location so defaults work regardless of cwd.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly DEFAULT_LOG_FILE="${SCRIPT_DIR}/logs/access.log"
# ── Arguments ─────────────────────────────────────────────────
COUNT="${1:-${DEFAULT_COUNT}}"
SOURCE_IP="${2:-${DEFAULT_IP}}"
LOG_FILE="${3:-${DEFAULT_LOG_FILE}}"
# ── Validate COUNT is a positive integer ──────────────────────
if ! [[ "${COUNT}" =~ ^[1-9][0-9]*$ ]]; then
echo "ERROR: COUNT must be a positive integer, got: '${COUNT}'" >&2
exit 1
fi
# ── Common bot-scan paths ─────────────────────────────────────
PATHS=(
"GET /.env HTTP/1.1"
"GET /wp-login.php HTTP/1.1"
"GET /admin HTTP/1.1"
"POST /wp-login.php HTTP/1.1"
"GET /.git/config HTTP/1.1"
"GET /phpinfo.php HTTP/1.1"
"GET /wp-config.php HTTP/1.1"
"GET /phpmyadmin HTTP/1.1"
)
readonly PATHS
NUM_PATHS="${#PATHS[@]}"
# ── Ensure log directory exists ───────────────────────────────
LOG_DIR="$(dirname "${LOG_FILE}")"
mkdir -p "${LOG_DIR}"
# ── Write scan lines ──────────────────────────────────────────
echo "Writing ${COUNT} HTTP-scan line(s) for ${SOURCE_IP} to ${LOG_FILE} ..."
for ((i = 1; i <= COUNT; i++)); do
TIMESTAMP="$(date '+%Y-%m-%d %H:%M:%S')"
# Cycle through the scan paths so each run looks varied.
PATH_ENTRY="${PATHS[$(( (i - 1) % NUM_PATHS ))]}"
printf '%s bangui-access: http scan from %s "%s" 404\n' \
"${TIMESTAMP}" "${SOURCE_IP}" "${PATH_ENTRY}" >> "${LOG_FILE}"
sleep 0.5
done
# ── Summary ───────────────────────────────────────────────────
echo "Done."
echo " Lines written : ${COUNT}"
echo " Source IP : ${SOURCE_IP}"
echo " Log file : ${LOG_FILE}"
echo ""
echo " fail2ban bans after maxretry=3 matching lines."
echo " Check ban status with: bash Docker/check_ban_status.sh"

View File

@@ -302,8 +302,8 @@ frontend/
│ ├── pages/ # Route-level page components (one per route)
│ │ ├── SetupPage.tsx # First-run wizard
│ │ ├── LoginPage.tsx # Password prompt
│ │ ├── DashboardPage.tsx # Ban overview, status bar, access list
│ │ ├── WorldMapPage.tsx # Geographical ban map + access table
│ │ ├── DashboardPage.tsx # Ban overview, status bar
│ │ ├── WorldMapPage.tsx # Geographical ban map
│ │ ├── JailsPage.tsx # Jail list, detail, controls, ban/unban
│ │ ├── ConfigPage.tsx # Configuration viewer/editor
│ │ ├── HistoryPage.tsx # Ban history browser
@@ -347,8 +347,8 @@ Top-level route components. Each page composes layout, components, and hooks to
|---|---|---|
| `SetupPage` | `/setup` | First-run wizard: set master password, database path, fail2ban connection, preferences |
| `LoginPage` | `/login` | Single-field password prompt; redirects to requested page after success |
| `DashboardPage` | `/` | Server status bar, ban list table, access list tab, time-range selector |
| `WorldMapPage` | `/map` | World map with per-country ban counts, companion access table, country filter |
| `DashboardPage` | `/` | Server status bar, ban list table, time-range selector |
| `WorldMapPage` | `/map` | World map with per-country ban counts, country filter |
| `JailsPage` | `/jails` | Jail overview list, jail detail panel, controls (start/stop/reload), ban/unban forms, IP lookup, whitelist management |
| `ConfigPage` | `/config` | View and edit jail parameters, filter regex, server settings, regex tester, add log observation |
| `HistoryPage` | `/history` | Browse all past bans, filter by jail/IP/time, per-IP timeline drill-down |

View File

@@ -53,12 +53,6 @@ The main landing page after login. Shows recent ban activity at a glance.
- Last 30 days (month)
- Last 365 days (year)
### Access List
- A secondary view (tab or toggle) on the same page showing **all recorded accesses**, not just bans.
- Uses the same table format: time, IP address, requested URL, country, domain, subdomain.
- Shares the same time-range presets so the user can compare total traffic against banned traffic for the same period.
---
## 4. World Map View
@@ -76,12 +70,6 @@ A geographical overview of ban activity.
- Last 30 days
- Last 365 days
### Access List (Map context)
- A companion table below or beside the map listing all accesses for the selected time range.
- Same columns as the Ban Overview tables: time, IP, URL, country, domain, subdomain.
- Selecting a country on the map filters the table to show only entries from that country.
---
## 5. Jail Management

View File

@@ -3,12 +3,9 @@
This document breaks the entire BanGUI project into development stages, ordered so that each stage builds on the previous one. Every task is described in prose with enough detail for a developer to begin work. References point to the relevant documentation.
---
1. config not found issue — **DONE**
Have not found any log file for bangui-access jail
1d6112acb250 2026-03-06 18:51:31,995 fail2ban [352]: ERROR Failed during configuration: Have not found any log file for bangui-access jail
1d6112acb250 2026-03-06 18:51:33,074 fail2ban [355]: ERROR Failed during configuration: Have not found any log file for bangui-access jail
## Remove the Access List Feature
**Fix:** Two root causes identified and resolved:
1. `Docker/compose.debug.yml` had an incorrect volume mount path `./Docker/logs` (which resolved to the empty `Docker/Docker/logs/` relative to the compose file). Corrected to `./logs` so it correctly mounts `Docker/logs/` at `/remotelogs/bangui` inside the container.
2. `Docker/logs/access.log` did not exist. Created the empty file so fail2ban can open and watch it for the `bangui-access` jail.
The "access list" feature displays individual log-line matches (the raw lines that triggered fail2ban bans) in a dedicated tab on the Dashboard and as a companion table on the World Map page. It is being removed entirely. The tasks below must be executed in order. After completion, no code, config, test, type, or documentation reference to access lists should remain.
---

View File

@@ -204,7 +204,7 @@ Use Fluent UI React components as the building blocks. The following mapping sho
|---|---|---|
| Side navigation | `Nav` | Persistent on large screens, collapsible on small. Groups: Dashboard, Map, Jails, Config, History, Blocklists. |
| Breadcrumbs | `Breadcrumb` | Show on detail pages (Jail > sshd, History > IP detail). |
| Page tabs | `Pivot` | Dashboard (Ban List / Access List), Map (Map / Access List). |
| Page tabs | `Pivot` | None currently (previous tabs removed). |
### Data Display

View File

@@ -45,7 +45,7 @@ RUNTIME := $(shell command -v podman 2>/dev/null || echo "docker")
## Ensures log stub files exist so fail2ban can open them on first start.
up:
@mkdir -p Docker/logs
@touch Docker/logs/access.log Docker/logs/auth.log
@touch Docker/logs/auth.log
$(COMPOSE) -f $(COMPOSE_FILE) up -d
## Stop the debug stack.

View File

@@ -110,7 +110,7 @@ class ActiveBanListResponse(BaseModel):
# ---------------------------------------------------------------------------
# Dashboard ban-list / access-list view models
# Dashboard ban-list view models
# ---------------------------------------------------------------------------
@@ -159,40 +159,6 @@ class DashboardBanListResponse(BaseModel):
page_size: int = Field(..., ge=1)
class AccessListItem(BaseModel):
"""A single row in the dashboard access-list table.
Each row represents one matched log line (failure) that contributed to a
ban — essentially the individual access events that led to bans within the
selected time window.
"""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="IP address of the access event.")
jail: str = Field(..., description="Jail that recorded the access.")
timestamp: str = Field(
...,
description="ISO 8601 UTC timestamp of the ban that captured this access.",
)
line: str = Field(..., description="Raw matched log line.")
country_code: str | None = Field(default=None)
country_name: str | None = Field(default=None)
asn: str | None = Field(default=None)
org: str | None = Field(default=None)
class AccessListResponse(BaseModel):
"""Paginated dashboard access-list response."""
model_config = ConfigDict(strict=True)
items: list[AccessListItem] = Field(default_factory=list)
total: int = Field(..., ge=0)
page: int = Field(..., ge=1)
page_size: int = Field(..., ge=1)
class BansByCountryResponse(BaseModel):
"""Response for the bans-by-country aggregation endpoint.

View File

@@ -4,8 +4,7 @@ Provides the ``GET /api/dashboard/status`` endpoint that returns the cached
fail2ban server health snapshot. The snapshot is maintained by the
background health-check task and refreshed every 30 seconds.
Also provides ``GET /api/dashboard/bans`` and ``GET /api/dashboard/accesses``
for the dashboard ban-list and access-list tables.
Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table.
"""
from __future__ import annotations
@@ -19,7 +18,6 @@ from fastapi import APIRouter, Query, Request
from app.dependencies import AuthDep
from app.models.ban import (
AccessListResponse,
BansByCountryResponse,
DashboardBanListResponse,
TimeRange,
@@ -113,51 +111,6 @@ async def get_dashboard_bans(
)
@router.get(
"/accesses",
response_model=AccessListResponse,
summary="Return a paginated list of individual access events",
)
async def get_dashboard_accesses(
request: Request,
_auth: AuthDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
page: int = Query(default=1, ge=1, description="1-based page number."),
page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page."),
) -> AccessListResponse:
"""Return a paginated list of individual access events (matched log lines).
Expands the ``data.matches`` JSON stored inside each ban record so that
every matched log line is returned as a separate row. Useful for
the "Access List" tab which shows all recorded access attempts — not
just the aggregate bans.
Args:
request: The incoming request.
_auth: Validated session dependency.
range: Time-range preset.
page: 1-based page number.
page_size: Maximum items per page (1500).
Returns:
:class:`~app.models.ban.AccessListResponse` with individual access
items expanded from ``data.matches``.
"""
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)
return await ban_service.list_accesses(
socket_path,
range,
page=page,
page_size=page_size,
geo_enricher=_enricher,
)
@router.get(
"/bans/by-country",
response_model=BansByCountryResponse,

View File

@@ -19,8 +19,6 @@ import structlog
from app.models.ban import (
TIME_RANGE_SECONDS,
AccessListItem,
AccessListResponse,
BansByCountryResponse,
DashboardBanItem,
DashboardBanListResponse,
@@ -245,90 +243,6 @@ async def list_bans(
)
async def list_accesses(
socket_path: str,
range_: TimeRange,
*,
page: int = 1,
page_size: int = _DEFAULT_PAGE_SIZE,
geo_enricher: Any | None = None,
) -> AccessListResponse:
"""Return a paginated list of individual access events (matched log lines).
Each row in the fail2ban ``bans`` table can contain multiple matched log
lines in its ``data.matches`` JSON field. This function expands those
into individual :class:`~app.models.ban.AccessListItem` objects so callers
see each distinct access attempt.
Args:
socket_path: Path to the fail2ban Unix domain socket.
range_: Time-range preset.
page: 1-based page number (default: ``1``).
page_size: Maximum items per page, capped at ``_MAX_PAGE_SIZE``.
geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``.
Returns:
:class:`~app.models.ban.AccessListResponse` containing the paginated
expanded access items and total count.
"""
since: int = _since_unix(range_)
effective_page_size: int = min(page_size, _MAX_PAGE_SIZE)
db_path: str = await _get_fail2ban_db_path(socket_path)
log.info("ban_service_list_accesses", db_path=db_path, since=since, range=range_)
async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as f2b_db:
f2b_db.row_factory = aiosqlite.Row
async with f2b_db.execute(
"SELECT jail, ip, timeofban, data "
"FROM bans "
"WHERE timeofban >= ? "
"ORDER BY timeofban DESC",
(since,),
) as cur:
rows = await cur.fetchall()
# Expand each ban record into its individual matched log lines.
all_items: list[AccessListItem] = []
for row in rows:
jail = str(row["jail"])
ip = str(row["ip"])
timestamp = _ts_to_iso(int(row["timeofban"]))
matches, _ = _parse_data_json(row["data"])
geo = None
if geo_enricher is not None:
try:
geo = await geo_enricher(ip)
except Exception: # noqa: BLE001
log.warning("ban_service_geo_lookup_failed", ip=ip)
for line in matches:
all_items.append(
AccessListItem(
ip=ip,
jail=jail,
timestamp=timestamp,
line=line,
country_code=geo.country_code if geo else None,
country_name=geo.country_name if geo else None,
asn=geo.asn if geo else None,
org=geo.org if geo else None,
)
)
total: int = len(all_items)
offset: int = (page - 1) * effective_page_size
page_items: list[AccessListItem] = all_items[offset : offset + effective_page_size]
return AccessListResponse(
items=page_items,
total=total,
page=page,
page_size=effective_page_size,
)
# ---------------------------------------------------------------------------
# bans_by_country
# ---------------------------------------------------------------------------

View File

@@ -1,4 +1,4 @@
"""Tests for the dashboard router (GET /api/dashboard/status, GET /api/dashboard/bans, GET /api/dashboard/accesses)."""
"""Tests for the dashboard router (GET /api/dashboard/status, GET /api/dashboard/bans)."""
from __future__ import annotations
@@ -13,8 +13,6 @@ from app.config import Settings
from app.db import init_db
from app.main import create_app
from app.models.ban import (
AccessListItem,
AccessListResponse,
DashboardBanItem,
DashboardBanListResponse,
)
@@ -228,24 +226,6 @@ def _make_ban_list_response(n: int = 2) -> DashboardBanListResponse:
return DashboardBanListResponse(items=items, total=n, page=1, page_size=100)
def _make_access_list_response(n: int = 2) -> AccessListResponse:
"""Build a mock AccessListResponse with *n* items."""
items = [
AccessListItem(
ip=f"5.6.7.{i}",
jail="nginx",
timestamp="2026-03-01T10:00:00+00:00",
line=f"GET /admin HTTP/1.1 attempt {i}",
country_code="US",
country_name="United States",
asn="AS15169",
org="Google LLC",
)
for i in range(n)
]
return AccessListResponse(items=items, total=n, page=1, page_size=100)
class TestDashboardBans:
"""GET /api/dashboard/bans."""
@@ -334,62 +314,6 @@ class TestDashboardBans:
assert "ban_count" in item
# ---------------------------------------------------------------------------
# Dashboard accesses endpoint
# ---------------------------------------------------------------------------
class TestDashboardAccesses:
"""GET /api/dashboard/accesses."""
async def test_returns_200_when_authenticated(
self, dashboard_client: AsyncClient
) -> None:
"""Authenticated request returns HTTP 200."""
with patch(
"app.routers.dashboard.ban_service.list_accesses",
new=AsyncMock(return_value=_make_access_list_response()),
):
response = await dashboard_client.get("/api/dashboard/accesses")
assert response.status_code == 200
async def test_returns_401_when_unauthenticated(
self, client: AsyncClient
) -> None:
"""Unauthenticated request returns HTTP 401."""
await client.post("/api/setup", json=_SETUP_PAYLOAD)
response = await client.get("/api/dashboard/accesses")
assert response.status_code == 401
async def test_response_contains_access_items(
self, dashboard_client: AsyncClient
) -> None:
"""Response body contains ``items`` with ``line`` fields."""
with patch(
"app.routers.dashboard.ban_service.list_accesses",
new=AsyncMock(return_value=_make_access_list_response(2)),
):
response = await dashboard_client.get("/api/dashboard/accesses")
body = response.json()
assert body["total"] == 2
assert len(body["items"]) == 2
assert "line" in body["items"][0]
async def test_default_range_is_24h(
self, dashboard_client: AsyncClient
) -> None:
"""If no ``range`` param is provided the default ``24h`` preset is used."""
mock_list = AsyncMock(return_value=_make_access_list_response())
with patch(
"app.routers.dashboard.ban_service.list_accesses", new=mock_list
):
await dashboard_client.get("/api/dashboard/accesses")
called_range = mock_list.call_args[0][1]
assert called_range == "24h"
# ---------------------------------------------------------------------------
# Bans by country endpoint
# ---------------------------------------------------------------------------

View File

@@ -1,4 +1,4 @@
"""Tests for ban_service.list_bans() and ban_service.list_accesses()."""
"""Tests for ban_service.list_bans()."""
from __future__ import annotations
@@ -299,61 +299,3 @@ class TestListBansPagination:
result = await ban_service.list_bans("/fake/sock", "7d", page_size=1)
assert result.total == 3 # All three bans are within 7d.
# ---------------------------------------------------------------------------
# list_accesses
# ---------------------------------------------------------------------------
class TestListAccesses:
"""Verify ban_service.list_accesses()."""
async def test_expands_matches_into_rows(self, f2b_db_path: str) -> None:
"""Each element in ``data.matches`` becomes a separate row."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await ban_service.list_accesses("/fake/sock", "24h")
# Two bans in last 24h: sshd (1 match) + nginx (1 match) = 2 rows.
assert result.total == 2
assert len(result.items) == 2
async def test_access_item_has_line_field(self, f2b_db_path: str) -> None:
"""Each access item contains the raw matched log line."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await ban_service.list_accesses("/fake/sock", "24h")
for item in result.items:
assert item.line
async def test_ban_with_no_matches_produces_no_access_rows(
self, f2b_db_path: str
) -> None:
"""Bans with empty matches list do not contribute rows."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await ban_service.list_accesses("/fake/sock", "7d")
# Third ban (9.10.11.12) has no matches, so only 2 rows total.
assert result.total == 2
async def test_empty_db_returns_zero_accesses(
self, empty_f2b_db_path: str
) -> None:
"""Returns empty result when no bans exist."""
with patch(
"app.services.ban_service._get_fail2ban_db_path",
new=AsyncMock(return_value=empty_f2b_db_path),
):
result = await ban_service.list_accesses("/fake/sock", "24h")
assert result.total == 0
assert result.items == []

View File

@@ -1,13 +1,12 @@
/**
* Dashboard API module.
*
* Wraps `GET /api/dashboard/status`, `GET /api/dashboard/bans`, and
* `GET /api/dashboard/accesses`.
* Wraps `GET /api/dashboard/status` and `GET /api/dashboard/bans`.
*/
import { get } from "./client";
import { ENDPOINTS } from "./endpoints";
import type { AccessListResponse, DashboardBanListResponse, TimeRange } from "../types/ban";
import type { DashboardBanListResponse, TimeRange } from "../types/ban";
import type { ServerStatusResponse } from "../types/server";
/**
@@ -43,26 +42,3 @@ export async function fetchBans(
return get<DashboardBanListResponse>(`${ENDPOINTS.dashboardBans}?${params.toString()}`);
}
/**
* Fetch a paginated access list (individual matched log lines) for the
* selected time window.
*
* @param range - Time-range preset.
* @param page - 1-based page number (default `1`).
* @param pageSize - Items per page (default `100`).
* @returns Paginated {@link AccessListResponse}.
* @throws {ApiError} When the server returns a non-2xx status.
*/
export async function fetchAccesses(
range: TimeRange,
page = 1,
pageSize = 100,
): Promise<AccessListResponse> {
const params = new URLSearchParams({
range,
page: String(page),
page_size: String(pageSize),
});
return get<AccessListResponse>(`${ENDPOINTS.dashboardAccesses}?${params.toString()}`);
}

View File

@@ -30,7 +30,6 @@ export const ENDPOINTS = {
dashboardStatus: "/dashboard/status",
dashboardBans: "/dashboard/bans",
dashboardBansByCountry: "/dashboard/bans/by-country",
dashboardAccesses: "/dashboard/accesses",
// -------------------------------------------------------------------------
// Jails

View File

@@ -1,13 +1,11 @@
/**
* `BanTable` component.
*
* Renders a Fluent UI v9 `DataGrid` for the dashboard ban-list and
* access-list views. Uses the {@link useBans} hook to fetch and manage
* paginated data from the backend.
* Renders a Fluent UI v9 `DataGrid` for the dashboard ban-list view.
* Uses the {@link useBans} hook to fetch and manage paginated data from
* the backend.
*
* Columns differ between modes:
* - `"bans"` — Time, IP, Service, Country, Jail, Ban Count.
* - `"accesses"` — Time, IP, Log Line, Country, Jail.
* Columns: Time, IP, Service, Country, Jail, Ban Count.
*/
import {
@@ -28,8 +26,8 @@ import {
} from "@fluentui/react-components";
import { PageEmpty, PageError, PageLoading } from "./PageFeedback";
import { ChevronLeftRegular, ChevronRightRegular } from "@fluentui/react-icons";
import { useBans, type BanTableMode } from "../hooks/useBans";
import type { AccessListItem, DashboardBanItem, TimeRange } from "../types/ban";
import { useBans } from "../hooks/useBans";
import type { DashboardBanItem, TimeRange } from "../types/ban";
// ---------------------------------------------------------------------------
// Types
@@ -37,8 +35,6 @@ import type { AccessListItem, DashboardBanItem, TimeRange } from "../types/ban";
/** Props for the {@link BanTable} component. */
interface BanTableProps {
/** Whether to render ban records or individual access events. */
mode: BanTableMode;
/**
* Active time-range preset — controlled by the parent `DashboardPage`.
* Changing this value triggers a re-fetch.
@@ -179,68 +175,20 @@ function buildBanColumns(styles: ReturnType<typeof useStyles>): TableColumnDefin
];
}
/** Columns for the access-list view (`mode === "accesses"`). */
function buildAccessColumns(styles: ReturnType<typeof useStyles>): TableColumnDefinition<AccessListItem>[] {
return [
createTableColumn<AccessListItem>({
columnId: "timestamp",
renderHeaderCell: () => "Timestamp",
renderCell: (item) => (
<Text size={200}>{formatTimestamp(item.timestamp)}</Text>
),
}),
createTableColumn<AccessListItem>({
columnId: "ip",
renderHeaderCell: () => "IP Address",
renderCell: (item) => (
<span className={styles.mono}>{item.ip}</span>
),
}),
createTableColumn<AccessListItem>({
columnId: "line",
renderHeaderCell: () => "Log Line",
renderCell: (item) => (
<Tooltip content={item.line} relationship="description">
<span className={`${styles.mono} ${styles.truncate}`}>{item.line}</span>
</Tooltip>
),
}),
createTableColumn<AccessListItem>({
columnId: "country",
renderHeaderCell: () => "Country",
renderCell: (item) => (
<Text size={200}>
{item.country_name ?? item.country_code ?? "—"}
</Text>
),
}),
createTableColumn<AccessListItem>({
columnId: "jail",
renderHeaderCell: () => "Jail",
renderCell: (item) => <Text size={200}>{item.jail}</Text>,
}),
];
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Data table for the dashboard ban-list and access-list views.
* Data table for the dashboard ban-list view.
*
* @param props.mode - `"bans"` or `"accesses"`.
* @param props.timeRange - Active time-range preset from the parent page.
*/
export function BanTable({ mode, timeRange }: BanTableProps): React.JSX.Element {
export function BanTable({ timeRange }: BanTableProps): React.JSX.Element {
const styles = useStyles();
const { banItems, accessItems, total, page, setPage, loading, error, refresh } = useBans(
mode,
timeRange,
);
const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange);
const banColumns = buildBanColumns(styles);
const accessColumns = buildAccessColumns(styles);
// --------------------------------------------------------------------------
// Loading state
@@ -259,15 +207,8 @@ export function BanTable({ mode, timeRange }: BanTableProps): React.JSX.Element
// --------------------------------------------------------------------------
// Empty state
// --------------------------------------------------------------------------
const isEmpty = mode === "bans" ? banItems.length === 0 : accessItems.length === 0;
if (isEmpty) {
return (
<PageEmpty
message={`No ${
mode === "bans" ? "bans" : "accesses"
} recorded in the selected time window.`}
/>
);
if (banItems.length === 0) {
return <PageEmpty message="No bans recorded in the selected time window." />;
}
// --------------------------------------------------------------------------
@@ -279,68 +220,15 @@ export function BanTable({ mode, timeRange }: BanTableProps): React.JSX.Element
const hasNext = page < totalPages;
// --------------------------------------------------------------------------
// Render — bans mode
// --------------------------------------------------------------------------
if (mode === "bans") {
return (
<div className={styles.root}>
<div className={styles.tableWrapper}>
<DataGrid
items={banItems}
columns={banColumns}
getRowId={(item: DashboardBanItem) => `${item.ip}:${item.jail}:${item.banned_at}`}
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody<DashboardBanItem>>
{({ item, rowId }) => (
<DataGridRow<DashboardBanItem> key={rowId}>
{({ renderCell }) => (
<DataGridCell>{renderCell(item)}</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
</div>
<div className={styles.pagination}>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
{total} total · Page {page} of {totalPages}
</Text>
<Button
icon={<ChevronLeftRegular />}
appearance="subtle"
disabled={!hasPrev}
onClick={() => { setPage(page - 1); }}
aria-label="Previous page"
/>
<Button
icon={<ChevronRightRegular />}
appearance="subtle"
disabled={!hasNext}
onClick={() => { setPage(page + 1); }}
aria-label="Next page"
/>
</div>
</div>
);
}
// --------------------------------------------------------------------------
// Render — accesses mode
// Render
// --------------------------------------------------------------------------
return (
<div className={styles.root}>
<div className={styles.tableWrapper}>
<DataGrid
items={accessItems}
columns={accessColumns}
getRowId={(item: AccessListItem) => `${item.ip}:${item.jail}:${item.timestamp}:${item.line.slice(0, 40)}`}
items={banItems}
columns={banColumns}
getRowId={(item: DashboardBanItem) => `${item.ip}:${item.jail}:${item.banned_at}`}
>
<DataGridHeader>
<DataGridRow>
@@ -349,9 +237,9 @@ export function BanTable({ mode, timeRange }: BanTableProps): React.JSX.Element
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody<AccessListItem>>
<DataGridBody<DashboardBanItem>>
{({ item, rowId }) => (
<DataGridRow<AccessListItem> key={rowId}>
<DataGridRow<DashboardBanItem> key={rowId}>
{({ renderCell }) => (
<DataGridCell>{renderCell(item)}</DataGridCell>
)}

View File

@@ -1,27 +1,21 @@
/**
* `useBans` hook.
*
* Fetches and manages paginated ban-list or access-list data from the
* dashboard endpoints. Re-fetches automatically when `timeRange` or `page`
* changes.
* Fetches and manages paginated ban-list data from the dashboard endpoint.
* Re-fetches automatically when `timeRange` or `page` changes.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchAccesses, fetchBans } from "../api/dashboard";
import type { AccessListItem, DashboardBanItem, TimeRange } from "../types/ban";
import { fetchBans } from "../api/dashboard";
import type { DashboardBanItem, TimeRange } from "../types/ban";
/** The dashboard view mode: aggregate bans or individual access events. */
export type BanTableMode = "bans" | "accesses";
/** Items per page for the ban/access tables. */
/** Items per page for the ban table. */
const PAGE_SIZE = 100;
/** Return value shape for {@link useBans}. */
export interface UseBansResult {
/** Ban items — populated when `mode === "bans"`, otherwise empty. */
/** Ban items for the current page. */
banItems: DashboardBanItem[];
/** Access items — populated when `mode === "accesses"`, otherwise empty. */
accessItems: AccessListItem[];
/** Total records in the selected time window (for pagination). */
total: number;
/** Current 1-based page number. */
@@ -37,50 +31,39 @@ export interface UseBansResult {
}
/**
* Fetch and manage dashboard ban-list or access-list data.
* Fetch and manage dashboard ban-list data.
*
* Automatically re-fetches when `mode`, `timeRange`, or `page` changes.
* Automatically re-fetches when `timeRange` or `page` changes.
*
* @param mode - `"bans"` for the ban-list view; `"accesses"` for the
* access-list view.
* @param timeRange - Time-range preset that controls how far back to look.
* @returns Current data, pagination state, loading flag, and a `refresh`
* callback.
*/
export function useBans(mode: BanTableMode, timeRange: TimeRange): UseBansResult {
export function useBans(timeRange: TimeRange): UseBansResult {
const [banItems, setBanItems] = useState<DashboardBanItem[]>([]);
const [accessItems, setAccessItems] = useState<AccessListItem[]>([]);
const [total, setTotal] = useState<number>(0);
const [page, setPage] = useState<number>(1);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Reset page when mode or time range changes.
// Reset page when time range changes.
useEffect(() => {
setPage(1);
}, [mode, timeRange]);
}, [timeRange]);
const doFetch = useCallback(async (): Promise<void> => {
setLoading(true);
setError(null);
try {
if (mode === "bans") {
const data = await fetchBans(timeRange, page, PAGE_SIZE);
setBanItems(data.items);
setAccessItems([]);
setTotal(data.total);
} else {
const data = await fetchAccesses(timeRange, page, PAGE_SIZE);
setAccessItems(data.items);
setBanItems([]);
setTotal(data.total);
}
const data = await fetchBans(timeRange, page, PAGE_SIZE);
setBanItems(data.items);
setTotal(data.total);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch data");
} finally {
setLoading(false);
}
}, [mode, timeRange, page]);
}, [timeRange, page]);
// Stable ref to the latest doFetch so the refresh callback is always current.
const doFetchRef = useRef(doFetch);
@@ -96,7 +79,6 @@ export function useBans(mode: BanTableMode, timeRange: TimeRange): UseBansResult
return {
banItems,
accessItems,
total,
page,
setPage,

View File

@@ -2,15 +2,12 @@
* Dashboard page.
*
* Composes the fail2ban server status bar at the top, a shared time-range
* selector, and two tabs: "Ban List" (aggregate bans) and "Access List"
* (individual matched log lines). The time-range selection is shared
* between both tabs so users can compare data for the same period.
* selector, and the ban list showing aggregate bans from the fail2ban
* database. The time-range selection controls how far back to look.
*/
import { useState } from "react";
import {
Tab,
TabList,
Text,
ToggleButton,
Toolbar,
@@ -21,7 +18,7 @@ import { BanTable } from "../components/BanTable";
import { ServerStatusBar } from "../components/ServerStatusBar";
import type { TimeRange } from "../types/ban";
import { TIME_RANGE_LABELS } from "../types/ban";
import type { BanTableMode } from "../hooks/useBans";
// ---------------------------------------------------------------------------
// Styles
@@ -83,13 +80,12 @@ const TIME_RANGES: TimeRange[] = ["24h", "7d", "30d", "365d"];
/**
* Main dashboard landing page.
*
* Displays the fail2ban server status, a time-range selector, and a
* tabbed view toggling between the ban list and the access list.
* Displays the fail2ban server status, a time-range selector, and the
* ban list table.
*/
export function DashboardPage(): React.JSX.Element {
const styles = useStyles();
const [timeRange, setTimeRange] = useState<TimeRange>("24h");
const [activeTab, setActiveTab] = useState<BanTableMode>("bans");
return (
<div className={styles.root}>
@@ -99,15 +95,15 @@ export function DashboardPage(): React.JSX.Element {
<ServerStatusBar />
{/* ------------------------------------------------------------------ */}
{/* Ban / access list section */}
{/* Ban list section */}
{/* ------------------------------------------------------------------ */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
{activeTab === "bans" ? "Ban List" : "Access List"}
Ban List
</Text>
{/* Shared time-range selector */}
{/* Time-range selector */}
<Toolbar aria-label="Time range" size="small">
{TIME_RANGES.map((r) => (
<ToggleButton
@@ -125,21 +121,9 @@ export function DashboardPage(): React.JSX.Element {
</Toolbar>
</div>
{/* Tab switcher */}
<TabList
selectedValue={activeTab}
onTabSelect={(_, data) => {
setActiveTab(data.value as BanTableMode);
}}
size="small"
>
<Tab value="bans">Ban List</Tab>
<Tab value="accesses">Access List</Tab>
</TabList>
{/* Active tab content */}
{/* Ban table */}
<div className={styles.tabContent}>
<BanTable mode={activeTab} timeRange={timeRange} />
<BanTable timeRange={timeRange} />
</div>
</div>
</div>

View File

@@ -64,50 +64,3 @@ export interface DashboardBanListResponse {
/** Maximum items per page. */
page_size: number;
}
// ---------------------------------------------------------------------------
// Access-list table item
// ---------------------------------------------------------------------------
/**
* A single row in the dashboard access-list table.
*
* Each row represents one matched log line (failure attempt) that
* contributed to a ban.
*
* Mirrors `AccessListItem` from `backend/app/models/ban.py`.
*/
export interface AccessListItem {
/** IP address of the access event. */
ip: string;
/** Jail that recorded the access. */
jail: string;
/** ISO 8601 UTC timestamp of the ban that captured this access. */
timestamp: string;
/** Raw matched log line. */
line: string;
/** ISO 3166-1 alpha-2 country code, or null. */
country_code: string | null;
/** Human-readable country name, or null. */
country_name: string | null;
/** ASN string, or null. */
asn: string | null;
/** Organisation name, or null. */
org: string | null;
}
/**
* Paginated access-list response from `GET /api/dashboard/accesses`.
*
* Mirrors `AccessListResponse` from `backend/app/models/ban.py`.
*/
export interface AccessListResponse {
/** Access items for the current page. */
items: AccessListItem[];
/** Total number of access events in the selected window. */
total: number;
/** Current 1-based page number. */
page: number;
/** Maximum items per page. */
page_size: number;
}