15 Commits

Author SHA1 Message Date
e99920e616 chore: release v0.9.14 2026-03-24 19:38:05 +01:00
670ff3e8a2 chore: release v0.9.13 2026-03-24 19:23:43 +01:00
f6672d0d16 better release script 2026-03-22 21:51:02 +01:00
d909f93efc chore: release v0.9.12 2026-03-22 21:47:40 +01:00
965cdd765b Fix useJailDetail mocked command responses to match JailCommandResponse type 2026-03-22 21:46:01 +01:00
0663740b08 chore: release v0.9.11 2026-03-22 21:43:31 +01:00
29587f2353 backup 2026-03-22 21:42:19 +01:00
798ed08ddd Refactor service status response: migrate bangui_version into version field 2026-03-22 21:42:08 +01:00
ed184f1c84 Fix config status and missing historical filter imports
1) Added _get_active_jail_names import in jail_config_service 2) Added _get_active_jail_names and _parse_jails_sync imports in filter_config_service and resolved constants/exceptions 3) Added bangui_version=__version__ in config_service.get_service_status and tests
2026-03-22 20:54:44 +01:00
8e1b4fa978 test: add regression test for 500 errors 2026-03-22 20:33:43 +01:00
e604e3aadf chore: commit changes from Copilot session 2026-03-22 20:33:06 +01:00
cf721513e8 Fix history origin filter path and add regression tests 2026-03-22 20:32:40 +01:00
a32cc82851 fix(blocklists): load useBlocklistStyles from blocklistStyles instead of commonStyles 2026-03-22 18:17:47 +01:00
26af69e2a3 Merge branch 'main' of https://git.lpl-mind.de/lukas.pupkalipinski/BanGUI 2026-03-22 14:31:20 +01:00
00e702a2c0 backup 2026-03-22 14:30:02 +01:00
28 changed files with 454 additions and 94 deletions

View File

@@ -1 +1 @@
v0.9.10 v0.9.14

View File

@@ -68,6 +68,13 @@ FRONT_PKG="${SCRIPT_DIR}/../frontend/package.json"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}" sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}"
echo "frontend/package.json version updated → ${FRONT_VERSION}" echo "frontend/package.json version updated → ${FRONT_VERSION}"
# ---------------------------------------------------------------------------
# Push containers
# ---------------------------------------------------------------------------
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
bash "${SCRIPT_DIR}/push.sh"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Git tag (local only; push after container build) # Git tag (local only; push after container build)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -77,12 +84,6 @@ git commit -m "chore: release ${NEW_TAG}"
git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}" git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}"
echo "Local git commit + tag ${NEW_TAG} created." echo "Local git commit + tag ${NEW_TAG} created."
# ---------------------------------------------------------------------------
# Push containers
# ---------------------------------------------------------------------------
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
bash "${SCRIPT_DIR}/push.sh"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Push git commits & tag # Push git commits & tag
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -1001,8 +1001,7 @@ class ServiceStatusResponse(BaseModel):
model_config = ConfigDict(strict=True) model_config = ConfigDict(strict=True)
online: bool = Field(..., description="Whether fail2ban is reachable via its socket.") online: bool = Field(..., description="Whether fail2ban is reachable via its socket.")
version: str | None = Field(default=None, description="fail2ban version string, or None when offline.") version: str | None = Field(default=None, description="BanGUI application version (or None when offline).")
bangui_version: str = Field(..., description="BanGUI application version.")
jail_count: int = Field(default=0, ge=0, description="Number of currently active jails.") jail_count: int = Field(default=0, ge=0, description="Number of currently active jails.")
total_bans: int = Field(default=0, ge=0, description="Aggregated current ban count across all jails.") total_bans: int = Field(default=0, ge=0, description="Aggregated current ban count across all jails.")
total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.") total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.")

View File

@@ -24,7 +24,6 @@ class ServerStatusResponse(BaseModel):
model_config = ConfigDict(strict=True) model_config = ConfigDict(strict=True)
status: ServerStatus status: ServerStatus
bangui_version: str = Field(..., description="BanGUI application version.")
class ServerSettings(BaseModel): class ServerSettings(BaseModel):

View File

