diff --git a/backend/src/api/nft_manager.py b/backend/src/api/nft_manager.py index f988cda..9ba9871 100644 --- a/backend/src/api/nft_manager.py +++ b/backend/src/api/nft_manager.py @@ -255,7 +255,7 @@ class CreateRuleRequest(BaseModel): 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)") + position: Optional[int] = Field(None, description="Optional zero-based insertion position (0 = top). If omitted the rule is appended.") comment: Optional[str] = Field(None, description="Optional comment") class Config: @@ -264,6 +264,7 @@ class CreateRuleRequest(BaseModel): "family": "bridge", "table": "filter", "chain": "forward", + "position": 1, "expr": [{"match": {"left": {"payload": {"protocol": "ip", "field": "protocol"}}, "op": "==", "right": "icmp"}}, {"drop": None}], } } @@ -706,7 +707,7 @@ def list_rules(): def create_rule_json(req: CreateRuleRequest): """ Create a rule from JSON (expr required). - - Attempts to render expr -> textual fragment and execute: `add rule ` + - Attempts to render expr -> textual fragment and execute: `add rule
[position N] ` - If rendering fails: 400 instructing the client to use POST /firewall/raw - Returns ExecResult on success (201) or on error (400) with stdout/stderr in body. - If nft wrapper returns an invalid rc but the command produced no stderr, we double-check the chain @@ -728,7 +729,39 @@ def create_rule_json(req: CreateRuleRequest): ) expr_text = rendered.strip() - cmd = f"add rule {family} {table} {chain} {expr_text}" + + # If a position is provided, try to determine chain length to clamp the position. + position_token = "" + if req.position is not None: + # ensure numeric and non-negative + try: + pos_candidate = int(req.position) + except Exception: + raise NftError("position must be an integer >= 0") + if pos_candidate < 0: + raise NftError("position must be >= 0") + + # attempt to read current ruleset to know chain length + try: + nft_json = mgr.list_rules_json() + custom = build_predictable_ruleset(nft_json) + chain_rules: List[Dict[str, Any]] = [] + for t in custom.get("tables", []): + if t.get("family") == family and t.get("name") == table: + for ch in t.get("chains", []): + if ch.get("name") == chain: + chain_rules = ch.get("rules", []) + break + chain_len = len(chain_rules) + # clamp position to [0, chain_len] + pos = max(0, min(chain_len, pos_candidate)) + except Exception: + # if we cannot read ruleset, just use provided pos_candidate (server may still accept or fail) + pos = pos_candidate + + position_token = f" position {pos}" + + cmd = f"add rule {family} {table} {chain}{position_token} {expr_text}" logger.info("create_rule_json executing command: %s", cmd) res = mgr.cmd(cmd) diff --git a/frontend/src/components/FirewallRuleBuilder.tsx b/frontend/src/components/FirewallRuleBuilder.tsx index 838b1bf..fc54e08 100644 --- a/frontend/src/components/FirewallRuleBuilder.tsx +++ b/frontend/src/components/FirewallRuleBuilder.tsx @@ -320,13 +320,48 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { chain = chainVal; } + // compute insertion position if user selected an "insert after" handle + let positionToken = ''; + if ( + vals.insertAfterHandle && + tableSelect && + tableSelect !== '__manual__' && + chain && + chain !== '__manual_chain__' + ) { + // find index of the selected handle in current tables state + try { + const [f, n] = String(tableSelect).split(':'); + const tbl = tables.find((t) => t.family === f && t.name === n); + if (tbl) { + const ch = tbl.chains.find((c) => c.name === chain); + if (ch) { + const idx = ch.rules.findIndex((r) => String(r.handle) === String(vals.insertAfterHandle)); + if (idx >= 0) { + const pos = idx + 1; // insert after -> index+1 + positionToken = ` position ${pos}`; + } + } + } + } catch { + positionToken = ''; + } + } + const short = textFromExpr(expr); - const cmd = `add rule ${family} ${tableName} ${chain} ${short}`.trim(); + const cmd = `add rule ${family} ${tableName} ${chain}${positionToken} ${short}`.trim(); setCmdPreview(cmd); - const reqObj = { family, table: tableName, chain, expr }; + const reqObj: any = { family, table: tableName, chain, expr }; + if (positionToken) { + // extract numeric pos and include in request body + const m = positionToken.match(/position\s+(\d+)/); + if (m) { + reqObj.position = Number(m[1]); + } + } setRequestJsonPreview(reqObj); - }, [form]); + }, [form, tables]); useEffect(() => { updatePreviews(); @@ -377,9 +412,33 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { } const expr = buildExprFromValues(values); + + // compute optional position from insertAfterHandle + const reqObj: any = { family, table: tableName, chain, expr }; + if ( + values.insertAfterHandle && + tableSelect && + tableSelect !== '__manual__' && + chain && + chain !== '__manual_chain__' + ) { + // locate index of handle and set position = index+1 + const [f, n] = String(tableSelect).split(':'); + const tbl = tables.find((t) => t.family === f && t.name === n); + if (tbl) { + const ch = tbl.chains.find((c) => c.name === chain); + if (ch) { + const idx = ch.rules.findIndex((r) => String(r.handle) === String(values.insertAfterHandle)); + if (idx >= 0) { + reqObj.position = idx + 1; + } + } + } + } + const textual = textFromExpr(expr); - const cmd = `add rule ${family} ${tableName} ${chain} ${textual}`.trim(); - const reqObj = { family, table: tableName, chain, expr }; + const posStr = reqObj.position !== undefined ? ` position ${reqObj.position}` : ''; + const cmd = `add rule ${family} ${tableName} ${chain}${posStr} ${textual}`.trim(); Modal.confirm({ title: 'Create rule (JSON)', @@ -409,7 +468,7 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { // refresh tables/chains after successful creation await loadTables(); if (onCreated) await onCreated(); - form.resetFields(['advanced']); + form.resetFields(['advanced', 'insertAfterHandle']); } else { message.error(`Create failed: ${res?.stderr ?? 'unknown error'}`); } @@ -426,7 +485,7 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { ); await loadTables(); if (onCreated) await onCreated(); - form.resetFields(['advanced']); + form.resetFields(['advanced', 'insertAfterHandle']); } else { const errMsg = typeof stderr === 'string' ? stderr : JSON.stringify(stderr); message.error(`Create failed: ${errMsg}`); @@ -445,7 +504,7 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { }, }); }, - [form, onCreated], + [form, onCreated, tables], ); // prepare chain options for currently selected table @@ -478,6 +537,24 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { ); }, [form, tables]); + // build insert-after options for currently selected table+chain + const insertAfterOptions = useMemo(() => { + const ts = form.getFieldValue('tableSelect'); + const cs = form.getFieldValue('chainSelect'); + if (!ts || ts === '__manual__' || !cs || cs === '__manual_chain__') return []; + const [f, n] = String(ts).split(':'); + const tbl = tables.find((t) => t.family === f && t.name === n); + if (!tbl) return []; + const ch = tbl.chains.find((c) => c.name === cs); + if (!ch || !Array.isArray(ch.rules)) return []; + return ch.rules + .filter((r: any) => r && r.handle !== undefined && r.handle !== null) + .map((r: any) => ({ + value: r.handle, + label: `#${r.handle} — ${r.text ?? (typeof r.expr === 'string' ? r.expr : JSON.stringify(r.expr || r).slice(0, 120))}`, + })); + }, [form, tables]); + return ( @@ -569,6 +646,41 @@ export const RuleBuilder: React.FC = ({ onCreated }) => { + {/* Insert-after control */} + {form.getFieldValue('tableSelect') !== '__manual__' && + form.getFieldValue('chainSelect') && + form.getFieldValue('chainSelect') !== '__manual_chain__' && ( + + + + + + + + + Use when you want the new rule to appear right after a known handle. Refresh tables to see latest + handles. + + + + )} + {/* Protocol / addresses */}