Config page tasks 1-4: dropdowns, key props, inactive jail full GUI, banaction fix

Task 1: Backend/LogEncoding/DatePattern dropdowns in JailConfigDetail
- Added BACKENDS, LOG_ENCODINGS, DATE_PATTERN_PRESETS constants
- Backend and Log Encoding: <Input readOnly> → <Select> (editable, auto-saves)
- Date Pattern: <Input> → <Combobox freeform> with presets
- Extended JailConfigUpdate model (backend, log_encoding) and service
- Added readOnly prop to JailConfigDetail (all fields, toggles, buttons)
- Extended RegexList with readOnly prop

Task 2: Fix raw action/filter config always blank
- Added key={selectedAction.name} to ActionDetail in ActionsTab
- Added key={selectedFilter.name} to FilterDetail in FiltersTab

Task 3: Inactive jail full GUI same as active jails
- Extended InactiveJail Pydantic model with all config fields
- Added _parse_time_to_seconds helper to config_file_service
- Updated _build_inactive_jail to populate all extended fields
- Extended InactiveJail TypeScript type to match
- Rewrote InactiveJailDetail to reuse JailConfigDetail (readOnly=true)

Task 4: Fix banaction interpolation error when activating jails
- _write_local_override_sync now includes banaction=iptables-multiport
  and banaction_allports=iptables-allports in every .local file
This commit is contained in:
2026-03-14 09:28:30 +01:00
parent 201cca8b66
commit c110352e9e
9 changed files with 541 additions and 246 deletions

View File

