Add filter discovery endpoints with active/inactive status (Task 2.1)

- Add list_filters() and get_filter() to config_file_service.py:
  scans filter.d/, parses [Definition] + [Init] sections, merges .local
  overrides, and cross-references running jails to set active/used_by_jails
- Add FilterConfig.active, used_by_jails, source_file, has_local_override
  fields to the Pydantic model; add FilterListResponse and FilterNotFoundError
- Add GET /api/config/filters and GET /api/config/filters/{name} to config.py
- Remove the shadowed GET /api/config/filters list route from file_config.py;
  rename GET /api/config/filters/{name} raw variant to /filters/{name}/raw
- Update frontend: fetchFilterFiles() adapts FilterListResponse -> ConfFilesResponse;
  add fetchFilters() and fetchFilter() to api/config.ts; remove unused
  fetchFilterFiles/fetchActionFiles calls from useConfigActiveStatus
- Fix ConfigPageLogPath test mock to include fetchInactiveJails and related
  exports introduced by Stage 1
- Backend: 169 tests pass, mypy --strict clean, ruff clean
- Frontend: 63 tests pass, tsc --noEmit clean, eslint clean
This commit is contained in:
2026-03-13 16:48:27 +01:00
parent 8d9d63b866
commit 4c138424a5
14 changed files with 989 additions and 92 deletions

View File

@@ -15,6 +15,7 @@ import type {
ConfFileUpdateRequest,
FilterConfig,
FilterConfigUpdate,
FilterListResponse,
GlobalConfig,
GlobalConfigUpdate,
InactiveJailListResponse,
@@ -200,15 +201,27 @@ export async function setJailConfigFileEnabled(
}
// ---------------------------------------------------------------------------
// Filter files (Task 4d)
// 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> {
return get<ConfFilesResponse>(ENDPOINTS.configFilters);
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.configFilter(name));
return get<ConfFileContent>(ENDPOINTS.configFilterRaw(name));
}
export async function updateFilterFile(
@@ -264,6 +277,32 @@ export async function updateParsedFilter(
await put<undefined>(ENDPOINTS.configFilterParsed(name), update);
}
// ---------------------------------------------------------------------------
// 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)
// ---------------------------------------------------------------------------

View File

@@ -85,6 +85,7 @@ export const ENDPOINTS = {
`/config/jail-files/${encodeURIComponent(filename)}/parsed`,
configFilters: "/config/filters",
configFilter: (name: string): string => `/config/filters/${encodeURIComponent(name)}`,
configFilterRaw: (name: string): string => `/config/filters/${encodeURIComponent(name)}/raw`,
configFilterParsed: (name: string): string =>
`/config/filters/${encodeURIComponent(name)}/parsed`,
configActions: "/config/actions",

View File

@@ -99,12 +99,23 @@ vi.mock("../../api/config", () => ({
fetchFilterFile: vi.fn(),
updateFilterFile: vi.fn(),
createFilterFile: vi.fn(),
fetchFilters: vi.fn().mockResolvedValue({ filters: [], total: 0 }),
fetchFilter: vi.fn(),
fetchActionFiles: mockFetchActionFiles,
fetchActionFile: vi.fn(),
updateActionFile: vi.fn(),
createActionFile: vi.fn(),
previewLog: vi.fn(),
testRegex: vi.fn(),
fetchInactiveJails: vi.fn().mockResolvedValue({ jails: [], total: 0 }),
activateJail: vi.fn(),
deactivateJail: vi.fn(),
fetchParsedFilter: vi.fn(),
updateParsedFilter: vi.fn(),
fetchParsedAction: vi.fn(),
updateParsedAction: vi.fn(),
fetchParsedJailFile: vi.fn(),
updateParsedJailFile: vi.fn(),
}));
vi.mock("../../api/jails", () => ({

View File

@@ -23,6 +23,10 @@ const mockConfig: FilterConfig = {
maxlines: null,
datepattern: null,
journalmatch: null,
active: false,
used_by_jails: [],
source_file: "/etc/fail2ban/filter.d/sshd.conf",
has_local_override: false,
};
function renderForm(name: string) {

View File

@@ -12,11 +12,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchJails } from "../api/jails";
import {
fetchActionFiles,
fetchFilterFiles,
fetchJailConfigs,
} from "../api/config";
import { fetchJailConfigs } from "../api/config";
import type { JailConfig } from "../types/config";
import type { JailSummary } from "../types/jail";
@@ -44,8 +40,10 @@ export interface UseConfigActiveStatusResult {
// ---------------------------------------------------------------------------
/**
* Fetch jails, jail configs, filter files, and action files in parallel and
* derive active-status sets for each config type.
* Fetch jails and jail configs, then derive active-status sets for each
* config type. Active status is computed from live jail data; filter and
* action files are not fetched directly because their active state is already
* available via {@link fetchFilters} / {@link fetchActions}.
*
* @returns Active-status sets, loading flag, error, and refresh function.
*/
@@ -69,10 +67,8 @@ export function useConfigActiveStatus(): UseConfigActiveStatusResult {
Promise.all([
fetchJails(),
fetchJailConfigs(),
fetchFilterFiles(),
fetchActionFiles(),
])
.then(([jailsResp, configsResp, _filterResp, _actionResp]) => {
.then(([jailsResp, configsResp]) => {
if (ctrl.signal.aborted) return;
const summaries: JailSummary[] = jailsResp.jails;

View File

@@ -274,6 +274,29 @@ export interface FilterConfig {
datepattern: string | null;
/** journalmatch, or null. */
journalmatch: string | null;
/**
* True when this filter is referenced by at least one currently running jail.
* Defaults to false when the status was not computed (e.g. /parsed endpoint).
*/
active: boolean;
/**
* Names of currently enabled jails that reference this filter.
* Empty when active is false.
*/
used_by_jails: string[];
/** Absolute path to the .conf source file. Empty string when not computed. */
source_file: string;
/** True when a .local override file exists alongside the base .conf. */
has_local_override: boolean;
}
/**
* Response for GET /api/config/filters.
* Lists all discovered filters with active/inactive status.
*/
export interface FilterListResponse {
filters: FilterConfig[];
total: number;
}
/**