feat(stage-1): inactive jail discovery and activation

- Backend: config_file_service.py parses jail.conf/jail.local/jail.d/*
  following fail2ban merge order; discovers jails not running in fail2ban
- Backend: 3 new API endpoints (GET /jails/inactive, POST /jails/{name}/activate,
  POST /jails/{name}/deactivate); moved /jails/inactive before /jails/{name}
  to fix route-ordering conflict
- Frontend: ActivateJailDialog component with optional parameter overrides
- Frontend: JailsTab extended with inactive jail list and InactiveJailDetail pane
- Frontend: JailsPage JailOverviewSection shows inactive jails with toggle
- Tests: 57 service tests + 16 router tests for all new endpoints (all pass)
- Docs: Features.md, Architekture.md, Tasks.md updated; Tasks 1.1-1.5 marked done
This commit is contained in:
2026-03-13 15:44:36 +01:00
parent a344f1035b
commit 8d9d63b866
15 changed files with 2711 additions and 182 deletions

View File

@@ -171,6 +171,7 @@ The business logic layer. Services orchestrate operations, enforce rules, and co
| `ban_service.py` | Executes ban and unban commands via the fail2ban socket, queries the currently banned IP list, validates IPs before banning | | `ban_service.py` | Executes ban and unban commands via the fail2ban socket, queries the currently banned IP list, validates IPs before banning |
| `config_service.py` | Reads active jail and filter configuration from fail2ban, writes configuration changes, validates regex patterns, triggers reload | | `config_service.py` | Reads active jail and filter configuration from fail2ban, writes configuration changes, validates regex patterns, triggers reload |
| `file_config_service.py` | Reads and writes raw fail2ban config files on disk (jail.d/, filter.d/, action.d/); lists files, reads content, overwrites files, toggles enabled/disabled | | `file_config_service.py` | Reads and writes raw fail2ban config files on disk (jail.d/, filter.d/, action.d/); lists files, reads content, overwrites files, toggles enabled/disabled |
| `config_file_service.py` | Parses jail.conf / jail.local / jail.d/* to discover inactive jails; writes .local overrides to activate or deactivate jails; triggers fail2ban reload |
| `conffile_parser.py` | Parses fail2ban `.conf` files into structured Python types (jail config, filter config, action config); also serialises back to text | | `conffile_parser.py` | Parses fail2ban `.conf` files into structured Python types (jail config, filter config, action config); also serialises back to text |
| `history_service.py` | Queries the fail2ban database for historical ban records, builds per-IP timelines, computes ban counts and repeat-offender flags | | `history_service.py` | Queries the fail2ban database for historical ban records, builds per-IP timelines, computes ban counts and repeat-offender flags |
| `blocklist_service.py` | Downloads blocklists via aiohttp, validates IPs/CIDRs, applies bans through fail2ban or iptables, logs import results | | `blocklist_service.py` | Downloads blocklists via aiohttp, validates IPs/CIDRs, applies bans through fail2ban or iptables, logs import results |

View File

@@ -90,6 +90,8 @@ A dedicated view for managing fail2ban jails and taking manual ban actions.
- A list of all jails showing their name, current status (running / stopped / idle), backend type, and key metrics. - A list of all jails showing their name, current status (running / stopped / idle), backend type, and key metrics.
- For each jail: number of currently banned IPs, total bans since start, current failures detected, and total failures. - For each jail: number of currently banned IPs, total bans since start, current failures detected, and total failures.
- Quick indicators for the jail's find time, ban time, and max retries. - Quick indicators for the jail's find time, ban time, and max retries.
- A toggle to also show **Inactive Jails** — jails that are defined in fail2ban config files but are not currently running.
- Each inactive jail has an **Activate** button that enables and reloads it immediately, with optional overrides for ban time, find time, max retries, port, and log path.
### Jail Detail ### Jail Detail
@@ -154,6 +156,7 @@ A page to inspect and modify the fail2ban configuration without leaving the web
- A scrollable left pane lists all items (jail names, filter filenames, action filenames). - A scrollable left pane lists all items (jail names, filter filenames, action filenames).
- Each item displays an **Active** or **Inactive** badge. Active items are sorted to the top; items within each group are sorted alphabetically. - Each item displays an **Active** or **Inactive** badge. Active items are sorted to the top; items within each group are sorted alphabetically.
- A jail is "active" if fail2ban reports it as enabled at runtime. A filter or action is "active" if it is referenced by at least one enabled jail. - A jail is "active" if fail2ban reports it as enabled at runtime. A filter or action is "active" if it is referenced by at least one enabled jail.
- Inactive jails (present in config files but not running) are discoverable from the Jails tab. Selecting one shows its config file settings and allows activating it.
- Clicking an item loads its structured configuration form in the right detail pane. - Clicking an item loads its structured configuration form in the right detail pane.
- On narrow screens (< 900 px) the list pane collapses into a dropdown above the detail pane. - On narrow screens (< 900 px) the list pane collapses into a dropdown above the detail pane.
- Show global fail2ban settings (ban time, find time, max retries, etc.) on the Global Settings tab. - Show global fail2ban settings (ban time, find time, max retries, etc.) on the Global Settings tab.
@@ -169,6 +172,8 @@ A page to inspect and modify the fail2ban configuration without leaving the web
- Configure ban-time escalation: enable incremental banning and set factor, formula, multipliers, maximum ban time, and random jitter. - Configure ban-time escalation: enable incremental banning and set factor, formula, multipliers, maximum ban time, and random jitter.
- Save changes and optionally reload fail2ban to apply them immediately. - Save changes and optionally reload fail2ban to apply them immediately.
- Validation feedback if a regex pattern or setting value is invalid before saving. - Validation feedback if a regex pattern or setting value is invalid before saving.
- **Activate** an inactive jail directly from the Jails tab detail pane, with optional parameter overrides.
- **Deactivate** a running jail from the Jails tab; writes ``enabled = false`` to a local override file and reloads fail2ban.
### Raw Configuration Editing ### Raw Configuration Editing

View File

@@ -4,217 +4,348 @@ This document breaks the entire BanGUI project into development stages, ordered
--- ---
## Config View Redesign — List/Detail Layout with Active Status and Raw Export ✅ COMPLETE ## Stage 1 — Inactive Jail Discovery and Activation
### Overview Currently BanGUI only shows jails that are actively running in fail2ban. fail2ban ships with dozens of pre-defined jails in `jail.conf` (sshd, apache-auth, nginx-http-auth, postfix, etc.) that are disabled by default. Users must be able to see these inactive jails and activate them from the web interface.
Redesign the Jails, Filters, and Actions tabs on the Configuration page (`frontend/src/pages/ConfigPage.tsx`) to use a **master/detail list layout** instead of the current accordion pattern. The left pane shows a scrollable list of config items (jail names, filter names, action names). Each item displays an active/inactive badge. Active items sort to the top. Clicking an item shows its structured form editor in the right pane, with a collapsible raw-text export section appended at the bottom. ### Task 1.1 — Backend: Parse Inactive Jails from Config Files ✅ DONE
Use only **Fluent UI React v9** (`@fluentui/react-components`, `@fluentui/react-icons`) components as specified in [Web-Design.md](Web-Design.md) and [Web-Development.md](Web-Development.md). No additional UI libraries. **Goal:** Read all jail definitions from the fail2ban configuration files and identify which ones are not currently enabled.
### References **Details:**
- [Web-Design.md](Web-Design.md) — Design rules: Fluent UI components, tokens, spacing, accessibility. - Create a new service `config_file_service.py` in `app/services/` responsible for reading and writing fail2ban config files on disk.
- [Web-Development.md](Web-Development.md) — Code rules: TypeScript strict, `makeStyles`, component structure, hooks, API layer. - Parse `jail.conf` and any `jail.local` / `jail.d/*.conf` / `jail.d/*.local` override files. Use Python's `configparser` (INI format) since fail2ban configs follow that format. Local files override `.conf` files (fail2ban merge order: `jail.conf``jail.local``jail.d/*.conf``jail.d/*.local`).
- Fluent UI v9 docs: https://github.com/microsoft/fluentui — components reference. - For each jail section found, extract at minimum: jail name, `enabled` flag (defaults to `false` if absent), `filter` name (defaults to the jail name if absent), `action` references, `port`, `logpath`, `backend`, `bantime`, `findtime`, `maxretry`.
- Build a list of `InactiveJail` objects for jails where `enabled = false` or that are defined in config but not reported by `fail2ban-client status`.
- The fail2ban config base path should come from the application settings (default: `/etc/fail2ban/`). The path is already available via `config.py` settings or can be derived from the fail2ban socket connection.
- Handle the `[DEFAULT]` section properly — its values provide defaults inherited by every jail section.
- Handle `[INCLUDES]` sections (`before` / `after` directives) so that path includes like `paths-debian.conf` are resolved, providing variables like `%(sshd_log)s`.
**Files to create/modify:**
- `app/services/config_file_service.py` (new)
- `app/models/config.py` (add `InactiveJail`, `InactiveJailListResponse` models)
**References:** [Features.md §6](Features.md), [Architekture.md §2](Architekture.md), [Backend-Development.md](Backend-Development.md)
--- ---
### Task A — Shared List/Detail Layout Component ### Task 1.2 — Backend: API Endpoints for Inactive Jails ✅ DONE
**File:** `frontend/src/components/config/ConfigListDetail.tsx` **Goal:** Expose inactive jail data and an activation endpoint via the REST API.
Create a reusable layout component that renders a two-pane master/detail view. **Details:**
**Left pane (list):** - Add a `GET /api/config/jails/inactive` endpoint to the config router that returns all inactive jails found in the config files. Each entry should include: `name`, `filter`, `actions` (list), `port`, `logpath`, `bantime`, `findtime`, `maxretry`, and the source file where the jail is defined.
- Fixed width ~280 px, full height of the tab content area, with its own vertical scroll. - Add a `POST /api/config/jails/{name}/activate` endpoint that enables an inactive jail. This endpoint should:
- Use a vertical stack of clickable items. Each item is a Fluent UI `Card` (or a simple styled `div` with `tokens.colorNeutralBackground2` on hover) displaying: 1. Validate the jail name exists in the parsed config.
- The config **name** (e.g. `sshd`, `iptables-multiport`), truncated with ellipsis + tooltip for long names. 2. Write `enabled = true` into the appropriate `.local` override file (`jail.local` or `jail.d/{name}.local`). Never modify `.conf` files directly — always use `.local` overrides following fail2ban convention.
- A Fluent UI `Badge` to the right of the name: **"Active"** (`appearance="filled"`, `color="success"`) or **"Inactive"** (`appearance="outline"`, `color="informative"`). 3. Optionally accept a request body with override values (`bantime`, `findtime`, `maxretry`, `port`, `logpath`) that get written alongside the `enabled = true` line.
- The selected item gets a left border accent (`tokens.colorBrandBackground`) and a highlighted background (`tokens.colorNeutralBackground1Selected`). 4. Trigger a fail2ban reload so the jail starts immediately.
- Items are sorted: **active items first**, then inactive, alphabetical within each group. 5. Return the jail's new status after activation.
- Keyboard navigable (arrow keys, Enter to select). Follow the accessibility rules from Web-Design.md §15. - Add a `POST /api/config/jails/{name}/deactivate` endpoint that sets `enabled = false` in the `.local` file and reloads fail2ban to stop the jail.
- All endpoints require authentication. Return 404 if the jail name is not found in any config file. Return 409 if the jail is already active/inactive.
**Right pane (detail):** **Files to create/modify:**
- Takes remaining width. Renders whatever `children` or render-prop content the parent tab passes for the currently selected item. - `app/routers/config.py` (add endpoints)
- Displays an empty state (`"Select an item from the list"`) when nothing is selected. - `app/services/config_file_service.py` (add activate/deactivate logic)
- Uses `Skeleton` / `Spinner` while the detail is loading. - `app/models/config.py` (add request/response models: `ActivateJailRequest`, `JailActivationResponse`)
**Props interface (suggestion):** **References:** [Features.md §6](Features.md), [Backend-Development.md §3](Backend-Development.md)
```typescript
interface ConfigListDetailProps<T extends { name: string }> {
items: T[];
isActive: (item: T) => boolean;
selectedName: string | null;
onSelect: (name: string) => void;
loading: boolean;
error: string | null;
children: React.ReactNode; // detail content for selected item
}
```
**Styles:** Add new style slots to `frontend/src/components/config/configStyles.ts`:
- `listDetailRoot` — flex row, gap `tokens.spacingHorizontalM`.
- `listPane` — fixed width 280 px, overflow-y auto, border-right `tokens.colorNeutralStroke2`.
- `listItem` — padding, hover background, cursor pointer.
- `listItemSelected` — left brand-colour border, selected background.
- `detailPane` — flex 1, overflow-y auto, padding.
Responsive: On screens < 900 px, collapse the list pane into a `Dropdown` / `Select` above the detail pane so it doesn't take too much horizontal space.
--- ---
### Task B — Active Status for Jails, Filters, and Actions ### Task 1.3 — Frontend: Inactive Jails in Configuration View ✅ DONE
Determine "active" status for each config type so it can be shown in the list. **Goal:** Display inactive jails in the Configuration page and let users activate them.
**Jails:** **Details:**
- The existing `JailConfig` type does not carry an `enabled` flag directly. The jails API (`GET /api/jails`) returns `JailSummary` objects which contain `enabled: boolean`. To get the active status, fetch the jails list from `fetchJails()` (in `api/jails.ts`) alongside the jail configs from `fetchJailConfigs()`. Merge the two by name: a jail is "active" if `enabled === true` in the jails list.
- Alternatively, check the `JailConfigFile` entries from the jail files API which have `enabled: boolean` — determine which data source is more reliable for showing runtime active state.
**Filters:** - In the **Jails tab** of the Configuration page, extend the existing master/detail list layout. The left pane currently shows active jails — add a section or toggle that also shows inactive jails. Use the **Active/Inactive badge** system described in [Features.md §6](Features.md): active jails sorted to the top with an "Active" badge, inactive jails below with an "Inactive" badge, alphabetical within each group.
- A filter is "active" if it is referenced by at least one active jail. After fetching jail configs (`JailConfig[]`), collect all unique filter names used by enabled jails. Cross-reference with the filter files list (`ConfFileEntry[]`). Mark used ones as active. - Clicking an inactive jail in the list loads its config details in the right detail pane. Show all parsed fields (filter, actions, port, logpath, bantime, findtime, maxretry) as read-only or editable fields.
- This requires correlating data: jail config has a `name` field that often matches the filter name (convention in fail2ban: jail name = filter name unless overridden). For accuracy, the filter name a jail uses can be inferred from the jail name or, if the backend provides a `filter` field on `JailConfig`, from that. - Add an **"Activate Jail"** button (prominent, primary style) in the detail pane for inactive jails. Clicking it opens a confirmation dialog that allows the user to override default values (bantime, findtime, maxretry, port, logpath) before activation. On confirm, call `POST /api/config/jails/{name}/activate` with the overrides.
- After successful activation, refresh the jail list and show the jail under the active group with a success toast notification.
- For active jails, add a **"Deactivate Jail"** button (secondary/danger style) that calls the deactivate endpoint after confirmation.
- Add the API functions `fetchInactiveJails()`, `activateJail(name, overrides)`, `deactivateJail(name)` to `frontend/src/api/config.ts`.
- Add corresponding TypeScript types (`InactiveJail`, `ActivateJailRequest`) to `frontend/src/types/config.ts`.
**Actions:** **Files to create/modify:**
- An action is "active" if it is referenced by at least one active jail's `actions` array. After fetching jail configs, collect all unique action names from `JailConfig.actions[]` arrays of enabled jails. Cross-reference with action files. - `frontend/src/api/config.ts` (add API calls)
- `frontend/src/types/config.ts` (add types)
- `frontend/src/components/config/JailsTab.tsx` (add inactive jail list + activation UI)
- New component: `frontend/src/components/config/ActivateJailDialog.tsx`
**Implementation:** **References:** [Features.md §6](Features.md), [Web-Design.md](Web-Design.md), [Web-Development.md](Web-Development.md)
- Create a new hook `frontend/src/hooks/useConfigActiveStatus.ts` that:
1. Fetches jails list (`fetchJails`), jail configs (`fetchJailConfigs`), filter files (`fetchFilterFiles`), and action files (`fetchActionFiles`) in parallel.
2. Computes and returns `{ activeJails: Set<string>, activeFilters: Set<string>, activeActions: Set<string>, loading: boolean, error: string | null }`.
3. Cache results and re-fetch on demand (expose a `refresh` function).
- The hook is consumed by the three tabs so each tab knows which items are active.
--- ---
### Task C — Redesign JailsTab to List/Detail Layout ### Task 1.4 — Frontend: Inactive Jails on Jails Page ✅ DONE
**File:** `frontend/src/components/config/JailsTab.tsx` **Goal:** Show inactive jails alongside active jails in the Jail Management overview.
Replace the current `Accordion`-based layout with the `ConfigListDetail` component from Task A. **Details:**
1. Fetch jail configs via `useJailConfigs` (already exists in `hooks/useConfig.ts`). - On the **Jails Page** (`JailsPage.tsx`), extend the jail overview table to include inactive jails. Add a column or badge showing each jail's activation state.
2. Use the active-status hook from Task B to get `activeJails`. - Inactive jails should appear in the table with dimmed/muted styling and show "Inactive" as status. They should not have start/stop/idle/reload controls — only an "Activate" action button.
3. Render `ConfigListDetail` with: - Add a toggle or filter above the table: "Show inactive jails" (default: on). When off, only active jails are displayed.
- `items` = jail config list. - Clicking an inactive jail's name navigates to the Configuration page's jail detail for that jail, pre-selecting it in the list.
- `isActive` = `(jail) => activeJails.has(jail.name)`.
- `onSelect` updates `selectedName` state. **Files to create/modify:**
4. The right-pane detail content is the **existing `JailAccordionPanel` logic** (the form fields for ban_time, find_time, max_retry, regex patterns, log paths, escalation, etc.) — extract it into a standalone `JailConfigDetail` component if not already. Remove the accordion wrapper; it is just the form body now. - `frontend/src/pages/JailsPage.tsx`
5. **Export section** (new, at the bottom of the detail pane): - `frontend/src/hooks/useJails.ts` (extend to optionally fetch inactive jails)
- A collapsible section (use `Accordion` with a single item, or a `Button` toggle) titled **"Raw Configuration"**.
- Contains a Fluent UI `Textarea` (monospace font, full width, ~20 rows) pre-filled with the raw plain-text representation of the jail config. The raw text is fetched via the existing `fetchJailConfig` or `fetchJailConfigFile` endpoint that returns the file content as a string. **References:** [Features.md §5](Features.md), [Web-Design.md](Web-Design.md)
- The textarea is **editable**: the user can modify the raw text and click a "Save Raw" button to push the changes back via the existing `updateJailConfigFile` PUT endpoint.
- Show an `AutoSaveIndicator` or manual save button + success/error `MessageBar`.
--- ---
### Task D — Redesign FiltersTab to List/Detail Layout ### Task 1.5 — Tests: Inactive Jail Parsing and Activation ✅ DONE
**File:** `frontend/src/components/config/FiltersTab.tsx` **Goal:** Full test coverage for the new inactive-jail functionality.
Replace the current `Accordion`-based layout with `ConfigListDetail`. **Details:**
1. Fetch filter file list via `fetchFilterFiles` (already used). - **Service tests** (`tests/test_services/test_config_file_service.py`): Test config file parsing with mock jail.conf content containing multiple jails (enabled and disabled). Test the merge order (`.conf``.local``jail.d/`). Test DEFAULT section inheritance. Test variable interpolation (`%(sshd_log)s`). Test activation writes to `.local` files. Test deactivation.
2. Use `activeFilters` from the active-status hook (Task B). - **Router tests** (`tests/test_routers/test_config.py`): Add tests for the three new endpoints. Test 404 for unknown jail names. Test 409 for already-active/inactive jails. Test that override values are passed through correctly.
3. Render `ConfigListDetail` with: - **Frontend tests**: Add unit tests for the new API functions. Add component tests for the activation dialog and the inactive jail display.
- `items` = filter file entries.
- `isActive` = `(f) => activeFilters.has(f.name)`. **Files to create/modify:**
4. On item select, lazily load the parsed filter via `useFilterConfig` (already exists in `hooks/useFilterConfig.ts`). - `backend/tests/test_services/test_config_file_service.py` (new)
5. Right pane renders the **existing `FilterForm`** component with the loaded config. - `backend/tests/test_routers/test_config.py` (extend)
6. **Export section** at the bottom of the detail pane: - `frontend/src/components/config/__tests__/` (new tests)
- Collapsible "Raw Configuration" section.
- `Textarea` (monospace) pre-filled with the raw file content fetched via `fetchFilterFile(name)` (returns `ConfFileContent` with a `content: string` field). **References:** [Backend-Development.md](Backend-Development.md), [Web-Development.md](Web-Development.md)
- Editable with a "Save Raw" `Button` that calls `updateFilterFile(name, { content })`.
- Success/error feedback via `MessageBar`.
--- ---
### Task E — Redesign ActionsTab to List/Detail Layout ## Stage 2 — Filter Configuration Discovery and Activation
**File:** `frontend/src/components/config/ActionsTab.tsx` fail2ban ships with a large collection of filter definitions in `filter.d/` (over 80 files). Users need to see all available filters — both those currently in use by active jails and those available but unused — and assign them to jails.
Same pattern as Task D but for actions. ### Task 2.1 — Backend: List All Available Filters with Active/Inactive Status
1. Fetch action file list via `fetchActionFiles`. **Goal:** Enumerate all filter config files and mark each as active or inactive.
2. Use `activeActions` from the active-status hook (Task B).
3. Render `ConfigListDetail` with: **Details:**
- `items` = action file entries.
- `isActive` = `(a) => activeActions.has(a.name)`. - Add a method `list_filters()` to `config_file_service.py` that scans the `filter.d/` directory within the fail2ban config path.
4. On item select, lazily load the parsed action via `useActionConfig` (already exists). - For each `.conf` file found, parse its `[Definition]` section to extract: `failregex` (list of patterns), `ignoreregex` (list), `datepattern`, `journalmatch`, and any other fields present. Also parse `[Init]` sections for default variable bindings.
5. Right pane renders the **existing `ActionForm`** component. - Handle `.local` overrides: if `sshd.local` exists alongside `sshd.conf`, merge the local values on top of the conf values (local wins).
6. **Export section** at the bottom: - Determine active/inactive status: a filter is "active" if its name matches the `filter` field of any currently enabled jail (cross-reference with the active jail list from fail2ban and the inactive jail configs). Mark it accordingly.
- Collapsible "Raw Configuration" section. - Return a list of `FilterConfig` objects with: `name`, `active` (bool), `used_by_jails` (list of jail names using this filter), `failregex` (list), `ignoreregex` (list), `datepattern`, `source_file` (path to the `.conf` file), `has_local_override` (bool).
- `Textarea` (monospace) with raw file content from `fetchActionFile(name)`. - Add a `GET /api/config/filters` endpoint to the config router returning all filters.
- "Save Raw" button calling `updateActionFile(name, { content })`. - Add a `GET /api/config/filters/{name}` endpoint returning the full parsed detail of a single filter.
- Feedback messages.
**Files to create/modify:**
- `app/services/config_file_service.py` (add `list_filters`, `get_filter`)
- `app/models/config.py` (add `FilterConfig`, `FilterListResponse`, `FilterDetailResponse`)
- `app/routers/config.py` (add endpoints)
**References:** [Features.md §6](Features.md), [Architekture.md §2](Architekture.md)
--- ---
### Task F — Raw Export Section Component ### Task 2.2 — Backend: Activate and Edit Filters
**File:** `frontend/src/components/config/RawConfigSection.tsx` **Goal:** Allow users to assign a filter to a jail and edit filter regex patterns.
Extract the raw-export pattern into a reusable component so Jails, Filters, and Actions tabs don't duplicate logic. **Details:**
**Props:** - Add a `PUT /api/config/filters/{name}` endpoint that writes changes to a filter's `.local` override file. Accepts updated `failregex`, `ignoreregex`, `datepattern`, and `journalmatch` values. Never write to the `.conf` file directly.
- Add a `POST /api/config/jails/{jail_name}/filter` endpoint that changes which filter a jail uses. This writes `filter = {filter_name}` to the jail's `.local` config. Requires a reload for the change to take effect.
- Add a `POST /api/config/filters` endpoint to create a brand-new filter. Accepts a name and the filter definition fields. Creates a new file at `filter.d/{name}.local`.
- Add a `DELETE /api/config/filters/{name}` endpoint that deletes a custom filter's `.local` file. Refuse to delete files that are `.conf` (shipped defaults) — only user-created `.local` files without a corresponding `.conf` can be fully removed.
- Validate all regex patterns using Python's `re` module before writing them to disk. Return 422 with specific error details if any pattern is invalid.
- After any write operation, optionally trigger a fail2ban reload if the user requests it (query param `?reload=true`).
```typescript **Files to create/modify:**
interface RawConfigSectionProps { - `app/services/config_file_service.py` (add filter write/create/delete methods)
/** Async function that returns the raw file content string. */ - `app/routers/config.py` (add endpoints)
fetchContent: () => Promise<string>; - `app/models/config.py` (add `FilterUpdateRequest`, `FilterCreateRequest`)
/** Async function that saves updated raw content. */
saveContent: (content: string) => Promise<void>;
/** Label shown in the collapsible header, e.g. "Raw Jail Configuration". */
label?: string;
}
```
**Behaviour:** **References:** [Features.md §6](Features.md), [Backend-Development.md](Backend-Development.md)
- Renders a collapsible section (single `AccordionItem` or a disclosure `Button`).
- When expanded for the first time, calls `fetchContent()` and fills the `Textarea`.
- Uses monospace font (`fontFamily: "monospace"`) and a left brand-colour accent border (reuse `styles.codeInput` from `configStyles.ts`).
- "Save Raw" `Button` with `appearance="primary"` calls `saveContent(text)`.
- Shows `AutoSaveIndicator`-style feedback (idle → saving → saved / error).
- The `Textarea` is resizable vertically, minimum 15 rows.
--- ---
### Task G — Update Barrel Exports and ConfigPage ### Task 2.3 — Frontend: Filters Tab with Active/Inactive Display and Activation
1. **`frontend/src/components/config/index.ts`** — Add exports for `ConfigListDetail` and `RawConfigSection`. **Goal:** Enhance the Filters tab in the Configuration page to show all filters with their active/inactive status and allow editing.
2. **`frontend/src/pages/ConfigPage.tsx`** — No structural changes needed if the tabs internally switch to the new layout. Verify the page still renders all tabs correctly.
3. **`frontend/src/components/config/configStyles.ts`** — Add the new style slots described in Task A. Do not remove existing styles that may still be used by other tabs (Global, Server, Map, Regex Tester, Export). **Details:**
- Redesign the **Filters tab** to use the master/detail list layout described in [Features.md §6](Features.md).
- The **left pane** lists all filter names with an Active or Inactive badge. Active filters (those used by at least one enabled jail) are sorted to the top. Within each group, sort alphabetically.
- The badge should also show which jails use the filter (e.g., "Active — used by sshd, apache-auth").
- Clicking a filter loads its detail in the **right pane**: `failregex` patterns (editable list), `ignoreregex` patterns (editable list), `datepattern` (editable), source file info, and whether a `.local` override exists.
- Add a **"Save Changes"** button that calls `PUT /api/config/filters/{name}` to persist edits to the `.local` override. Show save-state feedback (idle → saving → saved → error).
- Add an **"Assign to Jail"** button that opens a dialog where the user selects a jail and assigns this filter to it. This calls the assign-filter endpoint.
- Add a **"Create Filter"** button at the top of the list pane that opens a dialog for entering a new filter name and regex patterns.
- On narrow screens (< 900 px), collapse the list pane into a dropdown as specified in [Features.md §6](Features.md).
- Include the existing **Raw Configuration** collapsible section at the bottom of the detail pane for direct file editing.
**Files to create/modify:**
- `frontend/src/components/config/FiltersTab.tsx` (rewrite with master/detail layout)
- `frontend/src/components/config/FilterForm.tsx` (update for editable fields)
- `frontend/src/api/config.ts` (add `fetchFilters`, `fetchFilter`, `updateFilter`, `createFilter`, `deleteFilter`, `assignFilterToJail`)
- `frontend/src/types/config.ts` (add types)
- New component: `frontend/src/components/config/AssignFilterDialog.tsx`
**References:** [Features.md §6](Features.md), [Web-Design.md](Web-Design.md), [Web-Development.md](Web-Development.md)
--- ---
### Task H — Testing and Validation ### Task 2.4 — Tests: Filter Discovery and Management
1. **Type-check:** Run `npx tsc --noEmit` — zero errors. **Goal:** Test coverage for filter listing, editing, creation, and assignment.
2. **Lint:** Run `npm run lint` — zero warnings.
3. **Existing tests:** Run `npx vitest run` — all existing tests pass. **Details:**
4. **Manual verification:**
- Navigate to Config → Jails tab. List pane shows jail names with active badges. Active jails appear at the top. Click a jail → right pane shows configuration form + collapsible raw editor. - **Service tests**: Mock the `filter.d/` directory with sample `.conf` and `.local` files. Test parsing of `[Definition]` sections. Test merge of `.local` over `.conf`. Test active/inactive detection by cross-referencing with mock jail data. Test write operations create correct `.local` content. Test regex validation rejects bad patterns.
- Navigate to Config → Filters tab. Same list/detail pattern. Active filters (used by running jails) show "Active" badge. - **Router tests**: Test all new filter endpoints (list, detail, update, create, delete, assign). Test auth required. Test 404/409/422 responses.
- Navigate to Config → Actions tab. Same pattern. - **Frontend tests**: Test the filter list rendering with mixed active/inactive items. Test the form submission in the detail pane. Test the assign dialog.
- Resize window below 900 px — list collapses to a dropdown selector above the detail.
- Keyboard: Tab into the list, arrow-key navigate, Enter to select. **Files to create/modify:**
5. **New tests (optional but recommended):** - `backend/tests/test_services/test_config_file_service.py` (extend)
- Unit test `ConfigListDetail` rendering with mock items, verifying sort order (active first) and selection callback. - `backend/tests/test_routers/test_config.py` (extend)
- Unit test `RawConfigSection` with mocked fetch/save functions. - `frontend/src/components/config/__tests__/` (add filter tests)
--- ---
### Implementation Order ## Stage 3 — Action Configuration Discovery and Activation
1. Task F (RawConfigSection) — standalone, no dependencies. fail2ban ships with many action definitions in `action.d/` (iptables, firewalld, cloudflare, sendmail, etc.). Users need to see all available actions, understand which are in use, and assign them to jails.
2. Task A (ConfigListDetail layout) — standalone component.
3. Task B (useConfigActiveStatus hook) — needs only the existing API layer. ### Task 3.1 — Backend: List All Available Actions with Active/Inactive Status
4. Task C (JailsTab redesign) — depends on A, B, F.
5. Task D (FiltersTab redesign) — depends on A, B, F. **Goal:** Enumerate all action config files and mark each as active or inactive based on jail usage.
6. Task E (ActionsTab redesign) — depends on A, B, F.
7. Task G (exports and wiring) — after C/D/E. **Details:**
8. Task H (testing) — last.
- Add a method `list_actions()` to `config_file_service.py` that scans the `action.d/` directory.
- For each `.conf` file, parse the `[Definition]` section to extract: `actionstart`, `actionstop`, `actioncheck`, `actionban`, `actionunban` commands. Also parse `[Init]` for default variable bindings (port, protocol, chain, etc.).
- Handle `.local` overrides the same way as filters.
- Determine active/inactive status: an action is "active" if its name appears in the `action` field of any currently enabled jail. Cross-reference against both running jails (from fail2ban) and the config files.
- Return `ActionConfig` objects with: `name`, `active` (bool), `used_by_jails` (list), `actionban` (command template), `actionunban` (command template), `actionstart`, `actionstop`, `init_params` (dict of Init variables), `source_file`, `has_local_override`.
- Add `GET /api/config/actions` endpoint returning all actions.
- Add `GET /api/config/actions/{name}` endpoint returning the full parsed detail of one action.
**Files to create/modify:**
- `app/services/config_file_service.py` (add `list_actions`, `get_action`)
- `app/models/config.py` (add `ActionConfig`, `ActionListResponse`, `ActionDetailResponse`)
- `app/routers/config.py` (add endpoints)
**References:** [Features.md §6](Features.md), [Architekture.md §2](Architekture.md)
--- ---
### Task 3.2 — Backend: Activate and Edit Actions
**Goal:** Allow users to assign actions to jails, edit action definitions, and create new actions.
**Details:**
- Add a `PUT /api/config/actions/{name}` endpoint that writes changes to an action's `.local` override file. Accepts updated `actionban`, `actionunban`, `actionstart`, `actionstop`, `actioncheck` values and any `[Init]` parameters.
- Add a `POST /api/config/jails/{jail_name}/action` endpoint that adds an action to a jail's action list. This writes the action reference into the jail's `.local` config file. Multiple actions per jail are supported (fail2ban allows comma-separated action lists). Include optional parameters for the action (e.g., port, protocol).
- Add a `DELETE /api/config/jails/{jail_name}/action/{action_name}` endpoint that removes an action from a jail's configuration.
- Add a `POST /api/config/actions` endpoint to create a brand-new action definition (`.local` file).
- Add a `DELETE /api/config/actions/{name}` endpoint to delete a custom action's `.local` file. Same safety rules as filters — refuse to delete shipped `.conf` files.
- After any write, optionally reload fail2ban (`?reload=true`).
**Files to create/modify:**
- `app/services/config_file_service.py` (add action write/create/delete/assign methods)
- `app/routers/config.py` (add endpoints)
- `app/models/config.py` (add `ActionUpdateRequest`, `ActionCreateRequest`, `AssignActionRequest`)
**References:** [Features.md §6](Features.md), [Backend-Development.md](Backend-Development.md)
---
### Task 3.3 — Frontend: Actions Tab with Active/Inactive Display and Activation
**Goal:** Enhance the Actions tab in the Configuration page to show all actions with active/inactive status and allow editing and assignment.
**Details:**
- Redesign the **Actions tab** to use the same master/detail list layout as Filters.
- The **left pane** lists all action names with Active/Inactive badges. Active actions (used by at least one enabled jail) sorted to the top.
- Badge shows which jails reference the action (e.g., "Active — used by sshd, postfix").
- Clicking an action loads its detail in the **right pane**: `actionban`, `actionunban`, `actionstart`, `actionstop`, `actioncheck` (each in a code/textarea editor), `[Init]` parameters as key-value fields.
- Add a **"Save Changes"** button for persisting edits to the `.local` override.
- Add an **"Assign to Jail"** button that opens a dialog for selecting a jail and providing action parameters (port, protocol, chain). Calls the assign-action endpoint.
- Add a **"Remove from Jail"** option in the detail pane — shows a list of jails currently using the action with a remove button next to each.
- Add a **"Create Action"** button at the top of the list pane.
- Raw Configuration collapsible section at the bottom.
- Responsive collapse on narrow screens.
**Files to create/modify:**
- `frontend/src/components/config/ActionsTab.tsx` (rewrite with master/detail layout)
- `frontend/src/components/config/ActionForm.tsx` (update for editable fields)
- `frontend/src/api/config.ts` (add `fetchActions`, `fetchAction`, `updateAction`, `createAction`, `deleteAction`, `assignActionToJail`, `removeActionFromJail`)
- `frontend/src/types/config.ts` (add types)
- New component: `frontend/src/components/config/AssignActionDialog.tsx`
**References:** [Features.md §6](Features.md), [Web-Design.md](Web-Design.md), [Web-Development.md](Web-Development.md)
---
### Task 3.4 — Tests: Action Discovery and Management
**Goal:** Test coverage for action listing, editing, creation, and assignment.
**Details:**
- **Service tests**: Mock `action.d/` with sample configs. Test parsing of `[Definition]` and `[Init]` sections. Test active/inactive detection. Test write and create operations. Test delete safety (refuse `.conf` deletion).
- **Router tests**: Test all new action endpoints. Test auth, 404, 409, 422 responses.
- **Frontend tests**: Test action list rendering, form editing, assign dialog, remove-from-jail flow.
**Files to create/modify:**
- `backend/tests/test_services/test_config_file_service.py` (extend)
- `backend/tests/test_routers/test_config.py` (extend)
- `frontend/src/components/config/__tests__/` (add action tests)
---
## Stage 4 — Unified Config File Service and Shared Utilities
### Task 4.1 — Config File Parser Utility
**Goal:** Build a robust, reusable parser for fail2ban INI-style config files that all config-related features share.
**Details:**
- Create `app/utils/config_parser.py` with a `Fail2BanConfigParser` class that wraps Python's `configparser.ConfigParser` with fail2ban-specific behaviour:
- **Merge order**: `.conf` file first, then `.local` overlay, then `*.d/` directory overrides.
- **Variable interpolation**: Support `%(variable)s` syntax, resolving against `[DEFAULT]` and `[Init]` sections.
- **Include directives**: Process `before = filename` and `after = filename` in `[INCLUDES]` sections, resolving paths relative to the config directory.
- **Multi-line values**: Handle backslash-continuation and multi-line regex lists.
- **Comments**: Strip `#` and `;` line/inline comments correctly.
- The parser should return structured `dict` data that the `config_file_service` methods consume.
- This is a pure utility — no I/O aside from reading files. Everything is synchronous, callable from async context via `run_in_executor`.
- Write comprehensive unit tests in `tests/test_utils/test_config_parser.py` covering all edge cases: empty sections, missing files, circular includes, invalid interpolation, multi-line regex, override merging.
**Files to create/modify:**
- `app/utils/config_parser.py` (new)
- `tests/test_utils/test_config_parser.py` (new)
**References:** [Backend-Development.md](Backend-Development.md)
---
### Task 4.2 — Config File Writer Utility
**Goal:** Build a safe writer utility for creating and updating `.local` override files.
**Details:**
- Create `app/utils/config_writer.py` with functions for:
- `write_local_override(base_path, section, key_values)` — Writes or updates a `.local` file. If the file exists, update only the specified keys under the given section. If it does not exist, create it with a header comment explaining it's a BanGUI-managed override.
- `remove_local_key(base_path, section, key)` — Removes a specific key from a `.local` file.
- `delete_local_file(path)` — Deletes a `.local` file, but only if no corresponding `.conf` exists or the caller explicitly allows orphan deletion.
- All write operations must be atomic: write to a temporary file first, then rename into place (`os.replace`). This prevents corruption on crash.
- Add a file-level lock (per config file) to prevent concurrent writes from racing.
- Never write to `.conf` files — assert this in every write function.
**Files to create/modify:**
- `app/utils/config_writer.py` (new)
- `tests/test_utils/test_config_writer.py` (new)
**References:** [Backend-Development.md](Backend-Development.md)

