Compare commits
8 Commits
4be2469f92
...
61daa8bbc0
| Author | SHA1 | Date | |
|---|---|---|---|
| 61daa8bbc0 | |||
| 57a0bbe36e | |||
| f62785aaf2 | |||
| 1e33220f59 | |||
| 1da38361a9 | |||
| 9630aea877 | |||
| 037c18eb00 | |||
| 2e1a4b3b2b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -105,6 +105,7 @@ Docker/fail2ban-dev-config/**
|
||||
!Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-sim.conf
|
||||
!Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-access.conf
|
||||
!Docker/fail2ban-dev-config/fail2ban/jail.d/blocklist-import.conf
|
||||
!Docker/fail2ban-dev-config/fail2ban/jail.local
|
||||
|
||||
# ── Misc ──────────────────────────────────────
|
||||
*.log
|
||||
|
||||
@@ -37,6 +37,11 @@ services:
|
||||
timeout: 5s
|
||||
start_period: 15s
|
||||
retries: 3
|
||||
# NOTE: The fail2ban-config volume must be pre-populated with the following files:
|
||||
# • fail2ban/jail.conf (or jail.d/*.conf) with the DEFAULT section containing:
|
||||
# banaction = iptables-allports[lockingopt="-w 5"]
|
||||
# This prevents xtables lock contention errors when multiple jails start in parallel.
|
||||
# See https://fail2ban.readthedocs.io/en/latest/development/environment.html
|
||||
|
||||
# ── Backend (FastAPI + uvicorn) ─────────────────────────────
|
||||
backend:
|
||||
|
||||
@@ -14,7 +14,6 @@ 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
|
||||
|
||||
@@ -20,7 +20,6 @@ maxretry = 1
|
||||
findtime = 1d
|
||||
# Block imported IPs for one week.
|
||||
bantime = 1w
|
||||
banaction = iptables-allports
|
||||
|
||||
# Never ban the Docker bridge network or localhost.
|
||||
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
||||
|
||||
6
Docker/fail2ban-dev-config/fail2ban/jail.local
Normal file
6
Docker/fail2ban-dev-config/fail2ban/jail.local
Normal file
@@ -0,0 +1,6 @@
|
||||
# Local overrides — not overwritten by the container init script.
|
||||
# Provides banaction so all jails can resolve %(action_)s interpolation.
|
||||
|
||||
[DEFAULT]
|
||||
banaction = iptables-multiport
|
||||
banaction_allports = iptables-allports
|
||||
187
Docs/Tasks.md
187
Docs/Tasks.md
@@ -4,97 +4,136 @@ This document breaks the entire BanGUI project into development stages, ordered
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Move "Configuration" to the Last Position in the Sidebar ✅ DONE
|
||||
## Agent Operating Instructions
|
||||
|
||||
**Summary:** Moved the `Configuration` entry in `NAV_ITEMS` to the last position in `frontend/src/layouts/MainLayout.tsx`.
|
||||
These instructions apply to every AI agent working in this repository. Read them fully before touching any file.
|
||||
|
||||
**File:** `frontend/src/layouts/MainLayout.tsx`
|
||||
### Before You Begin
|
||||
|
||||
The `NAV_ITEMS` array (around line 183) defines the sidebar menu order. Currently the order is: Dashboard, World Map, Jails, **Configuration**, History, Blocklists. Move the Configuration entry so it is the **last** element in the array. The resulting order must be:
|
||||
1. Read [Instructions.md](Instructions.md) in full — it defines the project context, coding standards, and workflow rules. Every rule there is authoritative and takes precedence over any assumption you make.
|
||||
2. Read [Architekture.md](Architekture.md) to understand the system structure before touching any component.
|
||||
3. Read the development guide relevant to your task: [Backend-Development.md](Backend-Development.md) or [Web-Development.md](Web-Development.md) (or both).
|
||||
4. Read [Features.md](Features.md) so you understand what the product is supposed to do and do not accidentally break intended behaviour.
|
||||
|
||||
1. Dashboard
|
||||
2. World Map
|
||||
3. Jails
|
||||
4. History
|
||||
5. Blocklists
|
||||
6. Configuration
|
||||
### How to Work Through This Document
|
||||
|
||||
Only the position in the array changes. Do not modify the label, path, or icon of any item.
|
||||
- Tasks are grouped by feature area. Each group is self-contained.
|
||||
- Work through tasks in the order they appear within a group; earlier tasks establish foundations for later ones.
|
||||
- Mark a task **in-progress** before you start it and **completed** the moment it is done. Never batch completions.
|
||||
- If a task depends on another task that is not yet complete, stop and complete the dependency first.
|
||||
- If you are uncertain whether a change is correct, read the relevant documentation section again before proceeding. Do not guess.
|
||||
|
||||
### Code Quality Rules (Summary)
|
||||
|
||||
- No TODOs, no placeholders, no half-finished functions.
|
||||
- Full type annotations on every function (Python) and full TypeScript types on every symbol (no `any`).
|
||||
- Layered architecture: routers → services → repositories. No layer may skip another.
|
||||
- All backend errors are raised as typed HTTP exceptions; all unexpected errors are logged via structlog before re-raising.
|
||||
- All frontend state lives in typed hooks; no raw `fetch` calls outside of the `api/` layer.
|
||||
- After every code change, run the full test suite (`make test`) and ensure it is green.
|
||||
|
||||
### Definition of Done
|
||||
|
||||
A task is done when:
|
||||
- The code compiles and the test suite passes (`make test`).
|
||||
- The feature works end-to-end in the dev stack (`make up`).
|
||||
- No new lint errors are introduced.
|
||||
- The change is consistent with all documentation rules.
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — Auto-Recovery When Jail Activation Fails ✅ DONE
|
||||
|
||||
**Summary:** Added `recovered: bool | None` field to `JailActivationResponse` model. Implemented `_restore_local_file_sync` and `_rollback_activation_async` helpers. Updated `activate_jail` to back up the original `.local` file, roll back on any post-write failure (reload error, health-check failure, or jail not starting), and return `recovered=True/False`. Updated `ActivateJailDialog.tsx` to show warning/critical banners based on the `recovered` field. Added 3 new backend tests covering all rollback scenarios.
|
||||
|
||||
**Context:** When a user activates a jail via `POST /api/config/jails/{name}/activate`, the backend writes `enabled = true` to `jail.d/{name}.local` and then reloads fail2ban. If the new configuration is invalid or the server crashes after reload, fail2ban stays broken and all jails go offline. The system must automatically recover by rolling back the change and restarting fail2ban.
|
||||
|
||||
### Backend Changes
|
||||
|
||||
**File:** `backend/app/services/config_file_service.py` — `activate_jail()` method (around line 1086)
|
||||
|
||||
Wrap the reload-and-verify sequence in error handling that performs a rollback on failure:
|
||||
|
||||
1. **Before writing** the `.local` override file, check whether a `.local` file for that jail already exists. If it does, read and keep its content in memory as a backup. If it does not exist, remember that no file existed.
|
||||
2. **Write** the override file with `enabled = true` (existing logic).
|
||||
3. **Reload** fail2ban via `jail_service.reload_all()` (existing logic).
|
||||
4. **Health-check / verify** that fail2ban is responsive and the jail appears in the active list (existing logic).
|
||||
5. **If any step after the write fails** (reload error, health-check timeout, jail not appearing):
|
||||
- **Rollback the config**: restore the original `.local` file content (or delete the file if it did not exist before).
|
||||
- **Restart fail2ban**: call `jail_service.reload_all()` again so fail2ban recovers with the old configuration.
|
||||
- **Health-check again** to confirm fail2ban is back.
|
||||
- Return an appropriate error response (HTTP 502 or 500) with a message that explains the activation failed **and** the system was recovered. Include a field `recovered: true` in the JSON body so the frontend can display a recovery notice.
|
||||
6. If rollback itself fails, return an error with `recovered: false` so the frontend can display a critical alert.
|
||||
|
||||
**File:** `backend/app/routers/config.py` — `activate_jail` endpoint (around line 584)
|
||||
|
||||
Propagate the `recovered` field in the error response. No extra logic is needed in the router if the service already raises an appropriate exception or returns a result object with the recovery status.
|
||||
|
||||
### Frontend Changes
|
||||
|
||||
**File:** `frontend/src/components/config/JailsTab.tsx` (or wherever the activate mutation result is handled)
|
||||
|
||||
When the activation API call returns an error:
|
||||
- If `recovered` is `true`, show a warning banner/toast: *"Activation of jail '{name}' failed. The server has been automatically recovered."*
|
||||
- If `recovered` is `false`, show a critical error banner/toast: *"Activation of jail '{name}' failed and automatic recovery was unsuccessful. Manual intervention is required."*
|
||||
|
||||
### Tests
|
||||
|
||||
Add or extend tests in `backend/tests/test_services/test_config_file_service.py`:
|
||||
|
||||
- **test_activate_jail_rollback_on_reload_failure**: Mock `jail_service.reload_all()` to raise on the first call (activation reload) and succeed on the second call (recovery reload). Assert the `.local` file is restored to its original content and the response indicates `recovered: true`.
|
||||
- **test_activate_jail_rollback_on_health_check_failure**: Mock the health check to fail after reload. Assert rollback and recovery.
|
||||
- **test_activate_jail_rollback_failure**: Mock both the activation reload and the recovery reload to fail. Assert the response indicates `recovered: false`.
|
||||
## Bug Fixes
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — Match Pie Chart Slice Colors to Country Label Font Colors ✅ DONE
|
||||
### BUG-001 — fail2ban: `bangui-sim` jail fails to start due to missing `banaction`
|
||||
|
||||
**Summary:** Updated `legendFormatter` in `TopCountriesPieChart.tsx` to return `React.ReactNode` instead of `string`, using `<span style={{ color: entry.color }}>` to colour each legend label to match its pie slice. Imported `LegendPayload` from `recharts/types/component/DefaultLegendContent`.
|
||||
**Status:** Done
|
||||
|
||||
**Context:** The dashboard's Top Countries pie chart (`frontend/src/components/TopCountriesPieChart.tsx`) uses a color palette from `frontend/src/utils/chartTheme.ts` for the pie slices. The country names displayed in the legend next to the chart currently use the default text color. They should instead use the **same color as their corresponding pie slice**.
|
||||
**Summary:** `jail.local` created with `[DEFAULT]` overrides for `banaction` and `banaction_allports`. The container init script (`init-fail2ban-config`) overwrites `jail.conf` from the image's `/defaults/` on every start, so modifying `jail.conf` directly is ineffective. `jail.local` is not in the container's defaults and thus persists correctly. Additionally fixed a `TypeError` in `config_file_service.py` where `except jail_service.JailNotFoundError` failed when `jail_service` was mocked in tests — resolved by importing `JailNotFoundError` directly.
|
||||
|
||||
### Changes
|
||||
#### Error
|
||||
|
||||
**File:** `frontend/src/components/TopCountriesPieChart.tsx`
|
||||
|
||||
In the `<Legend>` component (rendered by Recharts), the `formatter` prop already receives the legend entry value. Apply a custom renderer so each country name is rendered with its matching slice color as the **font color**. The Recharts `<Legend>` accepts a `formatter` function whose second argument is the entry object containing the `color` property. Use that color to wrap the text in a `<span>` with `style={{ color: entry.color }}`. Example:
|
||||
|
||||
```tsx
|
||||
formatter={(value: string, entry: LegendPayload) => {
|
||||
const slice = slices.find((s) => s.name === value);
|
||||
if (slice == null || total === 0) return value;
|
||||
const pct = ((slice.value / total) * 100).toFixed(1);
|
||||
return (
|
||||
<span style={{ color: entry.color }}>
|
||||
{value} ({pct}%)
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
```
|
||||
Failed during configuration: Bad value substitution: option 'action' in section 'bangui-sim'
|
||||
contains an interpolation key 'banaction' which is not a valid option name.
|
||||
Raw value: '%(action_)s'
|
||||
```
|
||||
|
||||
Make sure the `formatter` return type is `ReactNode` (not `string`). Import the Recharts `Payload` type if needed: `import type { Payload } from "recharts/types/component/DefaultLegendContent"` . Adjust the import path to match the Recharts version in the project.
|
||||
#### Root Cause
|
||||
|
||||
Do **not** change the pie slice colors themselves — only the country label font color must match the slice it corresponds to.
|
||||
fail2ban's interpolation system resolves option values at configuration load time by
|
||||
substituting `%(key)s` placeholders with values from the same section or from `[DEFAULT]`.
|
||||
|
||||
The chain that fails is:
|
||||
|
||||
1. Every jail inherits `action = %(action_)s` from `[DEFAULT]` (no override in `bangui-sim.conf`).
|
||||
2. `action_` is defined in `[DEFAULT]` as `%(banaction)s[port="%(port)s", protocol="%(protocol)s", chain="%(chain)s"]`.
|
||||
3. `banaction` is **commented out** in `[DEFAULT]`:
|
||||
```ini
|
||||
# Docker/fail2ban-dev-config/fail2ban/jail.conf [DEFAULT]
|
||||
#banaction = iptables-multiport ← this line is disabled
|
||||
```
|
||||
4. Because `banaction` is absent from the interpolation namespace, fail2ban cannot resolve
|
||||
`action_`, which makes it unable to resolve `action`, and the jail fails to load.
|
||||
|
||||
The same root cause affects every jail in `jail.d/` that does not define its own `banaction`,
|
||||
including `blocklist-import.conf`.
|
||||
|
||||
#### Fix
|
||||
|
||||
**File:** `Docker/fail2ban-dev-config/fail2ban/jail.conf`
|
||||
|
||||
Uncomment the `banaction` line inside the `[DEFAULT]` section so the value is globally
|
||||
available to all jails:
|
||||
|
||||
```ini
|
||||
banaction = iptables-multiport
|
||||
banaction_allports = iptables-allports
|
||||
```
|
||||
|
||||
This is safe: the dev compose (`Docker/compose.debug.yml`) already grants the fail2ban
|
||||
container `NET_ADMIN` and `NET_RAW` capabilities, which are the prerequisites for
|
||||
iptables-based banning.
|
||||
|
||||
#### Tasks
|
||||
|
||||
- [x] **BUG-001-T1 — Add `banaction` override via `jail.local` [DEFAULT]**
|
||||
|
||||
Open `Docker/fail2ban-dev-config/fail2ban/jail.conf`.
|
||||
Find the two commented-out lines near the `action_` definition:
|
||||
```ini
|
||||
#banaction = iptables-multiport
|
||||
#banaction_allports = iptables-allports
|
||||
```
|
||||
Remove the leading `#` from both lines so they become active options.
|
||||
Do not change any other part of the file.
|
||||
|
||||
- [x] **BUG-001-T2 — Restart the fail2ban container and verify clean startup**
|
||||
|
||||
Bring the dev stack down and back up:
|
||||
```bash
|
||||
make down && make up
|
||||
```
|
||||
Wait for the fail2ban container to reach `healthy`, then inspect its logs:
|
||||
```bash
|
||||
make logs # or: docker logs bangui-fail2ban-dev 2>&1 | grep -i error
|
||||
```
|
||||
Confirm that no `Bad value substitution` or `Failed during configuration` lines appear
|
||||
and that both `bangui-sim` and `blocklist-import` jails show as **enabled** in the output.
|
||||
|
||||
- [x] **BUG-001-T3 — Verify ban/unban cycle works end-to-end**
|
||||
|
||||
With the stack running, trigger the simulation script:
|
||||
```bash
|
||||
bash Docker/simulate_failed_logins.sh
|
||||
```
|
||||
Then confirm fail2ban has recorded a ban:
|
||||
```bash
|
||||
bash Docker/check_ban_status.sh
|
||||
```
|
||||
The script should report at least one banned IP in the `bangui-sim` jail.
|
||||
Also verify that the BanGUI dashboard reflects the new ban entry.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -366,6 +366,40 @@ async def reload_fail2ban(
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
# Restart endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/restart",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Restart the fail2ban service",
|
||||
)
|
||||
async def restart_fail2ban(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
) -> None:
|
||||
"""Trigger a full fail2ban service restart.
|
||||
|
||||
The fail2ban daemon is completely stopped and then started again,
|
||||
re-reading all configuration files in the process.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
|
||||
Raises:
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
# Perform restart by sending the restart command via the fail2ban socket.
|
||||
# If fail2ban is not running, this will raise an exception, and we return 502.
|
||||
await jail_service.restart(socket_path)
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regex tester (stateless)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -55,6 +55,7 @@ from app.models.config import (
|
||||
RollbackResponse,
|
||||
)
|
||||
from app.services import conffile_parser, jail_service
|
||||
from app.services.jail_service import JailNotFoundError as JailNotFoundError
|
||||
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
@@ -1231,6 +1232,30 @@ async def activate_jail(
|
||||
# ---------------------------------------------------------------------- #
|
||||
try:
|
||||
await jail_service.reload_all(socket_path, include_jails=[name])
|
||||
except JailNotFoundError as exc:
|
||||
# Jail configuration is invalid (e.g. missing logpath that prevents
|
||||
# fail2ban from loading the jail). Roll back and provide a specific error.
|
||||
log.warning(
|
||||
"reload_after_activate_failed_jail_not_found",
|
||||
jail=name,
|
||||
error=str(exc),
|
||||
)
|
||||
recovered = await _rollback_activation_async(
|
||||
config_dir, name, socket_path, original_content
|
||||
)
|
||||
return JailActivationResponse(
|
||||
name=name,
|
||||
active=False,
|
||||
fail2ban_running=False,
|
||||
recovered=recovered,
|
||||
validation_warnings=warnings,
|
||||
message=(
|
||||
f"Jail {name!r} activation failed: {str(exc)}. "
|
||||
"Check that all logpath files exist and are readable. "
|
||||
"The configuration was "
|
||||
+ ("automatically recovered." if recovered else "not recovered — manual intervention is required.")
|
||||
),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("reload_after_activate_failed", jail=name, error=str(exc))
|
||||
recovered = await _rollback_activation_async(
|
||||
|
||||
@@ -43,6 +43,13 @@ _SOCKET_TIMEOUT: float = 10.0
|
||||
# ensures only one reload stream is in-flight at a time.
|
||||
_reload_all_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
# Capability detection for optional fail2ban transmitter commands (backend, idle).
|
||||
# These commands are not supported in all fail2ban versions. Caching the result
|
||||
# avoids sending unsupported commands every polling cycle and spamming the
|
||||
# fail2ban log with "Invalid command" errors.
|
||||
_backend_cmd_supported: bool | None = None
|
||||
_backend_cmd_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -185,6 +192,51 @@ async def _safe_get(
|
||||
return default
|
||||
|
||||
|
||||
async def _check_backend_cmd_supported(
|
||||
client: Fail2BanClient,
|
||||
jail_name: str,
|
||||
) -> bool:
|
||||
"""Detect whether the fail2ban daemon supports optional ``get ... backend`` command.
|
||||
|
||||
Some fail2ban versions (e.g. LinuxServer.io container) do not implement the
|
||||
optional ``get <jail> backend`` and ``get <jail> idle`` transmitter sub-commands.
|
||||
This helper probes the daemon once and caches the result to avoid repeated
|
||||
"Invalid command" errors in the fail2ban log.
|
||||
|
||||
Uses double-check locking to minimize lock contention in concurrent polls.
|
||||
|
||||
Args:
|
||||
client: The :class:`~app.utils.fail2ban_client.Fail2BanClient` to use.
|
||||
jail_name: Name of any jail to use for the probe command.
|
||||
|
||||
Returns:
|
||||
``True`` if the command is supported, ``False`` otherwise.
|
||||
Once determined, the result is cached and reused for all jails.
|
||||
"""
|
||||
global _backend_cmd_supported
|
||||
|
||||
# Fast path: return cached result if already determined.
|
||||
if _backend_cmd_supported is not None:
|
||||
return _backend_cmd_supported
|
||||
|
||||
# Slow path: acquire lock and probe the command once.
|
||||
async with _backend_cmd_lock:
|
||||
# Double-check idiom: another coroutine may have probed while we waited.
|
||||
if _backend_cmd_supported is not None:
|
||||
return _backend_cmd_supported
|
||||
|
||||
# Probe: send the command and catch any exception.
|
||||
try:
|
||||
_ok(await client.send(["get", jail_name, "backend"]))
|
||||
_backend_cmd_supported = True
|
||||
log.debug("backend_cmd_supported_detected")
|
||||
except Exception:
|
||||
_backend_cmd_supported = False
|
||||
log.debug("backend_cmd_unsupported_detected")
|
||||
|
||||
return _backend_cmd_supported
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — Jail listing & detail
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -238,7 +290,11 @@ async def _fetch_jail_summary(
|
||||
"""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.
|
||||
``backend``, and ``idle`` commands in parallel (if supported).
|
||||
|
||||
The ``backend`` and ``idle`` commands are optional and not supported in
|
||||
all fail2ban versions. If not supported, this function will not send them
|
||||
to avoid spamming the fail2ban log with "Invalid command" errors.
|
||||
|
||||
Args:
|
||||
client: Shared :class:`~app.utils.fail2ban_client.Fail2BanClient`.
|
||||
@@ -247,15 +303,38 @@ async def _fetch_jail_summary(
|
||||
Returns:
|
||||
A :class:`~app.models.jail.JailSummary` populated from the responses.
|
||||
"""
|
||||
_r = await asyncio.gather(
|
||||
# Check whether optional backend/idle commands are supported.
|
||||
# This probe happens once per session and is cached to avoid repeated
|
||||
# "Invalid command" errors in the fail2ban log.
|
||||
backend_cmd_is_supported = await _check_backend_cmd_supported(client, name)
|
||||
|
||||
# Build the gather list based on command support.
|
||||
gather_list: list[Any] = [
|
||||
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,
|
||||
)
|
||||
]
|
||||
|
||||
if backend_cmd_is_supported:
|
||||
# Commands are supported; send them for real values.
|
||||
gather_list.extend([
|
||||
client.send(["get", name, "backend"]),
|
||||
client.send(["get", name, "idle"]),
|
||||
])
|
||||
uses_backend_backend_commands = True
|
||||
else:
|
||||
# Commands not supported; return default values without sending.
|
||||
async def _return_default(value: Any) -> tuple[int, Any]:
|
||||
return (0, value)
|
||||
|
||||
gather_list.extend([
|
||||
_return_default("polling"), # backend default
|
||||
_return_default(False), # idle default
|
||||
])
|
||||
uses_backend_backend_commands = False
|
||||
|
||||
_r = await asyncio.gather(*gather_list, return_exceptions=True)
|
||||
status_raw: Any = _r[0]
|
||||
bantime_raw: Any = _r[1]
|
||||
findtime_raw: Any = _r[2]
|
||||
@@ -569,7 +648,10 @@ async def reload_all(
|
||||
exclude_jails: Jail names to remove from the start stream.
|
||||
|
||||
Raises:
|
||||
JailOperationError: If fail2ban reports the operation failed.
|
||||
JailNotFoundError: If a jail in *include_jails* does not exist or
|
||||
its configuration is invalid (e.g. missing logpath).
|
||||
JailOperationError: If fail2ban reports the operation failed for
|
||||
a different reason.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
@@ -593,9 +675,38 @@ async def reload_all(
|
||||
_ok(await client.send(["reload", "--all", [], stream]))
|
||||
log.info("all_jails_reloaded")
|
||||
except ValueError as exc:
|
||||
# Detect UnknownJailException (missing or invalid jail configuration)
|
||||
# and re-raise as JailNotFoundError for better error specificity.
|
||||
if _is_not_found_error(exc):
|
||||
# Extract the jail name from include_jails if available.
|
||||
jail_name = include_jails[0] if include_jails else "unknown"
|
||||
raise JailNotFoundError(jail_name) from exc
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
|
||||
async def restart(socket_path: str) -> None:
|
||||
"""Restart the fail2ban service (daemon).
|
||||
|
||||
Sends the 'restart' command to the fail2ban daemon via the Unix socket.
|
||||
All jails are stopped and the daemon is restarted, re-reading all
|
||||
configuration from scratch.
|
||||
|
||||
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(["restart"]))
|
||||
log.info("fail2ban_restarted")
|
||||
except ValueError as exc:
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — Ban / Unban
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3110,4 +3110,68 @@ class TestActivateJailRollback:
|
||||
assert result.active is False
|
||||
assert result.recovered is False
|
||||
|
||||
async def test_activate_jail_rollback_on_jail_not_found_error(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""Rollback when reload_all raises JailNotFoundError (invalid config).
|
||||
|
||||
When fail2ban cannot create a jail due to invalid configuration
|
||||
(e.g., missing logpath), it raises UnknownJailException which becomes
|
||||
JailNotFoundError. This test verifies proper handling and rollback.
|
||||
|
||||
Expects:
|
||||
- The .local file is restored to its original content.
|
||||
- The response indicates recovered=True.
|
||||
- The error message mentions the logpath issue.
|
||||
"""
|
||||
from app.models.config import ActivateJailRequest, JailValidationResult
|
||||
from app.services.jail_service import JailNotFoundError
|
||||
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
original_local = "[apache-auth]\nenabled = false\n"
|
||||
local_path = tmp_path / "jail.d" / "apache-auth.local"
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_text(original_local)
|
||||
|
||||
req = ActivateJailRequest()
|
||||
reload_call_count = 0
|
||||
|
||||
async def reload_side_effect(socket_path: str, **kwargs: object) -> None:
|
||||
nonlocal reload_call_count
|
||||
reload_call_count += 1
|
||||
if reload_call_count == 1:
|
||||
# Simulate UnknownJailException from fail2ban due to missing logpath.
|
||||
raise JailNotFoundError("apache-auth")
|
||||
# Recovery reload succeeds.
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value=set()),
|
||||
),
|
||||
patch("app.services.config_file_service.jail_service") as mock_js,
|
||||
patch(
|
||||
"app.services.config_file_service._probe_fail2ban_running",
|
||||
new=AsyncMock(return_value=True),
|
||||
),
|
||||
patch(
|
||||
"app.services.config_file_service._validate_jail_config_sync",
|
||||
return_value=JailValidationResult(
|
||||
jail_name="apache-auth", valid=True
|
||||
),
|
||||
),
|
||||
):
|
||||
mock_js.reload_all = AsyncMock(side_effect=reload_side_effect)
|
||||
mock_js.JailNotFoundError = JailNotFoundError
|
||||
result = await activate_jail(
|
||||
str(tmp_path), "/fake.sock", "apache-auth", req
|
||||
)
|
||||
|
||||
assert result.active is False
|
||||
assert result.recovered is True
|
||||
assert local_path.read_text() == original_local
|
||||
# Verify the error message mentions logpath issues.
|
||||
assert "logpath" in result.message.lower() or "check that all logpath" in result.message.lower()
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -184,10 +184,90 @@ class TestListJails:
|
||||
with patch("app.services.jail_service.Fail2BanClient", _FailClient), pytest.raises(Fail2BanConnectionError):
|
||||
await jail_service.list_jails(_SOCKET)
|
||||
|
||||
async def test_backend_idle_commands_unsupported(self) -> None:
|
||||
"""list_jails handles unsupported backend and idle commands gracefully.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_jail
|
||||
# ---------------------------------------------------------------------------
|
||||
When the fail2ban daemon does not support get ... backend/idle commands,
|
||||
list_jails should not send them, avoiding "Invalid command" errors in the
|
||||
fail2ban log.
|
||||
"""
|
||||
# Reset the capability cache to test detection.
|
||||
jail_service._backend_cmd_supported = None
|
||||
|
||||
responses = {
|
||||
"status": _make_global_status("sshd"),
|
||||
"status|sshd|short": _make_short_status(),
|
||||
# Capability probe: get backend fails (command not supported).
|
||||
"get|sshd|backend": (1, Exception("Invalid command (no get action or not yet implemented)")),
|
||||
# Subsequent gets should still work.
|
||||
"get|sshd|bantime": (0, 600),
|
||||
"get|sshd|findtime": (0, 600),
|
||||
"get|sshd|maxretry": (0, 5),
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await jail_service.list_jails(_SOCKET)
|
||||
|
||||
# Verify the result uses the default values for backend and idle.
|
||||
jail = result.jails[0]
|
||||
assert jail.backend == "polling" # default
|
||||
assert jail.idle is False # default
|
||||
# Capability should now be cached as False.
|
||||
assert jail_service._backend_cmd_supported is False
|
||||
|
||||
async def test_backend_idle_commands_supported(self) -> None:
|
||||
"""list_jails detects and sends backend/idle commands when supported."""
|
||||
# Reset the capability cache to test detection.
|
||||
jail_service._backend_cmd_supported = None
|
||||
|
||||
responses = {
|
||||
"status": _make_global_status("sshd"),
|
||||
"status|sshd|short": _make_short_status(),
|
||||
# Capability probe: get backend succeeds.
|
||||
"get|sshd|backend": (0, "systemd"),
|
||||
# All other commands.
|
||||
"get|sshd|bantime": (0, 600),
|
||||
"get|sshd|findtime": (0, 600),
|
||||
"get|sshd|maxretry": (0, 5),
|
||||
"get|sshd|idle": (0, True),
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await jail_service.list_jails(_SOCKET)
|
||||
|
||||
# Verify real values are returned.
|
||||
jail = result.jails[0]
|
||||
assert jail.backend == "systemd" # real value
|
||||
assert jail.idle is True # real value
|
||||
# Capability should now be cached as True.
|
||||
assert jail_service._backend_cmd_supported is True
|
||||
|
||||
async def test_backend_idle_commands_cached_after_first_probe(self) -> None:
|
||||
"""list_jails caches capability result and reuses it across polling cycles."""
|
||||
# Reset the capability cache.
|
||||
jail_service._backend_cmd_supported = None
|
||||
|
||||
responses = {
|
||||
"status": _make_global_status("sshd, nginx"),
|
||||
# Probes happen once per polling cycle (for the first jail listed).
|
||||
"status|sshd|short": _make_short_status(),
|
||||
"status|nginx|short": _make_short_status(),
|
||||
# Capability probe: backend is unsupported.
|
||||
"get|sshd|backend": (1, Exception("Invalid command")),
|
||||
# Subsequent jails do not trigger another probe; they use cached result.
|
||||
# (The mock doesn't have get|nginx|backend because it shouldn't be called.)
|
||||
"get|sshd|bantime": (0, 600),
|
||||
"get|sshd|findtime": (0, 600),
|
||||
"get|sshd|maxretry": (0, 5),
|
||||
"get|nginx|bantime": (0, 600),
|
||||
"get|nginx|findtime": (0, 600),
|
||||
"get|nginx|maxretry": (0, 5),
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await jail_service.list_jails(_SOCKET)
|
||||
|
||||
# Both jails should return default values (cached result is False).
|
||||
for jail in result.jails:
|
||||
assert jail.backend == "polling"
|
||||
assert jail.idle is False
|
||||
|
||||
|
||||
class TestGetJail:
|
||||
@@ -339,6 +419,28 @@ class TestJailControls:
|
||||
_SOCKET, include_jails=["new"], exclude_jails=["old"]
|
||||
)
|
||||
|
||||
async def test_reload_all_unknown_jail_raises_jail_not_found(self) -> None:
|
||||
"""reload_all detects UnknownJailException and raises JailNotFoundError.
|
||||
|
||||
When fail2ban cannot load a jail due to invalid configuration (e.g.,
|
||||
missing logpath), it raises UnknownJailException during reload. This
|
||||
test verifies that reload_all detects this and re-raises as
|
||||
JailNotFoundError instead of the generic JailOperationError.
|
||||
"""
|
||||
with _patch_client(
|
||||
{
|
||||
"status": _make_global_status("sshd"),
|
||||
"reload|--all|[]|[['start', 'airsonic-auth'], ['start', 'sshd']]": (
|
||||
1,
|
||||
Exception("UnknownJailException('airsonic-auth')"),
|
||||
),
|
||||
}
|
||||
), pytest.raises(jail_service.JailNotFoundError) as exc_info:
|
||||
await jail_service.reload_all(
|
||||
_SOCKET, include_jails=["airsonic-auth"]
|
||||
)
|
||||
assert exc_info.value.name == "airsonic-auth"
|
||||
|
||||
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'"))}), pytest.raises(JailNotFoundError):
|
||||
|
||||
@@ -88,7 +88,7 @@ export async function updateGlobalConfig(
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reload
|
||||
// Reload and Restart
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function reloadConfig(
|
||||
@@ -96,6 +96,11 @@ export async function reloadConfig(
|
||||
await post<undefined>(ENDPOINTS.configReload, undefined);
|
||||
}
|
||||
|
||||
export async function restartFail2Ban(
|
||||
): Promise<void> {
|
||||
await post<undefined>(ENDPOINTS.configRestart, undefined);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regex tester
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -78,6 +78,7 @@ export const ENDPOINTS = {
|
||||
configPendingRecovery: "/config/pending-recovery" as string,
|
||||
configGlobal: "/config/global",
|
||||
configReload: "/config/reload",
|
||||
configRestart: "/config/restart",
|
||||
configRegexTest: "/config/regex-test",
|
||||
configPreviewLog: "/config/preview-log",
|
||||
configMapColorThresholds: "/config/map-color-thresholds",
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* GlobalTab — global fail2ban settings editor.
|
||||
*
|
||||
* Provides form fields for log level, log target, database purge age,
|
||||
* and database max matches.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Spinner,
|
||||
} from "@fluentui/react-components";
|
||||
import type { GlobalConfigUpdate } from "../../types/config";
|
||||
import { useGlobalConfig } from "../../hooks/useConfig";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
/** Available fail2ban log levels in descending severity order. */
|
||||
const LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"];
|
||||
|
||||
/**
|
||||
* Tab component for editing global fail2ban configuration.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function GlobalTab(): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const { config, loading, error, updateConfig } = useGlobalConfig();
|
||||
const [logLevel, setLogLevel] = useState("");
|
||||
const [logTarget, setLogTarget] = useState("");
|
||||
const [dbPurgeAge, setDbPurgeAge] = useState("");
|
||||
const [dbMaxMatches, setDbMaxMatches] = useState("");
|
||||
|
||||
// Sync local state when config loads for the first time.
|
||||
useEffect(() => {
|
||||
if (config && logLevel === "") {
|
||||
setLogLevel(config.log_level);
|
||||
setLogTarget(config.log_target);
|
||||
setDbPurgeAge(String(config.db_purge_age));
|
||||
setDbMaxMatches(String(config.db_max_matches));
|
||||
}
|
||||
// Only run on first config load.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
|
||||
const effectiveLogLevel = logLevel || config?.log_level || "";
|
||||
const effectiveLogTarget = logTarget || config?.log_target || "";
|
||||
const effectiveDbPurgeAge =
|
||||
dbPurgeAge || (config ? String(config.db_purge_age) : "");
|
||||
const effectiveDbMaxMatches =
|
||||
dbMaxMatches || (config ? String(config.db_max_matches) : "");
|
||||
|
||||
const updatePayload = useMemo<GlobalConfigUpdate>(() => {
|
||||
const update: GlobalConfigUpdate = {};
|
||||
if (effectiveLogLevel) update.log_level = effectiveLogLevel;
|
||||
if (effectiveLogTarget) update.log_target = effectiveLogTarget;
|
||||
if (effectiveDbPurgeAge)
|
||||
update.db_purge_age = Number(effectiveDbPurgeAge);
|
||||
if (effectiveDbMaxMatches)
|
||||
update.db_max_matches = Number(effectiveDbMaxMatches);
|
||||
return update;
|
||||
}, [effectiveLogLevel, effectiveLogTarget, effectiveDbPurgeAge, effectiveDbMaxMatches]);
|
||||
|
||||
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
|
||||
useAutoSave(updatePayload, updateConfig);
|
||||
|
||||
if (loading) return <Spinner label="Loading global config…" />;
|
||||
if (error)
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.sectionCard}>
|
||||
<AutoSaveIndicator
|
||||
status={saveStatus}
|
||||
errorText={saveErrorText}
|
||||
onRetry={retrySave}
|
||||
/>
|
||||
<div className={styles.fieldRow}>
|
||||
<Field label="Log Level">
|
||||
<Select
|
||||
value={effectiveLogLevel}
|
||||
onChange={(_e, d) => {
|
||||
setLogLevel(d.value);
|
||||
}}
|
||||
>
|
||||
{LOG_LEVELS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Log Target">
|
||||
<Input
|
||||
value={effectiveLogTarget}
|
||||
placeholder="STDOUT / /var/log/fail2ban.log"
|
||||
onChange={(_e, d) => {
|
||||
setLogTarget(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<Field
|
||||
label="DB Purge Age (s)"
|
||||
hint="Ban records older than this are removed from the fail2ban database."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={effectiveDbPurgeAge}
|
||||
onChange={(_e, d) => {
|
||||
setDbPurgeAge(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="DB Max Matches"
|
||||
hint="Maximum number of log-line matches stored per ban record."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={effectiveDbMaxMatches}
|
||||
onChange={(_e, d) => {
|
||||
setDbMaxMatches(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
/**
|
||||
* MapTab — world map color threshold configuration editor.
|
||||
*
|
||||
* Allows the user to set the low / medium / high ban-count thresholds
|
||||
* that drive country fill colors on the World Map page.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Skeleton,
|
||||
SkeletonItem,
|
||||
Text,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { ApiError } from "../../api/client";
|
||||
import {
|
||||
fetchMapColorThresholds,
|
||||
updateMapColorThresholds,
|
||||
} from "../../api/config";
|
||||
import type { MapColorThresholdsResponse, MapColorThresholdsUpdate } from "../../types/config";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inner form — only mounted after data is loaded.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MapFormProps {
|
||||
initial: MapColorThresholdsResponse;
|
||||
}
|
||||
|
||||
function MapForm({ initial }: MapFormProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const [thresholdHigh, setThresholdHigh] = useState(String(initial.threshold_high));
|
||||
const [thresholdMedium, setThresholdMedium] = useState(String(initial.threshold_medium));
|
||||
const [thresholdLow, setThresholdLow] = useState(String(initial.threshold_low));
|
||||
|
||||
const high = Number(thresholdHigh);
|
||||
const medium = Number(thresholdMedium);
|
||||
const low = Number(thresholdLow);
|
||||
|
||||
const validationError = useMemo<string | null>(() => {
|
||||
if (isNaN(high) || isNaN(medium) || isNaN(low))
|
||||
return "All thresholds must be valid numbers.";
|
||||
if (high <= 0 || medium <= 0 || low <= 0)
|
||||
return "All thresholds must be positive integers.";
|
||||
if (!(high > medium && medium > low))
|
||||
return "Thresholds must satisfy: high > medium > low.";
|
||||
return null;
|
||||
}, [high, medium, low]);
|
||||
|
||||
// Only pass a new payload to useAutoSave when all values are valid.
|
||||
const [validPayload, setValidPayload] = useState<MapColorThresholdsUpdate>({
|
||||
threshold_high: initial.threshold_high,
|
||||
threshold_medium: initial.threshold_medium,
|
||||
threshold_low: initial.threshold_low,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (validationError !== null) return;
|
||||
setValidPayload({ threshold_high: high, threshold_medium: medium, threshold_low: low });
|
||||
}, [high, medium, low, validationError]);
|
||||
|
||||
const saveThresholds = useCallback(
|
||||
async (payload: MapColorThresholdsUpdate): Promise<void> => {
|
||||
await updateMapColorThresholds(payload);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
|
||||
useAutoSave(validPayload, saveThresholds);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.sectionCard}>
|
||||
<Text as="h3" size={500} weight="semibold" block>
|
||||
Map Color Thresholds
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
size={300}
|
||||
className={styles.infoText}
|
||||
block
|
||||
style={{ marginBottom: tokens.spacingVerticalM }}
|
||||
>
|
||||
Configure the ban count thresholds that determine country fill colors on
|
||||
the World Map. Countries with zero bans remain transparent. Colors
|
||||
smoothly interpolate between thresholds.
|
||||
</Text>
|
||||
|
||||
<div style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
<AutoSaveIndicator
|
||||
status={validationError ? "idle" : saveStatus}
|
||||
errorText={saveErrorText}
|
||||
onRetry={retrySave}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
<MessageBarBody>{validationError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
<div className={styles.fieldRowThree}>
|
||||
<Field label="Low Threshold (Green)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={thresholdLow}
|
||||
onChange={(_, d) => {
|
||||
setThresholdLow(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Medium Threshold (Yellow)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={thresholdMedium}
|
||||
onChange={(_, d) => {
|
||||
setThresholdMedium(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="High Threshold (Red)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={thresholdHigh}
|
||||
onChange={(_, d) => {
|
||||
setThresholdHigh(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Text
|
||||
as="p"
|
||||
size={200}
|
||||
className={styles.infoText}
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
>
|
||||
• 1 to {thresholdLow}: Light green → Full green
|
||||
<br />• {thresholdLow} to {thresholdMedium}: Green → Yellow
|
||||
<br />• {thresholdMedium} to {thresholdHigh}: Yellow → Red
|
||||
<br />• {thresholdHigh}+: Solid red
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outer loader component.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Tab component for editing world-map ban-count color thresholds.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function MapTab(): React.JSX.Element {
|
||||
const [thresholds, setThresholds] = useState<MapColorThresholdsResponse | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const data = await fetchMapColorThresholds();
|
||||
setThresholds(data);
|
||||
} catch (err) {
|
||||
setLoadError(
|
||||
err instanceof ApiError ? err.message : "Failed to load map color thresholds",
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (!thresholds && !loadError) {
|
||||
return (
|
||||
<Skeleton aria-label="Loading map settings…">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
|
||||
<SkeletonItem size={32} />
|
||||
<SkeletonItem size={32} />
|
||||
<SkeletonItem size={32} />
|
||||
</div>
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError)
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{loadError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
|
||||
if (!thresholds) return <></>;
|
||||
|
||||
return <MapForm initial={thresholds} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* LogTab — fail2ban log viewer and service health panel.
|
||||
* ServerHealthSection — service health panel and log viewer for ServerTab.
|
||||
*
|
||||
* Renders two sections:
|
||||
* 1. **Service Health panel** — shows online/offline state, version, active
|
||||
* jail count, total bans, total failures, log level, and log target.
|
||||
* 2. **Log viewer** — displays the tail of the fail2ban daemon log file with
|
||||
* toolbar controls for line count, substring filter, manual refresh, and
|
||||
* optional auto-refresh. Log lines are color-coded by severity.
|
||||
* optional auto-refresh. Log lines are color-coded by severity.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -167,13 +167,11 @@ function detectSeverity(line: string): "error" | "warning" | "debug" | "default"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Log tab component for the Configuration page.
|
||||
*
|
||||
* Shows fail2ban service health and a live log viewer with refresh controls.
|
||||
* Server health panel and log viewer section for ServerTab.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function LogTab(): React.JSX.Element {
|
||||
export function ServerHealthSection(): React.JSX.Element {
|
||||
const configStyles = useConfigStyles();
|
||||
const styles = useStyles();
|
||||
|
||||
@@ -317,10 +315,8 @@ export function LogTab(): React.JSX.Element {
|
||||
logData != null && logData.total_lines > logData.lines.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Service Health Panel */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<>
|
||||
{/* Service Health Panel */}
|
||||
<div className={configStyles.sectionCard}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
|
||||
<DocumentBulletList24Regular />
|
||||
@@ -384,9 +380,7 @@ export function LogTab(): React.JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Log Viewer */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Log Viewer */}
|
||||
<div className={configStyles.sectionCard}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, marginBottom: tokens.spacingVerticalM }}>
|
||||
<Text weight="semibold" size={400}>
|
||||
@@ -513,6 +507,6 @@ export function LogTab(): React.JSX.Element {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,12 @@
|
||||
* ServerTab — fail2ban server-level settings editor.
|
||||
*
|
||||
* Provides form fields for live server settings (log level, log target,
|
||||
* DB purge age, DB max matches) and a "Flush Logs" action button.
|
||||
* DB purge age, DB max matches), action buttons (flush logs, reload fail2ban,
|
||||
* restart fail2ban), world map color threshold configuration, and service
|
||||
* health + log viewer.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
@@ -15,16 +17,25 @@ import {
|
||||
Select,
|
||||
Skeleton,
|
||||
SkeletonItem,
|
||||
Text,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import {
|
||||
DocumentArrowDown24Regular,
|
||||
ArrowSync24Regular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { ApiError } from "../../api/client";
|
||||
import type { ServerSettingsUpdate } from "../../types/config";
|
||||
import type { ServerSettingsUpdate, MapColorThresholdsResponse, MapColorThresholdsUpdate } from "../../types/config";
|
||||
import { useServerSettings } from "../../hooks/useConfig";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import {
|
||||
fetchMapColorThresholds,
|
||||
updateMapColorThresholds,
|
||||
reloadConfig,
|
||||
restartFail2Ban,
|
||||
} from "../../api/config";
|
||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
import { ServerHealthSection } from "./ServerHealthSection";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
/** Available fail2ban log levels in descending severity order. */
|
||||
@@ -46,6 +57,17 @@ export function ServerTab(): React.JSX.Element {
|
||||
const [flushing, setFlushing] = useState(false);
|
||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||
|
||||
// Reload/Restart state
|
||||
const [isReloading, setIsReloading] = useState(false);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
|
||||
// Map color thresholds
|
||||
const [mapThresholds, setMapThresholds] = useState<MapColorThresholdsResponse | null>(null);
|
||||
const [mapThresholdHigh, setMapThresholdHigh] = useState("");
|
||||
const [mapThresholdMedium, setMapThresholdMedium] = useState("");
|
||||
const [mapThresholdLow, setMapThresholdLow] = useState("");
|
||||
const [mapLoadError, setMapLoadError] = useState<string | null>(null);
|
||||
|
||||
const effectiveLogLevel = logLevel || settings?.log_level || "";
|
||||
const effectiveLogTarget = logTarget || settings?.log_target || "";
|
||||
const effectiveDbPurgeAge =
|
||||
@@ -83,6 +105,99 @@ export function ServerTab(): React.JSX.Element {
|
||||
}
|
||||
}, [flush]);
|
||||
|
||||
const handleReload = useCallback(async () => {
|
||||
setIsReloading(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await reloadConfig();
|
||||
setMsg({ text: "fail2ban reloaded successfully", ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Reload failed.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setIsReloading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setIsRestarting(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await restartFail2Ban();
|
||||
setMsg({ text: "fail2ban restart initiated", ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Restart failed.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setIsRestarting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load map color thresholds on mount.
|
||||
const loadMapThresholds = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const data = await fetchMapColorThresholds();
|
||||
setMapThresholds(data);
|
||||
setMapThresholdHigh(String(data.threshold_high));
|
||||
setMapThresholdMedium(String(data.threshold_medium));
|
||||
setMapThresholdLow(String(data.threshold_low));
|
||||
setMapLoadError(null);
|
||||
} catch (err) {
|
||||
setMapLoadError(
|
||||
err instanceof ApiError ? err.message : "Failed to load map color thresholds",
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadMapThresholds();
|
||||
}, [loadMapThresholds]);
|
||||
|
||||
// Map threshold validation and auto-save.
|
||||
const mapHigh = Number(mapThresholdHigh);
|
||||
const mapMedium = Number(mapThresholdMedium);
|
||||
const mapLow = Number(mapThresholdLow);
|
||||
|
||||
const mapValidationError = useMemo<string | null>(() => {
|
||||
if (!mapThresholds) return null;
|
||||
if (isNaN(mapHigh) || isNaN(mapMedium) || isNaN(mapLow))
|
||||
return "All thresholds must be valid numbers.";
|
||||
if (mapHigh <= 0 || mapMedium <= 0 || mapLow <= 0)
|
||||
return "All thresholds must be positive integers.";
|
||||
if (!(mapHigh > mapMedium && mapMedium > mapLow))
|
||||
return "Thresholds must satisfy: high > medium > low.";
|
||||
return null;
|
||||
}, [mapHigh, mapMedium, mapLow, mapThresholds]);
|
||||
|
||||
const [mapValidPayload, setMapValidPayload] = useState<MapColorThresholdsUpdate>({
|
||||
threshold_high: mapThresholds?.threshold_high ?? 0,
|
||||
threshold_medium: mapThresholds?.threshold_medium ?? 0,
|
||||
threshold_low: mapThresholds?.threshold_low ?? 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (mapValidationError !== null || !mapThresholds) return;
|
||||
setMapValidPayload({
|
||||
threshold_high: mapHigh,
|
||||
threshold_medium: mapMedium,
|
||||
threshold_low: mapLow,
|
||||
});
|
||||
}, [mapHigh, mapMedium, mapLow, mapValidationError, mapThresholds]);
|
||||
|
||||
const saveMapThresholds = useCallback(
|
||||
async (payload: MapColorThresholdsUpdate): Promise<void> => {
|
||||
await updateMapColorThresholds(payload);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { status: mapSaveStatus, errorText: mapSaveErrorText, retry: retryMapSave } =
|
||||
useAutoSave(mapValidPayload, saveMapThresholds);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Skeleton aria-label="Loading server settings…">
|
||||
@@ -154,7 +269,10 @@ export function ServerTab(): React.JSX.Element {
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<Field label="DB Purge Age (s)">
|
||||
<Field
|
||||
label="DB Purge Age (s)"
|
||||
hint="Ban records older than this are removed from the fail2ban database."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={effectiveDbPurgeAge}
|
||||
@@ -163,7 +281,10 @@ export function ServerTab(): React.JSX.Element {
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="DB Max Matches">
|
||||
<Field
|
||||
label="DB Max Matches"
|
||||
hint="Maximum number of log-line matches stored per ban record."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={effectiveDbMaxMatches}
|
||||
@@ -182,6 +303,22 @@ export function ServerTab(): React.JSX.Element {
|
||||
>
|
||||
{flushing ? "Flushing…" : "Flush Logs"}
|
||||
</Button>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<ArrowSync24Regular />}
|
||||
disabled={isReloading}
|
||||
onClick={() => void handleReload()}
|
||||
>
|
||||
{isReloading ? "Reloading…" : "Reload fail2ban"}
|
||||
</Button>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<ArrowSync24Regular />}
|
||||
disabled={isRestarting}
|
||||
onClick={() => void handleRestart()}
|
||||
>
|
||||
{isRestarting ? "Restarting…" : "Restart fail2ban"}
|
||||
</Button>
|
||||
</div>
|
||||
{msg && (
|
||||
<MessageBar intent={msg.ok ? "success" : "error"}>
|
||||
@@ -189,6 +326,94 @@ export function ServerTab(): React.JSX.Element {
|
||||
</MessageBar>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Map Color Thresholds section */}
|
||||
{mapLoadError ? (
|
||||
<div className={styles.sectionCard}>
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{mapLoadError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
</div>
|
||||
) : mapThresholds ? (
|
||||
<div className={styles.sectionCard}>
|
||||
<Text as="h3" size={500} weight="semibold" block>
|
||||
Map Color Thresholds
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
size={300}
|
||||
className={styles.infoText}
|
||||
block
|
||||
style={{ marginBottom: tokens.spacingVerticalM }}
|
||||
>
|
||||
Configure the ban count thresholds that determine country fill colors on
|
||||
the World Map. Countries with zero bans remain transparent. Colors
|
||||
smoothly interpolate between thresholds.
|
||||
</Text>
|
||||
|
||||
<div style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
<AutoSaveIndicator
|
||||
status={mapValidationError ? "idle" : mapSaveStatus}
|
||||
errorText={mapSaveErrorText}
|
||||
onRetry={retryMapSave}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mapValidationError && (
|
||||
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
<MessageBarBody>{mapValidationError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
<div className={styles.fieldRowThree}>
|
||||
<Field label="Low Threshold (Green)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={mapThresholdLow}
|
||||
onChange={(_, d) => {
|
||||
setMapThresholdLow(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Medium Threshold (Yellow)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={mapThresholdMedium}
|
||||
onChange={(_, d) => {
|
||||
setMapThresholdMedium(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="High Threshold (Red)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={mapThresholdHigh}
|
||||
onChange={(_, d) => {
|
||||
setMapThresholdHigh(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Text
|
||||
as="p"
|
||||
size={200}
|
||||
className={styles.infoText}
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
>
|
||||
• 1 to {mapThresholdLow}: Light green → Full green
|
||||
<br />• {mapThresholdLow} to {mapThresholdMedium}: Green → Yellow
|
||||
<br />• {mapThresholdMedium} to {mapThresholdHigh}: Yellow → Red
|
||||
<br />• {mapThresholdHigh}+: Solid red
|
||||
</Text>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Service Health & Log Viewer section */}
|
||||
<ServerHealthSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* Tests for the LogTab component (Task 2).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { LogTab } from "../LogTab";
|
||||
import type { Fail2BanLogResponse, ServiceStatusResponse } from "../../../types/config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("../../../api/config", () => ({
|
||||
fetchFail2BanLog: vi.fn(),
|
||||
fetchServiceStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchFail2BanLog, fetchServiceStatus } from "../../../api/config";
|
||||
|
||||
const mockFetchLog = vi.mocked(fetchFail2BanLog);
|
||||
const mockFetchStatus = vi.mocked(fetchServiceStatus);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const onlineStatus: ServiceStatusResponse = {
|
||||
online: true,
|
||||
version: "1.0.2",
|
||||
jail_count: 3,
|
||||
total_bans: 12,
|
||||
total_failures: 5,
|
||||
log_level: "INFO",
|
||||
log_target: "/var/log/fail2ban.log",
|
||||
};
|
||||
|
||||
const offlineStatus: ServiceStatusResponse = {
|
||||
online: false,
|
||||
version: null,
|
||||
jail_count: 0,
|
||||
total_bans: 0,
|
||||
total_failures: 0,
|
||||
log_level: "UNKNOWN",
|
||||
log_target: "UNKNOWN",
|
||||
};
|
||||
|
||||
const logResponse: Fail2BanLogResponse = {
|
||||
log_path: "/var/log/fail2ban.log",
|
||||
lines: [
|
||||
"2025-01-01 12:00:00 INFO sshd Found 1.2.3.4",
|
||||
"2025-01-01 12:00:01 WARNING sshd Too many failures",
|
||||
"2025-01-01 12:00:02 ERROR fail2ban something went wrong",
|
||||
],
|
||||
total_lines: 1000,
|
||||
log_level: "INFO",
|
||||
log_target: "/var/log/fail2ban.log",
|
||||
};
|
||||
|
||||
const nonFileLogResponse: Fail2BanLogResponse = {
|
||||
...logResponse,
|
||||
log_target: "STDOUT",
|
||||
lines: [],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderTab() {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<LogTab />
|
||||
</FluentProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("LogTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows a spinner while loading", () => {
|
||||
// Never resolves during this test.
|
||||
mockFetchStatus.mockReturnValue(new Promise(() => undefined));
|
||||
mockFetchLog.mockReturnValue(new Promise(() => undefined));
|
||||
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByText(/loading log viewer/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the health panel with Running badge when online", async () => {
|
||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
||||
mockFetchLog.mockResolvedValue(logResponse);
|
||||
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => { expect(screen.queryByText(/loading log viewer/i)).toBeNull(); });
|
||||
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
expect(screen.getByText("1.0.2")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument(); // active jails
|
||||
expect(screen.getByText("12")).toBeInTheDocument(); // total bans
|
||||
});
|
||||
|
||||
it("renders the Offline badge and warning when fail2ban is down", async () => {
|
||||
mockFetchStatus.mockResolvedValue(offlineStatus);
|
||||
mockFetchLog.mockRejectedValue(new Error("not running"));
|
||||
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => { expect(screen.queryByText(/loading log viewer/i)).toBeNull(); });
|
||||
|
||||
expect(screen.getByText("Offline")).toBeInTheDocument();
|
||||
expect(screen.getByText(/not running or unreachable/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders log lines in the log viewer", async () => {
|
||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
||||
mockFetchLog.mockResolvedValue(logResponse);
|
||||
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/2025-01-01 12:00:00 INFO/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/2025-01-01 12:00:01 WARNING/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2025-01-01 12:00:02 ERROR/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a non-file target info banner when log_target is STDOUT", async () => {
|
||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
||||
mockFetchLog.mockResolvedValue(nonFileLogResponse);
|
||||
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/fail2ban is logging to/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/STDOUT/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Refresh/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows empty state when no lines match the filter", async () => {
|
||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
||||
mockFetchLog.mockResolvedValue({ ...logResponse, lines: [] });
|
||||
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no log entries found/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows truncation notice when total_lines > lines.length", async () => {
|
||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
||||
mockFetchLog.mockResolvedValue({ ...logResponse, lines: logResponse.lines, total_lines: 1000 });
|
||||
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/showing last/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls fetchFail2BanLog again on Refresh button click", async () => {
|
||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
||||
mockFetchLog.mockResolvedValue(logResponse);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => { expect(screen.getByText(/Refresh/)).toBeInTheDocument(); });
|
||||
|
||||
const refreshBtn = screen.getByRole("button", { name: /refresh/i });
|
||||
await user.click(refreshBtn);
|
||||
|
||||
await waitFor(() => { expect(mockFetchLog).toHaveBeenCalledTimes(2); });
|
||||
});
|
||||
});
|
||||
@@ -30,16 +30,14 @@ export { ExportTab } from "./ExportTab";
|
||||
export { FilterForm } from "./FilterForm";
|
||||
export type { FilterFormProps } from "./FilterForm";
|
||||
export { FiltersTab } from "./FiltersTab";
|
||||
export { GlobalTab } from "./GlobalTab";
|
||||
export { JailFilesTab } from "./JailFilesTab";
|
||||
export { JailFileForm } from "./JailFileForm";
|
||||
export { JailsTab } from "./JailsTab";
|
||||
export { LogTab } from "./LogTab";
|
||||
export { MapTab } from "./MapTab";
|
||||
export { RawConfigSection } from "./RawConfigSection";
|
||||
export type { RawConfigSectionProps } from "./RawConfigSection";
|
||||
export { RegexList } from "./RegexList";
|
||||
export type { RegexListProps } from "./RegexList";
|
||||
export { RegexTesterTab } from "./RegexTesterTab";
|
||||
export { ServerTab } from "./ServerTab";
|
||||
export { ServerHealthSection } from "./ServerHealthSection";
|
||||
export { useConfigStyles } from "./configStyles";
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
* Jails — per-jail config accordion with inline editing
|
||||
* Filters — structured filter.d form editor
|
||||
* Actions — structured action.d form editor
|
||||
* Global — global fail2ban settings (log level, DB config)
|
||||
* Server — server-level settings + flush logs
|
||||
* Map — map color threshold configuration
|
||||
* Server — server-level settings, map thresholds, service health + log viewer
|
||||
* Regex Tester — live pattern tester
|
||||
* Export — raw file editors for jail, filter, and action files
|
||||
*/
|
||||
@@ -20,10 +18,7 @@ import { Tab, TabList, Text, makeStyles, tokens } from "@fluentui/react-componen
|
||||
import {
|
||||
ActionsTab,
|
||||
FiltersTab,
|
||||
GlobalTab,
|
||||
JailsTab,
|
||||
LogTab,
|
||||
MapTab,
|
||||
RegexTesterTab,
|
||||
ServerTab,
|
||||
} from "../components/config";
|
||||
@@ -58,11 +53,8 @@ type TabValue =
|
||||
| "jails"
|
||||
| "filters"
|
||||
| "actions"
|
||||
| "global"
|
||||
| "server"
|
||||
| "map"
|
||||
| "regex"
|
||||
| "log";
|
||||
| "regex";
|
||||
|
||||
export function ConfigPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
@@ -89,22 +81,16 @@ export function ConfigPage(): React.JSX.Element {
|
||||
<Tab value="jails">Jails</Tab>
|
||||
<Tab value="filters">Filters</Tab>
|
||||
<Tab value="actions">Actions</Tab>
|
||||
<Tab value="global">Global</Tab>
|
||||
<Tab value="server">Server</Tab>
|
||||
<Tab value="map">Map</Tab>
|
||||
<Tab value="regex">Regex Tester</Tab>
|
||||
<Tab value="log">Log</Tab>
|
||||
</TabList>
|
||||
|
||||
<div className={styles.tabContent} key={tab}>
|
||||
{tab === "jails" && <JailsTab />}
|
||||
{tab === "filters" && <FiltersTab />}
|
||||
{tab === "actions" && <ActionsTab />}
|
||||
{tab === "global" && <GlobalTab />}
|
||||
{tab === "server" && <ServerTab />}
|
||||
{tab === "map" && <MapTab />}
|
||||
{tab === "regex" && <RegexTesterTab />}
|
||||
{tab === "log" && <LogTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,9 +9,7 @@ vi.mock("../../components/config", () => ({
|
||||
JailsTab: () => <div data-testid="jails-tab">JailsTab</div>,
|
||||
FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>,
|
||||
ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>,
|
||||
GlobalTab: () => <div data-testid="global-tab">GlobalTab</div>,
|
||||
ServerTab: () => <div data-testid="server-tab">ServerTab</div>,
|
||||
MapTab: () => <div data-testid="map-tab">MapTab</div>,
|
||||
RegexTesterTab: () => <div data-testid="regex-tab">RegexTesterTab</div>,
|
||||
ExportTab: () => <div data-testid="export-tab">ExportTab</div>,
|
||||
}));
|
||||
@@ -45,12 +43,6 @@ describe("ConfigPage", () => {
|
||||
expect(screen.getByTestId("actions-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to Global tab when Global tab is clicked", () => {
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByRole("tab", { name: /global/i }));
|
||||
expect(screen.getByTestId("global-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to Server tab when Server tab is clicked", () => {
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByRole("tab", { name: /server/i }));
|
||||
|
||||
@@ -30,7 +30,12 @@ import { tokens } from "@fluentui/react-components";
|
||||
export function resolveFluentToken(tokenValue: string): string {
|
||||
const match = /var\((--[^,)]+)/.exec(tokenValue);
|
||||
if (match == null || match[1] == null) return tokenValue;
|
||||
const resolved = getComputedStyle(document.documentElement)
|
||||
|
||||
// FluentProvider injects CSS custom properties on its own wrapper <div>,
|
||||
// not on :root. Query that element so we resolve actual colour values.
|
||||
const el =
|
||||
document.querySelector(".fui-FluentProvider") ?? document.documentElement;
|
||||
const resolved = getComputedStyle(el)
|
||||
.getPropertyValue(match[1])
|
||||
.trim();
|
||||
return resolved !== "" ? resolved : tokenValue;
|
||||
|
||||
Reference in New Issue
Block a user