diff --git a/backend/src/api/nft_manager.py b/backend/src/api/nft_manager.py index bb342ae..a4d9848 100644 --- a/backend/src/api/nft_manager.py +++ b/backend/src/api/nft_manager.py @@ -181,7 +181,7 @@ class NftManager: # ---------- FastAPI + Router ---------- -app = FastAPI(title="Unrestricted nftables API (textual only)") +app = FastAPI(title="Unrestricted nftables API (json create)") router = APIRouter(prefix="/firewall", tags=["firewall"]) mgr = NftManager() @@ -208,40 +208,40 @@ class RuleOut(BaseModel): 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") + +# ---------- New: CreateRuleRequest (JSON, expr required) ---------- +class CreateRuleRequest(BaseModel): + family: str = Field(..., description="Table family (e.g. inet, bridge, ip, ip6)", example="bridge") + table: str = Field(..., description="Table name (e.g. filter)", example="filter") + chain: str = Field(..., description="Chain name (e.g. forward)", example="forward") + expr: Any = Field(..., description="nft JSON expression (machine-readable). This field is required for JSON rule creation.") + position: Optional[Any] = Field(None, description="Optional position metadata (if you want to specify insertion position)") + comment: Optional[str] = Field(None, description="Optional comment") + class Config: - schema_extra = {"example": {"tables": []}} + schema_extra = { + "example": { + "family": "bridge", + "table": "filter", + "chain": "forward", + "expr": [{"match": {"left": {"payload": {"protocol": "ip", "field": "protocol"}}, "op": "==", "right": "icmp"}}, {"drop": None}], + } + } # ruleset may be typed RulesetModel or raw textual string (fallback) @@ -263,6 +263,7 @@ def rule_text_from_expr(expr: Any) -> str: """ Deterministic serializer to produce a compact UI-friendly string from expr list. Covers common constructs; falls back to JSON dump for unknown constructs. + (Used for display in GET /rules). """ if expr is None: return "" @@ -373,6 +374,101 @@ def build_predictable_ruleset(nft_json: Dict[str, Any]) -> Dict[str, Any]: return result +# ---------- Helpers to render expr -> textual nft (best-effort) ---------- +def expr_to_text(expr: Any) -> Optional[str]: + """ + Best-effort renderer that converts a typical nft JSON expr (list) into a textual + fragment suitable to append to 'add rule ...'. + Returns None when it cannot deterministically render the provided expr. + Supported cases (common): + - [{'match': {'left': {'payload': {'protocol':'ip','field':'protocol'}}, 'op':'==', 'right':'icmp'}}, {'drop': None}] + -> 'ip protocol icmp drop' + - payload / tcp / udp / counter / accept + - simple dicts where keys are 'drop' | 'accept' | 'counter' + This intentionally does not attempt to support every nft JSON construct. + """ + if expr is None: + return "" + if isinstance(expr, str): + return expr + if not isinstance(expr, list): + # unsupported top-level type + return None + + parts: List[str] = [] + for element in expr: + if isinstance(element, dict): + # handle drop/accept/counter directly + if "drop" in element: + parts.append("drop") + continue + if "accept" in element: + parts.append("accept") + continue + if "counter" in element: + parts.append("counter") + continue + + # match left/right payload equals -> ip protocol icmp, or ip saddr/daddr + if "match" in element: + m = element["match"] + left = m.get("left") + right = m.get("right") + # payload matches + if isinstance(left, dict) and "payload" in left and isinstance(right, (str, int)): + p = left["payload"] + prot = p.get("protocol") + field = p.get("field") + # common: protocol field match (protocol == icmp) + if prot and field and isinstance(right, str): + # ip vs ip6 decision is left to the frontend; here we render 'ip protocol icmp' (works for many setups) + if field == "protocol": + parts.append(f"{prot} {field} {right}") + continue + # payload might be l4 ports etc; produce generic payload(...) token + parts.append(f"payload({prot}.{field}) {right}") + continue + # fallback for match: try to stringify right + parts.append("match") + continue + + # payload shorthand + if "payload" in element: + p = element["payload"] + prot = p.get("protocol") + field = p.get("field") + if prot and field: + parts.append(f"payload({prot}.{field})") + continue + parts.append("payload") + continue + + # tcp/udp as nested dicts sometimes appear + if "tcp" in element or "udp" in element: + proto = "tcp" if "tcp" in element else "udp" + val = element.get(proto) + # attempt to detect dport/sport keys + if isinstance(val, dict): + if "dport" in val: + parts.append(f"{proto} dport {val['dport']}") + continue + if "sport" in val: + parts.append(f"{proto} sport {val['sport']}") + continue + parts.append(proto) + continue + + # cmp/binary operators etc — not supported deterministically + # return None to indicate we can't safely render this expr + return None + else: + # non-dict token (string/number) + parts.append(str(element)) + + # join tokens + return " ".join(parts).strip() + + # ---------- Routes ---------- @router.get("/rules", response_model=RulesetOut, summary="List ruleset") @@ -395,7 +491,6 @@ def list_rules(): return RulesetOut(ruleset=text.strip() if text is not None else None) custom = build_predictable_ruleset(nft_json) - # 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: @@ -406,25 +501,45 @@ def list_rules(): raise HTTPException(status_code=500, detail=str(e)) -@router.post("/rules", response_model=ExecResult, status_code=status.HTTP_201_CREATED, summary="Execute textual rule command") -def create_rule_text(req: RawCmdRequest): +@router.post("/rules", response_model=ExecResult, status_code=status.HTTP_201_CREATED, summary="Create rule (JSON, expr required)") +def create_rule_json(req: CreateRuleRequest): """ - Execute a textual nft command (convenience, returns structured exec result). - Example: add rule inet filter input ip saddr 10.0.0.0/8 drop + Create a rule from JSON. + Preferred usage: provide `expr` (nft JSON expr). Server attempts to render it to textual nft. + If `expr` can't be deterministically rendered, the server returns 400 instructing the client + to use POST /firewall/raw for raw textual commands. """ try: - res = mgr.cmd(req.cmd) + family = req.family + table = req.table + chain = req.chain + + if req.expr is None: + raise NftError("field 'expr' is required for JSON rule creation") + + rendered = expr_to_text(req.expr) + if rendered is None: + # cannot render - instruct client to use textual API + raise NftError( + "cannot render provided 'expr' to textual nft syntax. " + "Please use POST /firewall/raw to execute the textual nft command." + ) + expr_text = rendered + + # construct final add rule command + cmd = f"add rule {family} {table} {chain} {expr_text}" + # execute + res = mgr.cmd(cmd) rc = int(res.get("rc", -1) or -1) if rc != 0: - # Bad request: command failed (client-provided textual command) - # Return 400 with the stderr detail - raise NftError(f"cmd failed rc={rc}: {res.get('stderr')}") + # return 400 to indicate client-provided rule failed + raise NftError(f"create rule failed rc={rc}: {res.get('stderr')}") return ExecResult(rc=rc, stdout=res.get("stdout"), stderr=res.get("stderr")) except NftError as e: - logger.warning("create_rule_text failed: %s", e) + logger.warning("create_rule_json failed: %s", e) raise HTTPException(status_code=400, detail=str(e)) except Exception as e: - logger.exception("create_rule_text internal error") + logger.exception("create_rule_json internal error") raise HTTPException(status_code=500, detail=str(e)) @@ -437,7 +552,6 @@ def delete_rule(handle: int, family: str = "inet", table: str = "filter", chain: """ try: mgr.delete_rule_by_handle_text(family=family, table=table, chain=chain, handle=handle) - # 204 No Content — nothing to return except ValueError as e: logger.warning("delete_rule client error: %s", e) raise HTTPException(status_code=400, detail=str(e)) diff --git a/frontend/src/Layout/Sidebar.tsx b/frontend/src/Layout/Sidebar.tsx index 2856d7b..5a773d1 100644 --- a/frontend/src/Layout/Sidebar.tsx +++ b/frontend/src/Layout/Sidebar.tsx @@ -18,6 +18,7 @@ const menuItems = [ icon: , label: 'Scripting', }, + { key: PATHS.FIREWALL, icon: , label: 'Firewall' }, { key: '/about', icon: , label: 'About' }, { key: '/settings', icon: , label: 'Settings' }, ]; diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 038500f..fde8e3e 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -1,6 +1,6 @@ // src/apiClient.ts import axios from "axios"; -import { RulesetModel } from "../types/firewall"; +import { CreateRuleRequest, ExecResult, RulesetModel } from "../types/firewall"; import { BridgeCreateRequest, BridgeInfo, @@ -155,6 +155,14 @@ export const deleteRule = async ( return res.data; }; +/** + * createRuleJson - POST /firewall/rules + * Body: CreateRuleRequest (must include expr) + */ +export const createRuleJson = async (req: CreateRuleRequest): Promise => { + const res = await api.post("/firewall/rules", req); + return res.data; +}; /* ------------------------- Scripts ------------------------- */ diff --git a/frontend/src/appRouter.tsx b/frontend/src/appRouter.tsx index e94dabf..ac5bd1c 100644 --- a/frontend/src/appRouter.tsx +++ b/frontend/src/appRouter.tsx @@ -1,6 +1,7 @@ // src/AppRouter.tsx import { Navigate, Route, Routes } from 'react-router-dom'; import App from './App'; // your layout component (has ) +import { Firewall } from './pages/Firewall'; import Home from './pages/Home'; import Network from './pages/Network'; import Scripting from './pages/Scripting'; @@ -20,6 +21,7 @@ export default function AppRouter() { } /> } /> } /> + } /> {/* Fallback (renders inside layout too) */} } /> diff --git a/frontend/src/components/FirewallRuleBuilder.tsx b/frontend/src/components/FirewallRuleBuilder.tsx new file mode 100644 index 0000000..bba94d6 --- /dev/null +++ b/frontend/src/components/FirewallRuleBuilder.tsx @@ -0,0 +1,576 @@ +// src/components/RuleBuilder.tsx +import { CopyOutlined, PlusOutlined } from '@ant-design/icons'; +import { + Button, + Card, + Col, + Divider, + Form, + Input, + InputNumber, + message, + Modal, + Radio, + Row, + Select, + Space, + Tooltip, + Typography, +} from 'antd'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { createRuleJson, fetchRuleset } from '../api/apiClient'; +import { Expr, TableOut } from '../types/firewall'; + +const { Option } = Select; +const { TextArea } = Input; +const { Title, Text } = Typography; + +interface RuleBuilderProps { + onCreated?: () => Promise | void; +} + +/** + * Helper: create an nft JSON 'expr' array from form values. + * The produced expr is intentionally conservative and designed to be renderable by the backend's renderer. + */ +function buildExprFromValues(values: any): Expr[] { + const expr: Expr[] = []; + + // protocol + const protoChoice = values.protocolChoice ?? 'preset'; + if (protoChoice === 'preset') { + const preset = values.protocolPreset; + if (preset === 'icmp') { + expr.push({ + match: { + left: { payload: { protocol: 'ip', field: 'protocol' } }, + op: '==', + right: 'icmp', + }, + }); + } else if (preset === 'icmpv6') { + expr.push({ + match: { + left: { payload: { protocol: 'ip6', field: 'nexthdr' } }, + op: '==', + right: 'icmpv6', + }, + }); + } else if (preset === 'tcp') { + // meta l4proto tcp can be expressed as a 'match' fallback, but backend handles tcp dicts for ports + // include a simple token so renderer can show "tcp" + expr.push({ tcp: {} }); + } else if (preset === 'udp') { + expr.push({ udp: {} }); + } + } else { + // custom protocol — the UI accepts free text; attempt to produce a match if the user entered "icmp" etc. + const custom = (values.protocolCustom || '').trim(); + if (custom) { + // simple heuristics + if (/^icmpv6$/i.test(custom)) { + expr.push({ + match: { + left: { payload: { protocol: 'ip6', field: 'nexthdr' } }, + op: '==', + right: 'icmpv6', + }, + }); + } else if (/^icmp$/i.test(custom)) { + expr.push({ + match: { + left: { payload: { protocol: 'ip', field: 'protocol' } }, + op: '==', + right: 'icmp', + }, + }); + } else if (/tcp/i.test(custom)) { + expr.push({ tcp: {} }); + } else if (/udp/i.test(custom)) { + expr.push({ udp: {} }); + } else { + // fallback: include as generic token (string) -- backend may not accept this + expr.push(custom); + } + } + } + + // source/destination addresses (encoded as payload matches) + if (values.saddr) { + expr.push({ + match: { + left: { payload: { protocol: 'ip', field: 'saddr' } }, + op: '==', + right: values.saddr, + }, + }); + } + if (values.daddr) { + expr.push({ + match: { + left: { payload: { protocol: 'ip', field: 'daddr' } }, + op: '==', + right: values.daddr, + }, + }); + } + + // ports for tcp/udp - encode with tcp/udp dicts if provided + if (values.sport) { + // heuristics: if protocol preset is udp or custom mentions udp -> use udp + const useUdp = + values.protocolPreset === 'udp' || + (values.protocolChoice === 'custom' && /(udp)/i.test(values.protocolCustom || '')); + const proto = useUdp ? 'udp' : 'tcp'; + const obj: any = {}; + obj[proto] = { sport: Number(values.sport) }; + expr.push(obj); + } + if (values.dport) { + const useUdp = + values.protocolPreset === 'udp' || + (values.protocolChoice === 'custom' && /(udp)/i.test(values.protocolCustom || '')); + const proto = useUdp ? 'udp' : 'tcp'; + const obj: any = {}; + obj[proto] = { dport: Number(values.dport) }; + expr.push(obj); + } + + // advanced free-text: we include as a string token so the backend can either render or reject + if (values.advanced) { + // try to include as raw JSON if looks like JSON, else include as string token + const adv = values.advanced.trim(); + try { + const parsed = JSON.parse(adv); + // if parsed is an object or array, append it directly + expr.push(parsed); + } catch { + // push as raw string token (backend may fail to render — user can use Raw) + expr.push(adv); + } + } + + // action: drop/accept/reject (we encode as dicts) + const action = values.action || 'drop'; + if (action === 'drop') expr.push({ drop: null }); + else if (action === 'accept') expr.push({ accept: null }); + else if (action === 'reject') expr.push({ reject: null }); // nft supports 'reject' textual; JSON might differ, backend may reject + + return expr; +} + +/** + * Deterministic short textual serializer for expr (for preview) + * Mirrors backend's serializer heuristics so preview matches server-side text generation. + */ +function textFromExpr(expr: Expr): string { + if (expr == null) return ''; + if (Array.isArray(expr)) { + const tokens: string[] = []; + for (const part of expr) { + if (typeof part === 'string') { + tokens.push(part); + } else if (part && typeof part === 'object') { + if ('match' in part) { + const m = (part as any).match; + const left = m.left; + const right = m.right; + if (left && left.payload && right !== undefined) { + const p = left.payload; + const prot = p.protocol; + const field = p.field; + if (prot && field && typeof right === 'string') { + tokens.push(`${prot} ${field} ${right}`); + continue; + } + tokens.push('match'); + continue; + } + tokens.push('match'); + } else if ('payload' in part) { + const p = (part as any).payload; + const prot = p.protocol; + const field = p.field; + tokens.push(`payload(${prot}.${field})`); + } else if ('tcp' in part) { + const v = (part as any).tcp; + if (v && v.dport) tokens.push(`tcp dport ${v.dport}`); + else if (v && v.sport) tokens.push(`tcp sport ${v.sport}`); + else tokens.push('tcp'); + } else if ('udp' in part) { + const v = (part as any).udp; + if (v && v.dport) tokens.push(`udp dport ${v.dport}`); + else if (v && v.sport) tokens.push(`udp sport ${v.sport}`); + else tokens.push('udp'); + } else if ('drop' in part) { + tokens.push('drop'); + } else if ('accept' in part) { + tokens.push('accept'); + } else if ('counter' in part) { + tokens.push('counter'); + } else { + const keys = Object.keys(part).sort().join('+'); + tokens.push(keys); + } + } else { + tokens.push(String(part)); + } + } + return tokens.join(' '); + } + return String(expr); +} + +export const RuleBuilder: React.FC = ({ onCreated }) => { + const [loading, setLoading] = useState(false); + const [tables, setTables] = useState([]); + const [form] = Form.useForm(); + const [preview, setPreview] = useState(''); + const [exprPreview, setExprPreview] = useState(null); + + // fetch tables/chains on mount + useEffect(() => { + setLoading(true); + fetchRuleset() + .then((res) => { + if (!res || res.ruleset === null) { + setTables([]); + } else if (typeof res.ruleset === 'string') { + setTables([]); + } else { + setTables(res.ruleset.tables ?? []); + } + }) + .catch((err) => { + console.warn('fetchRuleset failed:', err); + message.warning('Could not fetch tables/chains; you can still create rules manually.'); + setTables([]); + }) + .finally(() => setLoading(false)); + }, []); + + const tableOptions = useMemo( + () => tables.map((t) => ({ family: t.family, name: t.name, label: `${t.family}:${t.name}` })), + [tables], + ); + + // set initial defaults + useEffect(() => { + if (tableOptions.length > 0) { + const first = tableOptions[0]; + form.setFieldsValue({ + tableSelect: `${first.family}:${first.name}`, + protocolChoice: 'preset', + protocolPreset: 'icmp', + action: 'drop', + }); + } else { + form.setFieldsValue({ + tableSelect: '__manual__', + protocolChoice: 'preset', + protocolPreset: 'icmp', + action: 'drop', + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tableOptions]); + + // update previews when form changes + const updatePreviews = useCallback(() => { + const vals = form.getFieldsValue(); + const expr = buildExprFromValues(vals); + setExprPreview(expr); + setPreview(textFromExpr(expr)); + }, [form]); + + useEffect(() => { + updatePreviews(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // keep preview updated on values change + const onValuesChange = useCallback(() => { + updatePreviews(); + }, [updatePreviews]); + + // submit handler: build expr and call createRuleJson + const handleCreate = useCallback( + async (values: any) => { + const tableSelect = values.tableSelect; + let family: string; + let tableName: string; + if (tableSelect === '__manual__' || !tableSelect) { + family = values.familyManual || 'inet'; + tableName = values.tableManual || 'filter'; + } else { + [family, tableName] = String(tableSelect).split(':'); + } + + let chain: string | undefined = values.chainSelect; + if (!chain || chain === '__manual_chain__') { + chain = values.chainManual || 'input'; + } + + const expr = buildExprFromValues(values); + // POST JSON + Modal.confirm({ + title: 'Create rule (JSON)', + content: ( +
+ + About to create rule in{' '} + + {family}:{tableName} {chain} + + + + Preview: +
{preview || '(no textual preview available)'}
+ + Expression (JSON): +
{JSON.stringify(expr, null, 2)}
+
+ ), + okText: 'Create', + onOk: async () => { + setLoading(true); + try { + const req = { family, table: tableName, chain, expr }; + const res = await createRuleJson(req); + if (res && res.rc === 0) { + message.success('Rule created'); + if (onCreated) await onCreated(); + form.resetFields(['advanced']); + } else { + message.error(`Create failed: ${res?.stderr ?? 'unknown error'}`); + } + } catch (err: any) { + message.error(`Create failed: ${err?.message ?? String(err)}`); + } finally { + setLoading(false); + } + }, + }); + }, + [form, preview, onCreated], + ); + + return ( + + Add Firewall Rule (JSON) + + This builder constructs a native nft JSON expr and sends it to the server. If the server cannot + render your expression to the system's nft syntax, use the Raw command UI instead. + + + + +
0 ? `${tableOptions[0].family}:${tableOptions[0].name}` : '__manual__', + }} + onFinish={handleCreate} + onValuesChange={onValuesChange} + > + +
+ + + + + + + {form.getFieldValue('tableSelect') === '__manual__' ? ( + + + + + + + + + ) : ( + + + + )} + + + + + + + + + + + + {form.getFieldValue('chainSelect') === '__manual_chain__' || + form.getFieldValue('tableSelect') === '__manual__' ? ( + + + + ) : ( + + + + )} + + + + + + + + + + Preset + Custom + + + + + + {form.getFieldValue('protocolChoice') === 'custom' ? ( + + + + ) : ( + + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Drop + Accept + Reject + + + + + + + + +