@@ -4,157 +4,203 @@ This document breaks the entire BanGUI project into development stages, ordered
--- ---
## Stage: Config Page Cleanup & Improvements ## ✅ Task 1 — Convert Backend, Log Encoding, and Date Pattern to Dropdowns in Jail Config
These tasks refine the Configuration page UI. Each task is self-contained and can be completed independently. References point to the specific files that need modification. **Completed.** Backend and Log Encoding are now `<Select>` dropdowns; Date Pattern is a `<Combobox>` with presets. Both are wired to auto-save. The `JailConfigDetail` component also gained a `readOnly` prop (wired throughout all fields, toggles, and buttons) in preparation for Task 3. Backend updated `JailConfigUpdate` model to accept `backend` and `log_encoding`, and `update_jail_config` sends them to the daemon.
--- ---
### ✅ Task 1Remove the Export tab from the Config page ## ✅ Task 2Fix Raw Action Configuration Always Blank in Actions Tab
**Status:** Done — Removed `ExportTab` import, `"export"` from `TabValue`, the `<Tab>` element, and the conditional render. `DocumentArrowDown24Regular` icon import also cleaned up. **Completed.** Added `key={selectedAction.name}` to `<ActionDetail>` in `ActionsTab.tsx` and `key={selectedFilter.name}` to `<FilterDetail>` in `FiltersTab.tsx`. This forces a full remount on item switch, resetting `RawConfigSection`'s `loadedRef` so it re-fetches content for every selected item.
**Goal:** Remove the "Export" tab entirely from the Configuration page. Users should no longer see the Export option in the tab bar, and its rendering logic should be cleaned up.
**Files to modify:**
- `frontend/src/pages/ConfigPage.tsx`
**What to do:**
1. Remove the `"export"` value from the `TabValue` union type.
2. Remove the `<Tab value="export" icon={<DocumentArrowDown24Regular />}>Export</Tab>` element from the `<TabList>`.
3. Remove the `{tab === "export" && <ExportTab />}` conditional render from the tab content area.
4. Remove the `ExportTab` import from the component imports.
5. Remove the `DocumentArrowDown24Regular` icon import if it is no longer used elsewhere.
**Verification:** The Config page should render without the Export tab. No TypeScript errors. All other tabs continue to work.
--- ---
### ✅ Task 2Remove "Refresh" and "Reload fail2ban" buttons from the Jails tab ## ✅ Task 3Give Inactive Jail Configs the Same GUI as Active Ones
**Status:** Done — Removed `buttonRow` div with both buttons, the `reloadMsg` `MessageBar`, all associated state (`reloading`, `reloadMsg`, `deactivating`) and handlers (`handleRefresh`, `handleReload`). Cleaned up `reloadAll` from `useJailConfigs()` destructure and `ArrowClockwise24Regular` icon import. **Completed.** Extended `InactiveJail` backend Pydantic model with all config fields (`ban_time_seconds`, `find_time_seconds`, `log_encoding`, `backend`, `date_pattern`, `use_dns`, `prefregex`, `fail_regex`, `ignore_regex`, `bantime_escalation`). Added `_parse_time_to_seconds` helper to `config_file_service.py` and updated `_build_inactive_jail` to populate all new fields. Extended the `InactiveJail` TypeScript type to match. Rewrote `InactiveJailDetail` to map fields into a `JailConfig`-compatible object and render `JailConfigDetail` with `readOnly=true`, showing filter/port/source_file as informational fields above the shared form.
**Goal:** Remove the two action buttons ("Refresh" and "Reload fail2ban") that appear at the top of the Jails tab.
**Files to modify:**
- `frontend/src/components/config/JailsTab.tsx`
**What to do:**
1. In the `JailsTab` component (the public export, starting around line 584), locate the `<div className={styles.buttonRow}>` block that renders the "Refresh" and "Reload fail2ban" buttons (around lines 722740).
2. Remove the entire `<div className={styles.buttonRow}>...</div>` block containing both buttons.
3. Remove the `reloadMsg` `<MessageBar>` block that follows the button row (around lines 741748), since it only displays messages from the reload action.
4. Clean up the now-unused state variables and handlers: `reloading`, `reloadMsg`, `setReloading`, `setReloadMsg`, `handleRefresh`, and `handleReload`.
5. Remove the `reloadAll` destructured value from the `useJailConfigs()` call if it is no longer used.
6. Remove the `ArrowClockwise24Regular` icon import if it is no longer used elsewhere in this file.
7. Keep `refresh` and `loadInactive` — they are still used by `handleDeactivate` and `handleActivated`.
**Verification:** The Jails tab no longer shows the two buttons or any reload message bar. Active and inactive jails still load and display correctly. Deactivate and activate flows still work.
--- ---
### ✅ Task 3Add a "Create Config" button to the Jails tab ## ✅ Task 4Fix `banaction` Interpolation Error When Activating Jails
**Status:** Done — Created `CreateJailDialog.tsx`, wired `listHeader` prop into `ConfigListDetail` with an `Add24Regular` button, added `createDialogOpen` state. Dialog calls `createJailConfigFile` API and refreshes list on success. Exported from `config/index.ts`. **Completed.** `_write_local_override_sync` now always writes `banaction = iptables-multiport` and `banaction_allports = iptables-allports` into the per-jail `.local` file (Option A), ensuring fail2ban can resolve the `%(banaction)s` interpolation used in `action_`.
**Goal:** Add a button at the top of the Jails tab list pane that opens a dialog to create a new jail configuration file by name. This is analogous to the "Create Filter" button already present in the Filters tab.
**Files to modify:**
- `frontend/src/components/config/JailsTab.tsx` — add button + dialog trigger
- Optionally create a new `frontend/src/components/config/CreateJailDialog.tsx` dialog component
**What to do:** **Problem:** In the Config → Jails page, the **Backend**, **Log Encoding**, and **Date Pattern** fields for active jails are currently plain text `<Input>` fields (Backend and Log Encoding are read-only, Date Pattern is a free-text input). These should be `<Select>` dropdowns so users pick from valid options instead of typing arbitrary values.
1. Add a "Create Config" button at the top of the Jails tab. The recommended approach is to use the `listHeader` prop of `<ConfigListDetail>`, the same pattern used in `FiltersTab.tsx` (see lines 233240). Pass a `<Button appearance="outline" icon={<Add24Regular />} size="small">Create Config</Button>` as the `listHeader`.
2. Create a `CreateJailDialog` component (or inline the logic). The dialog should:
- Accept a name input from the user (the jail config file name, e.g. `my-custom-jail`).
- On confirm, call the existing `createJailConfigFile` API function from `frontend/src/api/config.ts` with `{ name, content: "# <name>\n" }` as the payload.
- Show a loading state while the request is in flight.
- Display any errors from the API.
- On success, close the dialog and refresh the jail list (call `refresh()` and `loadInactive()`).
3. Wire the button's `onClick` to open the dialog, and handle the `onCreated` callback to refresh data.
**Reference:** See `CreateFilterDialog.tsx` for the established dialog pattern, and `JailFilesTab.tsx` lines 98110 for the `createJailConfigFile` API usage. **Where to change:**
**Verification:** Clicking "Create Config" opens a dialog. Entering a name and confirming creates a new jail config file. The new file appears in the jail list after creation. - **`frontend/src/components/config/JailsTab.tsx`** — the `JailConfigDetail` component, around lines 250270. This is the right-pane form for active jails. Replace the three `<Input>` fields with `<Select>` dropdowns.
**Dropdown options to use:**
### Backend
Reference: `fail2ban-master/config/jail.conf` lines 115132. The `JailFileForm.tsx` already defines a `BACKENDS` constant at line 163 — reuse or mirror it.
| Value | Label |
|-------------|----------------------------------------------|
| `auto` | auto — pyinotify, then polling |
| `polling` | polling — standard polling algorithm |
| `pyinotify` | pyinotify — requires pyinotify library |
| `systemd` | systemd — uses systemd journal |
| `gamin` | gamin — legacy file alteration monitor |
### Log Encoding
Reference: `fail2ban-master/config/jail.conf` lines 145150. The option `auto` uses the system locale; the rest are standard Python codec names.
| Value | Label |
|----------|---------------------------------------|
| `auto` | auto — use system locale |
| `ascii` | ascii |
| `utf-8` | utf-8 |
| `latin-1` | latin-1 (ISO 8859-1) |
### Date Pattern
Date Pattern should remain a **combobox** (editable dropdown) since users may enter custom patterns, but offer common presets as suggestions.
| Value | Label |
|--------------------------------|---------------------------------------|
| *(empty)* | auto-detect (leave blank) |
| `{^LN-BEG}` | {^LN-BEG} — line beginning |
| `%%Y-%%m-%%d %%H:%%M:%%S` | YYYY-MM-DD HH:MM:SS |
| `%%d/%%b/%%Y:%%H:%%M:%%S` | DD/Mon/YYYY:HH:MM:SS (Apache) |
| `%%b %%d %%H:%%M:%%S` | Mon DD HH:MM:SS (syslog) |
| `EPOCH` | EPOCH — Unix timestamp |
| `%%Y-%%m-%%dT%%H:%%M:%%S` | ISO 8601 |
| `TAI64N` | TAI64N |
**Implementation notes:**
1. Backend and Log Encoding are currently `readOnly`. They must become editable `<Select>` fields. Wire their `onChange` to local state and include them in the auto-save payload sent to the backend.
2. The backend model at `backend/app/models/config.py` line 67 already accepts `backend: str` and `log_encoding: str`, so no backend changes are needed unless you want to add server-side validation.
3. Date Pattern should use a Fluent UI `Combobox` (editable dropdown) instead of a `Select`, so users can still type a custom pattern.
4. Follow the existing DNS Mode dropdown pattern at lines 271280 of `JailsTab.tsx` as a reference for styling and onChange handling.
--- ---
### ✅ Task 4Remove the "Active" badge from the Filter and Action detail panes ## Task 2Fix Raw Action Configuration Always Blank in Actions Tab
**Status:** Done — Removed the `<div>` wrapper with Active/Inactive and "Has local override" badges from `FilterDetail` in `FiltersTab.tsx` and from `ActionDetail` in `ActionsTab.tsx`. Removed unused `Badge` import from both files. **Problem:** In the Config → Actions page, the **"Raw Action Configuration"** accordion section is always blank when expanded.
**Goal:** Remove the `<Badge>` component that shows "Active" or "Inactive" in the detail (right) pane of both the Filters tab and the Actions tab. The badge in the list pane (left side, inside `ConfigListDetail`) should remain — only the one in the detail section needs to be removed. **Where to look:**
**Files to modify:** - **`frontend/src/components/config/ActionsTab.tsx`** — the `ActionDetail` component (lines 65185). The `fetchRaw` callback (line 82) calls `fetchActionFile(action.name)` and returns `result.content`.
- `frontend/src/components/config/FiltersTab.tsx` - **`frontend/src/components/config/RawConfigSection.tsx`** — the collapsible raw editor. It uses a `loadedRef` (line 62) to fetch content only on first accordion expansion, and never resets.
- `frontend/src/components/config/ActionsTab.tsx` - **`frontend/src/api/config.ts`** line 257 — `fetchActionFile()` calls `GET /config/actions/{name}`.
- **`backend/app/services/file_config_service.py`** line 734 — `get_action_file()` reads from `action.d/` using `_read_conf_file()`, which tries `.conf` first, then `.local`.
**What to do in `FiltersTab.tsx`:** **Root cause investigation — check these two possibilities:**
1. In the `FilterDetail` component (around lines 90112), locate the `<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>` block that wraps the Active/Inactive `<Badge>` and the optional "Has local override" `<Badge>`.
2. Remove this entire `<div>` block, including both badges inside it.
3. If the `Badge` import from `@fluentui/react-components` is no longer used in this file, remove it.
**What to do in `ActionsTab.tsx`:** ### Possibility A: Stale `loadedRef` across action switches
1. In the `ActionDetail` component (around lines 115137), locate the same `<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>` block with the Active/Inactive badge and the "Has local override" badge. `ActionDetail` at line 301 of `ActionsTab.tsx` is rendered **without a `key` prop**:
2. Remove this entire `<div>` block, including both badges.
3. Clean up unused `Badge` import if applicable.
**Verification:** Selecting a filter or action in the list still works. The detail pane no longer shows the Active/Inactive badge or the "Has local override" badge. The list pane badges are unaffected.
---
### ✅ Task 5 — Fix Jail log paths: remove path text spans, populate editable fields
**Status:** Done — Replaced the read-only `<span>` elements for existing log paths with editable `<Input>` fields, each with an `onChange` handler that updates the corresponding index in `logPaths` state. Delete button preserved.
**Goal:** In the active jail detail pane, the "Log Paths" field currently renders each existing log path as a read-only `<span>` text element with a delete button. The actual `<Input>` field below is always empty and is only used for adding new paths. The user wants to remove the read-only text spans and instead make each log path an editable field.
**Files to modify:**
- `frontend/src/components/config/JailsTab.tsx`
**What to do:**
1. In the `JailConfigDetail` component, locate the `<Field label="Log Paths">` block (around lines 292350).
2. Remove the read-only span list that renders existing log paths:
```tsx
{logPaths.length === 0 ? (
<Text ...>(none)</Text>
) : (
logPaths.map((p) => (
<div key={p} className={styles.regexItem}>
<span className={styles.codeFont} style={{ flexGrow: 1 }}>{p}</span>
<Button ... title="Remove log path" ... />
</div>
))
)}
```
3. Replace it with editable `<Input>` fields for each existing log path. Each row should have:
- An `<Input>` pre-filled with the path value (`value={p}`), editable.
- An `onChange` handler that updates the corresponding entry in the `logPaths` state array.
- The same delete button (reuse the existing `handleDeleteLogPath` callback).
4. Keep the "Add new log path" form at the bottom unchanged (the `<Input>` with placeholder, `<Switch>` for tail/head, and "Add" button).
5. Ensure the `logPaths` state is properly initialized from `jail.log_paths` (this is already the case at line 87).
**Implementation hint:** Map over `logPaths` with an index, render an `<Input value={logPaths[i]}>` for each, and on change update the array at that index. Example:
```tsx ```tsx
logPaths.map((p, i) => ( <ActionDetail
<div key={i} className={styles.regexItem}> action={selectedAction}
<Input onAssignClick={() => { setAssignOpen(true); }}
className={styles.codeFont} onRemovedFromJail={handleRemovedFromJail}
style={{ flexGrow: 1 }} />
value={p} ```
onChange={(_e, d) => { Without `key={selectedAction.name}`, React reuses the same component instance when switching between actions. The `RawConfigSection` inside `ActionDetail` keeps its `loadedRef.current = true` from a previous expansion, so it never re-fetches for the new action.
setLogPaths((prev) => prev.map((v, j) => (j === i ? d.value : v)));
}} **Fix:** Add `key={selectedAction.name}` to `ActionDetail` so it fully unmounts/remounts on action switch, resetting all internal state including `loadedRef`.
/>
<Button ### Possibility B: Backend returning empty content
appearance="subtle" The `_read_conf_file` function at `file_config_service.py` line 492 tries `.conf` first then `.local`. If the action only has a `.conf` file and it's readable, content should be returned. Verify by:
icon={<Dismiss24Regular />} 1. Checking browser DevTools Network tab for the `GET /api/config/actions/{name}` response body.
size="small" 2. Confirming the response has a non-empty `content` field.
disabled={deletingPath === p}
title="Remove log path" **Implementation:**
onClick={() => void handleDeleteLogPath(p)}
/> 1. Add `key={selectedAction.name}` to the `<ActionDetail>` component in `ActionsTab.tsx` line 301.
</div> 2. Test by selecting different actions and expanding the raw config accordion — each should show the correct file content.
)) 3. If the backend is returning empty content, debug `_read_conf_file` to check which file path it resolves and whether it can read the file.
---
## Task 3 — Give Inactive Jail Configs the Same GUI as Active Ones
**Problem:** In the Config → Jails page, **inactive jails** show a minimal read-only preview (`InactiveJailDetail` component) with only basic fields (filter, port, ban time, find time, max retry, log paths, actions, source file). **Active jails** like `bangui-sim` show a full editable form (`JailConfigDetail` component) with all fields including Backend, Log Encoding, Date Pattern, DNS Mode, Prefix Regex, editable log paths, Fail/Ignore Regex lists, Ban-time Escalation, and a Raw Configuration editor.
The inactive jail detail should display the same rich GUI as the active one — same layout, same fields — but in read-only mode (since the jail is not running on fail2ban).
**Where to change:**
- **`frontend/src/components/config/JailsTab.tsx`**:
- `InactiveJailDetail` component (lines 493580): the current minimal read-only view.
- `JailConfigDetail` component (lines ~200490): the full editable form for active jails.
- Selection logic at lines 721752 determines which component renders based on jail kind.
- **`frontend/src/types/config.ts`** — `InactiveJail` type (around line 484) defines the data shape for inactive jails. Compare with `JailConfig` to see which fields are missing.
- **Backend** — check that the inactive jail endpoint returns enough data. The backend model `InactiveJail` may need additional fields (backend, log_encoding, date_pattern, dns_mode, prefix_regex, failregex, ignoreregex, ban-time escalation settings) to match the active `JailConfig` model.
**Implementation approach:**
1. **Extend the `InactiveJail` model** in the backend (`backend/app/models/config.py`) to include all fields that `JailConfig` has: `backend`, `log_encoding`, `date_pattern`, `usedns`, `prefixregex`, `failregex`, `ignoreregex`, `bantime_increment` (escalation settings), etc.
2. **Update the backend service** that parses inactive jail configs to extract these additional fields from the `.conf`/`.local` files.
3. **Rewrite `InactiveJailDetail`** to mirror `JailConfigDetail`'s layout but with all fields in **read-only** mode (all `<Input>` have `readOnly`, all `<Select>` are disabled). Keep the "Activate Jail" button at the bottom.
4. Alternatively, refactor `JailConfigDetail` to accept a `readOnly` prop and reuse it for both active and inactive jails, avoiding code duplication.
5. Include the **Raw Configuration** accordion (using `RawConfigSection`) for inactive jails as well, pointed at the jail's config file.
**Testing:** Select an inactive jail and verify it shows the same fields and layout as an active jail like `bangui-sim`, with all values populated from the config file but not editable.
---
## Task 4 — Fix `banaction` Interpolation Error When Activating Jails
**Problem:** Activating certain jails (e.g. `airsonic-auth`) causes fail2ban to crash with:
```
ERROR Failed during configuration: Bad value substitution: option 'action'
in section 'airsonic-auth' contains an interpolation key 'banaction' which
is not a valid option name. Raw value: '%(action_)s'
``` ```
**Verification:** When selecting an active jail that has log paths configured, the paths appear as editable input fields (not plain text). The input values can be modified. The delete button still works. The "Add" form at the bottom still works for adding new paths. **Root cause — the interpolation chain breaks:**
1. Most jails inherit `action = %(action_)s` from `[DEFAULT]`.
2. `action_` is defined in `fail2ban-master/config/jail.conf` line 212 as:
```ini
action_ = %(banaction)s[port="%(port)s", protocol="%(protocol)s", chain="%(chain)s"]
```
3. `banaction` is **commented out** in the `[DEFAULT]` section of `jail.conf` (line 208):
```ini
#banaction = iptables-multiport
#banaction_allports = iptables-allports
```
4. When BanGUI activates a jail, `_write_local_override_sync()` in `backend/app/services/config_file_service.py` (line 478) writes a `.local` file with only `enabled`, `bantime`, `findtime`, `maxretry`, `port`, and `logpath` — it does **not** include `banaction` or `action`.
5. fail2ban merges the configs, hits `%(banaction)s`, finds no value, and crashes.
**Where to change:**
- **`backend/app/services/config_file_service.py`** — function `_write_local_override_sync()` around line 478. This writes the per-jail `.local` override file.
- **`backend/app/models/config.py`** — the `ActivateJailRequest` or equivalent model that defines which fields are accepted when activating a jail.
**Fix options (pick one):**
### Option A: Write `banaction` in every jail `.local` file (recommended)
When BanGUI writes a jail `.local` file, include a `banaction` field with a sensible default (e.g. `iptables-multiport`). This ensures the interpolation chain always resolves.
1. Add a `banaction` field (defaulting to `iptables-multiport`) to the jail activation model/request.
2. In `_write_local_override_sync()`, always write `banaction = <value>` into the `[jailname]` section of the `.local` file.
3. Also include `banaction_allports = iptables-allports` as a fallback for jails that reference it.
### Option B: Write a global `jail.local` with DEFAULT banaction
Create or update a `jail.local` file in the fail2ban config directory with:
```ini
[DEFAULT]
banaction = iptables-multiport
banaction_allports = iptables-allports
```
This provides the missing defaults for all jails at once.
### Option C: Expose `banaction` in the jail config UI
Add `banaction` as a configurable field in the jail config form so users can choose the ban action (iptables-multiport, nftables-multiport, firewallcmd-rich-rules, etc.). Write the selected value to the `.local` file on save/activate.
**Testing:**
1. Activate `airsonic-auth` (or any jail that inherits `action = %(action_)s` without defining its own `banaction`).
2. Verify fail2ban starts without the interpolation error.
3. Verify `fail2ban-client status airsonic-auth` shows the jail running with the correct ban action.

View File

@@ -118,6 +118,8 @@ class JailConfigUpdate(BaseModel):
prefregex: str | None = Field(default=None, description="Prefix regex; None = skip, '' = clear, non-empty = set.") prefregex: str | None = Field(default=None, description="Prefix regex; None = skip, '' = clear, non-empty = set.")
date_pattern: str | None = Field(default=None) date_pattern: str | None = Field(default=None)
dns_mode: str | None = Field(default=None, description="DNS lookup mode: yes | warn | no | raw.") dns_mode: str | None = Field(default=None, description="DNS lookup mode: yes | warn | no | raw.")
backend: str | None = Field(default=None, description="Log monitoring backend.")
log_encoding: str | None = Field(default=None, description="Log file encoding.")
enabled: bool | None = Field(default=None) enabled: bool | None = Field(default=None)
bantime_escalation: BantimeEscalationUpdate | None = Field( bantime_escalation: BantimeEscalationUpdate | None = Field(
default=None, default=None,
@@ -751,6 +753,47 @@ class InactiveJail(BaseModel):
default=None, default=None,
description="Number of failures before a ban is issued.", description="Number of failures before a ban is issued.",
) )
# ---- Extended fields for full GUI display ----
ban_time_seconds: int = Field(
default=600,
description="Ban duration in seconds, parsed from bantime string.",
)
find_time_seconds: int = Field(
default=600,
description="Failure-counting window in seconds, parsed from findtime string.",
)
log_encoding: str = Field(
default="auto",
description="Log encoding, e.g. ``utf-8`` or ``auto``.",
)
backend: str = Field(
default="auto",
description="Log-monitoring backend, e.g. ``auto``, ``pyinotify``, ``polling``.",
)
date_pattern: str | None = Field(
default=None,
description="Date pattern for log parsing, or None for auto-detect.",
)
use_dns: str = Field(
default="warn",
description="DNS resolution mode: ``yes``, ``warn``, ``no``, or ``raw``.",
)
prefregex: str = Field(
default="",
description="Prefix regex prepended to every failregex.",
)
fail_regex: list[str] = Field(
default_factory=list,
description="List of failure regex patterns.",
)
ignore_regex: list[str] = Field(
default_factory=list,
description="List of ignore regex patterns.",
)
bantime_escalation: BantimeEscalation | None = Field(
default=None,
description="Ban-time escalation configuration, if enabled.",
)
source_file: str = Field( source_file: str = Field(
..., ...,
description="Absolute path to the config file where this jail is defined.", description="Absolute path to the config file where this jail is defined.",

View File

@@ -41,6 +41,7 @@ from app.models.config import (
ActivateJailRequest, ActivateJailRequest,
AssignActionRequest, AssignActionRequest,
AssignFilterRequest, AssignFilterRequest,
BantimeEscalation,
FilterConfig, FilterConfig,
FilterConfigUpdate, FilterConfigUpdate,
FilterCreateRequest, FilterCreateRequest,
@@ -290,6 +291,44 @@ def _parse_int_safe(value: str) -> int | None:
return None return None
def _parse_time_to_seconds(value: str | None, default: int) -> int:
"""Convert a fail2ban time string (e.g. ``1h``, ``10m``, ``3600``) to seconds.
Supports the suffixes ``s`` (seconds), ``m`` (minutes), ``h`` (hours),
``d`` (days), ``w`` (weeks), and plain integers (already seconds).
``-1`` is treated as a permanent ban and returned as-is.
Args:
value: Raw time string from config, or ``None``.
default: Value to return when ``value`` is absent or unparseable.
Returns:
Duration in seconds, or ``-1`` for permanent, or ``default`` on failure.
"""
if not value:
return default
stripped = value.strip()
if stripped == "-1":
return -1
multipliers: dict[str, int] = {
"w": 604800,
"d": 86400,
"h": 3600,
"m": 60,
"s": 1,
}
for suffix, factor in multipliers.items():
if stripped.endswith(suffix) and len(stripped) > 1:
try:
return int(stripped[:-1]) * factor
except ValueError:
return default
try:
return int(stripped)
except ValueError:
return default
def _parse_multiline(raw: str) -> list[str]: def _parse_multiline(raw: str) -> list[str]:
"""Split a multi-line INI value into individual non-blank lines. """Split a multi-line INI value into individual non-blank lines.
@@ -413,6 +452,42 @@ def _build_inactive_jail(
maxretry_raw = settings.get("maxretry", "") maxretry_raw = settings.get("maxretry", "")
maxretry = _parse_int_safe(maxretry_raw) maxretry = _parse_int_safe(maxretry_raw)
# Extended fields for full GUI display
ban_time_seconds = _parse_time_to_seconds(settings.get("bantime"), 600)
find_time_seconds = _parse_time_to_seconds(settings.get("findtime"), 600)
log_encoding = settings.get("logencoding") or "auto"
backend = settings.get("backend") or "auto"
date_pattern = settings.get("datepattern") or None
use_dns = settings.get("usedns") or "warn"
prefregex = settings.get("prefregex") or ""
fail_regex = _parse_multiline(settings.get("failregex", ""))
ignore_regex = _parse_multiline(settings.get("ignoreregex", ""))
# Ban-time escalation
esc_increment = _is_truthy(settings.get("bantime.increment", "false"))
esc_factor_raw = settings.get("bantime.factor")
esc_factor = float(esc_factor_raw) if esc_factor_raw else None
esc_formula = settings.get("bantime.formula") or None
esc_multipliers = settings.get("bantime.multipliers") or None
esc_max_raw = settings.get("bantime.maxtime")
esc_max_time = _parse_time_to_seconds(esc_max_raw, 0) if esc_max_raw else None
esc_rnd_raw = settings.get("bantime.rndtime")
esc_rnd_time = _parse_time_to_seconds(esc_rnd_raw, 0) if esc_rnd_raw else None
esc_overall = _is_truthy(settings.get("bantime.overalljails", "false"))
bantime_escalation = (
BantimeEscalation(
increment=esc_increment,
factor=esc_factor,
formula=esc_formula,
multipliers=esc_multipliers,
max_time=esc_max_time,
rnd_time=esc_rnd_time,
overall_jails=esc_overall,
)
if esc_increment
else None
)
return InactiveJail( return InactiveJail(
name=name, name=name,
filter=filter_name, filter=filter_name,
@@ -422,6 +497,16 @@ def _build_inactive_jail(
bantime=settings.get("bantime") or None, bantime=settings.get("bantime") or None,
findtime=settings.get("findtime") or None, findtime=settings.get("findtime") or None,
maxretry=maxretry, maxretry=maxretry,
ban_time_seconds=ban_time_seconds,
find_time_seconds=find_time_seconds,
log_encoding=log_encoding,
backend=backend,
date_pattern=date_pattern,
use_dns=use_dns,
prefregex=prefregex,
fail_regex=fail_regex,
ignore_regex=ignore_regex,
bantime_escalation=bantime_escalation,
source_file=source_file, source_file=source_file,
enabled=enabled, enabled=enabled,
) )
@@ -513,6 +598,10 @@ def _write_local_override_sync(
f"[{jail_name}]", f"[{jail_name}]",
"", "",
f"enabled = {'true' if enabled else 'false'}", f"enabled = {'true' if enabled else 'false'}",
# Provide explicit banaction defaults so fail2ban can resolve the
# %(banaction)s interpolation used in the built-in action_ chain.
"banaction = iptables-multiport",
"banaction_allports = iptables-allports",
] ]
if overrides.get("bantime") is not None: if overrides.get("bantime") is not None:

View File

@@ -366,6 +366,10 @@ async def update_jail_config(
await _set("datepattern", update.date_pattern) await _set("datepattern", update.date_pattern)
if update.dns_mode is not None: if update.dns_mode is not None:
await _set("usedns", update.dns_mode) await _set("usedns", update.dns_mode)
if update.backend is not None:
await _set("backend", update.backend)
if update.log_encoding is not None:
await _set("logencoding", update.log_encoding)
if update.prefregex is not None: if update.prefregex is not None:
await _set("prefregex", update.prefregex) await _set("prefregex", update.prefregex)
if update.enabled is not None: if update.enabled is not None:

View File

@@ -299,6 +299,7 @@ export function ActionsTab(): React.JSX.Element {
> >
{selectedAction !== null && ( {selectedAction !== null && (
<ActionDetail <ActionDetail
key={selectedAction.name}
action={selectedAction} action={selectedAction}
onAssignClick={() => { setAssignOpen(true); }} onAssignClick={() => { setAssignOpen(true); }}
onRemovedFromJail={handleRemovedFromJail} onRemovedFromJail={handleRemovedFromJail}

View File

@@ -233,6 +233,7 @@ export function FiltersTab(): React.JSX.Element {
> >
{selectedFilter !== null && ( {selectedFilter !== null && (
<FilterDetail <FilterDetail
key={selectedFilter.name}
filter={selectedFilter} filter={selectedFilter}
onAssignClick={() => { setAssignOpen(true); }} onAssignClick={() => { setAssignOpen(true); }}
/> />

View File

@@ -10,10 +10,12 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
Badge, Badge,
Button, Button,
Combobox,
Field, Field,
Input, Input,
MessageBar, MessageBar,
MessageBarBody, MessageBarBody,
Option,
Select, Select,
Skeleton, Skeleton,
SkeletonItem, SkeletonItem,
@@ -55,6 +57,36 @@ import { RawConfigSection } from "./RawConfigSection";
import { RegexList } from "./RegexList"; import { RegexList } from "./RegexList";
import { useConfigStyles } from "./configStyles"; import { useConfigStyles } from "./configStyles";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const BACKENDS = [
{ value: "auto", label: "auto — pyinotify, then polling" },
{ value: "polling", label: "polling — standard polling algorithm" },
{ value: "pyinotify", label: "pyinotify — requires pyinotify library" },
{ value: "systemd", label: "systemd — uses systemd journal" },
{ value: "gamin", label: "gamin — legacy file alteration monitor" },
] as const;
const LOG_ENCODINGS = [
{ value: "auto", label: "auto — use system locale" },
{ value: "ascii", label: "ascii" },
{ value: "utf-8", label: "utf-8" },
{ value: "latin-1", label: "latin-1 (ISO 8859-1)" },
] as const;
const DATE_PATTERN_PRESETS = [
{ value: "", label: "auto-detect (leave blank)" },
{ value: "{^LN-BEG}", label: "{^LN-BEG} — line beginning" },
{ value: "%%Y-%%m-%%d %%H:%%M:%%S", label: "YYYY-MM-DD HH:MM:SS" },
{ value: "%%d/%%b/%%Y:%%H:%%M:%%S", label: "DD/Mon/YYYY:HH:MM:SS (Apache)" },
{ value: "%%b %%d %%H:%%M:%%S", label: "Mon DD HH:MM:SS (syslog)" },
{ value: "EPOCH", label: "EPOCH — Unix timestamp" },
{ value: "%%Y-%%m-%%dT%%H:%%M:%%S", label: "ISO 8601" },
{ value: "TAI64N", label: "TAI64N" },
] as const;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// JailConfigDetail // JailConfigDetail
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -63,6 +95,10 @@ interface JailConfigDetailProps {
jail: JailConfig; jail: JailConfig;
onSave: (name: string, update: JailConfigUpdate) => Promise<void>; onSave: (name: string, update: JailConfigUpdate) => Promise<void>;
onDeactivate?: () => void; onDeactivate?: () => void;
/** When true all fields are read-only and auto-save is suppressed. */
readOnly?: boolean;
/** When provided (and readOnly=true) shows an Activate Jail button. */
onActivate?: () => void;
} }
/** /**
@@ -78,6 +114,8 @@ function JailConfigDetail({
jail, jail,
onSave, onSave,
onDeactivate, onDeactivate,
readOnly = false,
onActivate,
}: JailConfigDetailProps): React.JSX.Element { }: JailConfigDetailProps): React.JSX.Element {
const styles = useConfigStyles(); const styles = useConfigStyles();
const [banTime, setBanTime] = useState(String(jail.ban_time)); const [banTime, setBanTime] = useState(String(jail.ban_time));
@@ -88,6 +126,8 @@ function JailConfigDetail({
const [logPaths, setLogPaths] = useState<string[]>(jail.log_paths); const [logPaths, setLogPaths] = useState<string[]>(jail.log_paths);
const [datePattern, setDatePattern] = useState(jail.date_pattern ?? ""); const [datePattern, setDatePattern] = useState(jail.date_pattern ?? "");
const [dnsMode, setDnsMode] = useState(jail.use_dns); const [dnsMode, setDnsMode] = useState(jail.use_dns);
const [backend, setBackend] = useState(jail.backend);
const [logEncoding, setLogEncoding] = useState(jail.log_encoding);
const [prefRegex, setPrefRegex] = useState(jail.prefregex); const [prefRegex, setPrefRegex] = useState(jail.prefregex);
const [deletingPath, setDeletingPath] = useState<string | null>(null); const [deletingPath, setDeletingPath] = useState<string | null>(null);
const [newLogPath, setNewLogPath] = useState(""); const [newLogPath, setNewLogPath] = useState("");
@@ -165,6 +205,8 @@ function JailConfigDetail({
ignore_regex: ignoreRegex, ignore_regex: ignoreRegex,
date_pattern: datePattern !== "" ? datePattern : null, date_pattern: datePattern !== "" ? datePattern : null,
dns_mode: dnsMode, dns_mode: dnsMode,
backend,
log_encoding: logEncoding,
prefregex: prefRegex !== "" ? prefRegex : null, prefregex: prefRegex !== "" ? prefRegex : null,
bantime_escalation: { bantime_escalation: {
increment: escEnabled, increment: escEnabled,
@@ -178,17 +220,18 @@ function JailConfigDetail({
}), }),
[ [
banTime, findTime, maxRetry, failRegex, ignoreRegex, datePattern, banTime, findTime, maxRetry, failRegex, ignoreRegex, datePattern,
dnsMode, prefRegex, escEnabled, escFactor, escFormula, escMultipliers, dnsMode, backend, logEncoding, prefRegex, escEnabled, escFactor,
escMaxTime, escRndTime, escOverallJails, escFormula, escMultipliers, escMaxTime, escRndTime, escOverallJails,
jail.ban_time, jail.find_time, jail.max_retry, jail.ban_time, jail.find_time, jail.max_retry,
], ],
); );
const saveCurrent = useCallback( const saveCurrent = useCallback(
async (update: JailConfigUpdate): Promise<void> => { async (update: JailConfigUpdate): Promise<void> => {
if (readOnly) return;
await onSave(jail.name, update); await onSave(jail.name, update);
}, },
[jail.name, onSave], [jail.name, onSave, readOnly],
); );
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } = const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
@@ -220,6 +263,7 @@ function JailConfigDetail({
<Input <Input
type="number" type="number"
value={banTime} value={banTime}
readOnly={readOnly}
onChange={(_e, d) => { onChange={(_e, d) => {
setBanTime(d.value); setBanTime(d.value);
}} }}
@@ -229,6 +273,7 @@ function JailConfigDetail({
<Input <Input
type="number" type="number"
value={findTime} value={findTime}
readOnly={readOnly}
onChange={(_e, d) => { onChange={(_e, d) => {
setFindTime(d.value); setFindTime(d.value);
}} }}
@@ -238,6 +283,7 @@ function JailConfigDetail({
<Input <Input
type="number" type="number"
value={maxRetry} value={maxRetry}
readOnly={readOnly}
onChange={(_e, d) => { onChange={(_e, d) => {
setMaxRetry(d.value); setMaxRetry(d.value);
}} }}
@@ -246,26 +292,57 @@ function JailConfigDetail({
</div> </div>
<div className={styles.fieldRow}> <div className={styles.fieldRow}>
<Field label="Backend"> <Field label="Backend">
<Input readOnly value={jail.backend} /> <Select
value={backend}
disabled={readOnly}
onChange={(_e, d) => {
setBackend(d.value);
}}
>
{BACKENDS.map((b) => (
<option key={b.value} value={b.value}>{b.label}</option>
))}
</Select>
</Field> </Field>
<Field label="Log Encoding"> <Field label="Log Encoding">
<Input readOnly value={jail.log_encoding} /> <Select
value={logEncoding.toLowerCase()}
disabled={readOnly}
onChange={(_e, d) => {
setLogEncoding(d.value);
}}
>
{LOG_ENCODINGS.map((e) => (
<option key={e.value} value={e.value}>{e.label}</option>
))}
</Select>
</Field> </Field>
</div> </div>
<div className={styles.fieldRow}> <div className={styles.fieldRow}>
<Field label="Date Pattern" hint="Leave blank for auto-detect."> <Field label="Date Pattern" hint="Leave blank for auto-detect.">
<Input <Combobox
className={styles.codeFont} className={styles.codeFont}
placeholder="auto-detect" placeholder="auto-detect"
value={datePattern} value={datePattern}
onChange={(_e, d) => { selectedOptions={[datePattern]}
setDatePattern(d.value); freeform
disabled={readOnly}
onOptionSelect={(_e, d) => {
setDatePattern(d.optionValue ?? "");
}} }}
/> onChange={(e) => {
setDatePattern(e.target.value);
}}
>
{DATE_PATTERN_PRESETS.map((p) => (
<Option key={p.value} value={p.value}>{p.label}</Option>
))}
</Combobox>
</Field> </Field>
<Field label="DNS Mode"> <Field label="DNS Mode">
<Select <Select
value={dnsMode} value={dnsMode}
disabled={readOnly}
onChange={(_e, d) => { onChange={(_e, d) => {
setDnsMode(d.value); setDnsMode(d.value);
}} }}
@@ -285,6 +362,7 @@ function JailConfigDetail({
className={styles.codeFont} className={styles.codeFont}
placeholder="e.g. ^%(__prefix_line)s" placeholder="e.g. ^%(__prefix_line)s"
value={prefRegex} value={prefRegex}
readOnly={readOnly}
onChange={(_e, d) => { onChange={(_e, d) => {
setPrefRegex(d.value); setPrefRegex(d.value);
}} }}
@@ -302,22 +380,26 @@ function JailConfigDetail({
className={styles.codeFont} className={styles.codeFont}
style={{ flexGrow: 1 }} style={{ flexGrow: 1 }}
value={p} value={p}
readOnly={readOnly}
onChange={(_e, d) => { onChange={(_e, d) => {
setLogPaths((prev) => prev.map((v, j) => (j === i ? d.value : v))); setLogPaths((prev) => prev.map((v, j) => (j === i ? d.value : v)));
}} }}
/> />
<Button {!readOnly && (
appearance="subtle" <Button
icon={<Dismiss24Regular />} appearance="subtle"
size="small" icon={<Dismiss24Regular />}
disabled={deletingPath === p} size="small"
title="Remove log path" disabled={deletingPath === p}
onClick={() => void handleDeleteLogPath(p)} title="Remove log path"
/> onClick={() => void handleDeleteLogPath(p)}
/>
)}
</div> </div>
)) ))
)} )}
{/* Add log path inline form */} {/* Add log path inline form — hidden in read-only mode */}
{!readOnly && (
<div className={styles.regexItem} style={{ marginTop: tokens.spacingVerticalXS }}> <div className={styles.regexItem} style={{ marginTop: tokens.spacingVerticalXS }}>
<Input <Input
className={styles.codeFont} className={styles.codeFont}
@@ -347,12 +429,14 @@ function JailConfigDetail({
{addingLogPath ? "Adding…" : "Add"} {addingLogPath ? "Adding…" : "Add"}
</Button> </Button>
</div> </div>
)}
</Field> </Field>
<div style={{ marginTop: tokens.spacingVerticalS }}> <div style={{ marginTop: tokens.spacingVerticalS }}>
<RegexList <RegexList
label="Fail Regex" label="Fail Regex"
patterns={failRegex} patterns={failRegex}
onChange={setFailRegex} onChange={setFailRegex}
readOnly={readOnly}
/> />
</div> </div>
<div style={{ marginTop: tokens.spacingVerticalS }}> <div style={{ marginTop: tokens.spacingVerticalS }}>
@@ -360,6 +444,7 @@ function JailConfigDetail({
label="Ignore Regex" label="Ignore Regex"
patterns={ignoreRegex} patterns={ignoreRegex}
onChange={setIgnoreRegex} onChange={setIgnoreRegex}
readOnly={readOnly}
/> />
</div> </div>
{jail.actions.length > 0 && ( {jail.actions.length > 0 && (
@@ -387,6 +472,7 @@ function JailConfigDetail({
<Switch <Switch
label="Enable incremental banning" label="Enable incremental banning"
checked={escEnabled} checked={escEnabled}
disabled={readOnly}
onChange={(_e, d) => { onChange={(_e, d) => {
setEscEnabled(d.checked); setEscEnabled(d.checked);
}} }}
@@ -398,6 +484,7 @@ function JailConfigDetail({
<Input <Input
type="number" type="number"
value={escFactor} value={escFactor}
readOnly={readOnly}
onChange={(_e, d) => { onChange={(_e, d) => {
setEscFactor(d.value); setEscFactor(d.value);
}} }}
@@ -407,6 +494,7 @@ function JailConfigDetail({
<Input <Input
type="number" type="number"
value={escMaxTime} value={escMaxTime}
readOnly={readOnly}
onChange={(_e, d) => { onChange={(_e, d) => {
setEscMaxTime(d.value); setEscMaxTime(d.value);
}} }}
@@ -416,6 +504,7 @@ function JailConfigDetail({
<Input <Input
type="number" type="number"
value={escRndTime} value={escRndTime}
readOnly={readOnly}
onChange={(_e, d) => { onChange={(_e, d) => {
setEscRndTime(d.value); setEscRndTime(d.value);
}} }}
@@ -425,6 +514,7 @@ function JailConfigDetail({
<Field label="Formula"> <Field label="Formula">
<Input <Input
value={escFormula} value={escFormula}
readOnly={readOnly}
onChange={(_e, d) => { onChange={(_e, d) => {
setEscFormula(d.value); setEscFormula(d.value);
}} }}
@@ -433,6 +523,7 @@ function JailConfigDetail({
<Field label="Multipliers (space-separated)"> <Field label="Multipliers (space-separated)">
<Input <Input
value={escMultipliers} value={escMultipliers}
readOnly={readOnly}
onChange={(_e, d) => { onChange={(_e, d) => {
setEscMultipliers(d.value); setEscMultipliers(d.value);
}} }}
@@ -441,6 +532,7 @@ function JailConfigDetail({
<Switch <Switch
label="Count repeat offences across all jails" label="Count repeat offences across all jails"
checked={escOverallJails} checked={escOverallJails}
disabled={readOnly}
onChange={(_e, d) => { onChange={(_e, d) => {
setEscOverallJails(d.checked); setEscOverallJails(d.checked);
}} }}
@@ -449,15 +541,17 @@ function JailConfigDetail({
)} )}
</div> </div>
<div style={{ marginTop: tokens.spacingVerticalS }}> {!readOnly && (
<AutoSaveIndicator <div style={{ marginTop: tokens.spacingVerticalS }}>
status={saveStatus} <AutoSaveIndicator
errorText={saveErrorText} status={saveStatus}
onRetry={retrySave} errorText={saveErrorText}
/> onRetry={retrySave}
</div> />
</div>
)}
{onDeactivate !== undefined && ( {!readOnly && onDeactivate !== undefined && (
<div style={{ marginTop: tokens.spacingVerticalM }}> <div style={{ marginTop: tokens.spacingVerticalM }}>
<Button <Button
appearance="secondary" appearance="secondary"
@@ -469,14 +563,28 @@ function JailConfigDetail({
</div> </div>
)} )}
{/* Raw Configuration */} {readOnly && onActivate !== undefined && (
<div style={{ marginTop: tokens.spacingVerticalL }}> <div style={{ marginTop: tokens.spacingVerticalM }}>
<RawConfigSection <Button
fetchContent={fetchRaw} appearance="primary"
saveContent={saveRaw} icon={<Play24Regular />}
label="Raw Jail Configuration" onClick={onActivate}
/> >
</div> Activate Jail
</Button>
</div>
)}
{/* Raw Configuration — hidden in read-only (inactive jail) mode */}
{!readOnly && (
<div style={{ marginTop: tokens.spacingVerticalL }}>
<RawConfigSection
fetchContent={fetchRaw}
saveContent={saveRaw}
label="Raw Jail Configuration"
/>
</div>
)}
</div> </div>
); );
} }
@@ -491,7 +599,12 @@ interface InactiveJailDetailProps {
} }
/** /**
* Read-only detail view for an inactive jail, with an Activate button. * Detail view for an inactive jail.
*
* Maps the parsed config fields to a JailConfig-compatible object and renders
* JailConfigDetail in read-only mode, so the UI is identical to the active
* jail view but with all fields disabled and an Activate button instead of
* a Deactivate button.
* *
* @param props - Component props. * @param props - Component props.
* @returns JSX element. * @returns JSX element.
@@ -502,13 +615,29 @@ function InactiveJailDetail({
}: InactiveJailDetailProps): React.JSX.Element { }: InactiveJailDetailProps): React.JSX.Element {
const styles = useConfigStyles(); const styles = useConfigStyles();
return ( const jailConfig = useMemo<JailConfig>(
<div className={styles.detailPane}> () => ({
<Text weight="semibold" size={500} block style={{ marginBottom: tokens.spacingVerticalM }}> name: jail.name,
{jail.name} ban_time: jail.ban_time_seconds,
</Text> find_time: jail.find_time_seconds,
max_retry: jail.maxretry ?? 5,
fail_regex: jail.fail_regex,
ignore_regex: jail.ignore_regex,
log_paths: jail.logpath,
date_pattern: jail.date_pattern,
log_encoding: jail.log_encoding,
backend: jail.backend,
use_dns: jail.use_dns,
prefregex: jail.prefregex,
actions: jail.actions,
bantime_escalation: jail.bantime_escalation,
}),
[jail],
);
<div className={styles.fieldRow}> return (
<div>
<div className={styles.fieldRow} style={{ marginBottom: tokens.spacingVerticalS }}>
<Field label="Filter"> <Field label="Filter">
<Input readOnly value={jail.filter || "(none)"} className={styles.codeFont} /> <Input readOnly value={jail.filter || "(none)"} className={styles.codeFont} />
</Field> </Field>
@@ -516,63 +645,15 @@ function InactiveJailDetail({
<Input readOnly value={jail.port ?? "(auto)"} /> <Input readOnly value={jail.port ?? "(auto)"} />
</Field> </Field>
</div> </div>
<Field label="Source file" style={{ marginBottom: tokens.spacingVerticalM }}>
<div className={styles.fieldRowThree}>
<Field label="Ban time">
<Input readOnly value={jail.bantime ?? "(default)"} />
</Field>
<Field label="Find time">
<Input readOnly value={jail.findtime ?? "(default)"} />
</Field>
<Field label="Max retry">
<Input readOnly value={jail.maxretry != null ? String(jail.maxretry) : "(default)"} />
</Field>
</div>
<Field label="Log path(s)">
{jail.logpath.length === 0 ? (
<Text size={200} className={styles.infoText}>(none)</Text>
) : (
<div>
{jail.logpath.map((p) => (
<Text key={p} block size={200} className={styles.codeFont}>
{p}
</Text>
))}
</div>
)}
</Field>
{jail.actions.length > 0 && (
<Field label="Actions">
<div>
{jail.actions.map((a) => (
<Badge
key={a}
appearance="tint"
color="informative"
style={{ marginRight: tokens.spacingHorizontalXS }}
>
{a}
</Badge>
))}
</div>
</Field>
)}
<Field label="Source file">
<Input readOnly value={jail.source_file} className={styles.codeFont} /> <Input readOnly value={jail.source_file} className={styles.codeFont} />
</Field> </Field>
<JailConfigDetail
<div style={{ marginTop: tokens.spacingVerticalL }}> jail={jailConfig}
<Button onSave={async () => { /* read-only — never called */ }}
appearance="primary" readOnly
icon={<Play24Regular />} onActivate={onActivate}
onClick={onActivate} />
>
Activate Jail
</Button>
</div>
</div> </div>
); );
} }

View File

@@ -17,6 +17,8 @@ export interface RegexListProps {
patterns: string[]; patterns: string[];
/** Called when the list changes (add, delete, or edit). */ /** Called when the list changes (add, delete, or edit). */
onChange: (next: string[]) => void; onChange: (next: string[]) => void;
/** When true, patterns are displayed read-only with no add/delete controls. */
readOnly?: boolean;
} }
/** /**
@@ -29,6 +31,7 @@ export function RegexList({
label, label,
patterns, patterns,
onChange, onChange,
readOnly = false,
}: RegexListProps): React.JSX.Element { }: RegexListProps): React.JSX.Element {
const styles = useConfigStyles(); const styles = useConfigStyles();
const [newPattern, setNewPattern] = useState(""); const [newPattern, setNewPattern] = useState("");
@@ -64,6 +67,7 @@ export function RegexList({
<Input <Input
className={styles.regexInput} className={styles.regexInput}
value={p} value={p}
readOnly={readOnly}
aria-label={`${label} pattern ${String(i + 1)}`} aria-label={`${label} pattern ${String(i + 1)}`}
onChange={(_e, d) => { onChange={(_e, d) => {
const next = [...patterns]; const next = [...patterns];
@@ -71,33 +75,37 @@ export function RegexList({
onChange(next); onChange(next);
}} }}
/> />
<Button {!readOnly && (
appearance="subtle" <Button
icon={<Dismiss24Regular />} appearance="subtle"
size="small" icon={<Dismiss24Regular />}
aria-label={`Remove ${label} pattern ${String(i + 1)}`} size="small"
onClick={() => { aria-label={`Remove ${label} pattern ${String(i + 1)}`}
handleDelete(i); onClick={() => {
}} handleDelete(i);
/> }}
/>
)}
</div> </div>
))} ))}
<div className={styles.regexItem}> {!readOnly && (
<Input <div className={styles.regexItem}>
className={styles.regexInput} <Input
placeholder="New pattern…" className={styles.regexInput}
value={newPattern} placeholder="New pattern…"
onChange={(_e, d) => { value={newPattern}
setNewPattern(d.value); onChange={(_e, d) => {
}} setNewPattern(d.value);
onKeyDown={(e) => { }}
if (e.key === "Enter") handleAdd(); onKeyDown={(e) => {
}} if (e.key === "Enter") handleAdd();
/> }}
<Button size="small" onClick={handleAdd}> />
Add <Button size="small" onClick={handleAdd}>
</Button> Add
</div> </Button>
</div>
)}
</div> </div>
); );
} }

View File

@@ -77,6 +77,8 @@ export interface JailConfigUpdate {
prefregex?: string | null; prefregex?: string | null;
date_pattern?: string | null; date_pattern?: string | null;
dns_mode?: string | null; dns_mode?: string | null;
backend?: string | null;
log_encoding?: string | null;
enabled?: boolean | null; enabled?: boolean | null;
bantime_escalation?: BantimeEscalationUpdate | null; bantime_escalation?: BantimeEscalationUpdate | null;
} }
@@ -498,6 +500,26 @@ export interface InactiveJail {
findtime: string | null; findtime: string | null;
/** Number of failures before a ban is issued, or null. */ /** Number of failures before a ban is issued, or null. */
maxretry: number | null; maxretry: number | null;
/** Ban duration in seconds, parsed from bantime. */
ban_time_seconds: number;
/** Failure-counting window in seconds, parsed from findtime. */
find_time_seconds: number;
/** Log encoding, e.g. ``"auto"`` or ``"utf-8"``. */
log_encoding: string;
/** Log-monitoring backend. */
backend: string;
/** Date pattern for log parsing, or null for auto-detect. */
date_pattern: string | null;
/** DNS resolution mode. */
use_dns: string;
/** Prefix regex prepended to every failregex. */
prefregex: string;
/** List of failure regex patterns. */
fail_regex: string[];
/** List of ignore regex patterns. */
ignore_regex: string[];
/** Ban-time escalation configuration, or null. */
bantime_escalation: BantimeEscalation | null;
/** Absolute path to the config file where this jail is defined. */ /** Absolute path to the config file where this jail is defined. */
source_file: string; source_file: string;
/** Effective ``enabled`` value — always ``false`` for inactive jails. */ /** Effective ``enabled`` value — always ``false`` for inactive jails. */