Add better jail configuration: file CRUD, enable/disable, log paths

Task 4 (Better Jail Configuration) implementation:
- Add fail2ban_config_dir setting to app/config.py
- New file_config_service: list/view/edit/create jail.d, filter.d, action.d files
  with path-traversal prevention and 512 KB content size limit
- New file_config router: GET/PUT/POST endpoints for jail files, filter files,
  and action files; PUT .../enabled for toggle on/off
- Extend config_service with delete_log_path() and add_log_path()
- Add DELETE /api/config/jails/{name}/logpath and POST /api/config/jails/{name}/logpath
- Extend geo router with re-resolve endpoint; add geo_re_resolve background task
- Update blocklist_service with revised scheduling helpers
- Update Docker compose files with BANGUI_FAIL2BAN_CONFIG_DIR env var and
  rw volume mount for the fail2ban config directory
- Frontend: new Jail Files, Filters, Actions tabs in ConfigPage; file editor
  with accordion-per-file, editable textarea, save/create; add/delete log paths
- Frontend: types in types/config.ts; API calls in api/config.ts and api/endpoints.ts
- 63 new backend tests (test_file_config_service, test_file_config, test_geo_re_resolve)
- 6 new frontend tests in ConfigPageLogPath.test.tsx
- ruff, mypy --strict, tsc --noEmit, eslint: all clean; 617 backend tests pass
This commit is contained in:
2026-03-12 20:08:33 +01:00
parent 59464a1592
commit ea35695221
23 changed files with 2911 additions and 91 deletions

View File

@@ -2,12 +2,19 @@
* API functions for the configuration and server settings endpoints.
*/
import { get, post, put } from "./client";
import { del, get, post, put } from "./client";
import { ENDPOINTS } from "./endpoints";
import type {
AddLogPathRequest,
ConfFileContent,
ConfFileCreateRequest,
ConfFilesResponse,
ConfFileUpdateRequest,
GlobalConfig,
GlobalConfigUpdate,
JailConfigFileContent,
JailConfigFileEnabledUpdate,
JailConfigFilesResponse,
JailConfigListResponse,
JailConfigResponse,
JailConfigUpdate,
@@ -88,6 +95,15 @@ export async function addLogPath(
await post<undefined>(ENDPOINTS.configJailLogPath(jailName), req);
}
export async function deleteLogPath(
jailName: string,
logPath: string
): Promise<void> {
await del<undefined>(
`${ENDPOINTS.configJailLogPath(jailName)}?log_path=${encodeURIComponent(logPath)}`
);
}
// ---------------------------------------------------------------------------
// Log preview
// ---------------------------------------------------------------------------
@@ -139,3 +155,74 @@ export async function updateMapColorThresholds(
update,
);
}
// ---------------------------------------------------------------------------
// Jail config files (Task 4a)
// ---------------------------------------------------------------------------
export async function fetchJailConfigFiles(): Promise<JailConfigFilesResponse> {
return get<JailConfigFilesResponse>(ENDPOINTS.configJailFiles);
}
export async function fetchJailConfigFileContent(
filename: string
): Promise<JailConfigFileContent> {
return get<JailConfigFileContent>(ENDPOINTS.configJailFile(filename));
}
export async function setJailConfigFileEnabled(
filename: string,
update: JailConfigFileEnabledUpdate
): Promise<void> {
await put<undefined>(ENDPOINTS.configJailFileEnabled(filename), update);
}
// ---------------------------------------------------------------------------
// Filter files (Task 4d)
// ---------------------------------------------------------------------------
export async function fetchFilterFiles(): Promise<ConfFilesResponse> {
return get<ConfFilesResponse>(ENDPOINTS.configFilters);
}
export async function fetchFilterFile(name: string): Promise<ConfFileContent> {
return get<ConfFileContent>(ENDPOINTS.configFilter(name));
}
export async function updateFilterFile(
name: string,
req: ConfFileUpdateRequest
): Promise<void> {
await put<undefined>(ENDPOINTS.configFilter(name), req);
}
export async function createFilterFile(
req: ConfFileCreateRequest
): Promise<ConfFileContent> {
return post<ConfFileContent>(ENDPOINTS.configFilters, req);
}
// ---------------------------------------------------------------------------
// Action files (Task 4e)
// ---------------------------------------------------------------------------
export async function fetchActionFiles(): Promise<ConfFilesResponse> {
return get<ConfFilesResponse>(ENDPOINTS.configActions);
}
export async function fetchActionFile(name: string): Promise<ConfFileContent> {
return get<ConfFileContent>(ENDPOINTS.configAction(name));
}
export async function updateActionFile(
name: string,
req: ConfFileUpdateRequest
): Promise<void> {
await put<undefined>(ENDPOINTS.configAction(name), req);
}
export async function createActionFile(
req: ConfFileCreateRequest
): Promise<ConfFileContent> {
return post<ConfFileContent>(ENDPOINTS.configActions, req);
}

View File

