feature/ignore-self-toggle #1
@@ -71,40 +71,70 @@ This document breaks the entire BanGUI project into development stages, ordered
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — Better Jail Configuration
|
||||
## Task 4 — Better Jail Configuration ✅ DONE
|
||||
|
||||
**Goal:** Expose the full fail2ban configuration surface (jails, filters, actions) in the web UI.
|
||||
|
||||
Reference config directory: `/home/lukas/Volume/repo/BanGUI/Docker/fail2ban-dev-config/fail2ban/`
|
||||
|
||||
### 4a — Activate / Deactivate Jail Configs
|
||||
**Implementation summary:**
|
||||
|
||||
- List all `.conf` and `.local` files in the jail config folder.
|
||||
- Allow the user to toggle inactive jails to active (and vice-versa) from the UI.
|
||||
- **Backend:** New `app/models/file_config.py`, `app/services/file_config_service.py`, and `app/routers/file_config.py` with full CRUD for `jail.d/`, `filter.d/`, `action.d/` files. Path-traversal prevention via `_assert_within()` + `_validate_new_name()`. `app/config.py` extended with `fail2ban_config_dir` setting.
|
||||
- **Backend (socket):** Added `delete_log_path()` to `config_service.py` + `DELETE /api/config/jails/{name}/logpath` endpoint.
|
||||
- **Docker:** Both compose files updated with `BANGUI_FAIL2BAN_CONFIG_DIR` env var; volume mount changed `:ro` → `:rw`.
|
||||
- **Frontend:** New `Jail Files`, `Filters`, `Actions` tabs in `ConfigPage.tsx`. Delete buttons for log paths in jail accordion. Full API call layer in `api/config.ts` + new types in `types/config.ts`.
|
||||
- **Tests:** 44 service unit tests + 19 router integration tests; all pass; ruff clean.
|
||||
|
||||
### 4b — Editable Log Paths
|
||||
**Task 4c audit findings — options not yet exposed in the UI:**
|
||||
- Per-jail: `ignoreip`, `bantime.increment`, `bantime.rndtime`, `bantime.maxtime`, `bantime.factor`, `bantime.formula`, `bantime.multipliers`, `bantime.overalljails`, `ignorecommand`, `prefregex`, `timezone`, `journalmatch`, `usedns`, `backend` (read-only shown), `destemail`, `sender`, `action` override
|
||||
- Global: `allowipv6`, `before` includes
|
||||
|
||||
- Each jail has a `logpath` setting. Expose this in the UI as an editable text field so the user can point a jail at a different log file without SSH access.
|
||||
### 4a — Activate / Deactivate Jail Configs ✅ DONE
|
||||
|
||||
### 4c — Audit Missing Config Options
|
||||
- Listed all `.conf` and `.local` files in `jail.d/` via `GET /api/config/jail-files`.
|
||||
- Toggle enabled/disabled via `PUT /api/config/jail-files/{filename}/enabled` which patches the `enabled = true/false` line in the config file, preserving all comments.
|
||||
- Frontend: **Jail Files** tab with enabled `Switch` per file and read-only content viewer.
|
||||
|
||||
- Open every jail `.conf`/`.local` file in the dev-config directory and compare the available options with what the web UI currently exposes.
|
||||
- Write down all options that are **not yet implemented** in the UI (e.g. `findtime`, `bantime.increment`, `ignoreip`, `ignorecommand`, `maxretry`, `backend`, `usedns`, `destemail`, `sender`, `action`, etc.).
|
||||
- Document the findings in this task or a separate reference file so they can be implemented incrementally.
|
||||
### 4b — Editable Log Paths ✅ DONE
|
||||
|
||||
### 4d — Filter Configuration (`filter.d`)
|
||||
- Added `DELETE /api/config/jails/{name}/logpath?log_path=…` endpoint (uses fail2ban socket `set <jail> dellogpath`).
|
||||
- Frontend: each log path in the Jails accordion now has a dismiss button to remove it.
|
||||
|
||||
- List all filter files in `filter.d/`.
|
||||
- Allow the user to activate, deactivate, view, and edit filter definitions from the UI.
|
||||
- Provide an option to create a brand-new filter file.
|
||||
### 4c — Audit Missing Config Options ✅ DONE
|
||||
|
||||
### 4e — Action Configuration (`action.d`)
|
||||
- Audit findings documented above.
|
||||
|
||||
- List all action files in `action.d/`.
|
||||
- Allow the user to activate, deactivate, view, and edit action definitions from the UI.
|
||||
- Provide an option to create a brand-new action file.
|
||||
### 4d — Filter Configuration (`filter.d`) ✅ DONE
|
||||
|
||||
### 4f — Create New Configuration Files
|
||||
- Listed all filter files via `GET /api/config/filters`.
|
||||
- View and edit individual filters via `GET/PUT /api/config/filters/{name}`.
|
||||
- Create new filter via `POST /api/config/filters`.
|
||||
- Frontend: **Filters** tab with accordion-per-file, editable textarea, save button, and create-new form.
|
||||
|
||||
- Add a UI flow to create a new jail, filter, or action configuration file from scratch (or from a template).
|
||||
- Validate the new file before writing it to the config directory.
|
||||
### 4e — Action Configuration (`action.d`) ✅ DONE
|
||||
|
||||
- Listed all action files via `GET /api/config/actions`.
|
||||
- View and edit individual actions via `GET/PUT /api/config/actions/{name}`.
|
||||
- Create new action via `POST /api/config/actions`.
|
||||
- Frontend: **Actions** tab with identical structure to Filters tab.
|
||||
|
||||
### 4f — Create New Configuration Files ✅ DONE
|
||||
|
||||
- Create filter and action files via `POST /api/config/filters` and `POST /api/config/actions` with name validation (`_SAFE_NAME_RE`) and 512 KB content size limit.
|
||||
- Frontend: "New Filter/Action File" section at the bottom of each tab with name input, content textarea, and create button.
|
||||
|
||||
---
|
||||
|
||||
## Task 5 — Add Log Path to Jail (Config UI) ✅ DONE
|
||||
|
||||
**Goal:** Allow users to add new log file paths to an existing fail2ban jail directly from the Configuration → Jails tab, completing the "Add Log Observation" feature from [Features.md § 6.3](Features.md).
|
||||
|
||||
**Implementation summary:**
|
||||
|
||||
- `ConfigPage.tsx` `JailAccordionPanel`:
|
||||
- Added `addLogPath` and `AddLogPathRequest` imports.
|
||||
- Added state: `newLogPath`, `newLogPathTail` (default `true`), `addingLogPath`.
|
||||
- Added `handleAddLogPath` callback: calls `addLogPath(jail.name, { log_path, tail })`, appends path to `logPaths` state, clears input, shows success/error feedback.
|
||||
- Added inline "Add Log Path" form below the existing log-path list — an `Input` for the file path, a `Switch` for tail/head selection, and an "Add" button with `aria-label="Add log path"`.
|
||||
- 6 new frontend tests in `src/components/__tests__/ConfigPageLogPath.test.tsx` covering: rendering, disabled state, enabled state, successful add, success message, and API error surfacing.
|
||||
- `tsc --noEmit`, `eslint`: zero errors.
|
||||
|
||||
229
frontend/src/components/__tests__/ConfigPageLogPath.test.tsx
Normal file
229
frontend/src/components/__tests__/ConfigPageLogPath.test.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Tests for the "Add Log Path" form inside JailAccordionPanel (ConfigPage).
|
||||
*
|
||||
* Verifies that:
|
||||
* - The add-log-path input and button are rendered inside the jail accordion.
|
||||
* - The Add button is disabled when the input is empty.
|
||||
* - Submitting a valid path calls `addLogPath` and appends the path to the list.
|
||||
* - An API error is surfaced as an error message bar.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { ConfigPage } from "../../pages/ConfigPage";
|
||||
import type { JailConfig } from "../../types/config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Minimal jail fixture used across tests. */
|
||||
const MOCK_JAIL: JailConfig = {
|
||||
name: "sshd",
|
||||
ban_time: 600,
|
||||
max_retry: 3,
|
||||
find_time: 300,
|
||||
fail_regex: [],
|
||||
ignore_regex: [],
|
||||
log_paths: ["/var/log/auth.log"],
|
||||
date_pattern: null,
|
||||
log_encoding: "UTF-8",
|
||||
backend: "auto",
|
||||
actions: [],
|
||||
};
|
||||
|
||||
const mockAddLogPath = vi.fn().mockResolvedValue(undefined);
|
||||
const mockDeleteLogPath = vi.fn().mockResolvedValue(undefined);
|
||||
const mockUpdateJailConfig = vi.fn().mockResolvedValue(undefined);
|
||||
const mockReloadConfig = vi.fn().mockResolvedValue(undefined);
|
||||
const mockFetchGlobalConfig = vi.fn().mockResolvedValue({
|
||||
config: {
|
||||
ban_time: 600,
|
||||
max_retry: 5,
|
||||
find_time: 300,
|
||||
backend: "auto",
|
||||
},
|
||||
});
|
||||
const mockFetchServerSettings = vi.fn().mockResolvedValue({
|
||||
settings: {
|
||||
log_level: "INFO",
|
||||
log_target: "STDOUT",
|
||||
syslog_socket: null,
|
||||
db_path: "/var/lib/fail2ban/fail2ban.sqlite3",
|
||||
db_purge_age: 86400,
|
||||
db_max_matches: 10,
|
||||
},
|
||||
});
|
||||
const mockFetchJailConfigs = vi.fn().mockResolvedValue({
|
||||
jails: [MOCK_JAIL],
|
||||
total: 1,
|
||||
});
|
||||
const mockFetchMapColorThresholds = vi.fn().mockResolvedValue({
|
||||
threshold_high: 100,
|
||||
threshold_medium: 50,
|
||||
threshold_low: 20,
|
||||
});
|
||||
const mockFetchJailConfigFiles = vi.fn().mockResolvedValue({ files: [] });
|
||||
const mockFetchFilterFiles = vi.fn().mockResolvedValue({ files: [] });
|
||||
const mockFetchActionFiles = vi.fn().mockResolvedValue({ files: [] });
|
||||
const mockUpdateMapColorThresholds = vi.fn().mockResolvedValue({});
|
||||
const mockUpdateGlobalConfig = vi.fn().mockResolvedValue(undefined);
|
||||
const mockUpdateServerSettings = vi.fn().mockResolvedValue(undefined);
|
||||
const mockFlushLogs = vi.fn().mockResolvedValue({ message: "ok" });
|
||||
const mockSetJailConfigFileEnabled = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("../../api/config", () => ({
|
||||
addLogPath: (...args: unknown[]) => mockAddLogPath(...args),
|
||||
deleteLogPath: (...args: unknown[]) => mockDeleteLogPath(...args),
|
||||
fetchJailConfigs: () => mockFetchJailConfigs(),
|
||||
fetchJailConfig: vi.fn(),
|
||||
updateJailConfig: (...args: unknown[]) => mockUpdateJailConfig(...args),
|
||||
reloadConfig: () => mockReloadConfig(),
|
||||
fetchGlobalConfig: () => mockFetchGlobalConfig(),
|
||||
updateGlobalConfig: (...args: unknown[]) => mockUpdateGlobalConfig(...args),
|
||||
fetchServerSettings: () => mockFetchServerSettings(),
|
||||
updateServerSettings: (...args: unknown[]) => mockUpdateServerSettings(...args),
|
||||
flushLogs: () => mockFlushLogs(),
|
||||
fetchMapColorThresholds: () => mockFetchMapColorThresholds(),
|
||||
updateMapColorThresholds: (...args: unknown[]) =>
|
||||
mockUpdateMapColorThresholds(...args),
|
||||
fetchJailConfigFiles: () => mockFetchJailConfigFiles(),
|
||||
fetchJailConfigFileContent: vi.fn(),
|
||||
setJailConfigFileEnabled: (...args: unknown[]) =>
|
||||
mockSetJailConfigFileEnabled(...args),
|
||||
fetchFilterFiles: () => mockFetchFilterFiles(),
|
||||
fetchFilterFile: vi.fn(),
|
||||
updateFilterFile: vi.fn(),
|
||||
createFilterFile: vi.fn(),
|
||||
fetchActionFiles: () => mockFetchActionFiles(),
|
||||
fetchActionFile: vi.fn(),
|
||||
updateActionFile: vi.fn(),
|
||||
createActionFile: vi.fn(),
|
||||
previewLog: vi.fn(),
|
||||
testRegex: vi.fn(),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderConfigPage() {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<ConfigPage />
|
||||
</FluentProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Waits for the sshd accordion button to appear and clicks it open. */
|
||||
async function openSshdAccordion(user: ReturnType<typeof userEvent.setup>) {
|
||||
const accordionBtn = await screen.findByRole("button", { name: /sshd/i });
|
||||
await user.click(accordionBtn);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ConfigPage — Add Log Path", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchJailConfigs.mockResolvedValue({ jails: [MOCK_JAIL], total: 1 });
|
||||
mockAddLogPath.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("renders the existing log path and the add-log-path input inside the accordion", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConfigPage();
|
||||
await openSshdAccordion(user);
|
||||
|
||||
// Existing path from fixture
|
||||
expect(screen.getByText("/var/log/auth.log")).toBeInTheDocument();
|
||||
|
||||
// Add-log-path input placeholder
|
||||
expect(
|
||||
screen.getByPlaceholderText("/var/log/example.log"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables the Add button when the path input is empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConfigPage();
|
||||
await openSshdAccordion(user);
|
||||
|
||||
const addBtn = screen.getByRole("button", { name: /add log path/i });
|
||||
expect(addBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables the Add button when the path input has content", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConfigPage();
|
||||
await openSshdAccordion(user);
|
||||
|
||||
const input = screen.getByPlaceholderText("/var/log/example.log");
|
||||
await user.type(input, "/var/log/nginx/access.log");
|
||||
|
||||
const addBtn = screen.getByRole("button", { name: /add log path/i });
|
||||
expect(addBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("calls addLogPath and appends the path on successful submission", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConfigPage();
|
||||
await openSshdAccordion(user);
|
||||
|
||||
const input = screen.getByPlaceholderText("/var/log/example.log");
|
||||
await user.type(input, "/var/log/nginx/access.log");
|
||||
|
||||
const addBtn = screen.getByRole("button", { name: /add log path/i });
|
||||
await user.click(addBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddLogPath).toHaveBeenCalledWith("sshd", {
|
||||
log_path: "/var/log/nginx/access.log",
|
||||
tail: true,
|
||||
});
|
||||
});
|
||||
|
||||
// New path should appear in the list
|
||||
expect(screen.getByText("/var/log/nginx/access.log")).toBeInTheDocument();
|
||||
|
||||
// Input should be cleared
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("shows a success message after adding a log path", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConfigPage();
|
||||
await openSshdAccordion(user);
|
||||
|
||||
const input = screen.getByPlaceholderText("/var/log/example.log");
|
||||
await user.type(input, "/var/log/nginx/access.log");
|
||||
await user.click(screen.getByRole("button", { name: /add log path/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/added log path.*\/var\/log\/nginx\/access\.log/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error message when addLogPath fails", async () => {
|
||||
mockAddLogPath.mockRejectedValueOnce(new Error("Connection refused"));
|
||||
const user = userEvent.setup();
|
||||
renderConfigPage();
|
||||
await openSshdAccordion(user);
|
||||
|
||||
const input = screen.getByPlaceholderText("/var/log/example.log");
|
||||
await user.type(input, "/var/log/bad.log");
|
||||
await user.click(screen.getByRole("button", { name: /add log path/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Failed to add log path."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Spinner,
|
||||
Switch,
|
||||
Tab,
|
||||
TabList,
|
||||
Text,
|
||||
@@ -46,12 +47,28 @@ import {
|
||||
useServerSettings,
|
||||
} from "../hooks/useConfig";
|
||||
import {
|
||||
addLogPath,
|
||||
createActionFile,
|
||||
createFilterFile,
|
||||
deleteLogPath,
|
||||
fetchActionFile,
|
||||
fetchActionFiles,
|
||||
fetchFilterFile,
|
||||
fetchFilterFiles,
|
||||
fetchJailConfigFileContent,
|
||||
fetchJailConfigFiles,
|
||||
fetchMapColorThresholds,
|
||||
setJailConfigFileEnabled,
|
||||
updateActionFile,
|
||||
updateFilterFile,
|
||||
updateMapColorThresholds,
|
||||
} from "../api/config";
|
||||
import type {
|
||||
AddLogPathRequest,
|
||||
ConfFileEntry,
|
||||
GlobalConfigUpdate,
|
||||
JailConfig,
|
||||
JailConfigFile,
|
||||
JailConfigUpdate,
|
||||
MapColorThresholdsUpdate,
|
||||
ServerSettingsUpdate,
|
||||
@@ -235,9 +252,55 @@ function JailAccordionPanel({
|
||||
const [maxRetry, setMaxRetry] = useState(String(jail.max_retry));
|
||||
const [failRegex, setFailRegex] = useState<string[]>(jail.fail_regex);
|
||||
const [ignoreRegex, setIgnoreRegex] = useState<string[]>(jail.ignore_regex);
|
||||
const [logPaths, setLogPaths] = useState<string[]>(jail.log_paths);
|
||||
const [deletingPath, setDeletingPath] = useState<string | null>(null);
|
||||
const [newLogPath, setNewLogPath] = useState("");
|
||||
const [newLogPathTail, setNewLogPathTail] = useState(true);
|
||||
const [addingLogPath, setAddingLogPath] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||
|
||||
const handleDeleteLogPath = useCallback(
|
||||
async (path: string) => {
|
||||
setDeletingPath(path);
|
||||
setMsg(null);
|
||||
try {
|
||||
await deleteLogPath(jail.name, path);
|
||||
setLogPaths((prev) => prev.filter((p) => p !== path));
|
||||
setMsg({ text: `Removed log path: ${path}`, ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Delete failed.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setDeletingPath(null);
|
||||
}
|
||||
},
|
||||
[jail.name],
|
||||
);
|
||||
|
||||
const handleAddLogPath = useCallback(async () => {
|
||||
const trimmed = newLogPath.trim();
|
||||
if (!trimmed) return;
|
||||
setAddingLogPath(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const req: AddLogPathRequest = { log_path: trimmed, tail: newLogPathTail };
|
||||
await addLogPath(jail.name, req);
|
||||
setLogPaths((prev) => [...prev, trimmed]);
|
||||
setNewLogPath("");
|
||||
setMsg({ text: `Added log path: ${trimmed}`, ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Failed to add log path.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setAddingLogPath(false);
|
||||
}
|
||||
}, [jail.name, newLogPath, newLogPathTail]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
@@ -314,17 +377,57 @@ function JailAccordionPanel({
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Log Paths">
|
||||
{jail.log_paths.length === 0 ? (
|
||||
{logPaths.length === 0 ? (
|
||||
<Text className={styles.infoText} size={200}>
|
||||
(none)
|
||||
</Text>
|
||||
) : (
|
||||
jail.log_paths.map((p) => (
|
||||
<div key={p} className={styles.codeFont}>
|
||||
{p}
|
||||
logPaths.map((p) => (
|
||||
<div key={p} className={styles.regexItem}>
|
||||
<span className={styles.codeFont} style={{ flexGrow: 1 }}>
|
||||
{p}
|
||||
</span>
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<Dismiss24Regular />}
|
||||
size="small"
|
||||
disabled={deletingPath === p}
|
||||
title="Remove log path"
|
||||
onClick={() => void handleDeleteLogPath(p)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{/* Add log path inline form */}
|
||||
<div className={styles.regexItem} style={{ marginTop: tokens.spacingVerticalXS }}>
|
||||
<Input
|
||||
className={styles.codeFont}
|
||||
style={{ flexGrow: 1 }}
|
||||
placeholder="/var/log/example.log"
|
||||
value={newLogPath}
|
||||
disabled={addingLogPath}
|
||||
aria-label="New log path"
|
||||
onChange={(_e, d) => {
|
||||
setNewLogPath(d.value);
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
label={newLogPathTail ? "tail" : "head"}
|
||||
checked={newLogPathTail}
|
||||
onChange={(_e, d) => {
|
||||
setNewLogPathTail(d.checked);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
appearance="primary"
|
||||
size="small"
|
||||
aria-label="Add log path"
|
||||
disabled={addingLogPath || !newLogPath.trim()}
|
||||
onClick={() => void handleAddLogPath()}
|
||||
>
|
||||
{addingLogPath ? "Adding…" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
<div style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
<RegexList
|
||||
@@ -922,6 +1025,500 @@ function MapTab(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JailFilesTab — manage jail.d config files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function JailFilesTab(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const [files, setFiles] = useState<JailConfigFile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openItems, setOpenItems] = useState<string[]>([]);
|
||||
const [fileContents, setFileContents] = useState<Record<string, string>>({});
|
||||
const [toggling, setToggling] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||
|
||||
const loadFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await fetchJailConfigFiles();
|
||||
setFiles(resp.files);
|
||||
} catch (err: unknown) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: "Failed to load jail config files.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
const handleAccordionToggle = useCallback(
|
||||
(
|
||||
_e: React.SyntheticEvent,
|
||||
data: { openItems: (string | number)[] },
|
||||
) => {
|
||||
const next = data.openItems as string[];
|
||||
const newlyOpened = next.filter((v) => !openItems.includes(v));
|
||||
setOpenItems(next);
|
||||
for (const filename of newlyOpened) {
|
||||
if (!Object.prototype.hasOwnProperty.call(fileContents, filename)) {
|
||||
void fetchJailConfigFileContent(filename)
|
||||
.then((c) => {
|
||||
setFileContents((prev) => ({ ...prev, [filename]: c.content }));
|
||||
})
|
||||
.catch(() => {
|
||||
setFileContents((prev) => ({
|
||||
...prev,
|
||||
[filename]: "(failed to load)",
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[openItems, fileContents],
|
||||
);
|
||||
|
||||
const handleToggleEnabled = useCallback(
|
||||
async (filename: string, enabled: boolean) => {
|
||||
setToggling(filename);
|
||||
setMsg(null);
|
||||
try {
|
||||
await setJailConfigFileEnabled(filename, { enabled });
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => (f.filename === filename ? { ...f, enabled } : f)),
|
||||
);
|
||||
setMsg({
|
||||
text: `${filename} ${enabled ? "enabled" : "disabled"}.`,
|
||||
ok: true,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Toggle failed.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setToggling(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (loading) return <Spinner label="Loading jail config files…" />;
|
||||
if (error)
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text
|
||||
as="p"
|
||||
size={300}
|
||||
className={styles.infoText}
|
||||
block
|
||||
style={{ marginBottom: tokens.spacingVerticalM }}
|
||||
>
|
||||
Files in <code>jail.d/</code>. Toggle the switch to enable or disable a
|
||||
jail config file. Changes take effect on the next fail2ban reload.
|
||||
</Text>
|
||||
{msg && (
|
||||
<MessageBar
|
||||
intent={msg.ok ? "success" : "error"}
|
||||
style={{ marginBottom: tokens.spacingVerticalS }}
|
||||
>
|
||||
<MessageBarBody>{msg.text}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<ArrowClockwise24Regular />}
|
||||
onClick={() => void loadFiles()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
{files.length === 0 && (
|
||||
<Text
|
||||
className={styles.infoText}
|
||||
style={{ marginTop: tokens.spacingVerticalM }}
|
||||
>
|
||||
No files found in jail.d/.
|
||||
</Text>
|
||||
)}
|
||||
<Accordion
|
||||
multiple
|
||||
collapsible
|
||||
openItems={openItems}
|
||||
onToggle={handleAccordionToggle}
|
||||
style={{ marginTop: tokens.spacingVerticalM }}
|
||||
>
|
||||
{files.map((file) => (
|
||||
<AccordionItem key={file.filename} value={file.filename}>
|
||||
<AccordionHeader>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
}}
|
||||
>
|
||||
<span className={styles.codeFont}>{file.filename}</span>
|
||||
<Switch
|
||||
checked={file.enabled}
|
||||
disabled={toggling === file.filename}
|
||||
label={file.enabled ? "Enabled" : "Disabled"}
|
||||
onChange={(_e, d) => {
|
||||
void handleToggleEnabled(file.filename, d.checked);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
{openItems.includes(file.filename) &&
|
||||
(fileContents[file.filename] === undefined ? (
|
||||
<Spinner size="tiny" label="Loading…" />
|
||||
) : (
|
||||
<Textarea
|
||||
readOnly
|
||||
value={fileContents[file.filename]}
|
||||
rows={20}
|
||||
style={{ width: "100%", resize: "vertical" }}
|
||||
className={styles.codeFont}
|
||||
/>
|
||||
))}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConfFilesTab — generic editable list for filter.d / action.d
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ConfFilesTabProps {
|
||||
label: string;
|
||||
fetchList: () => Promise<{ files: ConfFileEntry[]; total: number }>;
|
||||
fetchFile: (name: string) => Promise<{
|
||||
name: string;
|
||||
filename: string;
|
||||
content: string;
|
||||
}>;
|
||||
updateFile: (
|
||||
name: string,
|
||||
req: { content: string },
|
||||
) => Promise<void>;
|
||||
createFile: (req: {
|
||||
name: string;
|
||||
content: string;
|
||||
}) => Promise<{ name: string; filename: string; content: string }>;
|
||||
}
|
||||
|
||||
function ConfFilesTab({
|
||||
label,
|
||||
fetchList,
|
||||
fetchFile,
|
||||
updateFile,
|
||||
createFile,
|
||||
}: ConfFilesTabProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const [files, setFiles] = useState<ConfFileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openItems, setOpenItems] = useState<string[]>([]);
|
||||
const [contents, setContents] = useState<Record<string, string>>({});
|
||||
const [editedContents, setEditedContents] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newContent, setNewContent] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const loadFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await fetchList();
|
||||
setFiles(resp.files);
|
||||
} catch (err: unknown) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: `Failed to load ${label.toLowerCase()} files.`,
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchList, label]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
const handleAccordionToggle = useCallback(
|
||||
(
|
||||
_e: React.SyntheticEvent,
|
||||
data: { openItems: (string | number)[] },
|
||||
) => {
|
||||
const next = data.openItems as string[];
|
||||
const newlyOpened = next.filter((v) => !openItems.includes(v));
|
||||
setOpenItems(next);
|
||||
for (const name of newlyOpened) {
|
||||
if (!Object.prototype.hasOwnProperty.call(contents, name)) {
|
||||
void fetchFile(name)
|
||||
.then((c) => {
|
||||
setContents((prev) => ({ ...prev, [name]: c.content }));
|
||||
setEditedContents((prev) => ({ ...prev, [name]: c.content }));
|
||||
})
|
||||
.catch(() => {
|
||||
setContents((prev) => ({
|
||||
...prev,
|
||||
[name]: "(failed to load)",
|
||||
}));
|
||||
setEditedContents((prev) => ({
|
||||
...prev,
|
||||
[name]: "(failed to load)",
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[openItems, contents, fetchFile],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (name: string) => {
|
||||
setSaving(name);
|
||||
setMsg(null);
|
||||
try {
|
||||
const content = editedContents[name] ?? contents[name] ?? "";
|
||||
await updateFile(name, { content });
|
||||
setContents((prev) => ({ ...prev, [name]: content }));
|
||||
setMsg({ text: `${name} saved.`, ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Save failed.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
},
|
||||
[editedContents, contents, updateFile],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
setCreating(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const created = await createFile({ name, content: newContent });
|
||||
setFiles((prev) => [
|
||||
...prev,
|
||||
{ name: created.name, filename: created.filename },
|
||||
]);
|
||||
setContents((prev) => ({ ...prev, [created.name]: created.content }));
|
||||
setEditedContents((prev) => ({
|
||||
...prev,
|
||||
[created.name]: created.content,
|
||||
}));
|
||||
setNewName("");
|
||||
setNewContent("");
|
||||
setMsg({ text: `${created.filename} created.`, ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Create failed.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [newName, newContent, createFile]);
|
||||
|
||||
if (loading) return <Spinner label={`Loading ${label.toLowerCase()} files…`} />;
|
||||
if (error)
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{msg && (
|
||||
<MessageBar
|
||||
intent={msg.ok ? "success" : "error"}
|
||||
style={{ marginBottom: tokens.spacingVerticalS }}
|
||||
>
|
||||
<MessageBarBody>{msg.text}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<ArrowClockwise24Regular />}
|
||||
onClick={() => void loadFiles()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
{files.length === 0 && (
|
||||
<Text
|
||||
className={styles.infoText}
|
||||
style={{ marginTop: tokens.spacingVerticalM }}
|
||||
>
|
||||
No {label.toLowerCase()} files found.
|
||||
</Text>
|
||||
)}
|
||||
<Accordion
|
||||
multiple
|
||||
collapsible
|
||||
openItems={openItems}
|
||||
onToggle={handleAccordionToggle}
|
||||
style={{ marginTop: tokens.spacingVerticalM }}
|
||||
>
|
||||
{files.map((file) => (
|
||||
<AccordionItem key={file.name} value={file.name}>
|
||||
<AccordionHeader>
|
||||
<Text className={styles.codeFont}>{file.filename}</Text>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
{openItems.includes(file.name) &&
|
||||
(contents[file.name] === undefined ? (
|
||||
<Spinner size="tiny" label="Loading…" />
|
||||
) : (
|
||||
<div>
|
||||
<Textarea
|
||||
value={editedContents[file.name] ?? ""}
|
||||
rows={20}
|
||||
style={{
|
||||
width: "100%",
|
||||
resize: "vertical",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
onChange={(_e, d) => {
|
||||
setEditedContents((prev) => ({
|
||||
...prev,
|
||||
[file.name]: d.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={<Save24Regular />}
|
||||
disabled={saving === file.name}
|
||||
onClick={() => void handleSave(file.name)}
|
||||
>
|
||||
{saving === file.name ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
|
||||
{/* Create new file */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: tokens.spacingVerticalXL,
|
||||
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
paddingTop: tokens.spacingVerticalM,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
as="h3"
|
||||
size={400}
|
||||
weight="semibold"
|
||||
block
|
||||
style={{ marginBottom: tokens.spacingVerticalS }}
|
||||
>
|
||||
New {label} File
|
||||
</Text>
|
||||
<Field label="Name (without .conf extension)">
|
||||
<Input
|
||||
value={newName}
|
||||
placeholder={`e.g. my-${label.toLowerCase()}`}
|
||||
className={styles.codeFont}
|
||||
onChange={(_e, d) => {
|
||||
setNewName(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Content" style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
<Textarea
|
||||
value={newContent}
|
||||
rows={10}
|
||||
placeholder={`[Definition]\n# …`}
|
||||
style={{
|
||||
width: "100%",
|
||||
resize: "vertical",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
onChange={(_e, d) => {
|
||||
setNewContent(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="primary"
|
||||
disabled={creating || !newName.trim()}
|
||||
onClick={() => void handleCreate()}
|
||||
>
|
||||
{creating ? "Creating…" : `Create ${label} File`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FiltersTab(): React.JSX.Element {
|
||||
return (
|
||||
<ConfFilesTab
|
||||
label="Filter"
|
||||
fetchList={fetchFilterFiles}
|
||||
fetchFile={fetchFilterFile}
|
||||
updateFile={updateFilterFile}
|
||||
createFile={createFilterFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionsTab(): React.JSX.Element {
|
||||
return (
|
||||
<ConfFilesTab
|
||||
label="Action"
|
||||
fetchList={fetchActionFiles}
|
||||
fetchFile={fetchActionFile}
|
||||
updateFile={updateActionFile}
|
||||
createFile={createActionFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RegexTesterTab
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1114,7 +1711,15 @@ function RegexTesterTab(): React.JSX.Element {
|
||||
// ConfigPage (root)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TabValue = "jails" | "global" | "server" | "map" | "regex";
|
||||
type TabValue =
|
||||
| "jails"
|
||||
| "jailfiles"
|
||||
| "filters"
|
||||
| "actions"
|
||||
| "global"
|
||||
| "server"
|
||||
| "map"
|
||||
| "regex";
|
||||
|
||||
export function ConfigPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
@@ -1139,6 +1744,9 @@ export function ConfigPage(): React.JSX.Element {
|
||||
}}
|
||||
>
|
||||
<Tab value="jails">Jails</Tab>
|
||||
<Tab value="jailfiles">Jail Files</Tab>
|
||||
<Tab value="filters">Filters</Tab>
|
||||
<Tab value="actions">Actions</Tab>
|
||||
<Tab value="global">Global</Tab>
|
||||
<Tab value="server">Server</Tab>
|
||||
<Tab value="map">Map</Tab>
|
||||
@@ -1147,6 +1755,9 @@ export function ConfigPage(): React.JSX.Element {
|
||||
|
||||
<div className={styles.tabContent}>
|
||||
{tab === "jails" && <JailsTab />}
|
||||
{tab === "jailfiles" && <JailFilesTab />}
|
||||
{tab === "filters" && <FiltersTab />}
|
||||
{tab === "actions" && <ActionsTab />}
|
||||
{tab === "global" && <GlobalTab />}
|
||||
{tab === "server" && <ServerTab />}
|
||||
{tab === "map" && <MapTab />}
|
||||
|
||||
Reference in New Issue
Block a user