View File

@@ -447,3 +447,119 @@ class JailFileConfigUpdate(BaseModel):
default=None, default=None,
description="Jail section updates. Only jails present in this dict are updated.", description="Jail section updates. Only jails present in this dict are updated.",
) )
# ---------------------------------------------------------------------------
# Inactive jail models (Stage 1)
# ---------------------------------------------------------------------------
class InactiveJail(BaseModel):
"""A jail defined in fail2ban config files that is not currently active.
A jail is considered inactive when its ``enabled`` key is ``false`` (or
absent from the config, since fail2ban defaults to disabled) **or** when it
is explicitly enabled in config but fail2ban is not reporting it as
running.
"""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Jail name from the config section header.")
filter: str = Field(
...,
description=(
"Filter name used by this jail. May include fail2ban mode suffix, "
"e.g. ``sshd[mode=normal]``."
),
)
actions: list[str] = Field(
default_factory=list,
description="Action references listed in the config (raw strings).",
)
port: str | None = Field(
default=None,
description="Port(s) to monitor, e.g. ``ssh`` or ``22,2222``.",
)
logpath: list[str] = Field(
default_factory=list,
description="Log file paths to monitor.",
)
bantime: str | None = Field(
default=None,
description="Ban duration as a raw config string, e.g. ``10m`` or ``-1``.",
)
findtime: str | None = Field(
default=None,
description="Failure-counting window as a raw config string, e.g. ``10m``.",
)
maxretry: int | None = Field(
default=None,
description="Number of failures before a ban is issued.",
)
source_file: str = Field(
...,
description="Absolute path to the config file where this jail is defined.",
)
enabled: bool = Field(
...,
description=(
"Effective ``enabled`` value from the merged config. ``False`` for "
"inactive jails that appear in this list."
),
)
class InactiveJailListResponse(BaseModel):
"""Response for ``GET /api/config/jails/inactive``."""
model_config = ConfigDict(strict=True)
jails: list[InactiveJail] = Field(default_factory=list)
total: int = Field(..., ge=0)
class ActivateJailRequest(BaseModel):
"""Optional override values when activating an inactive jail.
All fields are optional. Omitted fields are not written to the
``.local`` override file so that fail2ban falls back to its default
values.
"""
model_config = ConfigDict(strict=True)
bantime: str | None = Field(
default=None,
description="Override ban duration, e.g. ``1h`` or ``3600``.",
)
findtime: str | None = Field(
default=None,
description="Override failure-counting window, e.g. ``10m``.",
)
maxretry: int | None = Field(
default=None,
ge=1,
description="Override maximum failures before a ban.",
)
port: str | None = Field(
default=None,
description="Override port(s) to monitor.",
)
logpath: list[str] | None = Field(
default=None,
description="Override log file paths.",
)
class JailActivationResponse(BaseModel):
"""Response for jail activation and deactivation endpoints."""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Name of the affected jail.")
active: bool = Field(
...,
description="New activation state: ``True`` after activate, ``False`` after deactivate.",
)
message: str = Field(..., description="Human-readable result message.")

