diff --git a/backend/src/api/nft_manager.py b/backend/src/api/nft_manager.py index a8cf40b..b1e674b 100644 --- a/backend/src/api/nft_manager.py +++ b/backend/src/api/nft_manager.py @@ -154,6 +154,19 @@ class NftManager: tokens.append("accept") elif "counter" in part: tokens.append("counter") + elif "queue" in part: + # handle fallback queue textualization + q = part["queue"] + if isinstance(q, dict): + num = q.get("num") or q.get("number") or q.get("range") or q.get("from") + tok = "queue" + if num is not None: + tok += f" num{num}" + if q.get("bypass"): + tok += " bypass" + tokens.append(tok) + else: + tokens.append(f"queue{q}") else: tokens.append("+".join(part.keys())) rule_lines.append(" ".join(tokens)) @@ -240,10 +253,13 @@ class CreateRuleRequest(BaseModel): class Config: schema_extra = { "example": { - "family": "bridge", + "family": "inet", "table": "filter", - "chain": "forward", - "expr": [{"match": {"left": {"payload": {"protocol": "ip", "field": "protocol"}}, "op": "==", "right": "icmp"}}, {"drop": None}], + "chain": "input", + "expr": [ + {"match": {"left": {"payload": {"protocol": "ip", "field": "protocol"}}, "op": "==", "right": "icmp"}}, + {"queue": {"num": 0, "bypass": True}} + ], } } @@ -302,6 +318,7 @@ def parse_priority(val: Any) -> Optional[int]: return p return None + def rule_text_from_expr(expr: Any) -> str: """ Deterministic serializer to produce a compact UI-friendly string from expr list. @@ -314,6 +331,23 @@ def rule_text_from_expr(expr: Any) -> str: tokens: List[str] = [] for part in expr: if isinstance(part, dict): + # queue handling: support {'queue': 0}, {'queue': '0-3'}, {'queue': {'num': 0, 'bypass': True}} + if "queue" in part: + q = part["queue"] + token = "queue" + if isinstance(q, dict): + num = q.get("num") or q.get("number") or q.get("queue_number") or q.get("from") or q.get("range") + if num is not None: + token += f" num{num}" + if q.get("bypass"): + token += " bypass" + elif isinstance(q, (int, float)): + token += f" num{int(q)}" + elif isinstance(q, str): + token += f" num{q}" + tokens.append(token) + continue + # common tokens if "match" in part: m = part["match"] @@ -393,10 +427,11 @@ def build_predictable_ruleset(nft_json: Dict[str, Any]) -> Dict[str, Any]: # extract metadata robustly ch_type = ch.get("type") ch_hook = ch.get("hook") - ch_priority = parse_priority(ch.get("priority") if "priority" in ch else ch.get("prio") if "prio" in ch else ch.get("prio", None)) - # also attempt to parse nested shapes if present (some nft JSON variations) - if ch_priority is None: - ch_priority = parse_priority(ch.get("hook") if isinstance(ch.get("hook"), dict) else None) + # priority may be provided in several forms; try them + ch_priority = parse_priority(ch.get("priority") if "priority" in ch else ch.get("prio") if "prio" in ch else ch.get("priority", None)) + # further attempt if ch_hook is dict + if ch_priority is None and isinstance(ch.get("hook"), dict): + ch_priority = parse_priority(ch.get("hook").get("priority") if ch.get("hook") else None) ch_policy = ch.get("policy") @@ -447,16 +482,12 @@ def build_predictable_ruleset(nft_json: Dict[str, Any]) -> Dict[str, Any]: chains_map[chain_name]["rules"].append(rule_obj) # Attempt to salvage chain metadata from rule record if present - # some nft JSON may include 'chain' subfields inside rule record - # e.g. r.get('chain') might be an object - handle that defensively if isinstance(r.get("chain"), dict): csub = r.get("chain") - # try to parse nested priority if chains_map[chain_name].get("priority") is None: parsed_prio = parse_priority(csub.get("priority") if "priority" in csub else csub.get("prio")) if parsed_prio is not None: chains_map[chain_name]["priority"] = parsed_prio - # type/hook/policy from nested if present if chains_map[chain_name].get("type") is None and csub.get("type") is not None: chains_map[chain_name]["type"] = csub.get("type") if chains_map[chain_name].get("hook") is None and csub.get("hook") is not None: @@ -491,12 +522,10 @@ 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. + Supports queue + bypass: + {'queue': 0} -> "queue num0" + {'queue': '0-3'} -> "queue num0-3" + {'queue': {'num': 0, 'bypass': True}} -> "queue num0 bypass" """ if expr is None: return "" @@ -520,6 +549,23 @@ def expr_to_text(expr: Any) -> Optional[str]: parts.append("counter") continue + # queue support (NEW) + if "queue" in element: + q = element["queue"] + token = "queue" + if isinstance(q, dict): + num = q.get("num") or q.get("number") or q.get("queue_number") or q.get("from") or q.get("range") + if num is not None: + token += f" num{num}" + if q.get("bypass"): + token += " bypass" + elif isinstance(q, (int, float)): + token += f" num{int(q)}" + elif isinstance(q, str): + token += f" num{q}" + parts.append(token) + continue + # match left/right payload equals -> ip protocol icmp, or ip saddr/daddr if "match" in element: m = element["match"] @@ -532,11 +578,9 @@ def expr_to_text(expr: Any) -> Optional[str]: 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 @@ -687,11 +731,8 @@ def create_rule_json(req: CreateRuleRequest): logger.warning("create_rule_json: failed to list chain for verification: %s", e_chain) # If we reach here -> treat as error: return 400 with exec_res in body. - # FastAPI cannot both raise HTTPException and include ExecResult as body easily, so raise HTTPException - # with detail that includes stderr and the executed cmd. detail = f"nft command failed rc={rc}. stderr: {stderr!r}. cmd: {cmd}" logger.warning("create_rule_json failed: %s", detail) - # Return an HTTPException with the detail (frontend can still inspect error.response.data if ExecResult was included) raise HTTPException(status_code=400, detail=detail) except NftError as e: @@ -705,7 +746,6 @@ def create_rule_json(req: CreateRuleRequest): raise HTTPException(status_code=500, detail=str(e)) - @router.delete("/rules/{handle}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete rule by handle") def delete_rule(handle: int, family: str = "inet", table: str = "filter", chain: str = "input"): """ diff --git a/frontend/src/components/FirewallRuleBuilder.tsx b/frontend/src/components/FirewallRuleBuilder.tsx index 2896a41..838b1bf 100644 --- a/frontend/src/components/FirewallRuleBuilder.tsx +++ b/frontend/src/components/FirewallRuleBuilder.tsx @@ -1,5 +1,5 @@ // src/components/RuleBuilder.tsx -import { CopyOutlined, PlusOutlined } from '@ant-design/icons'; +import { CopyOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import { Button, Card, @@ -82,7 +82,13 @@ function buildExprFromValues(values: any): Expr[] { } else if (/udp/i.test(custom)) { expr.push({ udp: {} }); } else { - expr.push(custom); + // allow user to paste a JSON token string (advanced) + try { + const parsed = JSON.parse(custom); + expr.push(parsed); + } catch { + expr.push(custom); + } } } } @@ -139,6 +145,16 @@ function buildExprFromValues(values: any): Expr[] { if (action === 'drop') expr.push({ drop: null }); else if (action === 'accept') expr.push({ accept: null }); else if (action === 'reject') expr.push({ reject: null }); + else if (action === 'nfqueue' || action === 'queue') { + // include a numeric queue token in the expr. backend should accept this shape. + const qnum = Number(values.nfqueue || 0); + if (Number.isFinite(qnum) && qnum > 0) { + expr.push({ queue: qnum }); + } else { + // if no valid queue number provided, just push a generic queue token (backend may reject) + expr.push({ queue: 0 }); + } + } return expr; } @@ -186,8 +202,14 @@ function textFromExpr(expr: Expr): string { tokens.push('drop'); } else if ('accept' in part) { tokens.push('accept'); + } else if ('reject' in part) { + tokens.push('reject'); } else if ('counter' in part) { tokens.push('counter'); + } else if ('queue' in part) { + // show NFQUEUE / queue + const q = (part as any).queue; + tokens.push(`queue ${q}`); } else { const keys = Object.keys(part).sort().join('+'); tokens.push(keys); @@ -210,26 +232,31 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { const [cmdPreview, setCmdPreview] = useState(''); const [requestJsonPreview, setRequestJsonPreview] = useState(null); + const [refreshing, setRefreshing] = useState(false); - // 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.'); + async function loadTables() { + setRefreshing(true); + try { + const res = await fetchRuleset(); + if (!res || res.ruleset === null) { setTables([]); - }) - .finally(() => setLoading(false)); + } 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 { + setRefreshing(false); + } + } + + // fetch on mount + useEffect(() => { + loadTables(); }, []); const tableOptions = useMemo( @@ -237,7 +264,7 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { [tables], ); - // set initial defaults + // set sensible defaults once tables are known useEffect(() => { if (tableOptions.length > 0) { const first = tableOptions[0]; @@ -247,12 +274,20 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { protocolPreset: 'icmp', action: 'drop', }); + // also set chainSelect to the first chain for that table if exists + const tbl = tables.find((x) => x.family === first.family && x.name === first.name); + if (tbl && tbl.chains && tbl.chains.length > 0) { + form.setFieldsValue({ chainSelect: tbl.chains[0].name }); + } else { + form.setFieldsValue({ chainSelect: '__manual_chain__' }); + } } else { form.setFieldsValue({ tableSelect: '__manual__', protocolChoice: 'preset', protocolPreset: 'icmp', action: 'drop', + chainSelect: '__manual_chain__', }); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -301,7 +336,27 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { // keep preview updated on values change const onValuesChange = useCallback(() => { updatePreviews(); - }, [updatePreviews]); + + // when tableSelect changes, auto-select chain (first) if any + const vals = form.getFieldsValue(); + const ts = vals.tableSelect; + if (ts && ts !== '__manual__') { + const [f, n] = String(ts).split(':'); + const tbl = tables.find((t) => t.family === f && t.name === n); + if (tbl) { + if (tbl.chains && tbl.chains.length > 0) { + // if currently no chain selected or manual, set to first available chain + const cs = form.getFieldValue('chainSelect'); + if (!cs || cs === '__manual_chain__') { + form.setFieldsValue({ chainSelect: tbl.chains[0].name }); + } + } else { + // if no chains, set chainSelect to manual so user can type + form.setFieldsValue({ chainSelect: '__manual_chain__' }); + } + } + } + }, [form, tables, updatePreviews]); // submit handler: build expr and call createRuleJson const handleCreate = useCallback( @@ -322,7 +377,8 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { } const expr = buildExprFromValues(values); - const cmd = `add rule ${family} ${tableName} ${chain} ${textFromExpr(expr)}`.trim(); + const textual = textFromExpr(expr); + const cmd = `add rule ${family} ${tableName} ${chain} ${textual}`.trim(); const reqObj = { family, table: tableName, chain, expr }; Modal.confirm({ @@ -350,6 +406,8 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { const res = await createRuleJson(reqObj); if (res && res.rc === 0) { message.success('Rule created'); + // refresh tables/chains after successful creation + await loadTables(); if (onCreated) await onCreated(); form.resetFields(['advanced']); } else { @@ -366,6 +424,7 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { message.warn( 'Rule appears to have been created, but server returned an error status. Check output for details.', ); + await loadTables(); if (onCreated) await onCreated(); form.resetFields(['advanced']); } else { @@ -396,27 +455,48 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { const [f, n] = String(ts).split(':'); const tbl = tables.find((t) => t.family === f && t.name === n); if (tbl && tbl.chains.length > 0) { - return tbl.chains.map((c) => ( - - )); + return ( + <> + {tbl.chains.map((c) => ( + + ))} + + + ); } } - return [ - , - ]; + return ( + <> + + + ); }, [form, tables]); 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. - + + + + Add Firewall Rule (JSON) + + + Builds an nft JSON expr and sends it to the server. If rendering fails, use the Raw UI. + + + + + + + + @@ -470,7 +550,7 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { {/* Chain selection */} - + @@ -505,7 +585,7 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { {form.getFieldValue('protocolChoice') === 'custom' ? ( - + ) : ( @@ -551,8 +631,15 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { Drop Accept Reject + NFQUEUE + + {form.getFieldValue('action') === 'nfqueue' && ( + + + + )} @@ -562,7 +649,7 @@ export const RuleBuilder: React.FC = ({ onCreated }) => {