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:
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
437
Docs/Tasks.md
437
Docs/Tasks.md
@@ -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)
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
666
backend/app/services/config_file_service.py
Normal file
666
backend/app/services/config_file_service.py
Normal 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.",
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
559
backend/tests/test_services/test_config_file_service.py
Normal file
559
backend/tests/test_services/test_config_file_service.py
Normal 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")
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
240
frontend/src/components/config/ActivateJailDialog.tsx
Normal file
240
frontend/src/components/config/ActivateJailDialog.tsx
Normal 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 “{jail.name}”</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user