Add Deactivate Jail button for inactive jails with local override
- Add has_local_override field to InactiveJail model
- Update _build_inactive_jail and list_inactive_jails to compute the field
- Add delete_jail_local_override() service function
- Add DELETE /api/config/jails/{name}/local router endpoint
- Surface has_local_override in frontend InactiveJail type
- Show Deactivate Jail button in JailsTab when has_local_override is true
- Add tests: TestBuildInactiveJail, TestListInactiveJails, TestDeleteJailLocalOverride
This commit is contained in:
@@ -807,6 +807,14 @@ class InactiveJail(BaseModel):
|
|||||||
"inactive jails that appear in this list."
|
"inactive jails that appear in this list."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
has_local_override: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"``True`` when a ``jail.d/{name}.local`` file exists for this jail. "
|
||||||
|
"Only meaningful for inactive jails; indicates that a cleanup action "
|
||||||
|
"is available."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class InactiveJailListResponse(BaseModel):
|
class InactiveJailListResponse(BaseModel):
|
||||||
|
|||||||
@@ -798,6 +798,60 @@ async def deactivate_jail(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/jails/{name}/local",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Delete the jail.d override file for an inactive jail",
|
||||||
|
)
|
||||||
|
async def delete_jail_local_override(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
name: _NamePath,
|
||||||
|
) -> None:
|
||||||
|
"""Remove the ``jail.d/{name}.local`` override file for an inactive jail.
|
||||||
|
|
||||||
|
This endpoint is the clean-up action for inactive jails that still carry
|
||||||
|
a ``.local`` override file (e.g. one written with ``enabled = false`` by a
|
||||||
|
previous deactivation). The file is deleted without modifying fail2ban's
|
||||||
|
running state, since the jail is already inactive.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session.
|
||||||
|
name: Name of the jail whose ``.local`` file should be removed.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if *name* contains invalid characters.
|
||||||
|
HTTPException: 404 if *name* is not found in any config file.
|
||||||
|
HTTPException: 409 if the jail is currently active.
|
||||||
|
HTTPException: 500 if the file cannot be deleted.
|
||||||
|
HTTPException: 502 if fail2ban is unreachable.
|
||||||
|
"""
|
||||||
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||||
|
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||||
|
|
||||||
|
try:
|
||||||
|
await config_file_service.delete_jail_local_override(
|
||||||
|
config_dir, socket_path, name
|
||||||
|
)
|
||||||
|
except JailNameError as exc:
|
||||||
|
raise _bad_request(str(exc)) from exc
|
||||||
|
except JailNotFoundInConfigError:
|
||||||
|
raise _not_found(name) from None
|
||||||
|
except JailAlreadyActiveError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Jail {name!r} is currently active; deactivate it first.",
|
||||||
|
) from None
|
||||||
|
except ConfigWriteError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to delete config override: {exc}",
|
||||||
|
) from exc
|
||||||
|
except Fail2BanConnectionError as exc:
|
||||||
|
raise _bad_gateway(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Jail validation & rollback endpoints (Task 3)
|
# Jail validation & rollback endpoints (Task 3)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -429,6 +429,7 @@ def _build_inactive_jail(
|
|||||||
name: str,
|
name: str,
|
||||||
settings: dict[str, str],
|
settings: dict[str, str],
|
||||||
source_file: str,
|
source_file: str,
|
||||||
|
config_dir: Path | None = None,
|
||||||
) -> InactiveJail:
|
) -> InactiveJail:
|
||||||
"""Construct an :class:`~app.models.config.InactiveJail` from raw settings.
|
"""Construct an :class:`~app.models.config.InactiveJail` from raw settings.
|
||||||
|
|
||||||
@@ -436,6 +437,8 @@ def _build_inactive_jail(
|
|||||||
name: Jail section name.
|
name: Jail section name.
|
||||||
settings: Merged key→value dict (DEFAULT values already applied).
|
settings: Merged key→value dict (DEFAULT values already applied).
|
||||||
source_file: Path of the file that last defined this section.
|
source_file: Path of the file that last defined this section.
|
||||||
|
config_dir: Absolute path to the fail2ban configuration directory, used
|
||||||
|
to check whether a ``jail.d/{name}.local`` override file exists.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Populated :class:`~app.models.config.InactiveJail`.
|
Populated :class:`~app.models.config.InactiveJail`.
|
||||||
@@ -513,6 +516,11 @@ def _build_inactive_jail(
|
|||||||
bantime_escalation=bantime_escalation,
|
bantime_escalation=bantime_escalation,
|
||||||
source_file=source_file,
|
source_file=source_file,
|
||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
|
has_local_override=(
|
||||||
|
(config_dir / "jail.d" / f"{name}.local").is_file()
|
||||||
|
if config_dir is not None
|
||||||
|
else False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1111,7 +1119,7 @@ async def list_inactive_jails(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
source = source_files.get(jail_name, config_dir)
|
source = source_files.get(jail_name, config_dir)
|
||||||
inactive.append(_build_inactive_jail(jail_name, settings, source))
|
inactive.append(_build_inactive_jail(jail_name, settings, source, Path(config_dir)))
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"inactive_jails_listed",
|
"inactive_jails_listed",
|
||||||
@@ -1469,6 +1477,57 @@ async def deactivate_jail(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_jail_local_override(
|
||||||
|
config_dir: str,
|
||||||
|
socket_path: str,
|
||||||
|
name: str,
|
||||||
|
) -> None:
|
||||||
|
"""Delete the ``jail.d/{name}.local`` override file for an inactive jail.
|
||||||
|
|
||||||
|
This is the clean-up action shown in the config UI when an inactive jail
|
||||||
|
still has a ``.local`` override file (e.g. ``enabled = false``). The
|
||||||
|
file is deleted outright; no fail2ban reload is required because the jail
|
||||||
|
is already inactive.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_dir: Absolute path to the fail2ban configuration directory.
|
||||||
|
socket_path: Path to the fail2ban Unix domain socket.
|
||||||
|
name: Name of the jail whose ``.local`` file should be removed.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
JailNameError: If *name* contains invalid characters.
|
||||||
|
JailNotFoundInConfigError: If *name* is not defined in any config file.
|
||||||
|
JailAlreadyActiveError: If the jail is currently active (refusing to
|
||||||
|
delete the live config file).
|
||||||
|
ConfigWriteError: If the file cannot be deleted.
|
||||||
|
"""
|
||||||
|
_safe_jail_name(name)
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
all_jails, _source_files = await loop.run_in_executor(
|
||||||
|
None, _parse_jails_sync, Path(config_dir)
|
||||||
|
)
|
||||||
|
|
||||||
|
if name not in all_jails:
|
||||||
|
raise JailNotFoundInConfigError(name)
|
||||||
|
|
||||||
|
active_names = await _get_active_jail_names(socket_path)
|
||||||
|
if name in active_names:
|
||||||
|
raise JailAlreadyActiveError(name)
|
||||||
|
|
||||||
|
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None, lambda: local_path.unlink(missing_ok=True)
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ConfigWriteError(
|
||||||
|
f"Failed to delete {local_path}: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
log.info("jail_local_override_deleted", jail=name, path=str(local_path))
|
||||||
|
|
||||||
|
|
||||||
async def validate_jail_config(
|
async def validate_jail_config(
|
||||||
config_dir: str,
|
config_dir: str,
|
||||||
name: str,
|
name: str,
|
||||||
|
|||||||
@@ -290,6 +290,28 @@ class TestBuildInactiveJail:
|
|||||||
jail = _build_inactive_jail("active-jail", settings, "/etc/fail2ban/jail.conf")
|
jail = _build_inactive_jail("active-jail", settings, "/etc/fail2ban/jail.conf")
|
||||||
assert jail.enabled is True
|
assert jail.enabled is True
|
||||||
|
|
||||||
|
def test_has_local_override_absent(self, tmp_path: Path) -> None:
|
||||||
|
"""has_local_override is False when no .local file exists."""
|
||||||
|
jail = _build_inactive_jail(
|
||||||
|
"sshd", {}, "/etc/fail2ban/jail.d/sshd.conf", config_dir=tmp_path
|
||||||
|
)
|
||||||
|
assert jail.has_local_override is False
|
||||||
|
|
||||||
|
def test_has_local_override_present(self, tmp_path: Path) -> None:
|
||||||
|
"""has_local_override is True when jail.d/{name}.local exists."""
|
||||||
|
local = tmp_path / "jail.d" / "sshd.local"
|
||||||
|
local.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
local.write_text("[sshd]\nenabled = false\n")
|
||||||
|
jail = _build_inactive_jail(
|
||||||
|
"sshd", {}, "/etc/fail2ban/jail.d/sshd.conf", config_dir=tmp_path
|
||||||
|
)
|
||||||
|
assert jail.has_local_override is True
|
||||||
|
|
||||||
|
def test_has_local_override_no_config_dir(self) -> None:
|
||||||
|
"""has_local_override is False when config_dir is not provided."""
|
||||||
|
jail = _build_inactive_jail("sshd", {}, "/etc/fail2ban/jail.conf")
|
||||||
|
assert jail.has_local_override is False
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _write_local_override_sync
|
# _write_local_override_sync
|
||||||
@@ -425,6 +447,121 @@ class TestListInactiveJails:
|
|||||||
assert "sshd" in names
|
assert "sshd" in names
|
||||||
assert "apache-auth" in names
|
assert "apache-auth" in names
|
||||||
|
|
||||||
|
async def test_has_local_override_true_when_local_file_exists(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""has_local_override is True for a jail whose jail.d .local file exists."""
|
||||||
|
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||||
|
local = tmp_path / "jail.d" / "apache-auth.local"
|
||||||
|
local.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
local.write_text("[apache-auth]\nenabled = false\n")
|
||||||
|
with patch(
|
||||||
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
|
new=AsyncMock(return_value=set()),
|
||||||
|
):
|
||||||
|
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
|
||||||
|
jail = next(j for j in result.jails if j.name == "apache-auth")
|
||||||
|
assert jail.has_local_override is True
|
||||||
|
|
||||||
|
async def test_has_local_override_false_when_no_local_file(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""has_local_override is False when no jail.d .local file exists."""
|
||||||
|
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||||
|
with patch(
|
||||||
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
|
new=AsyncMock(return_value=set()),
|
||||||
|
):
|
||||||
|
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
|
||||||
|
jail = next(j for j in result.jails if j.name == "apache-auth")
|
||||||
|
assert jail.has_local_override is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# delete_jail_local_override
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestDeleteJailLocalOverride:
|
||||||
|
"""Tests for :func:`~app.services.config_file_service.delete_jail_local_override`."""
|
||||||
|
|
||||||
|
async def test_deletes_local_file(self, tmp_path: Path) -> None:
|
||||||
|
"""delete_jail_local_override removes the jail.d/.local file."""
|
||||||
|
from app.services.config_file_service import delete_jail_local_override
|
||||||
|
|
||||||
|
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||||
|
local = tmp_path / "jail.d" / "apache-auth.local"
|
||||||
|
local.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
local.write_text("[apache-auth]\nenabled = false\n")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
|
new=AsyncMock(return_value=set()),
|
||||||
|
):
|
||||||
|
await delete_jail_local_override(str(tmp_path), "/fake.sock", "apache-auth")
|
||||||
|
|
||||||
|
assert not local.exists()
|
||||||
|
|
||||||
|
async def test_no_error_when_local_file_missing(self, tmp_path: Path) -> None:
|
||||||
|
"""delete_jail_local_override succeeds silently when no .local file exists."""
|
||||||
|
from app.services.config_file_service import delete_jail_local_override
|
||||||
|
|
||||||
|
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||||
|
with patch(
|
||||||
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
|
new=AsyncMock(return_value=set()),
|
||||||
|
):
|
||||||
|
# Must not raise even though there is no .local file.
|
||||||
|
await delete_jail_local_override(str(tmp_path), "/fake.sock", "apache-auth")
|
||||||
|
|
||||||
|
async def test_raises_jail_not_found(self, tmp_path: Path) -> None:
|
||||||
|
"""delete_jail_local_override raises JailNotFoundInConfigError for unknown jail."""
|
||||||
|
from app.services.config_file_service import (
|
||||||
|
JailNotFoundInConfigError,
|
||||||
|
delete_jail_local_override,
|
||||||
|
)
|
||||||
|
|
||||||
|
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
|
new=AsyncMock(return_value=set()),
|
||||||
|
),
|
||||||
|
pytest.raises(JailNotFoundInConfigError),
|
||||||
|
):
|
||||||
|
await delete_jail_local_override(str(tmp_path), "/fake.sock", "nonexistent")
|
||||||
|
|
||||||
|
async def test_raises_jail_already_active(self, tmp_path: Path) -> None:
|
||||||
|
"""delete_jail_local_override raises JailAlreadyActiveError when jail is running."""
|
||||||
|
from app.services.config_file_service import (
|
||||||
|
JailAlreadyActiveError,
|
||||||
|
delete_jail_local_override,
|
||||||
|
)
|
||||||
|
|
||||||
|
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||||
|
local = tmp_path / "jail.d" / "sshd.local"
|
||||||
|
local.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
local.write_text("[sshd]\nenabled = false\n")
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
|
new=AsyncMock(return_value={"sshd"}),
|
||||||
|
),
|
||||||
|
pytest.raises(JailAlreadyActiveError),
|
||||||
|
):
|
||||||
|
await delete_jail_local_override(str(tmp_path), "/fake.sock", "sshd")
|
||||||
|
|
||||||
|
async def test_raises_jail_name_error(self, tmp_path: Path) -> None:
|
||||||
|
"""delete_jail_local_override raises JailNameError for invalid jail names."""
|
||||||
|
from app.services.config_file_service import (
|
||||||
|
JailNameError,
|
||||||
|
delete_jail_local_override,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(JailNameError):
|
||||||
|
await delete_jail_local_override(str(tmp_path), "/fake.sock", "../evil")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# activate_jail
|
# activate_jail
|
||||||
@@ -3174,6 +3311,64 @@ class TestActivateJailRollback:
|
|||||||
# Verify the error message mentions logpath issues.
|
# Verify the error message mentions logpath issues.
|
||||||
assert "logpath" in result.message.lower() or "check that all logpath" in result.message.lower()
|
assert "logpath" in result.message.lower() or "check that all logpath" in result.message.lower()
|
||||||
|
|
||||||
|
async def test_activate_jail_rollback_deletes_file_when_no_prior_local(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Rollback deletes the .local file when none existed before activation.
|
||||||
|
|
||||||
|
When a jail had no .local override before activation, activate_jail
|
||||||
|
creates one with enabled = true. If reload then crashes, rollback must
|
||||||
|
delete that file (leaving the jail in the same state as before the
|
||||||
|
activation attempt).
|
||||||
|
|
||||||
|
Expects:
|
||||||
|
- The .local file is absent after rollback.
|
||||||
|
- The response indicates recovered=True.
|
||||||
|
"""
|
||||||
|
from app.models.config import ActivateJailRequest, JailValidationResult
|
||||||
|
|
||||||
|
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||||
|
(tmp_path / "jail.d").mkdir(parents=True, exist_ok=True)
|
||||||
|
local_path = tmp_path / "jail.d" / "apache-auth.local"
|
||||||
|
# No .local file exists before activation.
|
||||||
|
assert not local_path.exists()
|
||||||
|
|
||||||
|
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:
|
||||||
|
raise RuntimeError("fail2ban crashed")
|
||||||
|
# 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)
|
||||||
|
result = await activate_jail(
|
||||||
|
str(tmp_path), "/fake.sock", "apache-auth", req
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.active is False
|
||||||
|
assert result.recovered is True
|
||||||
|
assert not local_path.exists()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# rollback_jail
|
# rollback_jail
|
||||||
|
|||||||
@@ -39,10 +39,8 @@ import type {
|
|||||||
LogPreviewResponse,
|
LogPreviewResponse,
|
||||||
MapColorThresholdsResponse,
|
MapColorThresholdsResponse,
|
||||||
MapColorThresholdsUpdate,
|
MapColorThresholdsUpdate,
|
||||||
PendingRecovery,
|
|
||||||
RegexTestRequest,
|
RegexTestRequest,
|
||||||
RegexTestResponse,
|
RegexTestResponse,
|
||||||
RollbackResponse,
|
|
||||||
ServerSettingsResponse,
|
ServerSettingsResponse,
|
||||||
ServerSettingsUpdate,
|
ServerSettingsUpdate,
|
||||||
JailFileConfig,
|
JailFileConfig,
|
||||||
@@ -552,6 +550,18 @@ export async function deactivateJail(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the ``jail.d/{name}.local`` override file for an inactive jail.
|
||||||
|
*
|
||||||
|
* Only valid when the jail is **not** currently active. Use this to clean up
|
||||||
|
* leftover ``.local`` files after a jail has been fully deactivated.
|
||||||
|
*
|
||||||
|
* @param name - The jail name.
|
||||||
|
*/
|
||||||
|
export async function deleteJailLocalOverride(name: string): Promise<void> {
|
||||||
|
await del<undefined>(ENDPOINTS.configJailLocalOverride(name));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// fail2ban log viewer (Task 2)
|
// fail2ban log viewer (Task 2)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -593,21 +603,3 @@ export async function validateJailConfig(
|
|||||||
): Promise<JailValidationResult> {
|
): Promise<JailValidationResult> {
|
||||||
return post<JailValidationResult>(ENDPOINTS.configJailValidate(name), undefined);
|
return post<JailValidationResult>(ENDPOINTS.configJailValidate(name), undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the pending crash-recovery record, if any.
|
|
||||||
*
|
|
||||||
* Returns null when fail2ban is healthy and no recovery is pending.
|
|
||||||
*/
|
|
||||||
export async function fetchPendingRecovery(): Promise<PendingRecovery | null> {
|
|
||||||
return get<PendingRecovery | null>(ENDPOINTS.configPendingRecovery);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rollback a bad jail — disables it and attempts to restart fail2ban.
|
|
||||||
*
|
|
||||||
* @param name - Name of the jail to disable.
|
|
||||||
*/
|
|
||||||
export async function rollbackJail(name: string): Promise<RollbackResponse> {
|
|
||||||
return post<RollbackResponse>(ENDPOINTS.configJailRollback(name), undefined);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -71,11 +71,10 @@ export const ENDPOINTS = {
|
|||||||
`/config/jails/${encodeURIComponent(name)}/activate`,
|
`/config/jails/${encodeURIComponent(name)}/activate`,
|
||||||
configJailDeactivate: (name: string): string =>
|
configJailDeactivate: (name: string): string =>
|
||||||
`/config/jails/${encodeURIComponent(name)}/deactivate`,
|
`/config/jails/${encodeURIComponent(name)}/deactivate`,
|
||||||
|
configJailLocalOverride: (name: string): string =>
|
||||||
|
`/config/jails/${encodeURIComponent(name)}/local`,
|
||||||
configJailValidate: (name: string): string =>
|
configJailValidate: (name: string): string =>
|
||||||
`/config/jails/${encodeURIComponent(name)}/validate`,
|
`/config/jails/${encodeURIComponent(name)}/validate`,
|
||||||
configJailRollback: (name: string): string =>
|
|
||||||
`/config/jails/${encodeURIComponent(name)}/rollback`,
|
|
||||||
configPendingRecovery: "/config/pending-recovery" as string,
|
|
||||||
configGlobal: "/config/global",
|
configGlobal: "/config/global",
|
||||||
configReload: "/config/reload",
|
configReload: "/config/reload",
|
||||||
configRestart: "/config/restart",
|
configRestart: "/config/restart",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { ApiError } from "../../api/client";
|
|||||||
import {
|
import {
|
||||||
addLogPath,
|
addLogPath,
|
||||||
deactivateJail,
|
deactivateJail,
|
||||||
|
deleteJailLocalOverride,
|
||||||
deleteLogPath,
|
deleteLogPath,
|
||||||
fetchInactiveJails,
|
fetchInactiveJails,
|
||||||
fetchJailConfigFileContent,
|
fetchJailConfigFileContent,
|
||||||
@@ -573,7 +574,7 @@ function JailConfigDetail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{readOnly && (onActivate !== undefined || onValidate !== undefined) && (
|
{readOnly && (onActivate !== undefined || onValidate !== undefined || onDeactivate !== undefined) && (
|
||||||
<div style={{ marginTop: tokens.spacingVerticalM, display: "flex", gap: tokens.spacingHorizontalS, flexWrap: "wrap" }}>
|
<div style={{ marginTop: tokens.spacingVerticalM, display: "flex", gap: tokens.spacingHorizontalS, flexWrap: "wrap" }}>
|
||||||
{onValidate !== undefined && (
|
{onValidate !== undefined && (
|
||||||
<Button
|
<Button
|
||||||
@@ -585,6 +586,15 @@ function JailConfigDetail({
|
|||||||
{validating ? "Validating…" : "Validate Config"}
|
{validating ? "Validating…" : "Validate Config"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{onDeactivate !== undefined && (
|
||||||
|
<Button
|
||||||
|
appearance="secondary"
|
||||||
|
icon={<LockOpen24Regular />}
|
||||||
|
onClick={onDeactivate}
|
||||||
|
>
|
||||||
|
Deactivate Jail
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{onActivate !== undefined && (
|
{onActivate !== undefined && (
|
||||||
<Button
|
<Button
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
@@ -618,8 +628,8 @@ function JailConfigDetail({
|
|||||||
interface InactiveJailDetailProps {
|
interface InactiveJailDetailProps {
|
||||||
jail: InactiveJail;
|
jail: InactiveJail;
|
||||||
onActivate: () => void;
|
onActivate: () => void;
|
||||||
/** Whether to show and call onCrashDetected on activation crash. */
|
/** Called when the user requests removal of the .local override file. */
|
||||||
onCrashDetected?: () => void;
|
onDeactivate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -636,6 +646,7 @@ interface InactiveJailDetailProps {
|
|||||||
function InactiveJailDetail({
|
function InactiveJailDetail({
|
||||||
jail,
|
jail,
|
||||||
onActivate,
|
onActivate,
|
||||||
|
onDeactivate,
|
||||||
}: InactiveJailDetailProps): React.JSX.Element {
|
}: InactiveJailDetailProps): React.JSX.Element {
|
||||||
const styles = useConfigStyles();
|
const styles = useConfigStyles();
|
||||||
const [validating, setValidating] = useState(false);
|
const [validating, setValidating] = useState(false);
|
||||||
@@ -729,6 +740,7 @@ function InactiveJailDetail({
|
|||||||
onSave={async () => { /* read-only — never called */ }}
|
onSave={async () => { /* read-only — never called */ }}
|
||||||
readOnly
|
readOnly
|
||||||
onActivate={onActivate}
|
onActivate={onActivate}
|
||||||
|
onDeactivate={jail.has_local_override ? onDeactivate : undefined}
|
||||||
onValidate={handleValidate}
|
onValidate={handleValidate}
|
||||||
validating={validating}
|
validating={validating}
|
||||||
/>
|
/>
|
||||||
@@ -746,12 +758,7 @@ function InactiveJailDetail({
|
|||||||
*
|
*
|
||||||
* @returns JSX element.
|
* @returns JSX element.
|
||||||
*/
|
*/
|
||||||
export interface JailsTabProps {
|
export function JailsTab(): React.JSX.Element {
|
||||||
/** Called when fail2ban stopped responding after a jail was activated. */
|
|
||||||
onCrashDetected?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Element {
|
|
||||||
const styles = useConfigStyles();
|
const styles = useConfigStyles();
|
||||||
const { jails, loading, error, refresh, updateJail } =
|
const { jails, loading, error, refresh, updateJail } =
|
||||||
useJailConfigs();
|
useJailConfigs();
|
||||||
@@ -786,6 +793,15 @@ export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Ele
|
|||||||
.catch(() => { /* non-critical — list refreshes on next load */ });
|
.catch(() => { /* non-critical — list refreshes on next load */ });
|
||||||
}, [refresh, loadInactive]);
|
}, [refresh, loadInactive]);
|
||||||
|
|
||||||
|
const handleDeactivateInactive = useCallback((name: string): void => {
|
||||||
|
deleteJailLocalOverride(name)
|
||||||
|
.then(() => {
|
||||||
|
setSelectedName(null);
|
||||||
|
loadInactive();
|
||||||
|
})
|
||||||
|
.catch(() => { /* non-critical — list refreshes on next load */ });
|
||||||
|
}, [loadInactive]);
|
||||||
|
|
||||||
const handleActivated = useCallback((): void => {
|
const handleActivated = useCallback((): void => {
|
||||||
setActivateTarget(null);
|
setActivateTarget(null);
|
||||||
setSelectedName(null);
|
setSelectedName(null);
|
||||||
@@ -890,7 +906,11 @@ export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Ele
|
|||||||
<InactiveJailDetail
|
<InactiveJailDetail
|
||||||
jail={selectedInactiveJail}
|
jail={selectedInactiveJail}
|
||||||
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
|
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
|
||||||
onCrashDetected={onCrashDetected}
|
onDeactivate={
|
||||||
|
selectedInactiveJail.has_local_override
|
||||||
|
? (): void => { handleDeactivateInactive(selectedInactiveJail.name); }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</ConfigListDetail>
|
</ConfigListDetail>
|
||||||
@@ -901,7 +921,6 @@ export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Ele
|
|||||||
open={activateTarget !== null}
|
open={activateTarget !== null}
|
||||||
onClose={() => { setActivateTarget(null); }}
|
onClose={() => { setActivateTarget(null); }}
|
||||||
onActivated={handleActivated}
|
onActivated={handleActivated}
|
||||||
onCrashDetected={onCrashDetected}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CreateJailDialog
|
<CreateJailDialog
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
* - "Activate" button is enabled when validation passes.
|
* - "Activate" button is enabled when validation passes.
|
||||||
* - Dialog stays open and shows an error when the backend returns active=false.
|
* - Dialog stays open and shows an error when the backend returns active=false.
|
||||||
* - `onActivated` is called and dialog closes when backend returns active=true.
|
* - `onActivated` is called and dialog closes when backend returns active=true.
|
||||||
* - `onCrashDetected` is called when fail2ban_running is false after activation.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
@@ -55,6 +54,7 @@ const baseJail: InactiveJail = {
|
|||||||
bantime_escalation: null,
|
bantime_escalation: null,
|
||||||
source_file: "/config/fail2ban/jail.d/airsonic-auth.conf",
|
source_file: "/config/fail2ban/jail.d/airsonic-auth.conf",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
has_local_override: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Successful activation response. */
|
/** Successful activation response. */
|
||||||
@@ -98,7 +98,6 @@ interface DialogProps {
|
|||||||
open?: boolean;
|
open?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onActivated?: () => void;
|
onActivated?: () => void;
|
||||||
onCrashDetected?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDialog({
|
function renderDialog({
|
||||||
@@ -106,7 +105,6 @@ function renderDialog({
|
|||||||
open = true,
|
open = true,
|
||||||
onClose = vi.fn(),
|
onClose = vi.fn(),
|
||||||
onActivated = vi.fn(),
|
onActivated = vi.fn(),
|
||||||
onCrashDetected = vi.fn(),
|
|
||||||
}: DialogProps = {}) {
|
}: DialogProps = {}) {
|
||||||
return render(
|
return render(
|
||||||
<FluentProvider theme={webLightTheme}>
|
<FluentProvider theme={webLightTheme}>
|
||||||
@@ -115,7 +113,6 @@ function renderDialog({
|
|||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onActivated={onActivated}
|
onActivated={onActivated}
|
||||||
onCrashDetected={onCrashDetected}
|
|
||||||
/>
|
/>
|
||||||
</FluentProvider>,
|
</FluentProvider>,
|
||||||
);
|
);
|
||||||
@@ -202,28 +199,4 @@ describe("ActivateJailDialog", () => {
|
|||||||
expect(onActivated).toHaveBeenCalledOnce();
|
expect(onActivated).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onCrashDetected when fail2ban_running is false after activation", async () => {
|
|
||||||
mockValidateJailConfig.mockResolvedValue(validationPassed);
|
|
||||||
mockActivateJail.mockResolvedValue({
|
|
||||||
...successResponse,
|
|
||||||
fail2ban_running: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onActivated = vi.fn();
|
|
||||||
const onCrashDetected = vi.fn();
|
|
||||||
renderDialog({ onActivated, onCrashDetected });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText(/validating configuration/i)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
const activateBtn = screen.getByRole("button", { name: /^activate$/i });
|
|
||||||
await userEvent.click(activateBtn);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(onCrashDetected).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
expect(onActivated).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -524,6 +524,11 @@ export interface InactiveJail {
|
|||||||
source_file: string;
|
source_file: string;
|
||||||
/** Effective ``enabled`` value — always ``false`` for inactive jails. */
|
/** Effective ``enabled`` value — always ``false`` for inactive jails. */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
/**
|
||||||
|
* True when a ``jail.d/{name}.local`` override file exists for this jail.
|
||||||
|
* Indicates that a "Deactivate Jail" cleanup action is available.
|
||||||
|
*/
|
||||||
|
has_local_override: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InactiveJailListResponse {
|
export interface InactiveJailListResponse {
|
||||||
@@ -581,20 +586,6 @@ export interface JailValidationResult {
|
|||||||
issues: JailValidationIssue[];
|
issues: JailValidationIssue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Recorded when fail2ban stops responding shortly after a jail activation.
|
|
||||||
* Surfaced by `GET /api/config/pending-recovery`.
|
|
||||||
*/
|
|
||||||
export interface PendingRecovery {
|
|
||||||
jail_name: string;
|
|
||||||
/** ISO-8601 datetime string. */
|
|
||||||
activated_at: string;
|
|
||||||
/** ISO-8601 datetime string. */
|
|
||||||
detected_at: string;
|
|
||||||
/** True once fail2ban comes back online after the crash. */
|
|
||||||
recovered: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Response from `POST /api/config/jails/{name}/rollback`. */
|
/** Response from `POST /api/config/jails/{name}/rollback`. */
|
||||||
export interface RollbackResponse {
|
export interface RollbackResponse {
|
||||||
jail_name: string;
|
jail_name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user