@@ -294,6 +294,7 @@ async def get_history_page(
since: int | None = None, since: int | None = None,
jail: str | None = None, jail: str | None = None,
ip_filter: str | None = None, ip_filter: str | None = None,
origin: BanOrigin | None = None,
page: int = 1, page: int = 1,
page_size: int = 100, page_size: int = 100,
) -> tuple[list[HistoryRecord], int]: ) -> tuple[list[HistoryRecord], int]:
@@ -314,6 +315,12 @@ async def get_history_page(
wheres.append("ip LIKE ?") wheres.append("ip LIKE ?")
params.append(f"{ip_filter}%") params.append(f"{ip_filter}%")
origin_clause, origin_params = _origin_sql_filter(origin)
if origin_clause:
origin_clause_clean = origin_clause.removeprefix(" AND ")
wheres.append(origin_clause_clean)
params.extend(origin_params)
where_sql: str = ("WHERE " + " AND ".join(wheres)) if wheres else "" where_sql: str = ("WHERE " + " AND ".join(wheres)) if wheres else ""
effective_page_size: int = page_size effective_page_size: int = page_size

View File

@@ -70,7 +70,8 @@ async def get_server_status(
"server_status", "server_status",
ServerStatus(online=False), ServerStatus(online=False),
) )
return ServerStatusResponse(status=cached, bangui_version=__version__) cached.version = __version__
return ServerStatusResponse(status=cached)
@router.get( @router.get(

View File

@@ -15,7 +15,6 @@ from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
import re import re
from collections.abc import Awaitable, Callable
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, TypeVar, cast from typing import TYPE_CHECKING, TypeVar, cast
@@ -24,8 +23,12 @@ import structlog
from app.utils.fail2ban_client import Fail2BanCommand, Fail2BanResponse, Fail2BanToken from app.utils.fail2ban_client import Fail2BanCommand, Fail2BanResponse, Fail2BanToken
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
import aiosqlite import aiosqlite
from app import __version__
from app.exceptions import ConfigOperationError, ConfigValidationError, JailNotFoundError
from app.models.config import ( from app.models.config import (
AddLogPathRequest, AddLogPathRequest,
BantimeEscalation, BantimeEscalation,
@@ -44,11 +47,13 @@ from app.models.config import (
RegexTestResponse, RegexTestResponse,
ServiceStatusResponse, ServiceStatusResponse,
) )
from app.exceptions import ConfigOperationError, ConfigValidationError, JailNotFoundError
from app.utils.fail2ban_client import Fail2BanClient from app.utils.fail2ban_client import Fail2BanClient
from app.utils.log_utils import preview_log as util_preview_log, test_regex as util_test_regex from app.utils.log_utils import preview_log as util_preview_log
from app.utils.log_utils import test_regex as util_test_regex
from app.utils.setup_utils import ( from app.utils.setup_utils import (
get_map_color_thresholds as util_get_map_color_thresholds, get_map_color_thresholds as util_get_map_color_thresholds,
)
from app.utils.setup_utils import (
set_map_color_thresholds as util_set_map_color_thresholds, set_map_color_thresholds as util_set_map_color_thresholds,
) )
@@ -814,7 +819,7 @@ async def get_service_status(
return ServiceStatusResponse( return ServiceStatusResponse(
online=server_status.online, online=server_status.online,
version=server_status.version, version=__version__,
jail_count=server_status.active_jails, jail_count=server_status.active_jails,
total_bans=server_status.total_bans, total_bans=server_status.total_bans,
total_failures=server_status.total_failures, total_failures=server_status.total_failures,

View File

@@ -17,16 +17,21 @@ from pathlib import Path
import structlog import structlog
from app.exceptions import FilterInvalidRegexError
from app.models.config import ( from app.models.config import (
AssignFilterRequest,
FilterConfig, FilterConfig,
FilterConfigUpdate, FilterConfigUpdate,
FilterCreateRequest, FilterCreateRequest,
FilterListResponse, FilterListResponse,
FilterUpdateRequest, FilterUpdateRequest,
AssignFilterRequest,
) )
from app.exceptions import FilterInvalidRegexError, JailNotFoundError from app.services.config_file_service import _TRUE_VALUES, ConfigWriteError, JailNotFoundInConfigError
from app.utils import conffile_parser from app.utils import conffile_parser
from app.utils.config_file_utils import (
_get_active_jail_names,
_parse_jails_sync,
)
from app.utils.jail_utils import reload_jails from app.utils.jail_utils import reload_jails
log: structlog.stdlib.BoundLogger = structlog.get_logger() log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -90,6 +95,7 @@ class JailNameError(Exception):
"""Raised when a jail name contains invalid characters.""" """Raised when a jail name contains invalid characters."""
_SAFE_FILTER_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_SAFE_JAIL_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") _SAFE_JAIL_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")

View File

@@ -18,7 +18,7 @@ import structlog
if TYPE_CHECKING: if TYPE_CHECKING:
from app.models.geo import GeoEnricher from app.models.geo import GeoEnricher
from app.models.ban import TIME_RANGE_SECONDS, TimeRange from app.models.ban import TIME_RANGE_SECONDS, BanOrigin, TimeRange
from app.models.history import ( from app.models.history import (
HistoryBanItem, HistoryBanItem,
HistoryListResponse, HistoryListResponse,
@@ -62,6 +62,7 @@ async def list_history(
range_: TimeRange | None = None, range_: TimeRange | None = None,
jail: str | None = None, jail: str | None = None,
ip_filter: str | None = None, ip_filter: str | None = None,
origin: BanOrigin | None = None,
page: int = 1, page: int = 1,
page_size: int = _DEFAULT_PAGE_SIZE, page_size: int = _DEFAULT_PAGE_SIZE,
geo_enricher: GeoEnricher | None = None, geo_enricher: GeoEnricher | None = None,
@@ -108,6 +109,7 @@ async def list_history(
since=since, since=since,
jail=jail, jail=jail,
ip_filter=ip_filter, ip_filter=ip_filter,
origin=origin,
page=page, page=page,
page_size=effective_page_size, page_size=effective_page_size,
) )

View File

@@ -26,22 +26,17 @@ from app.models.config import (
InactiveJail, InactiveJail,
InactiveJailListResponse, InactiveJailListResponse,
JailActivationResponse, JailActivationResponse,
JailValidationIssue,
JailValidationResult, JailValidationResult,
RollbackResponse, RollbackResponse,
) )
from app.utils.config_file_utils import ( from app.utils.config_file_utils import (
_build_inactive_jail, _build_inactive_jail,
_ordered_config_files, _get_active_jail_names,
_parse_jails_sync, _parse_jails_sync,
_validate_jail_config_sync, _validate_jail_config_sync,
) )
from app.utils.fail2ban_client import Fail2BanClient
from app.utils.jail_utils import reload_jails from app.utils.jail_utils import reload_jails
from app.utils.fail2ban_client import (
Fail2BanClient,
Fail2BanConnectionError,
Fail2BanResponse,
)
log: structlog.stdlib.BoundLogger = structlog.get_logger() log: structlog.stdlib.BoundLogger = structlog.get_logger()

View File

@@ -0,0 +1,276 @@
"""Regression tests for the four 500-error bugs discovered on 2026-03-22.
Each test targets the exact code path that caused a 500 Internal Server Error.
These tests call the **real** service/repository functions (not the router)
so they fail even if the route layer is mocked in router-level tests.
Bugs covered:
1. ``list_history`` rejected the ``origin`` keyword argument (TypeError).
2. ``jail_config_service`` used ``_get_active_jail_names`` without importing it.
3. ``filter_config_service`` used ``_parse_jails_sync`` / ``_get_active_jail_names``
without importing them.
4. ``config_service.get_service_status`` omitted the required ``bangui_version``
field from the ``ServiceStatusResponse`` constructor (Pydantic ValidationError).
"""
from __future__ import annotations
import inspect
import json
import time
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, patch
import aiosqlite
import pytest
# ── Bug 1 ─────────────────────────────────────────────────────────────────
class TestHistoryOriginParameter:
"""Bug 1: ``origin`` parameter must be threaded through service → repo."""
# -- Service layer --
async def test_list_history_accepts_origin_kwarg(self) -> None:
"""``history_service.list_history()`` must accept an ``origin`` keyword."""
from app.services import history_service
sig = inspect.signature(history_service.list_history)
assert "origin" in sig.parameters, (
"list_history() is missing the 'origin' parameter — "
"the router passes origin=… which would cause a TypeError"
)
async def test_list_history_forwards_origin_to_repo(
self, tmp_path: Path
) -> None:
"""``list_history(origin='blocklist')`` must forward origin to the DB repo."""
from app.services import history_service
db_path = str(tmp_path / "f2b.db")
async with aiosqlite.connect(db_path) as db:
await db.execute(
"CREATE TABLE jails (name TEXT, enabled INTEGER DEFAULT 1)"
)
await db.execute(
"CREATE TABLE bans "
"(jail TEXT, ip TEXT, timeofban INTEGER, bantime INTEGER, "
"bancount INTEGER DEFAULT 1, data JSON)"
)
await db.execute(
"INSERT INTO bans VALUES (?, ?, ?, ?, ?, ?)",
("blocklist-import", "10.0.0.1", int(time.time()), 3600, 1, "{}"),
)
await db.execute(
"INSERT INTO bans VALUES (?, ?, ?, ?, ?, ?)",
("sshd", "10.0.0.2", int(time.time()), 3600, 1, "{}"),
)
await db.commit()
with patch(
"app.services.history_service.get_fail2ban_db_path",
new=AsyncMock(return_value=db_path),
):
result = await history_service.list_history(
"fake_socket", origin="blocklist"
)
assert all(
item.jail == "blocklist-import" for item in result.items
), "origin='blocklist' must filter to blocklist-import jail only"
# -- Repository layer --
async def test_get_history_page_accepts_origin_kwarg(self) -> None:
"""``fail2ban_db_repo.get_history_page()`` must accept ``origin``."""
from app.repositories import fail2ban_db_repo
sig = inspect.signature(fail2ban_db_repo.get_history_page)
assert "origin" in sig.parameters, (
"get_history_page() is missing the 'origin' parameter"
)
async def test_get_history_page_filters_by_origin(
self, tmp_path: Path
) -> None:
"""``get_history_page(origin='selfblock')`` excludes blocklist-import."""
from app.repositories import fail2ban_db_repo
db_path = str(tmp_path / "f2b.db")
async with aiosqlite.connect(db_path) as db:
await db.execute(
"CREATE TABLE bans "
"(jail TEXT, ip TEXT, timeofban INTEGER, bancount INTEGER, data TEXT)"
)
await db.executemany(
"INSERT INTO bans VALUES (?, ?, ?, ?, ?)",
[
("blocklist-import", "10.0.0.1", 100, 1, "{}"),
("sshd", "10.0.0.2", 200, 1, "{}"),
("sshd", "10.0.0.3", 300, 1, "{}"),
],
)
await db.commit()
rows, total = await fail2ban_db_repo.get_history_page(
db_path=db_path, origin="selfblock"
)
assert total == 2
assert all(r.jail != "blocklist-import" for r in rows)
# ── Bug 2 ─────────────────────────────────────────────────────────────────
class TestJailConfigImports:
"""Bug 2: ``jail_config_service`` must import ``_get_active_jail_names``."""
async def test_get_active_jail_names_is_importable(self) -> None:
"""The module must successfully import ``_get_active_jail_names``."""
import app.services.jail_config_service as mod
assert hasattr(mod, "_get_active_jail_names") or callable(
getattr(mod, "_get_active_jail_names", None)
), (
"_get_active_jail_names is not available in jail_config_service — "
"any call site will raise NameError → 500"
)
async def test_list_inactive_jails_does_not_raise_name_error(
self, tmp_path: Path
) -> None:
"""``list_inactive_jails`` must not crash with NameError."""
from app.services import jail_config_service
config_dir = str(tmp_path / "fail2ban")
Path(config_dir).mkdir()
(Path(config_dir) / "jail.conf").write_text("[DEFAULT]\n")
with patch(
"app.services.jail_config_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await jail_config_service.list_inactive_jails(
config_dir, "/fake/socket"
)
assert result.total >= 0
# ── Bug 3 ─────────────────────────────────────────────────────────────────
class TestFilterConfigImports:
"""Bug 3: ``filter_config_service`` must import ``_parse_jails_sync``
and ``_get_active_jail_names``."""
async def test_parse_jails_sync_is_available(self) -> None:
"""``_parse_jails_sync`` must be resolvable at module scope."""
import app.services.filter_config_service as mod
assert hasattr(mod, "_parse_jails_sync"), (
"_parse_jails_sync is not available in filter_config_service — "
"list_filters() will raise NameError → 500"
)
async def test_get_active_jail_names_is_available(self) -> None:
"""``_get_active_jail_names`` must be resolvable at module scope."""
import app.services.filter_config_service as mod
assert hasattr(mod, "_get_active_jail_names"), (
"_get_active_jail_names is not available in filter_config_service — "
"list_filters() will raise NameError → 500"
)
async def test_list_filters_does_not_raise_name_error(
self, tmp_path: Path
) -> None:
"""``list_filters`` must not crash with NameError."""
from app.services import filter_config_service
config_dir = str(tmp_path / "fail2ban")
filter_d = Path(config_dir) / "filter.d"
filter_d.mkdir(parents=True)
# Create a minimal filter file so _parse_filters_sync has something to scan.
(filter_d / "sshd.conf").write_text(
"[Definition]\nfailregex = ^Failed password\n"
)
with (
patch(
"app.services.filter_config_service._parse_jails_sync",
return_value=({}, {}),
),
patch(
"app.services.filter_config_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
),
):
result = await filter_config_service.list_filters(
config_dir, "/fake/socket"
)
assert result.total >= 0
# ── Bug 4 ─────────────────────────────────────────────────────────────────
class TestServiceStatusBanguiVersion:
"""Bug 4: ``get_service_status`` must include application version
in the ``version`` field of the ``ServiceStatusResponse``."""
async def test_online_response_contains_bangui_version(self) -> None:
"""The returned model must contain the ``bangui_version`` field."""
from app.models.server import ServerStatus
from app.services import config_service
import app
online_status = ServerStatus(
online=True,
version="1.0.0",
active_jails=2,
total_bans=5,
total_failures=3,
)
async def _send(command: list[Any]) -> Any:
key = "|".join(str(c) for c in command)
if key == "get|loglevel":
return (0, "INFO")
if key == "get|logtarget":
return (0, "/var/log/fail2ban.log")
return (0, None)
class _FakeClient:
def __init__(self, **_kw: Any) -> None:
self.send = AsyncMock(side_effect=_send)
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
result = await config_service.get_service_status(
"/fake/socket",
probe_fn=AsyncMock(return_value=online_status),
)
assert result.version == app.__version__, (
"ServiceStatusResponse must expose BanGUI version in version field"
)
async def test_offline_response_contains_bangui_version(self) -> None:
"""Even when fail2ban is offline, ``bangui_version`` must be present."""
from app.models.server import ServerStatus
from app.services import config_service
import app
offline_status = ServerStatus(online=False)
result = await config_service.get_service_status(
"/fake/socket",
probe_fn=AsyncMock(return_value=offline_status),
)
assert result.version == app.__version__

View File

@@ -136,3 +136,32 @@ async def test_get_history_page_and_for_ip(tmp_path: Path) -> None:
history_for_ip = await fail2ban_db_repo.get_history_for_ip(db_path=db_path, ip="2.2.2.2") history_for_ip = await fail2ban_db_repo.get_history_for_ip(db_path=db_path, ip="2.2.2.2")
assert len(history_for_ip) == 1 assert len(history_for_ip) == 1
assert history_for_ip[0].ip == "2.2.2.2" assert history_for_ip[0].ip == "2.2.2.2"
@pytest.mark.asyncio
async def test_get_history_page_origin_filter(tmp_path: Path) -> None:
db_path = str(tmp_path / "fail2ban.db")
async with aiosqlite.connect(db_path) as db:
await _create_bans_table(db)
await db.executemany(
"INSERT INTO bans (jail, ip, timeofban, bancount, data) VALUES (?, ?, ?, ?, ?)",
[
("jail1", "1.1.1.1", 100, 1, "{}"),
("blocklist-import", "2.2.2.2", 200, 1, "{}"),
],
)
await db.commit()
page, total = await fail2ban_db_repo.get_history_page(
db_path=db_path,
since=None,
jail=None,
ip_filter=None,
origin="selfblock",
page=1,
page_size=10,
)
assert total == 1
assert len(page) == 1
assert page[0].ip == "1.1.1.1"

View File

@@ -2001,8 +2001,7 @@ class TestGetServiceStatus:
def _mock_status(self, online: bool = True) -> ServiceStatusResponse: def _mock_status(self, online: bool = True) -> ServiceStatusResponse:
return ServiceStatusResponse( return ServiceStatusResponse(
online=online, online=online,
version="1.0.0" if online else None, version=app.__version__,
bangui_version=app.__version__,
jail_count=2 if online else 0, jail_count=2 if online else 0,
total_bans=10 if online else 0, total_bans=10 if online else 0,
total_failures=3 if online else 0, total_failures=3 if online else 0,
@@ -2021,7 +2020,7 @@ class TestGetServiceStatus:
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["online"] is True assert data["online"] is True
assert data["bangui_version"] == app.__version__ assert data["version"] == app.__version__
assert data["jail_count"] == 2 assert data["jail_count"] == 2
assert data["log_level"] == "INFO" assert data["log_level"] == "INFO"
@@ -2035,7 +2034,7 @@ class TestGetServiceStatus:
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["bangui_version"] == app.__version__ assert data["version"] == app.__version__
assert data["online"] is False assert data["online"] is False
assert data["log_level"] == "UNKNOWN" assert data["log_level"] == "UNKNOWN"

View File

@@ -153,8 +153,6 @@ class TestDashboardStatus:
body = response.json() body = response.json()
assert "status" in body assert "status" in body
assert "bangui_version" in body
assert body["bangui_version"] == app.__version__
status = body["status"] status = body["status"]
assert "online" in status assert "online" in status
@@ -171,9 +169,8 @@ class TestDashboardStatus:
body = response.json() body = response.json()
status = body["status"] status = body["status"]
assert body["bangui_version"] == app.__version__
assert status["online"] is True assert status["online"] is True
assert status["version"] == "1.0.2" assert status["version"] == app.__version__
assert status["active_jails"] == 2 assert status["active_jails"] == 2
assert status["total_bans"] == 10 assert status["total_bans"] == 10
assert status["total_failures"] == 5 assert status["total_failures"] == 5
@@ -187,9 +184,8 @@ class TestDashboardStatus:
body = response.json() body = response.json()
status = body["status"] status = body["status"]
assert body["bangui_version"] == app.__version__
assert status["online"] is False assert status["online"] is False
assert status["version"] is None assert status["version"] == app.__version__
assert status["active_jails"] == 0 assert status["active_jails"] == 0
assert status["total_bans"] == 0 assert status["total_bans"] == 0
assert status["total_failures"] == 0 assert status["total_failures"] == 0

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from typing import Any from typing import Any
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@@ -748,8 +749,10 @@ class TestGetServiceStatus:
probe_fn=AsyncMock(return_value=online_status), probe_fn=AsyncMock(return_value=online_status),
) )
from app import __version__
assert result.online is True assert result.online is True
assert result.version == "1.0.0" assert result.version == __version__
assert result.jail_count == 2 assert result.jail_count == 2
assert result.total_bans == 5 assert result.total_bans == 5
assert result.total_failures == 3 assert result.total_failures == 3
@@ -771,3 +774,62 @@ class TestGetServiceStatus:
assert result.jail_count == 0 assert result.jail_count == 0
assert result.log_level == "UNKNOWN" assert result.log_level == "UNKNOWN"
assert result.log_target == "UNKNOWN" assert result.log_target == "UNKNOWN"
@pytest.mark.asyncio
class TestConfigModuleIntegration:
async def test_jail_config_service_list_inactive_jails_uses_imports(self, tmp_path: Any) -> None:
from app.services.jail_config_service import list_inactive_jails
# Arrange: fake parse_jails output with one active and one inactive
def fake_parse_jails_sync(path: Path) -> tuple[dict[str, dict[str, str]], dict[str, str]]:
return (
{
"sshd": {
"enabled": "true",
"filter": "sshd",
"logpath": "/var/log/auth.log",
},
"apache-auth": {
"enabled": "false",
"filter": "apache-auth",
"logpath": "/var/log/apache2/error.log",
},
},
{
"sshd": str(path / "jail.conf"),
"apache-auth": str(path / "jail.conf"),
},
)
with patch(
"app.services.jail_config_service._parse_jails_sync",
new=fake_parse_jails_sync,
), patch(
"app.services.jail_config_service._get_active_jail_names",
new=AsyncMock(return_value={"sshd"}),
):
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
names = {j.name for j in result.jails}
assert "apache-auth" in names
assert "sshd" not in names
async def test_filter_config_service_list_filters_uses_imports(self, tmp_path: Any) -> None:
from app.services.filter_config_service import list_filters
# Arrange minimal filter and jail config files
filter_d = tmp_path / "filter.d"
filter_d.mkdir(parents=True)
(filter_d / "sshd.conf").write_text("[Definition]\nfailregex = ^%(__prefix_line)s.*$\n")
(tmp_path / "jail.conf").write_text("[sshd]\nfilter = sshd\nenabled = true\n")
with patch(
"app.services.filter_config_service._get_active_jail_names",
new=AsyncMock(return_value={"sshd"}),
):
result = await list_filters(str(tmp_path), "/fake.sock")
assert result.total == 1
assert result.filters[0].name == "sshd"
assert result.filters[0].active is True

View File

@@ -179,6 +179,19 @@ class TestListHistory:
# 2 sshd bans for 1.2.3.4 # 2 sshd bans for 1.2.3.4
assert result.total == 2 assert result.total == 2
async def test_origin_filter_selfblock(self, f2b_db_path: str) -> None:
"""Origin filter should include only selfblock entries."""
with patch(
"app.services.history_service.get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await history_service.list_history(
"fake_socket", origin="selfblock"
)
assert result.total == 4
assert all(item.jail != "blocklist-import" for item in result.items)
async def test_unknown_ip_returns_empty(self, f2b_db_path: str) -> None: async def test_unknown_ip_returns_empty(self, f2b_db_path: str) -> None:
"""Filtering by a non-existent IP returns an empty result set.""" """Filtering by a non-existent IP returns an empty result set."""
with patch( with patch(

View File

@@ -1,12 +1,12 @@
{ {
"name": "bangui-frontend", "name": "bangui-frontend",
"version": "0.9.4", "version": "0.9.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bangui-frontend", "name": "bangui-frontend",
"version": "0.9.4", "version": "0.9.10",
"dependencies": { "dependencies": {
"@fluentui/react-components": "^9.55.0", "@fluentui/react-components": "^9.55.0",
"@fluentui/react-icons": "^2.0.257", "@fluentui/react-icons": "^2.0.257",

View File

@@ -1,7 +1,7 @@
{ {
"name": "bangui-frontend", "name": "bangui-frontend",
"private": true, "private": true,
"version": "0.9.10", "version": "0.9.14",
"description": "BanGUI frontend — fail2ban web management interface", "description": "BanGUI frontend — fail2ban web management interface",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -70,7 +70,7 @@ const useStyles = makeStyles({
*/ */
export function ServerStatusBar(): React.JSX.Element { export function ServerStatusBar(): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const { status, banguiVersion, loading, error, refresh } = useServerStatus(); const { status, loading, error, refresh } = useServerStatus();
const cardStyles = useCardStyles(); const cardStyles = useCardStyles();
@@ -98,21 +98,13 @@ export function ServerStatusBar(): React.JSX.Element {
{/* Version */} {/* Version */}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
{status?.version != null && ( {status?.version != null && (
<Tooltip content="fail2ban daemon version" relationship="description"> <Tooltip content="BanGUI version" relationship="description">
<Text size={200} className={styles.statValue}> <Text size={200} className={styles.statValue}>
v{status.version} v{status.version}
</Text> </Text>
</Tooltip> </Tooltip>
)} )}
{banguiVersion != null && (
<Tooltip content="BanGUI version" relationship="description">
<Badge appearance="filled" size="small">
BanGUI v{banguiVersion}
</Badge>
</Tooltip>
)}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
{/* Stats (only when online) */} {/* Stats (only when online) */}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}

View File

@@ -41,7 +41,6 @@ describe("ServerStatusBar", () => {
it("shows a spinner while the initial load is in progress", () => { it("shows a spinner while the initial load is in progress", () => {
mockedUseServerStatus.mockReturnValue({ mockedUseServerStatus.mockReturnValue({
status: null, status: null,
banguiVersion: null,
loading: true, loading: true,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
@@ -60,7 +59,6 @@ describe("ServerStatusBar", () => {
total_bans: 10, total_bans: 10,
total_failures: 5, total_failures: 5,
}, },
banguiVersion: "1.1.0",
loading: false, loading: false,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
@@ -78,7 +76,6 @@ describe("ServerStatusBar", () => {
total_bans: 0, total_bans: 0,
total_failures: 0, total_failures: 0,
}, },
banguiVersion: "1.1.0",
loading: false, loading: false,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
@@ -96,7 +93,6 @@ describe("ServerStatusBar", () => {
total_bans: 0, total_bans: 0,
total_failures: 0, total_failures: 0,
}, },
banguiVersion: "1.2.3",
loading: false, loading: false,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
@@ -105,7 +101,7 @@ describe("ServerStatusBar", () => {
expect(screen.getByText("v1.2.3")).toBeInTheDocument(); expect(screen.getByText("v1.2.3")).toBeInTheDocument();
}); });
it("renders a BanGUI version badge", () => { it("does not render a separate BanGUI version badge", () => {
mockedUseServerStatus.mockReturnValue({ mockedUseServerStatus.mockReturnValue({
status: { status: {
online: true, online: true,
@@ -114,13 +110,12 @@ describe("ServerStatusBar", () => {
total_bans: 0, total_bans: 0,
total_failures: 0, total_failures: 0,
}, },
banguiVersion: "9.9.9",
loading: false, loading: false,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
}); });
renderBar(); renderBar();
expect(screen.getByText("BanGUI v9.9.9")).toBeInTheDocument(); expect(screen.queryByText("BanGUI v9.9.9")).toBeNull();
}); });
it("does not render the version element when version is null", () => { it("does not render the version element when version is null", () => {
@@ -132,7 +127,6 @@ describe("ServerStatusBar", () => {
total_bans: 0, total_bans: 0,
total_failures: 0, total_failures: 0,
}, },
banguiVersion: "1.2.3",
loading: false, loading: false,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
@@ -151,7 +145,6 @@ describe("ServerStatusBar", () => {
total_bans: 21, total_bans: 21,
total_failures: 99, total_failures: 99,
}, },
banguiVersion: "1.0.0",
loading: false, loading: false,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
@@ -167,7 +160,6 @@ describe("ServerStatusBar", () => {
it("renders an error message when the status fetch fails", () => { it("renders an error message when the status fetch fails", () => {
mockedUseServerStatus.mockReturnValue({ mockedUseServerStatus.mockReturnValue({
status: null, status: null,
banguiVersion: null,
loading: false, loading: false,
error: "Network error", error: "Network error",
refresh: vi.fn(), refresh: vi.fn(),

View File

@@ -352,12 +352,6 @@ export function ServerHealthSection(): React.JSX.Element {
<Text className={styles.statValue}>{status.version}</Text> <Text className={styles.statValue}>{status.version}</Text>
</div> </div>
)} )}
{status.bangui_version && (
<div className={styles.statCard}>
<Text className={styles.statLabel}>BanGUI</Text>
<Text className={styles.statValue}>{status.bangui_version}</Text>
</div>
)}
<div className={styles.statCard}> <div className={styles.statCard}>
<Text className={styles.statLabel}>Active Jails</Text> <Text className={styles.statLabel}>Active Jails</Text>
<Text className={styles.statValue}>{status.jail_count}</Text> <Text className={styles.statValue}>{status.jail_count}</Text>

View File

@@ -15,11 +15,10 @@ describe("ServerHealthSection", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("shows the BanGUI version in the service health panel", async () => { it("shows the version in the service health panel", async () => {
mockedFetchServiceStatus.mockResolvedValue({ mockedFetchServiceStatus.mockResolvedValue({
online: true, online: true,
version: "1.2.3", version: "1.2.3",
bangui_version: "1.2.3",
jail_count: 2, jail_count: 2,
total_bans: 5, total_bans: 5,
total_failures: 1, total_failures: 1,
@@ -41,11 +40,11 @@ describe("ServerHealthSection", () => {
</FluentProvider>, </FluentProvider>,
); );
// The service health panel should render and include the BanGUI version. // The service health panel should render and include the version.
const banGuiLabel = await screen.findByText("BanGUI"); const versionLabel = await screen.findByText("Version");
expect(banGuiLabel).toBeInTheDocument(); expect(versionLabel).toBeInTheDocument();
const banGuiCard = banGuiLabel.closest("div"); const versionCard = versionLabel.closest("div");
expect(banGuiCard).toHaveTextContent("1.2.3"); expect(versionCard).toHaveTextContent("1.2.3");
}); });
}); });

View File

@@ -36,7 +36,7 @@ describe("useJailDetail control methods", () => {
}); });
it("calls start() and refetches jail data", async () => { it("calls start() and refetches jail data", async () => {
vi.mocked(jailsApi.startJail).mockResolvedValue(undefined); vi.mocked(jailsApi.startJail).mockResolvedValue({ message: "jail started", jail: "sshd" });
const { result } = renderHook(() => useJailDetail("sshd")); const { result } = renderHook(() => useJailDetail("sshd"));
@@ -58,7 +58,7 @@ describe("useJailDetail control methods", () => {
}); });
it("calls stop() and refetches jail data", async () => { it("calls stop() and refetches jail data", async () => {
vi.mocked(jailsApi.stopJail).mockResolvedValue(undefined); vi.mocked(jailsApi.stopJail).mockResolvedValue({ message: "jail stopped", jail: "sshd" });
const { result } = renderHook(() => useJailDetail("sshd")); const { result } = renderHook(() => useJailDetail("sshd"));
@@ -77,7 +77,7 @@ describe("useJailDetail control methods", () => {
}); });
it("calls reload() and refetches jail data", async () => { it("calls reload() and refetches jail data", async () => {
vi.mocked(jailsApi.reloadJail).mockResolvedValue(undefined); vi.mocked(jailsApi.reloadJail).mockResolvedValue({ message: "jail reloaded", jail: "sshd" });
const { result } = renderHook(() => useJailDetail("sshd")); const { result } = renderHook(() => useJailDetail("sshd"));
@@ -96,7 +96,7 @@ describe("useJailDetail control methods", () => {
}); });
it("calls setIdle() with correct parameter and refetches jail data", async () => { it("calls setIdle() with correct parameter and refetches jail data", async () => {
vi.mocked(jailsApi.setJailIdle).mockResolvedValue(undefined); vi.mocked(jailsApi.setJailIdle).mockResolvedValue({ message: "jail idle toggled", jail: "sshd" });
const { result } = renderHook(() => useJailDetail("sshd")); const { result } = renderHook(() => useJailDetail("sshd"));

View File

@@ -18,8 +18,6 @@ const POLL_INTERVAL_MS = 30_000;
export interface UseServerStatusResult { export interface UseServerStatusResult {
/** The most recent server status snapshot, or `null` before the first fetch. */ /** The most recent server status snapshot, or `null` before the first fetch. */
status: ServerStatus | null; status: ServerStatus | null;
/** BanGUI application version string. */
banguiVersion: string | null;
/** Whether a fetch is currently in flight. */ /** Whether a fetch is currently in flight. */
loading: boolean; loading: boolean;
/** Error message string when the last fetch failed, otherwise `null`. */ /** Error message string when the last fetch failed, otherwise `null`. */
@@ -35,7 +33,6 @@ export interface UseServerStatusResult {
*/ */
export function useServerStatus(): UseServerStatusResult { export function useServerStatus(): UseServerStatusResult {
const [status, setStatus] = useState<ServerStatus | null>(null); const [status, setStatus] = useState<ServerStatus | null>(null);
const [banguiVersion, setBanguiVersion] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -47,7 +44,6 @@ export function useServerStatus(): UseServerStatusResult {
try { try {
const data = await fetchServerStatus(); const data = await fetchServerStatus();
setStatus(data.status); setStatus(data.status);
setBanguiVersion(data.bangui_version);
setError(null); setError(null);
} catch (err: unknown) { } catch (err: unknown) {
handleFetchError(err, setError, "Failed to fetch server status"); handleFetchError(err, setError, "Failed to fetch server status");
@@ -82,5 +78,5 @@ export function useServerStatus(): UseServerStatusResult {
void doFetch().catch((): void => undefined); void doFetch().catch((): void => undefined);
}, [doFetch]); }, [doFetch]);
return { status, banguiVersion, loading, error, refresh }; return { status, loading, error, refresh };
} }

View File

@@ -6,7 +6,7 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Button, MessageBar, MessageBarBody, Text } from "@fluentui/react-components"; import { Button, MessageBar, MessageBarBody, Text } from "@fluentui/react-components";
import { useBlocklistStyles } from "../theme/commonStyles"; import { useBlocklistStyles } from "../components/blocklist/blocklistStyles";
import { BlocklistSourcesSection } from "../components/blocklist/BlocklistSourcesSection"; import { BlocklistSourcesSection } from "../components/blocklist/BlocklistSourcesSection";
import { BlocklistScheduleSection } from "../components/blocklist/BlocklistScheduleSection"; import { BlocklistScheduleSection } from "../components/blocklist/BlocklistScheduleSection";

View File

@@ -659,10 +659,8 @@ export interface Fail2BanLogResponse {
export interface ServiceStatusResponse { export interface ServiceStatusResponse {
/** Whether fail2ban is reachable via its socket. */ /** Whether fail2ban is reachable via its socket. */
online: boolean; online: boolean;
/** fail2ban version string, or null when offline. */ /** BanGUI application version (or null when offline). */
version: string | null; version: string | null;
/** BanGUI application version (from the API). */
bangui_version: string;
/** Number of currently active jails. */ /** Number of currently active jails. */
jail_count: number; jail_count: number;
/** Aggregated current ban count across all jails. */ /** Aggregated current ban count across all jails. */

View File

@@ -21,6 +21,4 @@ export interface ServerStatus {
/** Response shape for ``GET /api/dashboard/status``. */ /** Response shape for ``GET /api/dashboard/status``. */
export interface ServerStatusResponse { export interface ServerStatusResponse {
status: ServerStatus; status: ServerStatus;
/** BanGUI application version (from the API). */
bangui_version: string;
} }

View File

@@ -1,14 +1,15 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { resolve } from "path"; import { resolve } from "path";
import { readFileSync } from "node:fs"; import { readFileSync, existsSync } from "node:fs";
const appVersion = readFileSync( let appVersion = "0.0.0";
resolve(__dirname, "../Docker/VERSION"), const versionFile = resolve(__dirname, "../Docker/VERSION");
"utf-8", if (existsSync(versionFile)) {
) appVersion = readFileSync(versionFile, "utf-8")
.trim() .trim()
.replace(/^v/, ""); .replace(/^v/, "");
}
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({