diff --git a/Docs/Architekture.md b/Docs/Architekture.md index f7103d6..2abb81d 100644 --- a/Docs/Architekture.md +++ b/Docs/Architekture.md @@ -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 | | `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 | +| `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 | | `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 | diff --git a/Docs/Features.md b/Docs/Features.md index 1e957bf..3e8fea9 100644 --- a/Docs/Features.md +++ b/Docs/Features.md @@ -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. - 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. +- 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 @@ -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). - 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. + - 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. - 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. @@ -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. - Save changes and optionally reload fail2ban to apply them immediately. - 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 diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 6c92218..535d7e4 100644 --- a/Docs/Tasks.md +++ b/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. -- [Web-Development.md](Web-Development.md) — Code rules: TypeScript strict, `makeStyles`, component structure, hooks, API layer. -- Fluent UI v9 docs: https://github.com/microsoft/fluentui — components reference. +- Create a new service `config_file_service.py` in `app/services/` responsible for reading and writing fail2ban config files on disk. +- 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`). +- 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):** -- Fixed width ~280 px, full height of the tab content area, with its own vertical scroll. -- 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: - - The config **name** (e.g. `sshd`, `iptables-multiport`), truncated with ellipsis + tooltip for long names. - - A Fluent UI `Badge` to the right of the name: **"Active"** (`appearance="filled"`, `color="success"`) or **"Inactive"** (`appearance="outline"`, `color="informative"`). -- The selected item gets a left border accent (`tokens.colorBrandBackground`) and a highlighted background (`tokens.colorNeutralBackground1Selected`). -- Items are sorted: **active items first**, then inactive, alphabetical within each group. -- Keyboard navigable (arrow keys, Enter to select). Follow the accessibility rules from Web-Design.md §15. +- 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. +- Add a `POST /api/config/jails/{name}/activate` endpoint that enables an inactive jail. This endpoint should: + 1. Validate the jail name exists in the parsed config. + 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. + 3. Optionally accept a request body with override values (`bantime`, `findtime`, `maxretry`, `port`, `logpath`) that get written alongside the `enabled = true` line. + 4. Trigger a fail2ban reload so the jail starts immediately. + 5. Return the jail's new status after activation. +- 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):** -- Takes remaining width. Renders whatever `children` or render-prop content the parent tab passes for the currently selected item. -- Displays an empty state (`"Select an item from the list"`) when nothing is selected. -- Uses `Skeleton` / `Spinner` while the detail is loading. +**Files to create/modify:** +- `app/routers/config.py` (add endpoints) +- `app/services/config_file_service.py` (add activate/deactivate logic) +- `app/models/config.py` (add request/response models: `ActivateJailRequest`, `JailActivationResponse`) -**Props interface (suggestion):** - -```typescript -interface ConfigListDetailProps { - 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. +**References:** [Features.md §6](Features.md), [Backend-Development.md §3](Backend-Development.md) --- -### 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:** -- 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. +**Details:** -**Filters:** -- 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. -- 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. +- 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. +- 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. +- 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:** -- 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. +**Files to create/modify:** +- `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:** -- 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, activeFilters: Set, activeActions: Set, 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. +**References:** [Features.md §6](Features.md), [Web-Design.md](Web-Design.md), [Web-Development.md](Web-Development.md) --- -### 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`). -2. Use the active-status hook from Task B to get `activeJails`. -3. Render `ConfigListDetail` with: - - `items` = jail config list. - - `isActive` = `(jail) => activeJails.has(jail.name)`. - - `onSelect` updates `selectedName` state. -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. -5. **Export section** (new, at the bottom of the detail pane): - - 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. - - 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`. +- 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. +- 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. +- Add a toggle or filter above the table: "Show inactive jails" (default: on). When off, only active jails are displayed. +- Clicking an inactive jail's name navigates to the Configuration page's jail detail for that jail, pre-selecting it in the list. + +**Files to create/modify:** +- `frontend/src/pages/JailsPage.tsx` +- `frontend/src/hooks/useJails.ts` (extend to optionally fetch inactive jails) + +**References:** [Features.md §5](Features.md), [Web-Design.md](Web-Design.md) --- -### 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). -2. Use `activeFilters` from the active-status hook (Task B). -3. Render `ConfigListDetail` with: - - `items` = filter file entries. - - `isActive` = `(f) => activeFilters.has(f.name)`. -4. On item select, lazily load the parsed filter via `useFilterConfig` (already exists in `hooks/useFilterConfig.ts`). -5. Right pane renders the **existing `FilterForm`** component with the loaded config. -6. **Export section** at the bottom of the detail pane: - - Collapsible "Raw Configuration" section. - - `Textarea` (monospace) pre-filled with the raw file content fetched via `fetchFilterFile(name)` (returns `ConfFileContent` with a `content: string` field). - - Editable with a "Save Raw" `Button` that calls `updateFilterFile(name, { content })`. - - Success/error feedback via `MessageBar`. +- **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. +- **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. +- **Frontend tests**: Add unit tests for the new API functions. Add component tests for the activation dialog and the inactive jail display. + +**Files to create/modify:** +- `backend/tests/test_services/test_config_file_service.py` (new) +- `backend/tests/test_routers/test_config.py` (extend) +- `frontend/src/components/config/__tests__/` (new tests) + +**References:** [Backend-Development.md](Backend-Development.md), [Web-Development.md](Web-Development.md) --- -### 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`. -2. Use `activeActions` from the active-status hook (Task B). -3. Render `ConfigListDetail` with: - - `items` = action file entries. - - `isActive` = `(a) => activeActions.has(a.name)`. -4. On item select, lazily load the parsed action via `useActionConfig` (already exists). -5. Right pane renders the **existing `ActionForm`** component. -6. **Export section** at the bottom: - - Collapsible "Raw Configuration" section. - - `Textarea` (monospace) with raw file content from `fetchActionFile(name)`. - - "Save Raw" button calling `updateActionFile(name, { content })`. - - Feedback messages. +**Goal:** Enumerate all filter config files and mark each as active or inactive. + +**Details:** + +- Add a method `list_filters()` to `config_file_service.py` that scans the `filter.d/` directory within the fail2ban config path. +- 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. +- Handle `.local` overrides: if `sshd.local` exists alongside `sshd.conf`, merge the local values on top of the conf values (local wins). +- 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. +- 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). +- Add a `GET /api/config/filters` endpoint to the config router returning all filters. +- Add a `GET /api/config/filters/{name}` endpoint returning the full parsed detail of a single filter. + +**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 -interface RawConfigSectionProps { - /** Async function that returns the raw file content string. */ - fetchContent: () => Promise; - /** Async function that saves updated raw content. */ - saveContent: (content: string) => Promise; - /** Label shown in the collapsible header, e.g. "Raw Jail Configuration". */ - label?: string; -} -``` +**Files to create/modify:** +- `app/services/config_file_service.py` (add filter write/create/delete methods) +- `app/routers/config.py` (add endpoints) +- `app/models/config.py` (add `FilterUpdateRequest`, `FilterCreateRequest`) -**Behaviour:** -- 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. +**References:** [Features.md §6](Features.md), [Backend-Development.md](Backend-Development.md) --- -### 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`. -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). +**Goal:** Enhance the Filters tab in the Configuration page to show all filters with their active/inactive status and allow editing. + +**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. -2. **Lint:** Run `npm run lint` — zero warnings. -3. **Existing tests:** Run `npx vitest run` — all existing tests pass. -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. - - Navigate to Config → Filters tab. Same list/detail pattern. Active filters (used by running jails) show "Active" badge. - - Navigate to Config → Actions tab. Same pattern. - - 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. -5. **New tests (optional but recommended):** - - Unit test `ConfigListDetail` rendering with mock items, verifying sort order (active first) and selection callback. - - Unit test `RawConfigSection` with mocked fetch/save functions. +**Goal:** Test coverage for filter listing, editing, creation, and assignment. + +**Details:** + +- **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. +- **Router tests**: Test all new filter endpoints (list, detail, update, create, delete, assign). Test auth required. Test 404/409/422 responses. +- **Frontend tests**: Test the filter list rendering with mixed active/inactive items. Test the form submission in the detail pane. Test the assign dialog. + +**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 filter tests) --- -### Implementation Order +## Stage 3 — Action Configuration Discovery and Activation -1. Task F (RawConfigSection) — standalone, no dependencies. -2. Task A (ConfigListDetail layout) — standalone component. -3. Task B (useConfigActiveStatus hook) — needs only the existing API layer. -4. Task C (JailsTab redesign) — depends on A, B, F. -5. Task D (FiltersTab redesign) — depends on A, B, F. -6. Task E (ActionsTab redesign) — depends on A, B, F. -7. Task G (exports and wiring) — after C/D/E. -8. Task H (testing) — last. +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. + +### Task 3.1 — Backend: List All Available Actions with Active/Inactive Status + +**Goal:** Enumerate all action config files and mark each as active or inactive based on jail usage. + +**Details:** + +- 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) diff --git a/backend/app/models/config.py b/backend/app/models/config.py index f817cdf..5d324d4 100644 --- a/backend/app/models/config.py +++ b/backend/app/models/config.py @@ -447,3 +447,119 @@ class JailFileConfigUpdate(BaseModel): default=None, 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.") diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py index 570f669..6e78de2 100644 --- a/backend/app/routers/config.py +++ b/backend/app/routers/config.py @@ -3,15 +3,18 @@ Provides endpoints to inspect and edit fail2ban jail configuration and global settings, test regex patterns, add log paths, and preview log files. -* ``GET /api/config/jails`` — list all jail configs -* ``GET /api/config/jails/{name}`` — full config for one jail -* ``PUT /api/config/jails/{name}`` — update a jail's config -* ``GET /api/config/global`` — global fail2ban settings -* ``PUT /api/config/global`` — update global settings -* ``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 +* ``GET /api/config/jails`` — list all jail configs +* ``GET /api/config/jails/{name}`` — full config for one jail +* ``PUT /api/config/jails/{name}`` — update a jail's config +* ``GET /api/config/jails/inactive`` — list all inactive jails +* ``POST /api/config/jails/{name}/activate`` — activate an inactive jail +* ``POST /api/config/jails/{name}/deactivate`` — deactivate an active jail +* ``GET /api/config/global`` — global fail2ban settings +* ``PUT /api/config/global`` — update global settings +* ``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 @@ -22,9 +25,12 @@ from fastapi import APIRouter, HTTPException, Path, Query, Request, status from app.dependencies import AuthDep from app.models.config import ( + ActivateJailRequest, AddLogPathRequest, GlobalConfigResponse, GlobalConfigUpdate, + InactiveJailListResponse, + JailActivationResponse, JailConfigListResponse, JailConfigResponse, JailConfigUpdate, @@ -35,7 +41,14 @@ from app.models.config import ( RegexTestRequest, 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 ( ConfigOperationError, ConfigValidationError, @@ -113,6 +126,33 @@ async def get_jail_configs( 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( "/jails/{name}", response_model=JailConfigResponse, @@ -495,3 +535,114 @@ async def update_map_color_thresholds( threshold_medium=body.threshold_medium, 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 diff --git a/backend/app/services/config_file_service.py b/backend/app/services/config_file_service.py new file mode 100644 index 0000000..c0791d0 --- /dev/null +++ b/backend/app/services/config_file_service.py @@ -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.", + ) diff --git a/backend/tests/test_routers/test_config.py b/backend/tests/test_routers/test_config.py index 992651e..a143123 100644 --- a/backend/tests/test_routers/test_config.py +++ b/backend/tests/test_routers/test_config.py @@ -575,3 +575,245 @@ class TestUpdateMapColorThresholds: # Pydantic validates ge=1 constraint before our service code runs 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 diff --git a/backend/tests/test_services/test_config_file_service.py b/backend/tests/test_services/test_config_file_service.py new file mode 100644 index 0000000..37b4bd2 --- /dev/null +++ b/backend/tests/test_services/test_config_file_service.py @@ -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") diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index e271b4c..7396385 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -7,6 +7,7 @@ import { ENDPOINTS } from "./endpoints"; import type { ActionConfig, ActionConfigUpdate, + ActivateJailRequest, AddLogPathRequest, ConfFileContent, ConfFileCreateRequest, @@ -16,6 +17,8 @@ import type { FilterConfigUpdate, GlobalConfig, GlobalConfigUpdate, + InactiveJailListResponse, + JailActivationResponse, JailConfigFileContent, JailConfigFileEnabledUpdate, JailConfigFilesResponse, @@ -290,3 +293,38 @@ export async function updateParsedJailFile( ): Promise { await put(ENDPOINTS.configJailFileParsed(filename), update); } + +// --------------------------------------------------------------------------- +// Inactive jails (Stage 1) +// --------------------------------------------------------------------------- + +/** Fetch all inactive jails from config files. */ +export async function fetchInactiveJails(): Promise { + return get(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 { + return post( + ENDPOINTS.configJailActivate(name), + overrides ?? {} + ); +} + +/** Deactivate an active jail. */ +export async function deactivateJail( + name: string +): Promise { + return post( + ENDPOINTS.configJailDeactivate(name), + undefined + ); +} diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index eb4940c..6483802 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -61,9 +61,14 @@ export const ENDPOINTS = { // Configuration // ------------------------------------------------------------------------- configJails: "/config/jails", + configJailsInactive: "/config/jails/inactive", configJail: (name: string): string => `/config/jails/${encodeURIComponent(name)}`, configJailLogPath: (name: string): string => `/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", configReload: "/config/reload", configRegexTest: "/config/regex-test", diff --git a/frontend/src/components/config/ActivateJailDialog.tsx b/frontend/src/components/config/ActivateJailDialog.tsx new file mode 100644 index 0000000..13b6a03 --- /dev/null +++ b/frontend/src/components/config/ActivateJailDialog.tsx @@ -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(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 ( + { if (!data.open) handleClose(); }}> + + + Activate jail “{jail.name}” + + + This will write enabled = true to{" "} + jail.d/{jail.name}.local and reload fail2ban. The + jail will start monitoring immediately. + + + Override values (leave blank to use config defaults) + +
+ + { setBantime(d.value); }} + /> + + + { setFindtime(d.value); }} + /> + + + { setMaxretry(d.value); }} + /> + + + { setPort(d.value); }} + /> + +
+ + 0 ? jail.logpath[0] : "/var/log/example.log" + } + value={logpath} + disabled={submitting} + onChange={(_e, d) => { setLogpath(d.value); }} + /> + + {error && ( + + {error} + + )} +
+ + + + +
+
+
+ ); +} diff --git a/frontend/src/components/config/JailsTab.tsx b/frontend/src/components/config/JailsTab.tsx index 277e06e..81d9f4d 100644 --- a/frontend/src/components/config/JailsTab.tsx +++ b/frontend/src/components/config/JailsTab.tsx @@ -6,7 +6,7 @@ * raw-config editor. */ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Badge, Button, @@ -25,23 +25,29 @@ import { ArrowClockwise24Regular, Dismiss24Regular, LockClosed24Regular, + LockOpen24Regular, + Play24Regular, } from "@fluentui/react-icons"; import { ApiError } from "../../api/client"; import { addLogPath, + deactivateJail, deleteLogPath, + fetchInactiveJails, fetchJailConfigFileContent, updateJailConfigFile, } from "../../api/config"; import type { AddLogPathRequest, ConfFileUpdateRequest, + InactiveJail, JailConfig, JailConfigUpdate, } from "../../types/config"; import { useAutoSave } from "../../hooks/useAutoSave"; import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus"; import { useJailConfigs } from "../../hooks/useConfig"; +import { ActivateJailDialog } from "./ActivateJailDialog"; import { AutoSaveIndicator } from "./AutoSaveIndicator"; import { ConfigListDetail } from "./ConfigListDetail"; import { RawConfigSection } from "./RawConfigSection"; @@ -55,6 +61,7 @@ import { useConfigStyles } from "./configStyles"; interface JailConfigDetailProps { jail: JailConfig; onSave: (name: string, update: JailConfigUpdate) => Promise; + onDeactivate?: () => void; } /** @@ -69,6 +76,7 @@ interface JailConfigDetailProps { function JailConfigDetail({ jail, onSave, + onDeactivate, }: JailConfigDetailProps): React.JSX.Element { const styles = useConfigStyles(); const [banTime, setBanTime] = useState(String(jail.ban_time)); @@ -443,6 +451,18 @@ function JailConfigDetail({ /> + {onDeactivate !== undefined && ( +
+ +
+ )} + {/* Raw Configuration */}
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 ( +
+ + {jail.name} + + +
+ + + + + + +
+ +
+ + + + + + + + + +
+ + + {jail.logpath.length === 0 ? ( + (none) + ) : ( +
+ {jail.logpath.map((p) => ( + + {p} + + ))} +
+ )} +
+ + {jail.actions.length > 0 && ( + +
+ {jail.actions.map((a) => ( + + {a} + + ))} +
+
+ )} + + + + + +
+ +
+
+ ); +} + // --------------------------------------------------------------------------- // JailsTab // --------------------------------------------------------------------------- @@ -474,20 +590,99 @@ export function JailsTab(): React.JSX.Element { const [reloading, setReloading] = useState(false); const [reloadMsg, setReloadMsg] = useState(null); + // Inactive jails + const [inactiveJails, setInactiveJails] = useState([]); + const [inactiveLoading, setInactiveLoading] = useState(false); + const [activateTarget, setActivateTarget] = useState(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 () => { setReloading(true); setReloadMsg(null); try { await reloadAll(); setReloadMsg("fail2ban reloaded."); + refresh(); + loadInactive(); } catch (err: unknown) { setReloadMsg(err instanceof ApiError ? err.message : "Reload failed."); } finally { 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>(() => { + 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 ( {[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 (
- No active jails found. + No jails found. 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 (
); } diff --git a/frontend/src/components/config/index.ts b/frontend/src/components/config/index.ts index 0ec8e4d..9c26b88 100644 --- a/frontend/src/components/config/index.ts +++ b/frontend/src/components/config/index.ts @@ -8,6 +8,8 @@ export { ActionsTab } from "./ActionsTab"; export { ActionForm } from "./ActionForm"; export type { ActionFormProps } from "./ActionForm"; +export { ActivateJailDialog } from "./ActivateJailDialog"; +export type { ActivateJailDialogProps } from "./ActivateJailDialog"; export { AutoSaveIndicator } from "./AutoSaveIndicator"; export type { AutoSaveStatus, AutoSaveIndicatorProps } from "./AutoSaveIndicator"; export { ConfFilesTab } from "./ConfFilesTab"; diff --git a/frontend/src/pages/JailsPage.tsx b/frontend/src/pages/JailsPage.tsx index f568470..02d4eab 100644 --- a/frontend/src/pages/JailsPage.tsx +++ b/frontend/src/pages/JailsPage.tsx @@ -10,7 +10,7 @@ * geo-location details. */ -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Badge, Button, @@ -32,6 +32,7 @@ import { MessageBarBody, Select, Spinner, + Switch, Text, Tooltip, makeStyles, @@ -52,8 +53,11 @@ import { StopRegular, } from "@fluentui/react-icons"; import { Link } from "react-router-dom"; +import { fetchInactiveJails } from "../api/config"; +import { ActivateJailDialog } from "../components/config"; import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails"; import type { ActiveBan, JailSummary } from "../types/jail"; +import type { InactiveJail } from "../types/config"; 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 } = useJails(); const [opError, setOpError] = useState(null); + const [showInactive, setShowInactive] = useState(true); + const [inactiveJails, setInactiveJails] = useState([]); + const [activateTarget, setActivateTarget] = useState(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 => { 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 (
@@ -339,13 +365,16 @@ function JailOverviewSection(): React.JSX.Element { )}
+ { setShowInactive(d.checked); }} + /> @@ -451,6 +480,86 @@ function JailOverviewSection(): React.JSX.Element {
)} + + {/* Inactive jails table */} + {showInactive && inactiveToShow.length > 0 && ( +
+ + Inactive jails ({String(inactiveToShow.length)}) + +
+ + + + + + + + + + + {inactiveToShow.map((j) => ( + + + + + + + + ))} + +
JailStatusFilterPort +
+ + {j.name} + + + inactive + + + {j.filter || "—"} + + + {j.port ?? "—"} + + +
+
+
+ )} + + { setActivateTarget(null); }} + onActivated={handleActivated} + />
); } diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts index 5630529..0c8e5d0 100644 --- a/frontend/src/types/config.ts +++ b/frontend/src/types/config.ts @@ -380,3 +380,60 @@ export interface JailFileConfig { export interface JailFileConfigUpdate { jails?: Record | 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; +}