@@ -70,6 +70,17 @@ export const ENDPOINTS = {
configPreviewLog: "/config/preview-log",
configMapColorThresholds: "/config/map-color-thresholds",
// File-based config (jail.d, filter.d, action.d)
configJailFiles: "/config/jail-files",
configJailFile: (filename: string): string =>
`/config/jail-files/${encodeURIComponent(filename)}`,
configJailFileEnabled: (filename: string): string =>
`/config/jail-files/${encodeURIComponent(filename)}/enabled`,
configFilters: "/config/filters",
configFilter: (name: string): string => `/config/filters/${encodeURIComponent(name)}`,
configActions: "/config/actions",
configAction: (name: string): string => `/config/actions/${encodeURIComponent(name)}`,
// -------------------------------------------------------------------------
// Server settings
// -------------------------------------------------------------------------

View File

@@ -16,9 +16,94 @@ import { ConfigPage } from "../../pages/ConfigPage";
import type { JailConfig } from "../../types/config";
// ---------------------------------------------------------------------------
// Module mocks
// Module mocks — use vi.hoisted so refs are available when vi.mock runs
// ---------------------------------------------------------------------------
const {
mockAddLogPath,
mockDeleteLogPath,
mockUpdateJailConfig,
mockReloadConfig,
mockFetchGlobalConfig,
mockFetchServerSettings,
mockFetchJailConfigs,
mockFetchMapColorThresholds,
mockFetchJailConfigFiles,
mockFetchFilterFiles,
mockFetchActionFiles,
mockUpdateMapColorThresholds,
mockUpdateGlobalConfig,
mockUpdateServerSettings,
mockFlushLogs,
mockSetJailConfigFileEnabled,
} = vi.hoisted(() => ({
mockAddLogPath: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
mockDeleteLogPath: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
mockUpdateJailConfig: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
mockReloadConfig: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
mockFetchGlobalConfig: vi.fn().mockResolvedValue({
config: {
ban_time: 600,
max_retry: 5,
find_time: 300,
backend: "auto",
},
}),
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,
},
}),
mockFetchJailConfigs: vi.fn(),
mockFetchMapColorThresholds: vi.fn().mockResolvedValue({
threshold_high: 100,
threshold_medium: 50,
threshold_low: 20,
}),
mockFetchJailConfigFiles: vi.fn().mockResolvedValue({ files: [] }),
mockFetchFilterFiles: vi.fn().mockResolvedValue({ files: [] }),
mockFetchActionFiles: vi.fn().mockResolvedValue({ files: [] }),
mockUpdateMapColorThresholds: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
mockUpdateGlobalConfig: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
mockUpdateServerSettings: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
mockFlushLogs: vi.fn().mockResolvedValue({ message: "ok" }),
mockSetJailConfigFileEnabled: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
}));
vi.mock("../../api/config", () => ({
addLogPath: mockAddLogPath,
deleteLogPath: mockDeleteLogPath,
fetchJailConfigs: mockFetchJailConfigs,
fetchJailConfig: vi.fn(),
updateJailConfig: mockUpdateJailConfig,
reloadConfig: mockReloadConfig,
fetchGlobalConfig: mockFetchGlobalConfig,
updateGlobalConfig: mockUpdateGlobalConfig,
fetchServerSettings: mockFetchServerSettings,
updateServerSettings: mockUpdateServerSettings,
flushLogs: mockFlushLogs,
fetchMapColorThresholds: mockFetchMapColorThresholds,
updateMapColorThresholds: mockUpdateMapColorThresholds,
fetchJailConfigFiles: mockFetchJailConfigFiles,
fetchJailConfigFileContent: vi.fn(),
setJailConfigFileEnabled: mockSetJailConfigFileEnabled,
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(),
}));
/** Minimal jail fixture used across tests. */
const MOCK_JAIL: JailConfig = {
name: "sshd",
@@ -34,77 +119,6 @@ const MOCK_JAIL: JailConfig = {
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
// ---------------------------------------------------------------------------

View File

@@ -144,3 +144,60 @@ export interface MapColorThresholdsUpdate {
threshold_medium: number;
threshold_low: number;
}
// ---------------------------------------------------------------------------
// Jail config files (Task 4a)
// ---------------------------------------------------------------------------
export interface JailConfigFile {
/** File stem, e.g. "sshd". */
name: string;
/** Actual filename, e.g. "sshd.conf". */
filename: string;
/** Whether the jail is currently enabled in its config file. */
enabled: boolean;
}
export interface JailConfigFilesResponse {
files: JailConfigFile[];
total: number;
}
export interface JailConfigFileContent extends JailConfigFile {
content: string;
}
export interface JailConfigFileEnabledUpdate {
enabled: boolean;
}
// ---------------------------------------------------------------------------
// Generic conf-file entry (filter.d / action.d)
// ---------------------------------------------------------------------------
export interface ConfFileEntry {
/** Base name without extension, e.g. "sshd". */
name: string;
/** Full filename, e.g. "sshd.conf". */
filename: string;
}
export interface ConfFilesResponse {
files: ConfFileEntry[];
total: number;
}
export interface ConfFileContent {
name: string;
filename: string;
content: string;
}
export interface ConfFileUpdateRequest {
content: string;
}
export interface ConfFileCreateRequest {
name: string;
content: string;
}