Task 2: adds a new Log tab to the Configuration page.
Backend:
- New Pydantic models: Fail2BanLogResponse, ServiceStatusResponse
(backend/app/models/config.py)
- New service methods in config_service.py:
read_fail2ban_log() — queries socket for log target/level, validates the
resolved path against a safe-prefix allowlist (/var/log) to prevent
path traversal, then reads the tail of the file via the existing
_read_tail_lines() helper; optional substring filter applied server-side.
get_service_status() — delegates to health_service.probe() and appends
log level/target from the socket.
- New endpoints in routers/config.py:
GET /api/config/fail2ban-log?lines=200&filter=...
GET /api/config/service-status
Both require authentication; log endpoint returns 400 for non-file log
targets or path-traversal attempts, 502 when fail2ban is unreachable.
Frontend:
- New LogTab.tsx component:
Service Health panel (Running/Offline badge, version, jail count, bans,
failures, log level/target, offline warning banner).
Log viewer with color-coded lines (error=red, warning=yellow,
debug=grey), toolbar (filter input + debounce, lines selector, manual
refresh, auto-refresh with interval selector), truncation notice, and
auto-scroll to bottom on data updates.
fetchData uses Promise.allSettled so a log-read failure never hides the
service-health panel.
- Types: Fail2BanLogResponse, ServiceStatusResponse (types/config.ts)
- API functions: fetchFail2BanLog, fetchServiceStatus (api/config.ts)
- Endpoint constants (api/endpoints.ts)
- ConfigPage.tsx: Log tab added after existing tabs
Tests:
- Backend service tests: TestReadFail2BanLog (6), TestGetServiceStatus (2)
- Backend router tests: TestGetFail2BanLog (8), TestGetServiceStatus (3)
- Frontend: LogTab.test.tsx (8 tests)
Docs:
- Features.md: Log section added under Configuration View
- Architekture.md: config.py router and config_service.py descriptions updated
- Tasks.md: Task 2 marked done
572 lines
18 KiB
TypeScript
572 lines
18 KiB
TypeScript
/**
|
||
* API functions for the configuration and server settings endpoints.
|
||
*/
|
||
|
||
import { del, get, post, put } from "./client";
|
||
import { ENDPOINTS } from "./endpoints";
|
||
import type {
|
||
ActionConfig,
|
||
ActionConfigUpdate,
|
||
ActionCreateRequest,
|
||
ActionListResponse,
|
||
ActionUpdateRequest,
|
||
ActivateJailRequest,
|
||
AddLogPathRequest,
|
||
AssignActionRequest,
|
||
AssignFilterRequest,
|
||
ConfFileContent,
|
||
ConfFileCreateRequest,
|
||
ConfFilesResponse,
|
||
ConfFileUpdateRequest,
|
||
Fail2BanLogResponse,
|
||
FilterConfig,
|
||
FilterConfigUpdate,
|
||
FilterCreateRequest,
|
||
FilterListResponse,
|
||
FilterUpdateRequest,
|
||
GlobalConfig,
|
||
GlobalConfigUpdate,
|
||
InactiveJailListResponse,
|
||
JailActivationResponse,
|
||
JailConfigFileContent,
|
||
JailConfigFileEnabledUpdate,
|
||
JailConfigFilesResponse,
|
||
JailConfigListResponse,
|
||
JailConfigResponse,
|
||
JailConfigUpdate,
|
||
LogPreviewRequest,
|
||
LogPreviewResponse,
|
||
MapColorThresholdsResponse,
|
||
MapColorThresholdsUpdate,
|
||
RegexTestRequest,
|
||
RegexTestResponse,
|
||
ServerSettingsResponse,
|
||
ServerSettingsUpdate,
|
||
JailFileConfig,
|
||
JailFileConfigUpdate,
|
||
ServiceStatusResponse,
|
||
} from "../types/config";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Jail configuration
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function fetchJailConfigs(
|
||
): Promise<JailConfigListResponse> {
|
||
return get<JailConfigListResponse>(ENDPOINTS.configJails);
|
||
}
|
||
|
||
export async function fetchJailConfig(
|
||
name: string
|
||
): Promise<JailConfigResponse> {
|
||
return get<JailConfigResponse>(ENDPOINTS.configJail(name));
|
||
}
|
||
|
||
export async function updateJailConfig(
|
||
name: string,
|
||
update: JailConfigUpdate
|
||
): Promise<void> {
|
||
await put<undefined>(ENDPOINTS.configJail(name), update);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Global configuration
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function fetchGlobalConfig(
|
||
): Promise<GlobalConfig> {
|
||
return get<GlobalConfig>(ENDPOINTS.configGlobal);
|
||
}
|
||
|
||
export async function updateGlobalConfig(
|
||
update: GlobalConfigUpdate
|
||
): Promise<void> {
|
||
await put<undefined>(ENDPOINTS.configGlobal, update);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Reload
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function reloadConfig(
|
||
): Promise<void> {
|
||
await post<undefined>(ENDPOINTS.configReload, undefined);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Regex tester
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function testRegex(
|
||
req: RegexTestRequest
|
||
): Promise<RegexTestResponse> {
|
||
return post<RegexTestResponse>(ENDPOINTS.configRegexTest, req);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Log path management
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function addLogPath(
|
||
jailName: string,
|
||
req: AddLogPathRequest
|
||
): Promise<void> {
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function previewLog(
|
||
req: LogPreviewRequest
|
||
): Promise<LogPreviewResponse> {
|
||
return post<LogPreviewResponse>(ENDPOINTS.configPreviewLog, req);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Server settings
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function fetchServerSettings(
|
||
): Promise<ServerSettingsResponse> {
|
||
return get<ServerSettingsResponse>(ENDPOINTS.serverSettings);
|
||
}
|
||
|
||
export async function updateServerSettings(
|
||
update: ServerSettingsUpdate
|
||
): Promise<void> {
|
||
await put<undefined>(ENDPOINTS.serverSettings, update);
|
||
}
|
||
|
||
export async function flushLogs(
|
||
): Promise<string> {
|
||
const resp = await post<{ message: string }>(
|
||
ENDPOINTS.serverFlushLogs,
|
||
undefined,
|
||
);
|
||
return resp.message;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Map color thresholds
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function fetchMapColorThresholds(
|
||
): Promise<MapColorThresholdsResponse> {
|
||
return get<MapColorThresholdsResponse>(ENDPOINTS.configMapColorThresholds);
|
||
}
|
||
|
||
export async function updateMapColorThresholds(
|
||
update: MapColorThresholdsUpdate
|
||
): Promise<MapColorThresholdsResponse> {
|
||
return put<MapColorThresholdsResponse>(
|
||
ENDPOINTS.configMapColorThresholds,
|
||
update,
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Jail config files (Task 4a)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function fetchJailConfigFiles(): Promise<JailConfigFilesResponse> {
|
||
return get<JailConfigFilesResponse>(ENDPOINTS.configJailFiles);
|
||
}
|
||
|
||
export async function createJailConfigFile(
|
||
req: ConfFileCreateRequest
|
||
): Promise<ConfFileContent> {
|
||
return post<ConfFileContent>(ENDPOINTS.configJailFiles, req);
|
||
}
|
||
|
||
export async function fetchJailConfigFileContent(
|
||
filename: string
|
||
): Promise<JailConfigFileContent> {
|
||
return get<JailConfigFileContent>(ENDPOINTS.configJailFile(filename));
|
||
}
|
||
|
||
export async function updateJailConfigFile(
|
||
filename: string,
|
||
req: ConfFileUpdateRequest
|
||
): Promise<void> {
|
||
await put<undefined>(ENDPOINTS.configJailFile(filename), req);
|
||
}
|
||
|
||
export async function setJailConfigFileEnabled(
|
||
filename: string,
|
||
update: JailConfigFileEnabledUpdate
|
||
): Promise<void> {
|
||
await put<undefined>(ENDPOINTS.configJailFileEnabled(filename), update);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Filter files (Task 4d) — raw file management
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Return a lightweight name/filename list of all filter files.
|
||
*
|
||
* Internally calls the enriched ``GET /config/filters`` endpoint (which also
|
||
* returns active-status data) and maps the result down to the simpler
|
||
* ``ConfFilesResponse`` shape expected by the raw-file editor and export tab.
|
||
*/
|
||
export async function fetchFilterFiles(): Promise<ConfFilesResponse> {
|
||
const result = await fetchFilters();
|
||
return {
|
||
files: result.filters.map((f) => ({ name: f.name, filename: f.filename })),
|
||
total: result.total,
|
||
};
|
||
}
|
||
|
||
/** Fetch the raw content of a filter definition file for the raw editor. */
|
||
export async function fetchFilterFile(name: string): Promise<ConfFileContent> {
|
||
return get<ConfFileContent>(ENDPOINTS.configFilterRaw(name));
|
||
}
|
||
|
||
/** Save raw content to a filter definition file (``PUT /filters/{name}/raw``). */
|
||
export async function updateFilterFile(
|
||
name: string,
|
||
req: ConfFileUpdateRequest
|
||
): Promise<void> {
|
||
await put<undefined>(ENDPOINTS.configFilterRaw(name), req);
|
||
}
|
||
|
||
/** Create a new raw filter file (``POST /filters/raw``). */
|
||
export async function createFilterFile(
|
||
req: ConfFileCreateRequest
|
||
): Promise<ConfFileContent> {
|
||
return post<ConfFileContent>(ENDPOINTS.configFiltersRaw, 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);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Parsed filter config (Task 2.2 / legacy /parsed endpoint)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function fetchParsedFilter(name: string): Promise<FilterConfig> {
|
||
return get<FilterConfig>(ENDPOINTS.configFilterParsed(name));
|
||
}
|
||
|
||
export async function updateParsedFilter(
|
||
name: string,
|
||
update: FilterConfigUpdate
|
||
): Promise<void> {
|
||
await put<undefined>(ENDPOINTS.configFilterParsed(name), update);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Filter structured update / create / delete (Task 2.3)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Update a filter's editable fields via the structured endpoint.
|
||
*
|
||
* Writes only the supplied fields to the ``.local`` override. Fields set
|
||
* to ``null`` are cleared; omitted fields are left unchanged.
|
||
*
|
||
* @param name - Filter base name (e.g. ``"sshd"``)
|
||
* @param req - Partial update payload.
|
||
*/
|
||
export async function updateFilter(
|
||
name: string,
|
||
req: FilterUpdateRequest
|
||
): Promise<void> {
|
||
await put<undefined>(ENDPOINTS.configFilter(name), req);
|
||
}
|
||
|
||
/**
|
||
* Create a brand-new user-defined filter in ``filter.d/{name}.local``.
|
||
*
|
||
* @param req - Name and optional regex patterns.
|
||
* @returns The newly created FilterConfig.
|
||
*/
|
||
export async function createFilter(
|
||
req: FilterCreateRequest
|
||
): Promise<FilterConfig> {
|
||
return post<FilterConfig>(ENDPOINTS.configFilters, req);
|
||
}
|
||
|
||
/**
|
||
* Delete a filter's ``.local`` override file.
|
||
*
|
||
* Only custom ``.local``-only filters can be deleted. Attempting to delete a
|
||
* filter that is backed by a shipped ``.conf`` file returns 409.
|
||
*
|
||
* @param name - Filter base name.
|
||
*/
|
||
export async function deleteFilter(name: string): Promise<void> {
|
||
await del<undefined>(ENDPOINTS.configFilter(name));
|
||
}
|
||
|
||
/**
|
||
* Assign a filter to a jail by writing ``filter = {filter_name}`` to the
|
||
* jail's ``.local`` config file.
|
||
*
|
||
* @param jailName - Jail name.
|
||
* @param req - The filter to assign.
|
||
* @param reload - When ``true``, trigger a fail2ban reload after writing.
|
||
*/
|
||
export async function assignFilterToJail(
|
||
jailName: string,
|
||
req: AssignFilterRequest,
|
||
reload = false
|
||
): Promise<void> {
|
||
const url = reload
|
||
? `${ENDPOINTS.configJailFilter(jailName)}?reload=true`
|
||
: ENDPOINTS.configJailFilter(jailName);
|
||
await post<undefined>(url, req);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Filter discovery with active/inactive status (Task 2.1)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Fetch all filters from filter.d/ with active/inactive status.
|
||
*
|
||
* Active filters (those referenced by running jails) are returned first,
|
||
* followed by inactive ones. Both groups are sorted alphabetically.
|
||
*
|
||
* @returns FilterListResponse with all discovered filters and status.
|
||
*/
|
||
export async function fetchFilters(): Promise<FilterListResponse> {
|
||
return get<FilterListResponse>(ENDPOINTS.configFilters);
|
||
}
|
||
|
||
/**
|
||
* Fetch full parsed detail for a single filter with active/inactive status.
|
||
*
|
||
* @param name - Filter base name (e.g. "sshd" or "sshd.conf").
|
||
* @returns FilterConfig with active, used_by_jails, source_file populated.
|
||
*/
|
||
export async function fetchFilter(name: string): Promise<FilterConfig> {
|
||
return get<FilterConfig>(ENDPOINTS.configFilter(name));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Parsed action config (Task 3.2)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function fetchParsedAction(name: string): Promise<ActionConfig> {
|
||
return get<ActionConfig>(ENDPOINTS.configActionParsed(name));
|
||
}
|
||
|
||
export async function updateParsedAction(
|
||
name: string,
|
||
update: ActionConfigUpdate
|
||
): Promise<void> {
|
||
await put<undefined>(ENDPOINTS.configActionParsed(name), update);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Action discovery with active/inactive status (Task 3.1 / 3.2)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Fetch all actions from action.d/ with active/inactive status.
|
||
*
|
||
* Active actions (those referenced by running jails) are returned first,
|
||
* followed by inactive ones. Both groups are sorted alphabetically.
|
||
*
|
||
* @returns ActionListResponse with all discovered actions and status.
|
||
*/
|
||
export async function fetchActions(): Promise<ActionListResponse> {
|
||
return get<ActionListResponse>(ENDPOINTS.configActions);
|
||
}
|
||
|
||
/**
|
||
* Fetch full parsed detail for a single action.
|
||
*
|
||
* @param name - Action base name (e.g. "iptables" or "iptables.conf").
|
||
* @returns ActionConfig with active, used_by_jails, source_file populated.
|
||
*/
|
||
export async function fetchAction(name: string): Promise<ActionConfig> {
|
||
return get<ActionConfig>(ENDPOINTS.configAction(name));
|
||
}
|
||
|
||
/**
|
||
* Update an action's editable fields via the structured endpoint.
|
||
*
|
||
* Writes only the supplied fields to the ``.local`` override. Fields set
|
||
* to ``null`` are cleared; omitted fields are left unchanged.
|
||
*
|
||
* @param name - Action base name (e.g. ``"iptables"``)
|
||
* @param req - Partial update payload.
|
||
*/
|
||
export async function updateAction(
|
||
name: string,
|
||
req: ActionUpdateRequest
|
||
): Promise<void> {
|
||
await put<undefined>(ENDPOINTS.configAction(name), req);
|
||
}
|
||
|
||
/**
|
||
* Create a brand-new user-defined action in ``action.d/{name}.local``.
|
||
*
|
||
* @param req - Name and optional lifecycle commands.
|
||
* @returns The newly created ActionConfig.
|
||
*/
|
||
export async function createAction(
|
||
req: ActionCreateRequest
|
||
): Promise<ActionConfig> {
|
||
return post<ActionConfig>(ENDPOINTS.configActions, req);
|
||
}
|
||
|
||
/**
|
||
* Delete an action's ``.local`` override file.
|
||
*
|
||
* Only custom ``.local``-only actions can be deleted. Attempting to delete an
|
||
* action backed by a shipped ``.conf`` file returns 409.
|
||
*
|
||
* @param name - Action base name.
|
||
*/
|
||
export async function deleteAction(name: string): Promise<void> {
|
||
await del<undefined>(ENDPOINTS.configAction(name));
|
||
}
|
||
|
||
/**
|
||
* Assign an action to a jail by appending it to the jail's action list.
|
||
*
|
||
* @param jailName - Jail name.
|
||
* @param req - The action to assign with optional parameters.
|
||
* @param reload - When ``true``, trigger a fail2ban reload after writing.
|
||
*/
|
||
export async function assignActionToJail(
|
||
jailName: string,
|
||
req: AssignActionRequest,
|
||
reload = false
|
||
): Promise<void> {
|
||
const url = reload
|
||
? `${ENDPOINTS.configJailAction(jailName)}?reload=true`
|
||
: ENDPOINTS.configJailAction(jailName);
|
||
await post<undefined>(url, req);
|
||
}
|
||
|
||
/**
|
||
* Remove an action from a jail's action list.
|
||
*
|
||
* @param jailName - Jail name.
|
||
* @param actionName - Action base name to remove.
|
||
* @param reload - When ``true``, trigger a fail2ban reload after writing.
|
||
*/
|
||
export async function removeActionFromJail(
|
||
jailName: string,
|
||
actionName: string,
|
||
reload = false
|
||
): Promise<void> {
|
||
const url = reload
|
||
? `${ENDPOINTS.configJailActionName(jailName, actionName)}?reload=true`
|
||
: ENDPOINTS.configJailActionName(jailName, actionName);
|
||
await del<undefined>(url);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Parsed jail file config (Task 6.1 / 6.2)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function fetchParsedJailFile(filename: string): Promise<JailFileConfig> {
|
||
return get<JailFileConfig>(ENDPOINTS.configJailFileParsed(filename));
|
||
}
|
||
|
||
export async function updateParsedJailFile(
|
||
filename: string,
|
||
update: JailFileConfigUpdate
|
||
): Promise<void> {
|
||
await put<undefined>(ENDPOINTS.configJailFileParsed(filename), update);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Inactive jails (Stage 1)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Fetch all inactive jails from config files. */
|
||
export async function fetchInactiveJails(): Promise<InactiveJailListResponse> {
|
||
return get<InactiveJailListResponse>(ENDPOINTS.configJailsInactive);
|
||
}
|
||
|
||
/**
|
||
* Activate an inactive jail, optionally providing override values.
|
||
*
|
||
* @param name - The jail name.
|
||
* @param overrides - Optional parameter overrides (bantime, findtime, etc.).
|
||
*/
|
||
export async function activateJail(
|
||
name: string,
|
||
overrides?: ActivateJailRequest
|
||
): Promise<JailActivationResponse> {
|
||
return post<JailActivationResponse>(
|
||
ENDPOINTS.configJailActivate(name),
|
||
overrides ?? {}
|
||
);
|
||
}
|
||
|
||
/** Deactivate an active jail. */
|
||
export async function deactivateJail(
|
||
name: string
|
||
): Promise<JailActivationResponse> {
|
||
return post<JailActivationResponse>(
|
||
ENDPOINTS.configJailDeactivate(name),
|
||
undefined
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// fail2ban log viewer (Task 2)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Fetch the tail of the fail2ban daemon log file.
|
||
*
|
||
* @param lines - Number of tail lines to return (1–2000, default 200).
|
||
* @param filter - Optional plain-text substring; only matching lines returned.
|
||
*/
|
||
export async function fetchFail2BanLog(
|
||
lines?: number,
|
||
filter?: string,
|
||
): Promise<Fail2BanLogResponse> {
|
||
const params = new URLSearchParams();
|
||
if (lines !== undefined) params.set("lines", String(lines));
|
||
if (filter !== undefined && filter !== "") params.set("filter", filter);
|
||
const query = params.toString() ? `?${params.toString()}` : "";
|
||
return get<Fail2BanLogResponse>(`${ENDPOINTS.configFail2BanLog}${query}`);
|
||
}
|
||
|
||
/** Fetch fail2ban service health status with current log configuration. */
|
||
export async function fetchServiceStatus(): Promise<ServiceStatusResponse> {
|
||
return get<ServiceStatusResponse>(ENDPOINTS.configServiceStatus);
|
||
}
|