diff --git a/backend/src/api/nft_manager.py b/backend/src/api/nft_manager.py index 283e467..798c0de 100644 --- a/backend/src/api/nft_manager.py +++ b/backend/src/api/nft_manager.py @@ -131,7 +131,7 @@ class NftManager: if isinstance(expr, list): tokens: List[str] = [] for part in expr: - if "match" in part: + if isinstance(part, dict) and "match" in part: m = part["match"] left = m.get("left") right = m.get("right") @@ -143,18 +143,18 @@ class NftManager: tokens.append(f"{prot} {field} {right}") continue tokens.append("match") - elif "payload" in part: + elif isinstance(part, dict) and "payload" in part: p = part["payload"] prot = p.get("protocol") field = p.get("field") tokens.append(f"payload({prot}.{field})") - elif "drop" in part: + elif isinstance(part, dict) and "drop" in part: tokens.append("drop") - elif "accept" in part: + elif isinstance(part, dict) and "accept" in part: tokens.append("accept") - elif "counter" in part: + elif isinstance(part, dict) and "counter" in part: tokens.append("counter") - elif "queue" in part: + elif isinstance(part, dict) and "queue" in part: # handle fallback queue textualization q = part["queue"] if isinstance(q, dict): @@ -172,7 +172,11 @@ class NftManager: else: tokens.append(f"queue num {q}") else: - tokens.append("+".join(part.keys())) + # fallback for unknown dict token + if isinstance(part, dict): + tokens.append("+".join(part.keys())) + else: + tokens.append(str(part)) rule_lines.append(" ".join(tokens)) else: rule_lines.append(json.dumps(r)) @@ -283,11 +287,13 @@ class MoveRequest(BaseModel): to_top: Optional[bool] = Field(False, description="Move to top (equivalent to position=0)") to_bottom: Optional[bool] = Field(False, description="Move to bottom (append)") + class MoveSubResult(BaseModel): cmd: str out: Optional[ExecResult] = None err: Optional[str] = None + class MoveResult(BaseModel): added: MoveSubResult deleted: MoveSubResult @@ -325,22 +331,22 @@ def parse_priority(val: Any) -> Optional[int]: # numeric string if isinstance(val, str): s = val.strip() - if s.isdigit() or (s.startswith("-") and s[1:].isdigit()): + # try integer parse + try: + return int(s) + except Exception: try: - return int(s) + # sometimes it's "0.0" or similar + return int(float(s)) except Exception: return None - # sometimes nft uses "0" etc with whitespace - try: - return int(float(s)) - except Exception: - return None # nested dicts sometimes appear if isinstance(val, dict): + # look for common keys for key in ("priority", "prio"): if key in val: return parse_priority(val.get(key)) - # sometimes structure like {'hook': {'priority': 0}} - try to dive in + # try nested dict values for v in val.values(): p = parse_priority(v) if p is not None: @@ -594,6 +600,7 @@ def expr_to_text(expr: Any) -> Optional[str]: elif isinstance(q, (int, float)): token += f" num {int(q)}" elif isinstance(q, str): + # preserve string but ensure spacing: client must supply numeric if nft expects it token += f" num {q}" parts.append(token) continue @@ -855,15 +862,19 @@ def move_rule(handle: int, req: MoveRequest): chain_rules = ch.get("rules", []) break - if not chain_rules: + if chain_rules is None or len(chain_rules) == 0: raise HTTPException(status_code=404, detail="Chain not found or chain contains no rules") # find the rule by handle source_rule = None for r in chain_rules: - if r.get("handle") == handle: - source_rule = r - break + # handle may be int or convertible; do tolerant compare + try: + if r.get("handle") is not None and int(r.get("handle")) == int(handle): + source_rule = r + break + except Exception: + continue if source_rule is None: raise HTTPException(status_code=404, detail=f"Rule with handle {handle} not found in chain") @@ -881,7 +892,7 @@ def move_rule(handle: int, req: MoveRequest): target_pos = len(chain_rules) # append elif req.before_handle is not None: # find index of before_handle - idx = next((i for i, rr in enumerate(chain_rules) if rr.get("handle") == req.before_handle), None) + idx = next((i for i, rr in enumerate(chain_rules) if rr.get("handle") is not None and int(rr.get("handle")) == int(req.before_handle)), None) if idx is None: raise HTTPException(status_code=404, detail=f"before_handle {req.before_handle} not found in chain") target_pos = idx @@ -932,20 +943,21 @@ def move_rule(handle: int, req: MoveRequest): # Now delete the original rule by handle try: - mgr.delete_rule_by_handle_text(family=family, table=table, chain=chain, handle=handle) + mgr.delete_rule_by_handle_text(family=family, table=table, chain=chain, handle=int(handle)) del_exec = MoveSubResult(cmd=f"delete rule {family} {table} {chain} handle {handle}", out=ExecResult(rc=0, stdout="", stderr=""), err=None) except Exception as e: # We succeeded adding but failed deleting - report both err_msg = f"added new rule but deleting original handle {handle} failed: {e}" logger.exception(err_msg) del_exec = MoveSubResult(cmd=f"delete rule {family} {table} {chain} handle {handle}", out=None, err=str(e)) - # Return 500 to indicate partial failure - return MoveResult(added=add_exec, deleted=del_exec) + # Return a 500 (partial success) + raise HTTPException(status_code=500, detail={"added": add_exec, "deleted": del_exec}) # success return MoveResult(added=add_exec, deleted=del_exec) except HTTPException: + # re-raise so FastAPI handles it raise except Exception as e: logger.exception("move_rule internal error") diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 44b23e9..a448d86 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 { CreateRuleRequest, ExecResult, RulesetModel } from "../types/firewall"; +import axios from 'axios'; +import { CreateRuleRequest, ExecResult, MoveRequest, MoveResult, RulesetModel } from '../types/firewall'; import { BridgeCreateRequest, BridgeInfo, @@ -8,17 +8,15 @@ import { FullState, InterfaceInfo, RouteInfo, -} from "../types/network"; -import { EnableRequest, ScriptInfo } from "../types/scripting"; -import { - SnifferStatusResponse, -} from "../types/sniffer"; +} from '../types/network'; +import { EnableRequest, ScriptInfo } from '../types/scripting'; +import { SnifferStatusResponse } from '../types/sniffer'; -const BASE = "http://mitm.lan/api"; +const BASE = 'http://mitm.lan/api'; export const api = axios.create({ baseURL: BASE, - headers: { "Content-Type": "application/json" }, + headers: { 'Content-Type': 'application/json' }, timeout: 10000, }); @@ -27,29 +25,24 @@ api.interceptors.response.use( (response) => response, (error) => { // FastAPI HTTPException format - const detail = - error?.response?.data?.detail ?? - error?.response?.data?.message ?? - error.message ?? - "Unknown error"; + const detail = error?.response?.data?.detail ?? error?.response?.data?.message ?? error.message ?? 'Unknown error'; // Always reject with a standard Error return Promise.reject(new Error(detail)); - } + }, ); - /* ------------------------- Basic endpoints ------------------------- */ export const fetchHello = async (): Promise => { - const res = await api.get("/hello"); + const res = await api.get('/hello'); return res.data; }; export const fetchVersions = async (): Promise => { - const res = await api.get("/versions"); + const res = await api.get('/versions'); return res.data; }; @@ -58,47 +51,46 @@ export const fetchVersions = async (): Promise => { ------------------------- */ export const fetchInterfaces = async (): Promise => { - const res = await api.get("/network/interfaces"); + const res = await api.get('/network/interfaces'); return res.data; }; export const fetchLinks = async (): Promise => { - const res = await api.get("/network/links"); + const res = await api.get('/network/links'); return res.data; }; export const fetchRoutes = async (): Promise => { - const res = await api.get("/network/routes"); + const res = await api.get('/network/routes'); return res.data; }; export const fetchBridges = async (): Promise => { - const res = await api.get("/network/bridges"); + const res = await api.get('/network/bridges'); return res.data; }; export const fetchFullState = async (): Promise => { - const res = await api.get("/network/full-state"); + const res = await api.get('/network/full-state'); return res.data; }; export const createBridge = async (req: BridgeCreateRequest) => { - const res = await api.post("/network/bridge/create", req); + const res = await api.post('/network/bridge/create', req); return res.data; }; export const removeBridge = async (req: BridgeRemoveRequest) => { - const res = await api.post("/network/bridge/remove", req); + const res = await api.post('/network/bridge/remove', req); return res.data; }; - /* ------------------------- Sniffer ------------------------- */ export const fetchSnifferStatus = async (): Promise => { - const res = await api.get("/sniffer/status"); + const res = await api.get('/sniffer/status'); return res.data; }; @@ -120,7 +112,7 @@ export const snifferStop = async (): Promise => { export const fetchPackets = async (limit = 100): Promise => { // limit default mirrors OpenAPI default - const res = await api.get("/packets/packets", { params: { limit } }); + const res = await api.get('/packets/packets', { params: { limit } }); return res.data; }; @@ -134,7 +126,7 @@ export const fetchPackets = async (limit = 100): Promise => { * - 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"); + const res = await api.get<{ ruleset: RulesetModel | string | null }>('/firewall/rules'); return res.data; }; @@ -142,12 +134,7 @@ export const fetchRuleset = async (): Promise<{ ruleset: RulesetModel | string | * 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, - family: string, - table: string, - chain: string -): Promise => { +export const deleteRule = async (handle: number, family: string, table: string, chain: string): Promise => { const res = await api.delete(`/firewall/rules/${encodeURIComponent(String(handle))}`, { params: { family, table, chain }, }); @@ -155,26 +142,31 @@ export const deleteRule = async ( return res.data; }; +export const moveRule = async (req: MoveRequest): Promise => { + const res = await api.post(`/firewall/rules/${encodeURIComponent(String(req.before_handle))}/move`, req); + 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); + const res = await api.post('/firewall/rules', req); return res.data; }; export const execFirewallRaw = async (cmd: string): Promise => { - const res = await api.post("/firewall/raw", { "cmd": cmd }); + const res = await api.post('/firewall/raw', { cmd: cmd }); return res.data; -} +}; /* ------------------------- Scripts ------------------------- */ export const fetchScriptsStatusAll = async (): Promise => { - const res = await api.get("/scripts/scripts/status"); + const res = await api.get('/scripts/scripts/status'); return res.data; }; @@ -184,24 +176,23 @@ export const fetchScriptStatusForName = async (name: string): Promise => { }; export const listScripts = async (): Promise => { - const res = await api.get("/scripts/scripts"); + const res = await api.get('/scripts/scripts'); return res.data; }; - export const uploadScript = async (opts: { name: string; script: File | Blob; requirements?: File | Blob | null; }): Promise => { const fd = new FormData(); - fd.append("name", opts.name); - fd.append("script", opts.script); - if (opts.requirements) fd.append("requirements", opts.requirements as Blob); + fd.append('name', opts.name); + fd.append('script', opts.script); + if (opts.requirements) fd.append('requirements', opts.requirements as Blob); // axios will set multipart/form-data boundary automatically when FormData passed - const res = await api.post("/scripts/scripts", fd, { - headers: { "Content-Type": "multipart/form-data" }, + const res = await api.post('/scripts/scripts', fd, { + headers: { 'Content-Type': 'multipart/form-data' }, }); return res.data; }; @@ -213,7 +204,7 @@ export const downloadScript = async (name: string): Promise => { export const deleteScript = async (name: string, qnum?: number | null): Promise => { const params: Record = {}; - if (typeof qnum !== "undefined") params.qnum = qnum; + if (typeof qnum !== 'undefined') params.qnum = qnum; const res = await api.delete(`/scripts/scripts/${encodeURIComponent(name)}`, { params }); return res.data; }; diff --git a/frontend/src/components/FirewallRulesetViewer.tsx b/frontend/src/components/FirewallRulesetViewer.tsx index c4c894e..077df19 100644 --- a/frontend/src/components/FirewallRulesetViewer.tsx +++ b/frontend/src/components/FirewallRulesetViewer.tsx @@ -1,16 +1,21 @@ -import { Alert, Button, Card, Modal, Space, Spin, Table, Typography, message } from 'antd'; +// src/components/FirewallTables.tsx +import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined } from '@ant-design/icons'; +import { Alert, Button, Card, Divider, message, Modal, Space, Spin, Table, Typography } from 'antd'; import { ColumnsType } from 'antd/lib/table'; import { ReactElement, useEffect, useState } from 'react'; -import { execFirewallRaw, fetchRuleset } from '../api/apiClient'; -import { CmdResult, ExecResult } from '../types/firewall'; +import { execFirewallRaw, fetchRuleset, moveRule } from '../api/apiClient'; +import type { CmdResult, ExecResult } from '../types/firewall'; import FirewallAddChainModal from './FireWallAddChainModal'; import FirewallAddTableModal from './FireWallAddTableModal'; const { Paragraph, Text, Title } = Typography; +/* Types */ type NFTRule = { handle?: number | string; expr?: any; + rule?: any; + text?: string; [k: string]: any; }; @@ -29,9 +34,7 @@ type NFTTable = { chains: NFTChain[]; }; -/* ------------------------- - Extract NFT structure -------------------------- */ +/* Extract ruleset -> tables (unchanged) */ function extractTablesFromParsed(parsed: any): NFTTable[] { if (!parsed) return []; @@ -108,14 +111,102 @@ function extractTablesFromParsed(parsed: any): NFTTable[] { return []; } -/* ------------------------- - Rule preview -------------------------- */ -function rulePreview(rule: NFTRule): string { - if (typeof rule === 'string') return rule; +/* Friendly rule renderer (keeps previous logic) */ +function renderRuleFriendly(rule: NFTRule): string { + if (rule.text && typeof rule.text === 'string' && rule.text.trim() !== '') return rule.text; + if (typeof rule.rule === 'string') return rule.rule; if (rule.expr && typeof rule.expr === 'string') return rule.expr; - if (rule.rule && typeof rule.rule === 'string') return rule.rule; - if (rule.handle && Object.keys(rule).length === 1) return `handle ${rule.handle}`; + + const expr = rule.expr ?? rule; + if (Array.isArray(expr)) { + const tokens: string[] = []; + for (const part of expr) { + if (part == null) continue; + if (typeof part === 'string' || typeof part === 'number') { + tokens.push(String(part)); + continue; + } + if (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 && (typeof right === 'string' || typeof right === 'number')) { + const p = left.payload; + const prot = p.protocol; + const field = p.field; + if (prot && field) { + tokens.push(`${prot} ${field} ${right}`); + continue; + } + } + tokens.push('match'); + continue; + } + if ('payload' in part) { + const p = (part as any).payload; + if (p?.protocol && p?.field) { + tokens.push(`payload(${p.protocol}.${p.field})`); + continue; + } + tokens.push('payload'); + continue; + } + 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'); + continue; + } + 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'); + continue; + } + if ('drop' in part) { + tokens.push('drop'); + continue; + } + if ('accept' in part) { + tokens.push('accept'); + continue; + } + if ('counter' in part) { + tokens.push('counter'); + continue; + } + if ('queue' in part) { + const q = (part as any).queue; + let tok = 'queue'; + if (typeof q === 'object' && q !== null) { + const num = q.num ?? q.number ?? q.queue_number ?? q.from ?? q.range; + if (num !== undefined) tok += ` num ${num}`; + if (q.bypass) tok += ' bypass'; + } else if (typeof q === 'number') { + tok += ` num ${q}`; + } else if (typeof q === 'string') { + tok += ` num ${q}`; + } + tokens.push(tok); + continue; + } + tokens.push(Object.keys(part).sort().join('+')); + continue; + } + } + if (tokens.length > 0) return tokens.join(' '); + } + + if (rule.expr && typeof rule.expr === 'object') { + try { + return JSON.stringify(rule.expr, (_k, v) => (v === undefined ? null : v)).slice(0, 500); + } catch { + // fallthrough + } + } try { return JSON.stringify(rule, null, 2); @@ -124,17 +215,13 @@ function rulePreview(rule: NFTRule): string { } } -/* Helper: determine success by rc - Some endpoints return rc === -1 on success in your environment, - so treat rc === 0 or rc === -1 as success. */ +/* Helper: success RC */ function isSuccessRc(out?: ExecResult | null): boolean { if (!out) return false; return out.rc === 0 || out.rc === -1; } -/* ------------------------- - Component -------------------------- */ +/* Component */ export default function FirewallTables(): ReactElement { const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -163,27 +250,22 @@ export default function FirewallTables(): ReactElement { loadRuleset(); }, []); - // helper to run multiple commands sequentially and collect results async function runCommands(cmds: string[]) { const acc: CmdResult[] = []; for (const cmd of cmds) { try { const out = (await execFirewallRaw(cmd)) as ExecResult; - if (isSuccessRc(out)) { - acc.push({ cmd, out }); - } else { - acc.push({ cmd, out, err: out ? `stderr: ${out.stderr ?? ''} rc: ${out.rc}` : 'Unknown error' }); - } + if (isSuccessRc(out)) acc.push({ cmd, out }); + else acc.push({ cmd, out, err: out ? `stderr: ${out.stderr ?? ''} rc: ${out.rc}` : 'Unknown error' }); } catch (err: any) { acc.push({ cmd, err: err?.message ?? String(err) }); } } - // prepend new results so the latest are visible first setResults((prev) => [...acc, ...prev]); return acc; } - // delete a single rule + // Delete helpers (unchanged behavior) async function handleDeleteRule( family: string | null | undefined, table: string, @@ -191,24 +273,21 @@ export default function FirewallTables(): ReactElement { handle: number | string, ) { const cmd = `delete rule ${family ?? 'inet'} ${table} ${chain} handle ${handle}`; - Modal.confirm({ title: 'Delete Rule', content: ( <> Are you sure you want to delete this rule? -
{cmd}
+ + {cmd} ), onOk: async () => { try { const res = await runCommands([cmd]); const first = res[0]; - if (!first.err) { - message.success('Rule deleted'); - } else { - message.error('Delete returned error — check results panel'); - } + if (!first.err) message.success('Rule deleted'); + else message.error('Delete returned error — check results panel'); } catch (err: any) { message.error('Delete failed: ' + (err?.message ?? String(err))); } finally { @@ -218,10 +297,8 @@ export default function FirewallTables(): ReactElement { }); } - // delete a chain (non-force) async function handleDeleteChain(family: string | null | undefined, table: string, chain: string) { const cmd = `delete chain ${family ?? 'inet'} ${table} ${chain}`; - Modal.confirm({ title: 'Delete Chain', content: ( @@ -229,18 +306,16 @@ export default function FirewallTables(): ReactElement { This will delete the chain {chain} in table {table} unrevertably. -
{cmd}
+ + {cmd} ), onOk: async () => { try { const res = await runCommands([cmd]); const first = res[0]; - if (!first.err) { - message.success(`Chain ${chain} deleted`); - } else { - message.error(`Chain deletion returned error — check results panel`); - } + if (!first.err) message.success(`Chain ${chain} deleted`); + else message.error(`Chain deletion returned error — check results panel`); } catch (err: any) { message.error('Chain deletion failed: ' + (err?.message ?? String(err))); } finally { @@ -250,10 +325,8 @@ export default function FirewallTables(): ReactElement { }); } - // delete a table (non-force) async function handleDeleteTable(family: string | null | undefined, table: string) { const cmd = `delete table ${family ?? 'inet'} ${table}`; - Modal.confirm({ title: 'Delete Table', content: ( @@ -261,18 +334,16 @@ export default function FirewallTables(): ReactElement { This will delete the table {table} including all its chains and rules unrevertably. -
{cmd}
+ + {cmd} ), onOk: async () => { try { const res = await runCommands([cmd]); const first = res[0]; - if (!first.err) { - message.success(`Table ${table} deleted`); - } else { - message.error(`Table deletion returned error — check results panel`); - } + if (!first.err) message.success(`Table ${table} deleted`); + else message.error(`Table deletion returned error — check results panel`); } catch (err: any) { message.error('Table deletion failed: ' + (err?.message ?? String(err))); } finally { @@ -282,19 +353,135 @@ export default function FirewallTables(): ReactElement { }); } - // Refresh handler async function handleRefresh() { setRefreshing(true); try { await loadRuleset(); message.success('Ruleset refreshed'); } catch { - // loadRuleset sets error state + // ignore } finally { setRefreshing(false); } } + // Move using position (0-based). We still send source handle in the URL by setting before_handle = + // sourceHandle so your helper constructs the correct path. The body contains "position". + async function performMoveInline( + family: string | null | undefined, + tableName: string, + chainName: string, + handle: number | string | undefined, + idx: number, + ) { + const t = tables.find((x) => x.family === family && x.name === tableName); + if (!t) { + message.error('Table not found in local state'); + return; + } + const ch = t.chains.find((c) => c.name === chainName); + if (!ch) { + message.error('Chain not found in local state'); + return; + } + + const lastIndex = ch.rules.length - 1; + + // if no handle -> disallow + if (handle === undefined || handle === null) { + message.error('Rule has no handle; cannot perform server-side move.'); + return; + } + + // compute target positions for up/down actions invoked from UI; we assume caller computed newPos already + // but here we'll ask the user which direction via a small confirm for safety. + const confirm = await new Promise((resolve) => { + Modal.confirm({ + title: 'Move rule', + content: `Move rule #${handle} (position ${idx + 1} of ${lastIndex + 1})?`, + okText: 'Move up one', + cancelText: 'Move down one', + onOk: () => resolve(true), + onCancel: () => resolve(false), + }); + }); + + // But above confirm uses ok/cancel as a quick choice. For inline buttons we call this function separately + // by passing the desired direction; however to keep this function generic we will not rely on confirm. + // For clarity: update callers to call with desired target position directly. (We handle here both.) + return; + } + + // Inline-specific wrappers: moveUp / moveDown (these compute position and call moveRule) + async function moveUp(family: string, tableName: string, chainName: string, handle: number | string, idx: number) { + if (idx <= 0) { + message.warning('Already at top'); + return; + } + // target position: idx - 1 + const targetPos = idx - 1; + await callMoveApi(family, tableName, chainName, handle, targetPos); + } + + async function moveDown(family: string, tableName: string, chainName: string, handle: number | string, idx: number) { + const t = tables.find((x) => x.family === family && x.name === tableName); + if (!t) return; + const ch = t.chains.find((c) => c.name === chainName); + if (!ch) return; + const lastIndex = ch.rules.length - 1; + if (idx >= lastIndex) { + message.warning('Already at bottom'); + return; + } + // target position: idx + 1 + const targetPos = idx + 1; + await callMoveApi(family, tableName, chainName, handle, targetPos); + } + + // call your moveRule helper; we must supply before_handle as the source handle so the helper puts that in the URL + // while the server will use 'position' from the body to place the rule. + async function callMoveApi( + family: string | null, + tableName: string, + chainName: string, + sourceHandle: number | string, + position: number, + ) { + if (sourceHandle === undefined || sourceHandle === null) { + message.error('Cannot move rule without a source handle.'); + return; + } + + const reqBody = { + family: family, + table: tableName, + chain: chainName, + position: Number(position), + before_handle: Number(sourceHandle), + } as any; + + message.loading({ content: 'Moving rule…', key: 'move' }); + try { + const res = await moveRule(reqBody); // uses your helper + message.success({ content: 'Rule moved', key: 'move' }); + } catch (err: any) { + const detail = err?.response?.data ?? err?.message ?? String(err); + message.error({ + content: 'Move failed: ' + (typeof detail === 'string' ? detail : JSON.stringify(detail)), + key: 'move', + }); + setResults((prev) => [ + { + cmd: `MOVE ${String(sourceHandle)} -> pos=${position}`, + err: typeof detail === 'string' ? detail : JSON.stringify(detail), + }, + ...prev, + ]); + } finally { + await loadRuleset(); + } + } + if (loading) return ; if (error) return ; @@ -342,61 +529,90 @@ export default function FirewallTables(): ReactElement { - } - extra={} > setIsOpenChainCreatorModal(false)} - table={{ family: table.family, name: table.name }} + table={{ family: table.family ?? '', name: table.name }} /> + {table.chains.map((chain) => { + const lastIndex = chain.rules.length - 1; const columns: ColumnsType = [ { title: 'Rule #', dataIndex: 'idx', width: 80 }, { title: 'Handle', dataIndex: 'handle', + width: 120, render: (v) => v ?? '-', }, { - title: 'Rule Content', + title: 'Rule', dataIndex: 'raw', - render: (v) => {v}, + render: (v) => ( + + {v} + + ), }, { title: 'Actions', + dataIndex: 'actions', + width: 160, render: (_: any, rec: any) => rec.handle ? ( + /> + + + /> } @@ -447,6 +662,40 @@ export default function FirewallTables(): ReactElement { ); })} + + {/* Results panel */} + {results.length > 0 && ( + + {results.map((r, i) => ( +
+ {r.cmd} + {r.err ? ( + + {r.err} + + ) : r.out ? ( + <> + + rc: {r.out.rc} + + {r.out.stdout ? ( + <> + stdout: +
{r.out.stdout}
+ + ) : null} + {r.out.stderr ? ( + <> + stderr: +
{r.out.stderr}
+ + ) : null} + + ) : null} +
+ ))} +
+ )} ); } diff --git a/frontend/src/types/firewall.ts b/frontend/src/types/firewall.ts index 23e8e80..18ff7f4 100644 --- a/frontend/src/types/firewall.ts +++ b/frontend/src/types/firewall.ts @@ -45,9 +45,83 @@ export interface CmdResult { cmd: string; out?: ExecResult; err?: string; -}; +} export interface TableProp { family: string; name: string; -}; +} + +/** + * Types derived from Python Pydantic models: + * - MoveRequest + * - MoveSubResult + * - MoveResult + * + * Field names intentionally match the Python/JSON names (snake_case) so they work + * directly with your backend API. + */ + +/** + * ExecResult mirrors the ExecResult Pydantic model returned by /firewall/raw and other endpoints. + */ +export interface ExecResult { + /** Return code from nft execution (example: 0 or -1). */ + rc: number; + /** Standard output from nft (may be null/undefined). */ + stdout?: string | null; + /** Standard error from nft (may be null/undefined). */ + stderr?: string | null; +} + +/** + * Request body for moving a rule. + * Exactly one of position / before_handle / to_top / to_bottom is typically provided. + * + * Examples: + * - Move to top: { family: "bridge", table: "filter", chain: "forward", to_top: true } + * - Move before handle: { family: "inet", table: "filter", chain: "input", before_handle: 42 } + * - Insert at position: { family: "ip", table: "filter", chain: "forward", position: 0 } + */ +export interface MoveRequest { + /** Table family, e.g. "bridge", "inet", "ip", "ip6". */ + family: string; + /** Table name, e.g. "filter". */ + table: string; + /** Chain name, e.g. "forward". */ + chain: string; + + /** Zero-based position to insert at (0 = top). Optional. */ + position?: number | null; + + /** Insert before this existing handle in the same chain. Optional. */ + before_handle?: number | null; + + /** Move to top (equivalent to position=0). Optional; default false on server. */ + to_top?: boolean; + + /** Move to bottom (append). Optional; default false on server. */ + to_bottom?: boolean; +} + +/** + * Sub-result for the move action (what was added / what was deleted). + * Mirrors MoveSubResult(BaseModel). + */ +export interface MoveSubResult { + /** Executed command as text (e.g. "add rule ..."). */ + cmd: string; + /** Exec result if command was executed (may be null/undefined). */ + out?: ExecResult | null; + /** Error string if the operation failed (may be null/undefined). */ + err?: string | null; +} + +/** + * Overall move result: what was added and what was deleted. + * Mirrors MoveResult(BaseModel). + */ +export interface MoveResult { + added: MoveSubResult; + deleted: MoveSubResult; +}