diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 8664e18..bcbcf26 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -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 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. diff --git a/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx b/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx new file mode 100644 index 0000000..d8686ef --- /dev/null +++ b/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx @@ -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( + + + , + ); +} + +/** Waits for the sshd accordion button to appear and clicks it open. */ +async function openSshdAccordion(user: ReturnType) { + 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(); + }); + }); +}); diff --git a/frontend/src/pages/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx index 8b24382..53084d5 100644 --- a/frontend/src/pages/ConfigPage.tsx +++ b/frontend/src/pages/ConfigPage.tsx @@ -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(jail.fail_regex); const [ignoreRegex, setIgnoreRegex] = useState(jail.ignore_regex); + const [logPaths, setLogPaths] = useState(jail.log_paths); + const [deletingPath, setDeletingPath] = useState(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({ - {jail.log_paths.length === 0 ? ( + {logPaths.length === 0 ? ( (none) ) : ( - jail.log_paths.map((p) => ( -
- {p} + logPaths.map((p) => ( +
+ + {p} + +
)) )} + {/* Add log path inline form */} +
+ { + setNewLogPath(d.value); + }} + /> + { + setNewLogPathTail(d.checked); + }} + /> + +
([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [openItems, setOpenItems] = useState([]); + const [fileContents, setFileContents] = useState>({}); + const [toggling, setToggling] = useState(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 ; + if (error) + return ( + + {error} + + ); + + return ( +
+ + Files in jail.d/. Toggle the switch to enable or disable a + jail config file. Changes take effect on the next fail2ban reload. + + {msg && ( + + {msg.text} + + )} +
+ +
+ {files.length === 0 && ( + + No files found in jail.d/. + + )} + + {files.map((file) => ( + + + + {file.filename} + { + void handleToggleEnabled(file.filename, d.checked); + }} + onClick={(e) => { + e.stopPropagation(); + }} + /> + + + + {openItems.includes(file.filename) && + (fileContents[file.filename] === undefined ? ( + + ) : ( +