View File

@@ -3,15 +3,18 @@
Provides endpoints to inspect and edit fail2ban jail configuration and Provides endpoints to inspect and edit fail2ban jail configuration and
global settings, test regex patterns, add log paths, and preview log files. global settings, test regex patterns, add log paths, and preview log files.
* ``GET /api/config/jails`` — list all jail configs * ``GET /api/config/jails`` — list all jail configs
* ``GET /api/config/jails/{name}`` — full config for one jail * ``GET /api/config/jails/{name}`` — full config for one jail
* ``PUT /api/config/jails/{name}`` — update a jail's config * ``PUT /api/config/jails/{name}`` — update a jail's config
* ``GET /api/config/global`` — global fail2ban settings * ``GET /api/config/jails/inactive`` — list all inactive jails
* ``PUT /api/config/global`` — update global settings * ``POST /api/config/jails/{name}/activate`` — activate an inactive jail
* ``POST /api/config/reload`` — reload fail2ban * ``POST /api/config/jails/{name}/deactivate`` — deactivate an active jail
* ``POST /api/config/regex-test`` — test a regex pattern * ``GET /api/config/global`` — global fail2ban settings
* ``POST /api/config/jails/{name}/logpath`` — add a log path to a jail * ``PUT /api/config/global`` — update global settings
* ``POST /api/config/preview-log`` — preview log matches * ``POST /api/config/reload`` — reload fail2ban
* ``POST /api/config/regex-test`` — test a regex pattern
* ``POST /api/config/jails/{name}/logpath`` — add a log path to a jail
* ``POST /api/config/preview-log`` — preview log matches
""" """
from __future__ import annotations from __future__ import annotations
@@ -22,9 +25,12 @@ from fastapi import APIRouter, HTTPException, Path, Query, Request, status
from app.dependencies import AuthDep from app.dependencies import AuthDep
from app.models.config import ( from app.models.config import (
ActivateJailRequest,
AddLogPathRequest, AddLogPathRequest,
GlobalConfigResponse, GlobalConfigResponse,
GlobalConfigUpdate, GlobalConfigUpdate,
InactiveJailListResponse,
JailActivationResponse,
JailConfigListResponse, JailConfigListResponse,
JailConfigResponse, JailConfigResponse,
JailConfigUpdate, JailConfigUpdate,
@@ -35,7 +41,14 @@ from app.models.config import (
RegexTestRequest, RegexTestRequest,
RegexTestResponse, RegexTestResponse,
) )
from app.services import config_service, jail_service from app.services import config_file_service, config_service, jail_service
from app.services.config_file_service import (
ConfigWriteError,
JailAlreadyActiveError,
JailAlreadyInactiveError,
JailNameError,
JailNotFoundInConfigError,
)
from app.services.config_service import ( from app.services.config_service import (
ConfigOperationError, ConfigOperationError,
ConfigValidationError, ConfigValidationError,
@@ -113,6 +126,33 @@ async def get_jail_configs(
raise _bad_gateway(exc) from exc raise _bad_gateway(exc) from exc
@router.get(
"/jails/inactive",
response_model=InactiveJailListResponse,
summary="List all inactive jails discovered in config files",
)
async def get_inactive_jails(
request: Request,
_auth: AuthDep,
) -> InactiveJailListResponse:
"""Return all jails defined in fail2ban config files that are not running.
Parses ``jail.conf``, ``jail.local``, and ``jail.d/`` following the
fail2ban merge order. Jails that fail2ban currently reports as running
are excluded; only truly inactive entries are returned.
Args:
request: FastAPI request object.
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.config.InactiveJailListResponse`.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
return await config_file_service.list_inactive_jails(config_dir, socket_path)
@router.get( @router.get(
"/jails/{name}", "/jails/{name}",
response_model=JailConfigResponse, response_model=JailConfigResponse,
@@ -495,3 +535,114 @@ async def update_map_color_thresholds(
threshold_medium=body.threshold_medium, threshold_medium=body.threshold_medium,
threshold_low=body.threshold_low, threshold_low=body.threshold_low,
) )
@router.post(
"/jails/{name}/activate",
response_model=JailActivationResponse,
summary="Activate an inactive jail",
)
async def activate_jail(
request: Request,
_auth: AuthDep,
name: _NamePath,
body: ActivateJailRequest | None = None,
) -> JailActivationResponse:
"""Enable an inactive jail and reload fail2ban.
Writes ``enabled = true`` (plus any override values from the request
body) to ``jail.d/{name}.local`` and triggers a full fail2ban reload so
the jail starts immediately.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Name of the jail to activate.
body: Optional override values (bantime, findtime, maxretry, port,
logpath).
Returns:
:class:`~app.models.config.JailActivationResponse`.
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 already active.
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
req = body if body is not None else ActivateJailRequest()
try:
return await config_file_service.activate_jail(
config_dir, socket_path, name, req
)
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 already active.",
) from None
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write config override: {exc}",
) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.post(
"/jails/{name}/deactivate",
response_model=JailActivationResponse,
summary="Deactivate an active jail",
)
async def deactivate_jail(
request: Request,
_auth: AuthDep,
name: _NamePath,
) -> JailActivationResponse:
"""Disable an active jail and reload fail2ban.
Writes ``enabled = false`` to ``jail.d/{name}.local`` and triggers a
full fail2ban reload so the jail stops immediately.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Name of the jail to deactivate.
Returns:
:class:`~app.models.config.JailActivationResponse`.
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 already inactive.
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:
return await config_file_service.deactivate_jail(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 JailAlreadyInactiveError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Jail {name!r} is already inactive.",
) from None
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write config override: {exc}",
) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc

View File

@@ -0,0 +1,666 @@
"""Fail2ban jail configuration file parser and activator.
Parses the full set of fail2ban jail configuration files
(``jail.conf``, ``jail.local``, ``jail.d/*.conf``, ``jail.d/*.local``)
to discover all defined jails — both active and inactive — and provides
functions to activate or deactivate them by writing ``.local`` override
files.
Merge order (fail2ban convention):
1. ``jail.conf``
2. ``jail.local``
3. ``jail.d/*.conf`` (alphabetical)
4. ``jail.d/*.local`` (alphabetical)
Security note: the ``activate_jail`` and ``deactivate_jail`` callers must
supply a validated jail name. This module validates the name against an
allowlist pattern before constructing any filesystem paths to prevent
directory traversal.
"""
from __future__ import annotations
import asyncio
import configparser
import os
import re
import tempfile
from pathlib import Path
from typing import Any
import structlog
from app.models.config import (
ActivateJailRequest,
InactiveJail,
InactiveJailListResponse,
JailActivationResponse,
)
from app.services import jail_service
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError
log: structlog.stdlib.BoundLogger = structlog.get_logger()
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_SOCKET_TIMEOUT: float = 10.0
# Allowlist pattern for jail names used in path construction.
_SAFE_JAIL_NAME_RE: re.Pattern[str] = re.compile(
r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
)
# Sections that are not jail definitions.
_META_SECTIONS: frozenset[str] = frozenset({"INCLUDES", "DEFAULT"})
# True-ish values for the ``enabled`` key.
_TRUE_VALUES: frozenset[str] = frozenset({"true", "yes", "1"})
# False-ish values for the ``enabled`` key.
_FALSE_VALUES: frozenset[str] = frozenset({"false", "no", "0"})
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
class JailNotFoundInConfigError(Exception):
"""Raised when the requested jail name is not defined in any config file."""
def __init__(self, name: str) -> None:
"""Initialise with the jail name that was not found.
Args:
name: The jail name that could not be located.
"""
self.name: str = name
super().__init__(f"Jail not found in config files: {name!r}")
class JailAlreadyActiveError(Exception):
"""Raised when trying to activate a jail that is already active."""
def __init__(self, name: str) -> None:
"""Initialise with the jail name.
Args:
name: The jail that is already active.
"""
self.name: str = name
super().__init__(f"Jail is already active: {name!r}")
class JailAlreadyInactiveError(Exception):
"""Raised when trying to deactivate a jail that is already inactive."""
def __init__(self, name: str) -> None:
"""Initialise with the jail name.
Args:
name: The jail that is already inactive.
"""
self.name: str = name
super().__init__(f"Jail is already inactive: {name!r}")
class JailNameError(Exception):
"""Raised when a jail name contains invalid characters."""
class ConfigWriteError(Exception):
"""Raised when writing a ``.local`` override file fails."""
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _safe_jail_name(name: str) -> str:
"""Validate *name* and return it unchanged or raise :class:`JailNameError`.
Args:
name: Proposed jail name.
Returns:
The name unchanged if valid.
Raises:
JailNameError: If *name* contains unsafe characters.
"""
if not _SAFE_JAIL_NAME_RE.match(name):
raise JailNameError(
f"Jail name {name!r} contains invalid characters. "
"Only alphanumeric characters, hyphens, underscores, and dots are "
"allowed; must start with an alphanumeric character."
)
return name
def _ordered_config_files(config_dir: Path) -> list[Path]:
"""Return all jail config files in fail2ban merge order.
Args:
config_dir: The fail2ban configuration root directory.
Returns:
List of paths in ascending priority order (later entries override
earlier ones).
"""
files: list[Path] = []
jail_conf = config_dir / "jail.conf"
if jail_conf.is_file():
files.append(jail_conf)
jail_local = config_dir / "jail.local"
if jail_local.is_file():
files.append(jail_local)
jail_d = config_dir / "jail.d"
if jail_d.is_dir():
files.extend(sorted(jail_d.glob("*.conf")))
files.extend(sorted(jail_d.glob("*.local")))
return files
def _build_parser() -> configparser.RawConfigParser:
"""Create a :class:`configparser.RawConfigParser` for fail2ban configs.
Returns:
Parser with interpolation disabled and case-sensitive option names.
"""
parser = configparser.RawConfigParser(interpolation=None, strict=False)
# fail2ban keys are lowercase but preserve case to be safe.
parser.optionxform = str # type: ignore[assignment]
return parser
def _is_truthy(value: str) -> bool:
"""Return ``True`` if *value* is a fail2ban boolean true string.
Args:
value: Raw string from config (e.g. ``"true"``, ``"yes"``, ``"1"``).
Returns:
``True`` when the value represents enabled.
"""
return value.strip().lower() in _TRUE_VALUES
def _parse_int_safe(value: str) -> int | None:
"""Parse *value* as int, returning ``None`` on failure.
Args:
value: Raw string to parse.
Returns:
Integer value, or ``None``.
"""
try:
return int(value.strip())
except (ValueError, AttributeError):
return None
def _parse_multiline(raw: str) -> list[str]:
"""Split a multi-line INI value into individual non-blank lines.
Args:
raw: Raw multi-line string from configparser.
Returns:
List of stripped, non-empty, non-comment strings.
"""
result: list[str] = []
for line in raw.splitlines():
stripped = line.strip()
if stripped and not stripped.startswith("#"):
result.append(stripped)
return result
def _resolve_filter(raw_filter: str, jail_name: str, mode: str) -> str:
"""Resolve fail2ban variable placeholders in a filter string.
Handles the common default ``%(__name__)s[mode=%(mode)s]`` pattern that
fail2ban uses so the filter name displayed to the user is readable.
Args:
raw_filter: Raw ``filter`` value from config (may contain ``%()s``).
jail_name: The jail's section name, used to substitute ``%(__name__)s``.
mode: The jail's ``mode`` value, used to substitute ``%(mode)s``.
Returns:
Human-readable filter string.
"""
result = raw_filter.replace("%(__name__)s", jail_name)
result = result.replace("%(mode)s", mode)
return result
def _parse_jails_sync(
config_dir: Path,
) -> tuple[dict[str, dict[str, str]], dict[str, str]]:
"""Synchronously parse all jail configs and return merged definitions.
This is a CPU-bound / IO-bound sync function; callers must dispatch to
an executor for async use.
Args:
config_dir: The fail2ban configuration root directory.
Returns:
A two-tuple ``(jails, source_files)`` where:
- ``jails``: ``{jail_name: {key: value}}`` merged settings for each
jail with DEFAULT values already applied.
- ``source_files``: ``{jail_name: str(path)}`` path of the file that
last defined each jail section (for display in the UI).
"""
parser = _build_parser()
files = _ordered_config_files(config_dir)
# Track which file each section came from (last write wins).
source_files: dict[str, str] = {}
for path in files:
try:
single = _build_parser()
single.read(str(path), encoding="utf-8")
for section in single.sections():
if section not in _META_SECTIONS:
source_files[section] = str(path)
except (configparser.Error, OSError) as exc:
log.warning("jail_config_read_error", path=str(path), error=str(exc))
# Full merged parse: configparser applies DEFAULT values to every section.
try:
parser.read([str(p) for p in files], encoding="utf-8")
except configparser.Error as exc:
log.warning("jail_config_parse_error", error=str(exc))
jails: dict[str, dict[str, str]] = {}
for section in parser.sections():
if section in _META_SECTIONS:
continue
try:
# items() merges DEFAULT values automatically.
jails[section] = dict(parser.items(section))
except configparser.Error as exc:
log.warning(
"jail_section_parse_error", section=section, error=str(exc)
)
log.debug("jails_parsed", count=len(jails), config_dir=str(config_dir))
return jails, source_files
def _build_inactive_jail(
name: str,
settings: dict[str, str],
source_file: str,
) -> InactiveJail:
"""Construct an :class:`~app.models.config.InactiveJail` from raw settings.
Args:
name: Jail section name.
settings: Merged key→value dict (DEFAULT values already applied).
source_file: Path of the file that last defined this section.
Returns:
Populated :class:`~app.models.config.InactiveJail`.
"""
raw_filter = settings.get("filter", "")
mode = settings.get("mode", "normal")
filter_name = _resolve_filter(raw_filter, name, mode) if raw_filter else name
raw_action = settings.get("action", "")
actions = _parse_multiline(raw_action) if raw_action else []
raw_logpath = settings.get("logpath", "")
logpath = _parse_multiline(raw_logpath) if raw_logpath else []
enabled_raw = settings.get("enabled", "false")
enabled = _is_truthy(enabled_raw)
maxretry_raw = settings.get("maxretry", "")
maxretry = _parse_int_safe(maxretry_raw)
return InactiveJail(
name=name,
filter=filter_name,
actions=actions,
port=settings.get("port") or None,
logpath=logpath,
bantime=settings.get("bantime") or None,
findtime=settings.get("findtime") or None,
maxretry=maxretry,
source_file=source_file,
enabled=enabled,
)
async def _get_active_jail_names(socket_path: str) -> set[str]:
"""Fetch the set of currently running jail names from fail2ban.
Returns an empty set gracefully if fail2ban is unreachable.
Args:
socket_path: Path to the fail2ban Unix domain socket.
Returns:
Set of active jail names, or empty set on connection failure.
"""
try:
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
def _to_dict_inner(pairs: Any) -> dict[str, Any]:
if not isinstance(pairs, (list, tuple)):
return {}
result: dict[str, Any] = {}
for item in pairs:
try:
k, v = item
result[str(k)] = v
except (TypeError, ValueError):
pass
return result
def _ok(response: Any) -> Any:
code, data = response
if code != 0:
raise ValueError(f"fail2ban error {code}: {data!r}")
return data
status_raw = _ok(await client.send(["status"]))
status_dict = _to_dict_inner(status_raw)
jail_list_raw: str = str(status_dict.get("Jail list", "") or "").strip()
if not jail_list_raw:
return set()
return {j.strip() for j in jail_list_raw.split(",") if j.strip()}
except Fail2BanConnectionError:
log.warning("fail2ban_unreachable_during_inactive_list")
return set()
except Exception as exc: # noqa: BLE001
log.warning(
"fail2ban_status_error_during_inactive_list", error=str(exc)
)
return set()
def _write_local_override_sync(
config_dir: Path,
jail_name: str,
enabled: bool,
overrides: dict[str, Any],
) -> None:
"""Write a ``jail.d/{name}.local`` file atomically.
Always writes to ``jail.d/{jail_name}.local``. If the file already
exists it is replaced entirely. The write is atomic: content is
written to a temp file first, then renamed into place.
Args:
config_dir: The fail2ban configuration root directory.
jail_name: Validated jail name (used as filename stem).
enabled: Value to write for ``enabled =``.
overrides: Optional setting overrides (bantime, findtime, maxretry,
port, logpath).
Raises:
ConfigWriteError: If writing fails.
"""
jail_d = config_dir / "jail.d"
try:
jail_d.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise ConfigWriteError(
f"Cannot create jail.d directory: {exc}"
) from exc
local_path = jail_d / f"{jail_name}.local"
lines: list[str] = [
"# Managed by BanGUI — do not edit manually",
"",
f"[{jail_name}]",
"",
f"enabled = {'true' if enabled else 'false'}",
]
if overrides.get("bantime") is not None:
lines.append(f"bantime = {overrides['bantime']}")
if overrides.get("findtime") is not None:
lines.append(f"findtime = {overrides['findtime']}")
if overrides.get("maxretry") is not None:
lines.append(f"maxretry = {overrides['maxretry']}")
if overrides.get("port") is not None:
lines.append(f"port = {overrides['port']}")
if overrides.get("logpath"):
paths: list[str] = overrides["logpath"]
if paths:
lines.append(f"logpath = {paths[0]}")
for p in paths[1:]:
lines.append(f" {p}")
content = "\n".join(lines) + "\n"
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=jail_d,
delete=False,
suffix=".tmp",
) as tmp:
tmp.write(content)
tmp_name = tmp.name
os.replace(tmp_name, local_path)
except OSError as exc:
# Clean up temp file if rename failed.
try:
os.unlink(tmp_name) # noqa: F821 — only reachable when tmp_name is set
except OSError:
pass
raise ConfigWriteError(
f"Failed to write {local_path}: {exc}"
) from exc
log.info(
"jail_local_written",
jail=jail_name,
path=str(local_path),
enabled=enabled,
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def list_inactive_jails(
config_dir: str,
socket_path: str,
) -> InactiveJailListResponse:
"""Return all jails defined in config files that are not currently active.
Parses ``jail.conf``, ``jail.local``, and ``jail.d/`` following the
fail2ban merge order. A jail is considered inactive when:
- Its merged ``enabled`` value is ``false`` (or absent, which defaults to
``false`` in fail2ban), **or**
- Its ``enabled`` value is ``true`` in config but fail2ban does not report
it as running.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
Returns:
:class:`~app.models.config.InactiveJailListResponse` with all
inactive jails.
"""
loop = asyncio.get_event_loop()
parsed_result: tuple[dict[str, dict[str, str]], dict[str, str]] = (
await loop.run_in_executor(None, _parse_jails_sync, Path(config_dir))
)
all_jails, source_files = parsed_result
active_names: set[str] = await _get_active_jail_names(socket_path)
inactive: list[InactiveJail] = []
for jail_name, settings in sorted(all_jails.items()):
if jail_name in active_names:
# fail2ban reports this jail as running — skip it.
continue
source = source_files.get(jail_name, config_dir)
inactive.append(_build_inactive_jail(jail_name, settings, source))
log.info(
"inactive_jails_listed",
total_defined=len(all_jails),
active=len(active_names),
inactive=len(inactive),
)
return InactiveJailListResponse(jails=inactive, total=len(inactive))
async def activate_jail(
config_dir: str,
socket_path: str,
name: str,
req: ActivateJailRequest,
) -> JailActivationResponse:
"""Enable an inactive jail and reload fail2ban.
Writes ``enabled = true`` (plus any override values from *req*) to
``jail.d/{name}.local`` and then triggers a full fail2ban reload so the
jail starts immediately.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
name: Name of the jail to activate. Must exist in the parsed config.
req: Optional override values to write alongside ``enabled = true``.
Returns:
:class:`~app.models.config.JailActivationResponse`.
Raises:
JailNameError: If *name* contains invalid characters.
JailNotFoundInConfigError: If *name* is not defined in any config file.
JailAlreadyActiveError: If fail2ban already reports *name* as running.
ConfigWriteError: If writing the ``.local`` file fails.
~app.utils.fail2ban_client.Fail2BanConnectionError: If the fail2ban
socket is unreachable during reload.
"""
_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)
overrides: dict[str, Any] = {
"bantime": req.bantime,
"findtime": req.findtime,
"maxretry": req.maxretry,
"port": req.port,
"logpath": req.logpath,
}
await loop.run_in_executor(
None,
_write_local_override_sync,
Path(config_dir),
name,
True,
overrides,
)
try:
await jail_service.reload_all(socket_path)
except Exception as exc: # noqa: BLE001
log.warning("reload_after_activate_failed", jail=name, error=str(exc))
log.info("jail_activated", jail=name)
return JailActivationResponse(
name=name,
active=True,
message=f"Jail {name!r} activated successfully.",
)
async def deactivate_jail(
config_dir: str,
socket_path: str,
name: str,
) -> JailActivationResponse:
"""Disable an active jail and reload fail2ban.
Writes ``enabled = false`` to ``jail.d/{name}.local`` and triggers a
full fail2ban reload so the jail stops immediately.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
name: Name of the jail to deactivate. Must exist in the parsed config.
Returns:
:class:`~app.models.config.JailActivationResponse`.
Raises:
JailNameError: If *name* contains invalid characters.
JailNotFoundInConfigError: If *name* is not defined in any config file.
JailAlreadyInactiveError: If fail2ban already reports *name* as not
running.
ConfigWriteError: If writing the ``.local`` file fails.
~app.utils.fail2ban_client.Fail2BanConnectionError: If the fail2ban
socket is unreachable during reload.
"""
_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 not in active_names:
raise JailAlreadyInactiveError(name)
await loop.run_in_executor(
None,
_write_local_override_sync,
Path(config_dir),
name,
False,
{},
)
try:
await jail_service.reload_all(socket_path)
except Exception as exc: # noqa: BLE001
log.warning("reload_after_deactivate_failed", jail=name, error=str(exc))
log.info("jail_deactivated", jail=name)
return JailActivationResponse(
name=name,
active=False,
message=f"Jail {name!r} deactivated successfully.",
)

View File

@@ -575,3 +575,245 @@ class TestUpdateMapColorThresholds:
# Pydantic validates ge=1 constraint before our service code runs # Pydantic validates ge=1 constraint before our service code runs
assert resp.status_code == 422 assert resp.status_code == 422
# ---------------------------------------------------------------------------
# GET /api/config/jails/inactive
# ---------------------------------------------------------------------------
class TestGetInactiveJails:
"""Tests for ``GET /api/config/jails/inactive``."""
async def test_200_returns_inactive_list(self, config_client: AsyncClient) -> None:
"""GET /api/config/jails/inactive returns 200 with InactiveJailListResponse."""
from app.models.config import InactiveJail, InactiveJailListResponse
mock_jail = InactiveJail(
name="apache-auth",
filter="apache-auth",
actions=[],
port="http,https",
logpath=["/var/log/apache2/error.log"],
bantime="10m",
findtime="5m",
maxretry=5,
source_file="/etc/fail2ban/jail.conf",
enabled=False,
)
mock_response = InactiveJailListResponse(jails=[mock_jail], total=1)
with patch(
"app.routers.config.config_file_service.list_inactive_jails",
AsyncMock(return_value=mock_response),
):
resp = await config_client.get("/api/config/jails/inactive")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["jails"][0]["name"] == "apache-auth"
async def test_200_empty_list(self, config_client: AsyncClient) -> None:
"""GET /api/config/jails/inactive returns 200 with empty list."""
from app.models.config import InactiveJailListResponse
with patch(
"app.routers.config.config_file_service.list_inactive_jails",
AsyncMock(return_value=InactiveJailListResponse(jails=[], total=0)),
):
resp = await config_client.get("/api/config/jails/inactive")
assert resp.status_code == 200
assert resp.json()["total"] == 0
assert resp.json()["jails"] == []
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""GET /api/config/jails/inactive returns 401 without a valid session."""
resp = await AsyncClient(
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).get("/api/config/jails/inactive")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# POST /api/config/jails/{name}/activate
# ---------------------------------------------------------------------------
class TestActivateJail:
"""Tests for ``POST /api/config/jails/{name}/activate``."""
async def test_200_activates_jail(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/apache-auth/activate returns 200."""
from app.models.config import JailActivationResponse
mock_response = JailActivationResponse(
name="apache-auth",
active=True,
message="Jail 'apache-auth' activated successfully.",
)
with patch(
"app.routers.config.config_file_service.activate_jail",
AsyncMock(return_value=mock_response),
):
resp = await config_client.post(
"/api/config/jails/apache-auth/activate", json={}
)
assert resp.status_code == 200
data = resp.json()
assert data["active"] is True
assert data["name"] == "apache-auth"
async def test_200_with_overrides(self, config_client: AsyncClient) -> None:
"""POST .../activate accepts override fields."""
from app.models.config import JailActivationResponse
mock_response = JailActivationResponse(
name="apache-auth", active=True, message="Activated."
)
with patch(
"app.routers.config.config_file_service.activate_jail",
AsyncMock(return_value=mock_response),
) as mock_activate:
resp = await config_client.post(
"/api/config/jails/apache-auth/activate",
json={"bantime": "1h", "maxretry": 3},
)
assert resp.status_code == 200
# Verify the override values were passed to the service
called_req = mock_activate.call_args.args[3]
assert called_req.bantime == "1h"
assert called_req.maxretry == 3
async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/missing/activate returns 404."""
from app.services.config_file_service import JailNotFoundInConfigError
with patch(
"app.routers.config.config_file_service.activate_jail",
AsyncMock(side_effect=JailNotFoundInConfigError("missing")),
):
resp = await config_client.post(
"/api/config/jails/missing/activate", json={}
)
assert resp.status_code == 404
async def test_409_when_already_active(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/activate returns 409 if already active."""
from app.services.config_file_service import JailAlreadyActiveError
with patch(
"app.routers.config.config_file_service.activate_jail",
AsyncMock(side_effect=JailAlreadyActiveError("sshd")),
):
resp = await config_client.post(
"/api/config/jails/sshd/activate", json={}
)
assert resp.status_code == 409
async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/ with bad name returns 400."""
from app.services.config_file_service import JailNameError
with patch(
"app.routers.config.config_file_service.activate_jail",
AsyncMock(side_effect=JailNameError("bad name")),
):
resp = await config_client.post(
"/api/config/jails/bad-name/activate", json={}
)
assert resp.status_code == 400
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/activate returns 401 without session."""
resp = await AsyncClient(
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).post("/api/config/jails/sshd/activate", json={})
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# POST /api/config/jails/{name}/deactivate
# ---------------------------------------------------------------------------
class TestDeactivateJail:
"""Tests for ``POST /api/config/jails/{name}/deactivate``."""
async def test_200_deactivates_jail(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/deactivate returns 200."""
from app.models.config import JailActivationResponse
mock_response = JailActivationResponse(
name="sshd",
active=False,
message="Jail 'sshd' deactivated successfully.",
)
with patch(
"app.routers.config.config_file_service.deactivate_jail",
AsyncMock(return_value=mock_response),
):
resp = await config_client.post("/api/config/jails/sshd/deactivate")
assert resp.status_code == 200
data = resp.json()
assert data["active"] is False
assert data["name"] == "sshd"
async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/missing/deactivate returns 404."""
from app.services.config_file_service import JailNotFoundInConfigError
with patch(
"app.routers.config.config_file_service.deactivate_jail",
AsyncMock(side_effect=JailNotFoundInConfigError("missing")),
):
resp = await config_client.post(
"/api/config/jails/missing/deactivate"
)
assert resp.status_code == 404
async def test_409_when_already_inactive(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/apache-auth/deactivate returns 409 if already inactive."""
from app.services.config_file_service import JailAlreadyInactiveError
with patch(
"app.routers.config.config_file_service.deactivate_jail",
AsyncMock(side_effect=JailAlreadyInactiveError("apache-auth")),
):
resp = await config_client.post(
"/api/config/jails/apache-auth/deactivate"
)
assert resp.status_code == 409
async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/.../deactivate with bad name returns 400."""
from app.services.config_file_service import JailNameError
with patch(
"app.routers.config.config_file_service.deactivate_jail",
AsyncMock(side_effect=JailNameError("bad")),
):
resp = await config_client.post(
"/api/config/jails/sshd/deactivate"
)
assert resp.status_code == 400
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/deactivate returns 401 without session."""
resp = await AsyncClient(
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).post("/api/config/jails/sshd/deactivate")
assert resp.status_code == 401

View File

@@ -0,0 +1,559 @@
"""Tests for config_file_service — fail2ban jail config parser and activator."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from app.services.config_file_service import (
JailAlreadyActiveError,
JailAlreadyInactiveError,
JailNameError,
JailNotFoundInConfigError,
_build_inactive_jail,
_ordered_config_files,
_parse_jails_sync,
_resolve_filter,
_safe_jail_name,
_write_local_override_sync,
activate_jail,
deactivate_jail,
list_inactive_jails,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _write(path: Path, content: str) -> None:
"""Write text to *path*, creating parent directories if needed."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
# ---------------------------------------------------------------------------
# _safe_jail_name
# ---------------------------------------------------------------------------
class TestSafeJailName:
def test_valid_simple(self) -> None:
assert _safe_jail_name("sshd") == "sshd"
def test_valid_with_hyphen(self) -> None:
assert _safe_jail_name("apache-auth") == "apache-auth"
def test_valid_with_dot(self) -> None:
assert _safe_jail_name("nginx.http") == "nginx.http"
def test_valid_with_underscore(self) -> None:
assert _safe_jail_name("my_jail") == "my_jail"
def test_invalid_path_traversal(self) -> None:
with pytest.raises(JailNameError):
_safe_jail_name("../evil")
def test_invalid_slash(self) -> None:
with pytest.raises(JailNameError):
_safe_jail_name("a/b")
def test_invalid_starts_with_dash(self) -> None:
with pytest.raises(JailNameError):
_safe_jail_name("-bad")
def test_invalid_empty(self) -> None:
with pytest.raises(JailNameError):
_safe_jail_name("")
# ---------------------------------------------------------------------------
# _resolve_filter
# ---------------------------------------------------------------------------
class TestResolveFilter:
def test_name_substitution(self) -> None:
result = _resolve_filter("%(__name__)s", "sshd", "normal")
assert result == "sshd"
def test_mode_substitution(self) -> None:
result = _resolve_filter("%(__name__)s[mode=%(mode)s]", "sshd", "aggressive")
assert result == "sshd[mode=aggressive]"
def test_no_substitution_needed(self) -> None:
result = _resolve_filter("my-filter", "sshd", "normal")
assert result == "my-filter"
def test_empty_raw(self) -> None:
result = _resolve_filter("", "sshd", "normal")
assert result == ""
# ---------------------------------------------------------------------------
# _ordered_config_files
# ---------------------------------------------------------------------------
class TestOrderedConfigFiles:
def test_empty_dir(self, tmp_path: Path) -> None:
result = _ordered_config_files(tmp_path)
assert result == []
def test_jail_conf_only(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", "[sshd]\nenabled=true\n")
result = _ordered_config_files(tmp_path)
assert result == [tmp_path / "jail.conf"]
def test_full_merge_order(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", "[DEFAULT]\n")
_write(tmp_path / "jail.local", "[DEFAULT]\n")
_write(tmp_path / "jail.d" / "custom.conf", "[sshd]\n")
_write(tmp_path / "jail.d" / "custom.local", "[sshd]\n")
result = _ordered_config_files(tmp_path)
assert result[0] == tmp_path / "jail.conf"
assert result[1] == tmp_path / "jail.local"
assert result[2] == tmp_path / "jail.d" / "custom.conf"
assert result[3] == tmp_path / "jail.d" / "custom.local"
def test_jail_d_sorted_alphabetically(self, tmp_path: Path) -> None:
(tmp_path / "jail.d").mkdir()
for name in ("zzz.conf", "aaa.conf", "mmm.conf"):
_write(tmp_path / "jail.d" / name, "")
result = _ordered_config_files(tmp_path)
names = [p.name for p in result]
assert names == ["aaa.conf", "mmm.conf", "zzz.conf"]
# ---------------------------------------------------------------------------
# _parse_jails_sync
# ---------------------------------------------------------------------------
JAIL_CONF = """\
[DEFAULT]
bantime = 10m
findtime = 5m
maxretry = 5
[sshd]
enabled = true
filter = sshd
port = ssh
logpath = /var/log/auth.log
[apache-auth]
enabled = false
filter = apache-auth
port = http,https
logpath = /var/log/apache2/error.log
"""
JAIL_LOCAL = """\
[sshd]
bantime = 1h
"""
JAIL_D_CUSTOM = """\
[nginx-http-auth]
enabled = false
filter = nginx-http-auth
port = http,https
logpath = /var/log/nginx/error.log
"""
class TestParseJailsSync:
def test_parses_all_jails(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
jails, _ = _parse_jails_sync(tmp_path)
assert "sshd" in jails
assert "apache-auth" in jails
def test_enabled_flag_parsing(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
jails, _ = _parse_jails_sync(tmp_path)
assert jails["sshd"]["enabled"] == "true"
assert jails["apache-auth"]["enabled"] == "false"
def test_default_inheritance(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
jails, _ = _parse_jails_sync(tmp_path)
# DEFAULT values should flow into each jail via configparser
assert jails["sshd"]["bantime"] == "10m"
assert jails["apache-auth"]["maxretry"] == "5"
def test_local_override_wins(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
_write(tmp_path / "jail.local", JAIL_LOCAL)
jails, _ = _parse_jails_sync(tmp_path)
# jail.local overrides bantime for sshd from 10m → 1h
assert jails["sshd"]["bantime"] == "1h"
def test_jail_d_conf_included(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
_write(tmp_path / "jail.d" / "custom.conf", JAIL_D_CUSTOM)
jails, _ = _parse_jails_sync(tmp_path)
assert "nginx-http-auth" in jails
def test_source_file_tracked(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
_write(tmp_path / "jail.d" / "custom.conf", JAIL_D_CUSTOM)
_, source_files = _parse_jails_sync(tmp_path)
# sshd comes from jail.conf; nginx-http-auth from jail.d/custom.conf
assert source_files["sshd"] == str(tmp_path / "jail.conf")
assert source_files["nginx-http-auth"] == str(tmp_path / "jail.d" / "custom.conf")
def test_source_file_local_override_tracked(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
_write(tmp_path / "jail.local", JAIL_LOCAL)
_, source_files = _parse_jails_sync(tmp_path)
# jail.local defines [sshd] again → that file wins
assert source_files["sshd"] == str(tmp_path / "jail.local")
def test_default_section_excluded(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
jails, _ = _parse_jails_sync(tmp_path)
assert "DEFAULT" not in jails
def test_includes_section_excluded(self, tmp_path: Path) -> None:
content = "[INCLUDES]\nbefore = paths-debian.conf\n" + JAIL_CONF
_write(tmp_path / "jail.conf", content)
jails, _ = _parse_jails_sync(tmp_path)
assert "INCLUDES" not in jails
def test_corrupt_file_skipped_gracefully(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", "[[bad section\n")
# Should not raise; bad section just yields no jails
jails, _ = _parse_jails_sync(tmp_path)
assert isinstance(jails, dict)
# ---------------------------------------------------------------------------
# _build_inactive_jail
# ---------------------------------------------------------------------------
class TestBuildInactiveJail:
def test_basic_fields(self) -> None:
settings = {
"enabled": "false",
"filter": "sshd",
"port": "ssh",
"logpath": "/var/log/auth.log",
"bantime": "10m",
"findtime": "5m",
"maxretry": "5",
"action": "",
}
jail = _build_inactive_jail("sshd", settings, "/etc/fail2ban/jail.d/sshd.conf")
assert jail.name == "sshd"
assert jail.filter == "sshd"
assert jail.port == "ssh"
assert jail.logpath == ["/var/log/auth.log"]
assert jail.bantime == "10m"
assert jail.findtime == "5m"
assert jail.maxretry == 5
assert jail.enabled is False
assert "sshd.conf" in jail.source_file
def test_filter_name_substitution(self) -> None:
settings = {"enabled": "false", "filter": "%(__name__)s"}
jail = _build_inactive_jail("myservice", settings, "/etc/fail2ban/jail.conf")
assert jail.filter == "myservice"
def test_missing_optional_fields(self) -> None:
jail = _build_inactive_jail("minimal", {}, "/etc/fail2ban/jail.conf")
assert jail.filter == "minimal" # falls back to name
assert jail.port is None
assert jail.logpath == []
assert jail.bantime is None
assert jail.maxretry is None
def test_multiline_logpath(self) -> None:
settings = {"logpath": "/var/log/app.log\n/var/log/app2.log"}
jail = _build_inactive_jail("app", settings, "/etc/fail2ban/jail.conf")
assert "/var/log/app.log" in jail.logpath
assert "/var/log/app2.log" in jail.logpath
def test_multiline_actions(self) -> None:
settings = {"action": "iptables-multiport\niptables-ipset"}
jail = _build_inactive_jail("app", settings, "/etc/fail2ban/jail.conf")
assert len(jail.actions) == 2
def test_enabled_true(self) -> None:
settings = {"enabled": "true"}
jail = _build_inactive_jail("active-jail", settings, "/etc/fail2ban/jail.conf")
assert jail.enabled is True
# ---------------------------------------------------------------------------
# _write_local_override_sync
# ---------------------------------------------------------------------------
class TestWriteLocalOverrideSync:
def test_creates_local_file(self, tmp_path: Path) -> None:
_write_local_override_sync(tmp_path, "sshd", True, {})
local = tmp_path / "jail.d" / "sshd.local"
assert local.is_file()
def test_enabled_true_written(self, tmp_path: Path) -> None:
_write_local_override_sync(tmp_path, "sshd", True, {})
content = (tmp_path / "jail.d" / "sshd.local").read_text()
assert "enabled = true" in content
def test_enabled_false_written(self, tmp_path: Path) -> None:
_write_local_override_sync(tmp_path, "sshd", False, {})
content = (tmp_path / "jail.d" / "sshd.local").read_text()
assert "enabled = false" in content
def test_section_header_written(self, tmp_path: Path) -> None:
_write_local_override_sync(tmp_path, "apache-auth", True, {})
content = (tmp_path / "jail.d" / "apache-auth.local").read_text()
assert "[apache-auth]" in content
def test_override_bantime(self, tmp_path: Path) -> None:
_write_local_override_sync(tmp_path, "sshd", True, {"bantime": "1h"})
content = (tmp_path / "jail.d" / "sshd.local").read_text()
assert "bantime" in content
assert "1h" in content
def test_override_findtime(self, tmp_path: Path) -> None:
_write_local_override_sync(tmp_path, "sshd", True, {"findtime": "30m"})
content = (tmp_path / "jail.d" / "sshd.local").read_text()
assert "findtime" in content
assert "30m" in content
def test_override_maxretry(self, tmp_path: Path) -> None:
_write_local_override_sync(tmp_path, "sshd", True, {"maxretry": 3})
content = (tmp_path / "jail.d" / "sshd.local").read_text()
assert "maxretry" in content
assert "3" in content
def test_override_port(self, tmp_path: Path) -> None:
_write_local_override_sync(tmp_path, "sshd", True, {"port": "2222"})
content = (tmp_path / "jail.d" / "sshd.local").read_text()
assert "2222" in content
def test_override_logpath_list(self, tmp_path: Path) -> None:
_write_local_override_sync(
tmp_path, "sshd", True, {"logpath": ["/var/log/auth.log", "/var/log/secure"]}
)
content = (tmp_path / "jail.d" / "sshd.local").read_text()
assert "/var/log/auth.log" in content
assert "/var/log/secure" in content
def test_bang_gui_header_comment(self, tmp_path: Path) -> None:
_write_local_override_sync(tmp_path, "sshd", True, {})
content = (tmp_path / "jail.d" / "sshd.local").read_text()
assert "BanGUI" in content
def test_overwrites_existing_file(self, tmp_path: Path) -> None:
local = tmp_path / "jail.d" / "sshd.local"
local.parent.mkdir()
local.write_text("old content")
_write_local_override_sync(tmp_path, "sshd", True, {})
assert "old content" not in local.read_text()
# ---------------------------------------------------------------------------
# list_inactive_jails
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestListInactiveJails:
async def test_returns_only_inactive(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
# sshd is enabled=true; apache-auth is enabled=false
with patch(
"app.services.config_file_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 "sshd" not in names
assert "apache-auth" in names
async def test_total_matches_jails_count(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value={"sshd"}),
):
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
assert result.total == len(result.jails)
async def test_empty_config_dir(self, tmp_path: Path) -> None:
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")
assert result.jails == []
assert result.total == 0
async def test_all_active_returns_empty(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value={"sshd", "apache-auth"}),
):
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
assert result.jails == []
async def test_fail2ban_unreachable_shows_all(self, tmp_path: Path) -> None:
# When fail2ban is unreachable, _get_active_jail_names returns empty set,
# so every config-defined jail appears as inactive.
_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")
names = {j.name for j in result.jails}
assert "sshd" in names
assert "apache-auth" in names
# ---------------------------------------------------------------------------
# activate_jail
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestActivateJail:
async def test_activates_known_inactive_jail(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
from app.models.config import ActivateJailRequest
req = ActivateJailRequest()
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,
):
mock_js.reload_all = AsyncMock()
result = await activate_jail(str(tmp_path), "/fake.sock", "apache-auth", req)
assert result.active is True
assert "apache-auth" in result.name
local = tmp_path / "jail.d" / "apache-auth.local"
assert local.is_file()
assert "enabled = true" in local.read_text()
async def test_raises_not_found_for_unknown_jail(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
from app.models.config import ActivateJailRequest
req = ActivateJailRequest()
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
),
):
with pytest.raises(JailNotFoundInConfigError):
await activate_jail(str(tmp_path), "/fake.sock", "nonexistent", req)
async def test_raises_already_active(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
from app.models.config import ActivateJailRequest
req = ActivateJailRequest()
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value={"sshd"}),
),
):
with pytest.raises(JailAlreadyActiveError):
await activate_jail(str(tmp_path), "/fake.sock", "sshd", req)
async def test_raises_name_error_for_bad_name(self, tmp_path: Path) -> None:
from app.models.config import ActivateJailRequest
req = ActivateJailRequest()
with pytest.raises(JailNameError):
await activate_jail(str(tmp_path), "/fake.sock", "../etc/passwd", req)
async def test_writes_overrides_to_local_file(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
from app.models.config import ActivateJailRequest
req = ActivateJailRequest(bantime="2h", maxretry=3)
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,
):
mock_js.reload_all = AsyncMock()
await activate_jail(str(tmp_path), "/fake.sock", "apache-auth", req)
content = (tmp_path / "jail.d" / "apache-auth.local").read_text()
assert "2h" in content
assert "3" in content
# ---------------------------------------------------------------------------
# deactivate_jail
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestDeactivateJail:
async def test_deactivates_active_jail(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value={"sshd"}),
),
patch("app.services.config_file_service.jail_service") as mock_js,
):
mock_js.reload_all = AsyncMock()
result = await deactivate_jail(str(tmp_path), "/fake.sock", "sshd")
assert result.active is False
local = tmp_path / "jail.d" / "sshd.local"
assert local.is_file()
assert "enabled = false" in local.read_text()
async def test_raises_not_found(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value={"sshd"}),
),
):
with pytest.raises(JailNotFoundInConfigError):
await deactivate_jail(str(tmp_path), "/fake.sock", "nonexistent")
async def test_raises_already_inactive(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
),
):
with pytest.raises(JailAlreadyInactiveError):
await deactivate_jail(str(tmp_path), "/fake.sock", "apache-auth")
async def test_raises_name_error(self, tmp_path: Path) -> None:
with pytest.raises(JailNameError):
await deactivate_jail(str(tmp_path), "/fake.sock", "a/b")

View File

@@ -7,6 +7,7 @@ import { ENDPOINTS } from "./endpoints";
import type { import type {
ActionConfig, ActionConfig,
ActionConfigUpdate, ActionConfigUpdate,
ActivateJailRequest,
AddLogPathRequest, AddLogPathRequest,
ConfFileContent, ConfFileContent,
ConfFileCreateRequest, ConfFileCreateRequest,
@@ -16,6 +17,8 @@ import type {
FilterConfigUpdate, FilterConfigUpdate,
GlobalConfig, GlobalConfig,
GlobalConfigUpdate, GlobalConfigUpdate,
InactiveJailListResponse,
JailActivationResponse,
JailConfigFileContent, JailConfigFileContent,
JailConfigFileEnabledUpdate, JailConfigFileEnabledUpdate,
JailConfigFilesResponse, JailConfigFilesResponse,
@@ -290,3 +293,38 @@ export async function updateParsedJailFile(
): Promise<void> { ): Promise<void> {
await put<undefined>(ENDPOINTS.configJailFileParsed(filename), update); await put<undefined>(ENDPOINTS.configJailFileParsed(filename), update);
} }
// ---------------------------------------------------------------------------
// Inactive jails (Stage 1)
// ---------------------------------------------------------------------------
/** Fetch all inactive jails from config files. */
export async function fetchInactiveJails(): Promise<InactiveJailListResponse> {
return get<InactiveJailListResponse>(ENDPOINTS.configJailsInactive);
}
/**
* Activate an inactive jail, optionally providing override values.
*
* @param name - The jail name.
* @param overrides - Optional parameter overrides (bantime, findtime, etc.).
*/
export async function activateJail(
name: string,
overrides?: ActivateJailRequest
): Promise<JailActivationResponse> {
return post<JailActivationResponse>(
ENDPOINTS.configJailActivate(name),
overrides ?? {}
);
}
/** Deactivate an active jail. */
export async function deactivateJail(
name: string
): Promise<JailActivationResponse> {
return post<JailActivationResponse>(
ENDPOINTS.configJailDeactivate(name),
undefined
);
}

View File

@@ -61,9 +61,14 @@ export const ENDPOINTS = {
// Configuration // Configuration
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
configJails: "/config/jails", configJails: "/config/jails",
configJailsInactive: "/config/jails/inactive",
configJail: (name: string): string => `/config/jails/${encodeURIComponent(name)}`, configJail: (name: string): string => `/config/jails/${encodeURIComponent(name)}`,
configJailLogPath: (name: string): string => configJailLogPath: (name: string): string =>
`/config/jails/${encodeURIComponent(name)}/logpath`, `/config/jails/${encodeURIComponent(name)}/logpath`,
configJailActivate: (name: string): string =>
`/config/jails/${encodeURIComponent(name)}/activate`,
configJailDeactivate: (name: string): string =>
`/config/jails/${encodeURIComponent(name)}/deactivate`,
configGlobal: "/config/global", configGlobal: "/config/global",
configReload: "/config/reload", configReload: "/config/reload",
configRegexTest: "/config/regex-test", configRegexTest: "/config/regex-test",

View File

@@ -0,0 +1,240 @@
/**
* ActivateJailDialog — confirmation dialog for activating an inactive jail.
*
* Displays the jail name and provides optional override fields for bantime,
* findtime, maxretry, port and logpath. Calls the activate endpoint on
* confirmation and propagates the result via callbacks.
*/
import { useState } from "react";
import {
Button,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Field,
Input,
MessageBar,
MessageBarBody,
Spinner,
Text,
tokens,
} from "@fluentui/react-components";
import { activateJail } from "../../api/config";
import type { ActivateJailRequest, InactiveJail } from "../../types/config";
import { ApiError } from "../../api/client";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ActivateJailDialogProps {
/** The inactive jail to activate, or null when the dialog is closed. */
jail: InactiveJail | null;
/** Whether the dialog is currently open. */
open: boolean;
/** Called when the dialog should be closed without taking action. */
onClose: () => void;
/** Called after the jail has been successfully activated. */
onActivated: () => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Confirmation dialog for activating an inactive jail.
*
* All override fields are optional — leaving them blank uses the values
* already in the config files.
*
* @param props - Component props.
* @returns JSX element.
*/
export function ActivateJailDialog({
jail,
open,
onClose,
onActivated,
}: ActivateJailDialogProps): React.JSX.Element {
const [bantime, setBantime] = useState("");
const [findtime, setFindtime] = useState("");
const [maxretry, setMaxretry] = useState("");
const [port, setPort] = useState("");
const [logpath, setLogpath] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const resetForm = (): void => {
setBantime("");
setFindtime("");
setMaxretry("");
setPort("");
setLogpath("");
setError(null);
};
const handleClose = (): void => {
if (submitting) return;
resetForm();
onClose();
};
const handleConfirm = (): void => {
if (!jail || submitting) return;
const overrides: ActivateJailRequest = {};
if (bantime.trim()) overrides.bantime = bantime.trim();
if (findtime.trim()) overrides.findtime = findtime.trim();
if (maxretry.trim()) {
const n = parseInt(maxretry.trim(), 10);
if (!isNaN(n)) overrides.maxretry = n;
}
if (port.trim()) overrides.port = port.trim();
if (logpath.trim()) {
overrides.logpath = logpath
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
}
setSubmitting(true);
setError(null);
activateJail(jail.name, overrides)
.then(() => {
resetForm();
onActivated();
})
.catch((err: unknown) => {
const msg =
err instanceof ApiError
? err.message
: err instanceof Error
? err.message
: String(err);
setError(msg);
})
.finally(() => {
setSubmitting(false);
});
};
if (!jail) return <></>;
return (
<Dialog open={open} onOpenChange={(_ev, data) => { if (!data.open) handleClose(); }}>
<DialogSurface>
<DialogBody>
<DialogTitle>Activate jail &ldquo;{jail.name}&rdquo;</DialogTitle>
<DialogContent>
<Text block style={{ marginBottom: tokens.spacingVerticalM }}>
This will write <code>enabled = true</code> to{" "}
<code>jail.d/{jail.name}.local</code> and reload fail2ban. The
jail will start monitoring immediately.
</Text>
<Text block weight="semibold" style={{ marginBottom: tokens.spacingVerticalS }}>
Override values (leave blank to use config defaults)
</Text>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: tokens.spacingVerticalS,
}}
>
<Field
label="Ban time"
hint={jail.bantime ? `Current: ${jail.bantime}` : undefined}
>
<Input
placeholder={jail.bantime ?? "e.g. 10m"}
value={bantime}
disabled={submitting}
onChange={(_e, d) => { setBantime(d.value); }}
/>
</Field>
<Field
label="Find time"
hint={jail.findtime ? `Current: ${jail.findtime}` : undefined}
>
<Input
placeholder={jail.findtime ?? "e.g. 10m"}
value={findtime}
disabled={submitting}
onChange={(_e, d) => { setFindtime(d.value); }}
/>
</Field>
<Field
label="Max retry"
hint={jail.maxretry != null ? `Current: ${String(jail.maxretry)}` : undefined}
>
<Input
type="number"
placeholder={jail.maxretry != null ? String(jail.maxretry) : "e.g. 5"}
value={maxretry}
disabled={submitting}
onChange={(_e, d) => { setMaxretry(d.value); }}
/>
</Field>
<Field
label="Port"
hint={jail.port ? `Current: ${jail.port}` : undefined}
>
<Input
placeholder={jail.port ?? "e.g. ssh"}
value={port}
disabled={submitting}
onChange={(_e, d) => { setPort(d.value); }}
/>
</Field>
</div>
<Field
label="Log path(s)"
hint="One path per line; leave blank to use config defaults."
style={{ marginTop: tokens.spacingVerticalS }}
>
<Input
placeholder={
jail.logpath.length > 0 ? jail.logpath[0] : "/var/log/example.log"
}
value={logpath}
disabled={submitting}
onChange={(_e, d) => { setLogpath(d.value); }}
/>
</Field>
{error && (
<MessageBar
intent="error"
style={{ marginTop: tokens.spacingVerticalS }}
>
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
</DialogContent>
<DialogActions>
<Button
appearance="secondary"
onClick={handleClose}
disabled={submitting}
>
Cancel
</Button>
<Button
appearance="primary"
onClick={handleConfirm}
disabled={submitting}
icon={submitting ? <Spinner size="tiny" /> : undefined}
>
{submitting ? "Activating…" : "Activate"}
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
}

View File

@@ -6,7 +6,7 @@
* raw-config editor. * raw-config editor.
*/ */
import { useCallback, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
Badge, Badge,
Button, Button,
@@ -25,23 +25,29 @@ import {
ArrowClockwise24Regular, ArrowClockwise24Regular,
Dismiss24Regular, Dismiss24Regular,
LockClosed24Regular, LockClosed24Regular,
LockOpen24Regular,
Play24Regular,
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
import { import {
addLogPath, addLogPath,
deactivateJail,
deleteLogPath, deleteLogPath,
fetchInactiveJails,
fetchJailConfigFileContent, fetchJailConfigFileContent,
updateJailConfigFile, updateJailConfigFile,
} from "../../api/config"; } from "../../api/config";
import type { import type {
AddLogPathRequest, AddLogPathRequest,
ConfFileUpdateRequest, ConfFileUpdateRequest,
InactiveJail,
JailConfig, JailConfig,
JailConfigUpdate, JailConfigUpdate,
} from "../../types/config"; } from "../../types/config";
import { useAutoSave } from "../../hooks/useAutoSave"; import { useAutoSave } from "../../hooks/useAutoSave";
import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus"; import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus";
import { useJailConfigs } from "../../hooks/useConfig"; import { useJailConfigs } from "../../hooks/useConfig";
import { ActivateJailDialog } from "./ActivateJailDialog";
import { AutoSaveIndicator } from "./AutoSaveIndicator"; import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { ConfigListDetail } from "./ConfigListDetail"; import { ConfigListDetail } from "./ConfigListDetail";
import { RawConfigSection } from "./RawConfigSection"; import { RawConfigSection } from "./RawConfigSection";
@@ -55,6 +61,7 @@ import { useConfigStyles } from "./configStyles";
interface JailConfigDetailProps { interface JailConfigDetailProps {
jail: JailConfig; jail: JailConfig;
onSave: (name: string, update: JailConfigUpdate) => Promise<void>; onSave: (name: string, update: JailConfigUpdate) => Promise<void>;
onDeactivate?: () => void;
} }
/** /**
@@ -69,6 +76,7 @@ interface JailConfigDetailProps {
function JailConfigDetail({ function JailConfigDetail({
jail, jail,
onSave, onSave,
onDeactivate,
}: JailConfigDetailProps): React.JSX.Element { }: JailConfigDetailProps): React.JSX.Element {
const styles = useConfigStyles(); const styles = useConfigStyles();
const [banTime, setBanTime] = useState(String(jail.ban_time)); const [banTime, setBanTime] = useState(String(jail.ban_time));
@@ -443,6 +451,18 @@ function JailConfigDetail({
/> />
</div> </div>
{onDeactivate !== undefined && (
<div style={{ marginTop: tokens.spacingVerticalM }}>
<Button
appearance="secondary"
icon={<LockOpen24Regular />}
onClick={onDeactivate}
>
Deactivate Jail
</Button>
</div>
)}
{/* Raw Configuration */} {/* Raw Configuration */}
<div style={{ marginTop: tokens.spacingVerticalL }}> <div style={{ marginTop: tokens.spacingVerticalL }}>
<RawConfigSection <RawConfigSection
@@ -455,6 +475,102 @@ function JailConfigDetail({
); );
} }
// ---------------------------------------------------------------------------
// InactiveJailDetail
// ---------------------------------------------------------------------------
interface InactiveJailDetailProps {
jail: InactiveJail;
onActivate: () => void;
}
/**
* Read-only detail view for an inactive jail, with an Activate button.
*
* @param props - Component props.
* @returns JSX element.
*/
function InactiveJailDetail({
jail,
onActivate,
}: InactiveJailDetailProps): React.JSX.Element {
const styles = useConfigStyles();
return (
<div className={styles.detailPane}>
<Text weight="semibold" size={500} block style={{ marginBottom: tokens.spacingVerticalM }}>
{jail.name}
</Text>
<div className={styles.fieldRow}>
<Field label="Filter">
<Input readOnly value={jail.filter || "(none)"} className={styles.codeFont} />
</Field>
<Field label="Port">
<Input readOnly value={jail.port ?? "(auto)"} />
</Field>
</div>
<div className={styles.fieldRowThree}>
<Field label="Ban time">
<Input readOnly value={jail.bantime ?? "(default)"} />
</Field>
<Field label="Find time">
<Input readOnly value={jail.findtime ?? "(default)"} />
</Field>
<Field label="Max retry">
<Input readOnly value={jail.maxretry != null ? String(jail.maxretry) : "(default)"} />
</Field>
</div>
<Field label="Log path(s)">
{jail.logpath.length === 0 ? (
<Text size={200} className={styles.infoText}>(none)</Text>
) : (
<div>
{jail.logpath.map((p) => (
<Text key={p} block size={200} className={styles.codeFont}>
{p}
</Text>
))}
</div>
)}
</Field>
{jail.actions.length > 0 && (
<Field label="Actions">
<div>
{jail.actions.map((a) => (
<Badge
key={a}
appearance="tint"
color="informative"
style={{ marginRight: tokens.spacingHorizontalXS }}
>
{a}
</Badge>
))}
</div>
</Field>
)}
<Field label="Source file">
<Input readOnly value={jail.source_file} className={styles.codeFont} />
</Field>
<div style={{ marginTop: tokens.spacingVerticalL }}>
<Button
appearance="primary"
icon={<Play24Regular />}
onClick={onActivate}
>
Activate Jail
</Button>
</div>
</div>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// JailsTab // JailsTab
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -474,20 +590,99 @@ export function JailsTab(): React.JSX.Element {
const [reloading, setReloading] = useState(false); const [reloading, setReloading] = useState(false);
const [reloadMsg, setReloadMsg] = useState<string | null>(null); const [reloadMsg, setReloadMsg] = useState<string | null>(null);
// Inactive jails
const [inactiveJails, setInactiveJails] = useState<InactiveJail[]>([]);
const [inactiveLoading, setInactiveLoading] = useState(false);
const [activateTarget, setActivateTarget] = useState<InactiveJail | null>(null);
const [deactivating, setDeactivating] = useState(false);
const loadInactive = useCallback((): void => {
setInactiveLoading(true);
fetchInactiveJails()
.then((res) => { setInactiveJails(res.jails); })
.catch(() => { /* non-critical — active-only view still works */ })
.finally(() => { setInactiveLoading(false); });
}, []);
useEffect(() => {
loadInactive();
}, [loadInactive]);
const handleRefresh = useCallback((): void => {
refresh();
loadInactive();
}, [refresh, loadInactive]);
const handleReload = useCallback(async () => { const handleReload = useCallback(async () => {
setReloading(true); setReloading(true);
setReloadMsg(null); setReloadMsg(null);
try { try {
await reloadAll(); await reloadAll();
setReloadMsg("fail2ban reloaded."); setReloadMsg("fail2ban reloaded.");
refresh();
loadInactive();
} catch (err: unknown) { } catch (err: unknown) {
setReloadMsg(err instanceof ApiError ? err.message : "Reload failed."); setReloadMsg(err instanceof ApiError ? err.message : "Reload failed.");
} finally { } finally {
setReloading(false); setReloading(false);
} }
}, [reloadAll]); }, [reloadAll, refresh, loadInactive]);
if (loading) { const handleDeactivate = useCallback((name: string): void => {
setDeactivating(true);
setReloadMsg(null);
deactivateJail(name)
.then(() => {
setReloadMsg(`Jail "${name}" deactivated.`);
setSelectedName(null);
refresh();
loadInactive();
})
.catch((err: unknown) => {
setReloadMsg(
err instanceof ApiError ? err.message : "Deactivation failed."
);
})
.finally(() => { setDeactivating(false); });
}, [refresh, loadInactive]);
const handleActivated = useCallback((): void => {
setActivateTarget(null);
setSelectedName(null);
refresh();
loadInactive();
}, [refresh, loadInactive]);
/** Unified list items: active jails first (from useJailConfigs), then inactive. */
const listItems = useMemo<Array<{ name: string; kind: "active" | "inactive" }>>(() => {
const activeItems = jails.map((j) => ({ name: j.name, kind: "active" as const }));
const activeNames = new Set(jails.map((j) => j.name));
const inactiveItems = inactiveJails
.filter((j) => !activeNames.has(j.name))
.map((j) => ({ name: j.name, kind: "inactive" as const }));
return [...activeItems, ...inactiveItems];
}, [jails, inactiveJails]);
const activeJailMap = useMemo(
() => new Map(jails.map((j) => [j.name, j])),
[jails],
);
const inactiveJailMap = useMemo(
() => new Map(inactiveJails.map((j) => [j.name, j])),
[inactiveJails],
);
const selectedListItem = listItems.find((item) => item.name === selectedName);
const selectedActiveJail =
selectedListItem?.kind === "active"
? activeJailMap.get(selectedListItem.name)
: undefined;
const selectedInactiveJail =
selectedListItem?.kind === "inactive"
? inactiveJailMap.get(selectedListItem.name)
: undefined;
if (loading && listItems.length === 0) {
return ( return (
<Skeleton aria-label="Loading jail configs…"> <Skeleton aria-label="Loading jail configs…">
{[0, 1, 2].map((i) => ( {[0, 1, 2].map((i) => (
@@ -505,7 +700,7 @@ export function JailsTab(): React.JSX.Element {
); );
} }
if (jails.length === 0) { if (listItems.length === 0 && !loading && !inactiveLoading) {
return ( return (
<div className={styles.emptyState}> <div className={styles.emptyState}>
<LockClosed24Regular <LockClosed24Regular
@@ -513,7 +708,7 @@ export function JailsTab(): React.JSX.Element {
aria-hidden aria-hidden
/> />
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}> <Text size={400} style={{ color: tokens.colorNeutralForeground3 }}>
No active jails found. No jails found.
</Text> </Text>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}> <Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
Ensure fail2ban is running and jails are configured. Ensure fail2ban is running and jails are configured.
@@ -522,24 +717,20 @@ export function JailsTab(): React.JSX.Element {
); );
} }
const selectedJail: JailConfig | undefined = jails.find(
(j) => j.name === selectedName,
);
return ( return (
<div className={styles.tabContent}> <div className={styles.tabContent}>
<div className={styles.buttonRow}> <div className={styles.buttonRow}>
<Button <Button
appearance="secondary" appearance="secondary"
icon={<ArrowClockwise24Regular />} icon={<ArrowClockwise24Regular />}
onClick={refresh} onClick={handleRefresh}
> >
Refresh Refresh
</Button> </Button>
<Button <Button
appearance="secondary" appearance="secondary"
icon={<ArrowClockwise24Regular />} icon={<ArrowClockwise24Regular />}
disabled={reloading} disabled={reloading || deactivating}
onClick={() => void handleReload()} onClick={() => void handleReload()}
> >
{reloading ? "Reloading…" : "Reload fail2ban"} {reloading ? "Reloading…" : "Reload fail2ban"}
@@ -556,18 +747,34 @@ export function JailsTab(): React.JSX.Element {
<div style={{ marginTop: tokens.spacingVerticalM }}> <div style={{ marginTop: tokens.spacingVerticalM }}>
<ConfigListDetail <ConfigListDetail
items={jails} items={listItems}
isActive={(jail) => activeJails.has(jail.name)} isActive={(item) => item.kind === "active" && activeJails.has(item.name)}
selectedName={selectedName} selectedName={selectedName}
onSelect={setSelectedName} onSelect={setSelectedName}
loading={false} loading={false}
error={null} error={null}
> >
{selectedJail !== undefined ? ( {selectedActiveJail !== undefined ? (
<JailConfigDetail jail={selectedJail} onSave={updateJail} /> <JailConfigDetail
jail={selectedActiveJail}
onSave={updateJail}
onDeactivate={() => { handleDeactivate(selectedActiveJail.name); }}
/>
) : selectedInactiveJail !== undefined ? (
<InactiveJailDetail
jail={selectedInactiveJail}
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
/>
) : null} ) : null}
</ConfigListDetail> </ConfigListDetail>
</div> </div>
<ActivateJailDialog
jail={activateTarget}
open={activateTarget !== null}
onClose={() => { setActivateTarget(null); }}
onActivated={handleActivated}
/>
</div> </div>
); );
} }

View File

@@ -8,6 +8,8 @@
export { ActionsTab } from "./ActionsTab"; export { ActionsTab } from "./ActionsTab";
export { ActionForm } from "./ActionForm"; export { ActionForm } from "./ActionForm";
export type { ActionFormProps } from "./ActionForm"; export type { ActionFormProps } from "./ActionForm";
export { ActivateJailDialog } from "./ActivateJailDialog";
export type { ActivateJailDialogProps } from "./ActivateJailDialog";
export { AutoSaveIndicator } from "./AutoSaveIndicator"; export { AutoSaveIndicator } from "./AutoSaveIndicator";
export type { AutoSaveStatus, AutoSaveIndicatorProps } from "./AutoSaveIndicator"; export type { AutoSaveStatus, AutoSaveIndicatorProps } from "./AutoSaveIndicator";
export { ConfFilesTab } from "./ConfFilesTab"; export { ConfFilesTab } from "./ConfFilesTab";

View File

@@ -10,7 +10,7 @@
* geo-location details. * geo-location details.
*/ */
import { useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
Badge, Badge,
Button, Button,
@@ -32,6 +32,7 @@ import {
MessageBarBody, MessageBarBody,
Select, Select,
Spinner, Spinner,
Switch,
Text, Text,
Tooltip, Tooltip,
makeStyles, makeStyles,
@@ -52,8 +53,11 @@ import {
StopRegular, StopRegular,
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { fetchInactiveJails } from "../api/config";
import { ActivateJailDialog } from "../components/config";
import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails"; import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails";
import type { ActiveBan, JailSummary } from "../types/jail"; import type { ActiveBan, JailSummary } from "../types/jail";
import type { InactiveJail } from "../types/config";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -319,6 +323,25 @@ function JailOverviewSection(): React.JSX.Element {
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } = const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } =
useJails(); useJails();
const [opError, setOpError] = useState<string | null>(null); const [opError, setOpError] = useState<string | null>(null);
const [showInactive, setShowInactive] = useState(true);
const [inactiveJails, setInactiveJails] = useState<InactiveJail[]>([]);
const [activateTarget, setActivateTarget] = useState<InactiveJail | null>(null);
const loadInactive = useCallback((): void => {
fetchInactiveJails()
.then((res) => { setInactiveJails(res.jails); })
.catch(() => { /* non-critical */ });
}, []);
useEffect(() => {
loadInactive();
}, [loadInactive]);
const handleActivated = useCallback((): void => {
setActivateTarget(null);
refresh();
loadInactive();
}, [refresh, loadInactive]);
const handle = (fn: () => Promise<void>): void => { const handle = (fn: () => Promise<void>): void => {
setOpError(null); setOpError(null);
@@ -327,6 +350,9 @@ function JailOverviewSection(): React.JSX.Element {
}); });
}; };
const activeNameSet = new Set(jails.map((j) => j.name));
const inactiveToShow = inactiveJails.filter((j) => !activeNameSet.has(j.name));
return ( return (
<div className={styles.section}> <div className={styles.section}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
@@ -339,13 +365,16 @@ function JailOverviewSection(): React.JSX.Element {
)} )}
</Text> </Text>
<div className={styles.actionRow}> <div className={styles.actionRow}>
<Switch
label="Show inactive"
checked={showInactive}
onChange={(_e, d) => { setShowInactive(d.checked); }}
/>
<Button <Button
size="small" size="small"
appearance="subtle" appearance="subtle"
icon={<ArrowSyncRegular />} icon={<ArrowSyncRegular />}
onClick={() => { onClick={() => { handle(reloadAll); }}
handle(reloadAll);
}}
> >
Reload All Reload All
</Button> </Button>
@@ -451,6 +480,86 @@ function JailOverviewSection(): React.JSX.Element {
</DataGrid> </DataGrid>
</div> </div>
)} )}
{/* Inactive jails table */}
{showInactive && inactiveToShow.length > 0 && (
<div style={{ marginTop: tokens.spacingVerticalM }}>
<Text
size={300}
weight="semibold"
style={{ color: tokens.colorNeutralForeground3, marginBottom: tokens.spacingVerticalXS }}
block
>
Inactive jails ({String(inactiveToShow.length)})
</Text>
<div className={styles.tableWrapper}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: tokens.fontSizeBase200 }}>
<thead>
<tr style={{ borderBottom: `1px solid ${tokens.colorNeutralStroke2}` }}>
<th style={{ textAlign: "left", padding: "6px 8px", fontWeight: tokens.fontWeightSemibold }}>Jail</th>
<th style={{ textAlign: "left", padding: "6px 8px", fontWeight: tokens.fontWeightSemibold }}>Status</th>
<th style={{ textAlign: "left", padding: "6px 8px", fontWeight: tokens.fontWeightSemibold }}>Filter</th>
<th style={{ textAlign: "left", padding: "6px 8px", fontWeight: tokens.fontWeightSemibold }}>Port</th>
<th style={{ textAlign: "left", padding: "6px 8px" }} />
</tr>
</thead>
<tbody>
{inactiveToShow.map((j) => (
<tr
key={j.name}
style={{
borderBottom: `1px solid ${tokens.colorNeutralStroke2}`,
opacity: 0.7,
}}
>
<td style={{ padding: "6px 8px" }}>
<Link
to="/config"
style={{
fontFamily: "Consolas, 'Courier New', monospace",
fontSize: "0.85rem",
textDecoration: "none",
color: tokens.colorBrandForeground1,
}}
>
{j.name}
</Link>
</td>
<td style={{ padding: "6px 8px" }}>
<Badge appearance="filled" color="subtle">inactive</Badge>
</td>
<td style={{ padding: "6px 8px" }}>
<Text size={200} style={{ fontFamily: "Consolas, 'Courier New', monospace" }}>
{j.filter || "—"}
</Text>
</td>
<td style={{ padding: "6px 8px" }}>
<Text size={200}>{j.port ?? "—"}</Text>
</td>
<td style={{ padding: "6px 8px" }}>
<Button
size="small"
appearance="primary"
icon={<PlayRegular />}
onClick={() => { setActivateTarget(j); }}
>
Activate
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<ActivateJailDialog
jail={activateTarget}
open={activateTarget !== null}
onClose={() => { setActivateTarget(null); }}
onActivated={handleActivated}
/>
</div> </div>
); );
} }

View File

@@ -380,3 +380,60 @@ export interface JailFileConfig {
export interface JailFileConfigUpdate { export interface JailFileConfigUpdate {
jails?: Record<string, JailSectionConfig> | null; jails?: Record<string, JailSectionConfig> | null;
} }
// ---------------------------------------------------------------------------
// Inactive jail models (Stage 1)
// ---------------------------------------------------------------------------
/**
* A jail discovered in fail2ban config files that is not currently active.
*
* Maps 1-to-1 with the backend ``InactiveJail`` Pydantic model.
*/
export interface InactiveJail {
/** Jail name from the config section header. */
name: string;
/** Filter name (may include mode suffix, e.g. ``sshd[mode=normal]``). */
filter: string;
/** Action references listed in the config (raw strings). */
actions: string[];
/** Port(s) to monitor, or null. */
port: string | null;
/** Log file paths to monitor. */
logpath: string[];
/** Ban duration as a raw config string (e.g. ``"10m"``), or null. */
bantime: string | null;
/** Failure-counting window as a raw config string, or null. */
findtime: string | null;
/** Number of failures before a ban is issued, or null. */
maxretry: number | null;
/** Absolute path to the config file where this jail is defined. */
source_file: string;
/** Effective ``enabled`` value — always ``false`` for inactive jails. */
enabled: boolean;
}
export interface InactiveJailListResponse {
jails: InactiveJail[];
total: number;
}
/**
* Optional override values when activating an inactive jail.
*/
export interface ActivateJailRequest {
bantime?: string | null;
findtime?: string | null;
maxretry?: number | null;
port?: string | null;
logpath?: string[] | null;
}
export interface JailActivationResponse {
/** Name of the affected jail. */
name: string;
/** New activation state: true after activate, false after deactivate. */
active: boolean;
/** Human-readable result message. */
message: string;
}