move rule

This commit is contained in:
2026-02-28 15:29:50 +01:00
parent 2c6cb2b7d9
commit 60fd926fc6
4 changed files with 467 additions and 141 deletions

View File

@@ -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: (
<>
<Paragraph>Are you sure you want to delete this rule?</Paragraph>
<pre style={{ whiteSpace: 'pre-wrap' }}>{cmd}</pre>
<Divider />
<Paragraph copyable>{cmd}</Paragraph>
</>
),
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 {
<Paragraph>
This will delete the chain <i>{chain}</i> in table <i>{table}</i> unrevertably.
</Paragraph>
<pre style={{ whiteSpace: 'pre-wrap' }}>{cmd}</pre>
<Divider />
<Paragraph copyable>{cmd}</Paragraph>
</>
),
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 {
<Paragraph>
This will delete the table <i>{table}</i> including all its chains and rules unrevertably.
</Paragraph>
<pre style={{ whiteSpace: 'pre-wrap' }}>{cmd}</pre>
<Divider />
<Paragraph copyable>{cmd}</Paragraph>
</>
),
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<boolean>((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 <Spin size="large" style={{ display: 'block', margin: '40px auto' }} />;
if (error) return <Alert type="error" message="Failed to load firewall rules" description={error} />;
@@ -342,61 +529,90 @@ export default function FirewallTables(): ReactElement {
</div>
<Space>
<Button danger size="small" onClick={() => handleDeleteTable(table.family, table.name)}>
Delete Table
<Button
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDeleteTable(table.family, table.name)}
/>
<Button size="small" onClick={() => setIsOpenChainCreatorModal(true)}>
Add Chain
</Button>
</Space>
</div>
}
extra={<Button onClick={() => setIsOpenChainCreatorModal(true)}>Add Chain</Button>}
>
<FirewallAddChainModal
open={isOpenChainCreatorModal}
onClose={() => setIsOpenChainCreatorModal(false)}
table={{ family: table.family, name: table.name }}
table={{ family: table.family ?? '', name: table.name }}
/>
<Space direction="vertical" style={{ width: '100%' }}>
{table.chains.map((chain) => {
const lastIndex = chain.rules.length - 1;
const columns: ColumnsType<any> = [
{ title: 'Rule #', dataIndex: 'idx', width: 80 },
{
title: 'Handle',
dataIndex: 'handle',
width: 120,
render: (v) => v ?? '-',
},
{
title: 'Rule Content',
title: 'Rule',
dataIndex: 'raw',
render: (v) => <Paragraph copyable>{v}</Paragraph>,
render: (v) => (
<Paragraph ellipsis={{ rows: 2, expandable: false }} copyable>
{v}
</Paragraph>
),
},
{
title: 'Actions',
dataIndex: 'actions',
width: 160,
render: (_: any, rec: any) =>
rec.handle ? (
<Space>
<Button
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDeleteRule(table.family, table.name, chain.name, rec.handle)}
>
Delete Rule
</Button>
/>
<Button
size="small"
icon={<ArrowUpOutlined />}
disabled={rec.idx <= 1 || rec.handle === undefined || rec.handle === null}
onClick={() =>
moveUp(table.family ?? '', table.name, chain.name, rec.handle, rec.idx - 1)
}
/>
<Button
size="small"
icon={<ArrowDownOutlined />}
disabled={rec.idx - 1 >= lastIndex || rec.handle === undefined || rec.handle === null}
onClick={() =>
moveDown(table.family ?? '', table.name, chain.name, rec.handle, rec.idx - 1)
}
/>
</Space>
) : (
<Space>
<Button size="small" disabled>
Delete Rule
</Button>
<Button size="small" disabled icon={<DeleteOutlined />} />
<Button size="small" disabled icon={<ArrowUpOutlined />} />
<Button size="small" disabled icon={<ArrowDownOutlined />} />
</Space>
),
},
];
const dataSource = chain.rules.map((r, idx) => ({
key: idx,
key: `${chain.name}:${idx}`,
idx: idx + 1,
handle: r.handle ?? null,
raw: rulePreview(r),
raw: renderRuleFriendly(r),
}));
return (
@@ -424,10 +640,9 @@ export default function FirewallTables(): ReactElement {
<Space>
<Button
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDeleteChain(table.family, table.name, chain.name)}
>
Delete Chain
</Button>
/>
</Space>
</div>
}
@@ -447,6 +662,40 @@ export default function FirewallTables(): ReactElement {
);
})}
</Card>
{/* Results panel */}
{results.length > 0 && (
<Card title="Command Results" style={{ marginTop: 20 }}>
{results.map((r, i) => (
<div key={i} style={{ marginBottom: 12 }}>
<Text strong>{r.cmd}</Text>
{r.err ? (
<Paragraph type="danger" style={{ marginTop: 6 }}>
{r.err}
</Paragraph>
) : r.out ? (
<>
<Paragraph>
<Text type="secondary">rc:</Text> {r.out.rc}
</Paragraph>
{r.out.stdout ? (
<>
<Text type="secondary">stdout:</Text>
<pre style={{ whiteSpace: 'pre-wrap', background: '#fff', padding: 8 }}>{r.out.stdout}</pre>
</>
) : null}
{r.out.stderr ? (
<>
<Text type="secondary">stderr:</Text>
<pre style={{ whiteSpace: 'pre-wrap', background: '#fff', padding: 8 }}>{r.out.stderr}</pre>
</>
) : null}
</>
) : null}
</div>
))}
</Card>
)}
</>
);
}