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:
2026-03-15 13:41:00 +01:00
parent 93dc699825
commit d4d04491d2
9 changed files with 367 additions and 77 deletions

View File

@@ -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):

View File

@@ -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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -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,

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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",

View File

@@ -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

View File

@@ -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();
});
}); });

View File

@@ -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;