From c34b71f5d797e97b7e9f296eefb32d9aab97d52e Mon Sep 17 00:00:00 2001 From: malmert Date: Wed, 11 Feb 2026 20:39:43 +0100 Subject: [PATCH] firewall api and FE --- backend/src/api/nft_manager.py | 75 ++++++++-- frontend/src/api/apiClient.ts | 67 +++------ frontend/src/pages/Firewall.tsx | 239 ++++++++++++++++++++++++++++++++ frontend/src/types/firewall.ts | 24 ++++ frontend/src/types/scripting.ts | 11 ++ 5 files changed, 357 insertions(+), 59 deletions(-) create mode 100644 frontend/src/pages/Firewall.tsx create mode 100644 frontend/src/types/firewall.ts create mode 100644 frontend/src/types/scripting.ts diff --git a/backend/src/api/nft_manager.py b/backend/src/api/nft_manager.py index e6e0d44..bb342ae 100644 --- a/backend/src/api/nft_manager.py +++ b/backend/src/api/nft_manager.py @@ -1,5 +1,5 @@ # app.py -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Tuple, Union from fastapi import FastAPI, APIRouter, HTTPException, status from pydantic import BaseModel, Field import logging @@ -119,7 +119,7 @@ class NftManager: try: parsed = json.loads(s) # parsed may be the whole ruleset (nftables list) or a list; find any "rule" objects - rule_lines = [] + rule_lines: List[str] = [] # parsed might be dict with "nftables" or a list of records records = parsed.get("nftables") if isinstance(parsed, dict) else parsed if not isinstance(records, list): @@ -186,33 +186,79 @@ router = APIRouter(prefix="/firewall", tags=["firewall"]) mgr = NftManager() -# ---------- Request/Response models ---------- +# ---------- Request/Response models (strongly typed) ---------- class RawCmdRequest(BaseModel): - cmd: str = Field(description="Textual nft command to execute", example="add rule inet filter input ip saddr 10.0.0.0/8 drop") + cmd: str = Field(..., description="Textual nft command to execute", example="add rule inet filter input ip saddr 10.0.0.0/8 drop") class ExecResult(BaseModel): - rc: int = Field(description="Return code from nft execution", example=0) - stdout: Optional[str] = Field(None, description="Standard output from nft", example="") + rc: int = Field(..., description="Return code from nft execution", example=0) + stdout: Optional[str] = Field(None, description="Standard output from nft", example="") stderr: Optional[str] = Field(None, description="Standard error from nft", example="") class Config: schema_extra = {"example": {"rc": 0, "stdout": "ok", "stderr": ""}} -# ruleset may be a parsed JSON object (dict/list) OR a raw string (text) OR null -RulesetValue = Union[Dict[str, Any], List[Any], str, None] +# --- Strong models returned to frontend --- +class RuleOut(BaseModel): + handle: Optional[int] = Field(None, description="The rule handle (unique per rule), if available", example=3) + expr: Any = Field(..., description="Machine-readable nft expression (original nft JSON expr).") + text: str = Field(..., description="Deterministic short display string derived from expr", example="ip protocol icmp drop") + position: Optional[Any] = Field(None, description="Optional position metadata from nft if present") + comment: Optional[str] = Field(None, description="Optional comment attached to the rule") + + class Config: + schema_extra = { + "example": { + "handle": 3, + "expr": [{"match": {"left": {"payload": {"protocol": "ip", "field": "protocol"}}, "op": "==", "right": "icmp"}}, {"drop": None}], + "text": "ip protocol icmp drop", + "position": None, + "comment": None, + } + } + + +class ChainOut(BaseModel): + name: str = Field(..., description="Chain name", example="forward") + rules: List[RuleOut] = Field(..., description="Rules in this chain (ordered)") + + class Config: + schema_extra = {"example": {"name": "forward", "rules": []}} + + +class TableOut(BaseModel): + family: str = Field(..., description="Table family (inet/bridge/ipv4/...)") + name: str = Field(..., description="Table name", example="filter") + chains: List[ChainOut] = Field(..., description="Chains in this table") + + class Config: + schema_extra = {"example": {"family": "bridge", "name": "filter", "chains": []}} + + +class RulesetModel(BaseModel): + tables: List[TableOut] = Field(..., description="Top-level tables list") + + class Config: + schema_extra = {"example": {"tables": []}} + + +# ruleset may be typed RulesetModel or raw textual string (fallback) +RulesetValue = Optional[Union[RulesetModel, str]] class RulesetOut(BaseModel): ruleset: RulesetValue = Field( - description="parsed JSON ruleset (dict/list), or the raw textual ruleset as a string.", + None, + description="Parsed, strongly-typed ruleset (RulesetModel) or raw textual ruleset string if JSON is unavailable.", ) # ---------- Helpers to convert to desired shape ---------- _handle_re = re.compile(r"\s+#\s*handle\s+\d+\s*$") + def rule_text_from_expr(expr: Any) -> str: """ Deterministic serializer to produce a compact UI-friendly string from expr list. @@ -278,7 +324,7 @@ def build_predictable_ruleset(nft_json: Dict[str, Any]) -> Dict[str, Any]: items = nft_json.get("nftables", []) if isinstance(nft_json, dict) else (nft_json or []) # Build intermediate map: (family, table) -> {family, name, chains: {chain_name: {"name", "rules":[]}}} - tables: Dict[tuple, Dict[str, Any]] = {} + tables: Dict[Tuple[str, str], Dict[str, Any]] = {} for rec in items: if "table" in rec: t = rec["table"] @@ -332,7 +378,8 @@ def build_predictable_ruleset(nft_json: Dict[str, Any]) -> Dict[str, Any]: @router.get("/rules", response_model=RulesetOut, summary="List ruleset") def list_rules(): """ - Returns the ruleset in a stable, predictable JSON shape derived from `nft -j list ruleset`. + Returns the ruleset in a stable, strongly-typed JSON shape derived from `nft -j list ruleset`. + Structure: { "ruleset": { "tables": [ { "family": ..., "name": ..., "chains": [ { "name": ..., "rules": [ { "handle", "expr", "text" } ] } ] } ] } } @@ -345,10 +392,12 @@ def list_rules(): except NftError as e: logger.debug("could not obtain nft JSON ruleset: %s", e) text = mgr.list_rules() - return {"ruleset": text.strip() if text is not None else None} + return RulesetOut(ruleset=text.strip() if text is not None else None) custom = build_predictable_ruleset(nft_json) - return {"ruleset": custom} + # Validate/construct Pydantic model so OpenAPI + client libs get accurate typing + ruleset_model = RulesetModel.parse_obj(custom) + return RulesetOut(ruleset=ruleset_model) except NftError as e: logger.exception("list_rules failed") raise HTTPException(status_code=500, detail=str(e)) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index be37f05..038500f 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -1,5 +1,6 @@ // src/apiClient.ts import axios from "axios"; +import { RulesetModel } from "../types/firewall"; import { BridgeCreateRequest, BridgeInfo, @@ -8,6 +9,7 @@ import { InterfaceInfo, RouteInfo, } from "../types/network"; +import { EnableRequest, ScriptInfo } from "../types/scripting"; import { SnifferStatusResponse, } from "../types/sniffer"; @@ -36,30 +38,6 @@ api.interceptors.response.use( } ); -/** - * Local lightweight types for a few endpoints where a dedicated project type - * wasn't imported above. If you already have these in your codebase, replace - * these with imports instead. - */ -export type RawCmdRequest = { - cmd: string; -}; - -export type RulesetOut = { - ruleset: string; -}; - -export type ScriptInfo = { - name: string; - path: string; -}; - -export type EnableRequest = { - qnum: number; - service_name?: string | null; - extra_args?: string | null; - enable_at_boot?: boolean | null; -}; /* ------------------------- Basic endpoints @@ -150,33 +128,30 @@ export const fetchPackets = async (limit = 100): Promise => { Firewall ------------------------- */ -export const listFirewallRules = async (): Promise => { - const res = await api.get("/firewall/rules"); +/** + * GET /firewall/rules + * Returns: { ruleset: RulesetModel | string | null } + * - If the server returns a raw textual fallback (string), the caller should handle it. + */ +export const fetchRuleset = async (): Promise<{ ruleset: RulesetModel | string | null }> => { + const res = await api.get<{ ruleset: RulesetModel | string | null }>("/firewall/rules"); return res.data; }; -export const createFirewallRuleText = async (req: RawCmdRequest): Promise => { - // Executes textual nft command, returns raw stdout (201 expected per spec) - const res = await api.post("/firewall/rules", req); - return res.data; -}; - -export const deleteFirewallRule = async ( +/** + * DELETE /firewall/rules/{handle}?family=...&table=...&chain=... + * On success the backend returns 204 No Content. This function resolves to void. + */ +export const deleteRule = async ( handle: number, - options?: { family?: string; table?: string; chain?: string } + family: string, + table: string, + chain: string ): Promise => { - // returns 204 on success (no content) - const params: Record = {}; - if (options?.family) params.family = options.family; - if (options?.table) params.table = options.table; - if (options?.chain) params.chain = options.chain; - - await api.delete(`/firewall/rules/${handle}`, { params }); -}; - -export const execFirewallRaw = async (req: RawCmdRequest): Promise => { - // Execute arbitrary textual nft command and return {rc, stdout, stderr} - const res = await api.post("/firewall/raw", req); + const res = await api.delete(`/firewall/rules/${encodeURIComponent(String(handle))}`, { + params: { family, table, chain }, + }); + // axios resolves non-2xx as reject; server uses 204 No Content so nothing to return return res.data; }; diff --git a/frontend/src/pages/Firewall.tsx b/frontend/src/pages/Firewall.tsx new file mode 100644 index 0000000..355a7fb --- /dev/null +++ b/frontend/src/pages/Firewall.tsx @@ -0,0 +1,239 @@ +// src/components/RulesView.tsx +import { DeleteOutlined, EyeOutlined, ReloadOutlined } from '@ant-design/icons'; +import { Button, Card, Collapse, Drawer, Empty, Popconfirm, Space, Spin, Table, Typography, message } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { deleteRule, fetchRuleset } from '../api/apiClient'; +import { ChainOut, RulesetModel, TableOut } from '../types/firewall'; + +const { Panel } = Collapse; +const { Paragraph, Text } = Typography; + +interface RuleRow { + key: string; + handle?: number | null; + text: string; + expr: any; + comment?: string | null; + position?: any; + // context for delete + family: string; + table: string; + chain: string; +} + +/** + * Render rules inside an antd Table. The UI layout: + * - Collapse per table (title = family:name) + * - Inside each table -> collapse per chain (only chains that have rules) + * - Each chain shows a Table of rules with delete and inspect actions + */ +export const RulesView: React.FC<{ apiBase?: string }> = ({ apiBase = '' }) => { + const [loading, setLoading] = useState(false); + const [ruleset, setRuleset] = useState(null); + const [rawFallback, setRawFallback] = useState(null); + const [exprDrawerVisible, setExprDrawerVisible] = useState(false); + const [exprForView, setExprForView] = useState(null); + const [selectedRuleContext, setSelectedRuleContext] = useState<{ + family: string; + table: string; + chain: string; + handle?: number | null; + } | null>(null); + + const fetchRules = useCallback(async () => { + setLoading(true); + try { + const data = await fetchRuleset(); + if (!data || data.ruleset === null) { + setRuleset({ tables: [] }); + setRawFallback(null); + } else if (typeof data.ruleset === 'string') { + // fallback textual ruleset + setRawFallback(data.ruleset); + setRuleset({ tables: [] }); + } else { + setRuleset(data.ruleset as RulesetModel); + setRawFallback(null); + } + } catch (err: any) { + message.error(`Failed to load ruleset: ${err?.message ?? err}`); + setRuleset({ tables: [] }); + setRawFallback(null); + } finally { + setLoading(false); + } + }, [apiBase]); + + useEffect(() => { + fetchRules(); + }, [fetchRules]); + + const onDelete = useCallback( + async (row: RuleRow) => { + if (!row.handle) { + message.error('Rule has no handle and cannot be deleted via API.'); + return; + } + setLoading(true); + try { + await deleteRule(row.handle, row.family, row.table, row.chain); + message.success('Rule deleted'); + await fetchRules(); + } catch (err: any) { + message.error(`Delete failed: ${err?.message ?? err}`); + } finally { + setLoading(false); + } + }, + [apiBase, fetchRules], + ); + + const openExprViewer = useCallback((expr: any) => { + setExprForView(expr); + setExprDrawerVisible(true); + }, []); + + const closeExprViewer = useCallback(() => { + setExprForView(null); + setExprDrawerVisible(false); + }, []); + + // columns for rule table + const columns: ColumnsType = useMemo( + () => [ + { + title: 'Handle', + dataIndex: 'handle', + key: 'handle', + width: 100, + render: (val) => val ?? '-', + }, + { + title: 'Rule', + dataIndex: 'text', + key: 'text', + render: (txt: string, rec: RuleRow) => ( + + {txt} + + ), + }, + { + title: 'Comment', + dataIndex: 'comment', + key: 'comment', + width: 200, + render: (c) => (c ? {c} : null), + }, + { + title: 'Actions', + key: 'actions', + width: 160, + align: 'right', + render: (_, rec) => ( + + + onDelete(rec)} okText="Delete" cancelText="Cancel"> + + + + ), + }, + ], + [onDelete, openExprViewer], + ); + + // flatten rules for each chain into a table datasource + const renderChainTable = (table: TableOut, chain: ChainOut) => { + const data: RuleRow[] = chain.rules.map((r, idx) => ({ + key: `${table.family}:${table.name}:${chain.name}:${String(r.handle ?? idx)}`, + handle: r.handle ?? null, + text: r.text, + expr: r.expr, + comment: r.comment ?? null, + position: r.position, + family: table.family, + table: table.name, + chain: chain.name, + })); + + return ( + rec.key} /> + ); + }; + + // UI when JSON is not available + if (!loading && rawFallback) { + return ( + + + Ruleset (raw) + {rawFallback} + + + + ); + } + + return ( + + + + } + > + {loading ? ( +
+ +
+ ) : ruleset && ruleset.tables.length > 0 ? ( + + {ruleset.tables.map((t) => { + // only show chains that have rules + const chainsWithRules = t.chains.filter((c) => c.rules && c.rules.length > 0); + if (chainsWithRules.length === 0) return null; + return ( + + + {chainsWithRules.map((c) => ( + + {renderChainTable(t, c)} + + ))} + + + ); + })} + + ) : ( + + )} + + + + Expression (raw JSON) + +
{JSON.stringify(exprForView, null, 2)}
+
+
+ ); +}; + +export default RulesView; diff --git a/frontend/src/types/firewall.ts b/frontend/src/types/firewall.ts new file mode 100644 index 0000000..a6badf7 --- /dev/null +++ b/frontend/src/types/firewall.ts @@ -0,0 +1,24 @@ +export type Expr = any; // raw nft expr JSON (opaque to frontend) + +export interface RuleOut { + handle?: number | null; + expr: Expr; + text: string; + position?: any; + comment?: string | null; +} + +export interface ChainOut { + name: string; + rules: RuleOut[]; +} + +export interface TableOut { + family: string; + name: string; + chains: ChainOut[]; +} + +export interface RulesetModel { + tables: TableOut[]; +} \ No newline at end of file diff --git a/frontend/src/types/scripting.ts b/frontend/src/types/scripting.ts new file mode 100644 index 0000000..33169e9 --- /dev/null +++ b/frontend/src/types/scripting.ts @@ -0,0 +1,11 @@ +export type ScriptInfo = { + name: string; + path: string; +}; + +export type EnableRequest = { + qnum: number; + service_name?: string | null; + extra_args?: string | null; + enable_at_boot?: boolean | null; +}; \ No newline at end of file