JSON WORKS HERE TAG
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
// src/apiClient.ts
|
// src/apiClient.ts
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { CreateRuleRequest, ExecResult, MoveRequest, MoveResult, RulesetModel } from '../types/firewall';
|
import { CreateRuleRequest, ExecResult, RulesetModel } from '../types/firewall';
|
||||||
import {
|
import {
|
||||||
BridgeCreateRequest,
|
BridgeCreateRequest,
|
||||||
BridgeInfo,
|
BridgeInfo,
|
||||||
@@ -125,8 +125,8 @@ export const fetchPackets = async (limit = 100): Promise<any> => {
|
|||||||
* Returns: { ruleset: RulesetModel | string | null }
|
* Returns: { ruleset: RulesetModel | string | null }
|
||||||
* - If the server returns a raw textual fallback (string), the caller should handle it.
|
* - If the server returns a raw textual fallback (string), the caller should handle it.
|
||||||
*/
|
*/
|
||||||
export const fetchRuleset = async (): Promise<{ ruleset: RulesetModel | string | null }> => {
|
export const fetchRuleset = async (): Promise<{ ruleset: RulesetModel }> => {
|
||||||
const res = await api.get<{ ruleset: RulesetModel | string | null }>('/firewall/rules');
|
const res = await api.get<{ ruleset: RulesetModel }>('/firewall/rules');
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,11 +142,6 @@ export const deleteRule = async (handle: number, family: string, table: string,
|
|||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const moveRule = async (req: MoveRequest): Promise<MoveResult> => {
|
|
||||||
const res = await api.post<MoveResult>(`/firewall/rules/${encodeURIComponent(String(req.before_handle))}/move`, req);
|
|
||||||
return res.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* createRuleJson - POST /firewall/rules
|
* createRuleJson - POST /firewall/rules
|
||||||
* Body: CreateRuleRequest (must include expr)
|
* Body: CreateRuleRequest (must include expr)
|
||||||
|
|||||||
@@ -18,20 +18,33 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { createRuleJson, fetchRuleset } from '../api/apiClient';
|
import { createRuleJson } from '../api/apiClient';
|
||||||
import { Expr, TableOut } from '../types/firewall';
|
import { ChainOut, Expr, RuleOut, TableOut } from '../types/firewall';
|
||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
const MANUAL_TABLE = '__manual__';
|
||||||
|
const MANUAL_CHAIN = '__manual_chain__';
|
||||||
|
|
||||||
|
type FormValues = Record<string, any>;
|
||||||
|
|
||||||
interface RuleBuilderProps {
|
interface RuleBuilderProps {
|
||||||
onCreated?: () => Promise<void> | void;
|
onCreated?: () => Promise<void> | void;
|
||||||
|
tables?: TableOut[] | null;
|
||||||
|
rulesLoading?: boolean;
|
||||||
|
rulesError?: string | null;
|
||||||
|
refreshRules?: () => Promise<void>;
|
||||||
|
onRulesChange?: (tables: TableOut[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- helpers ---------- */
|
/* Helpers (unchanged) */
|
||||||
|
function isPresetProtocolChoice(values: FormValues) {
|
||||||
|
return (values.protocolChoice ?? 'preset') === 'preset';
|
||||||
|
}
|
||||||
|
|
||||||
function buildExprFromValues(values: any): Expr[] {
|
export function buildExprFromValues(values: FormValues): Expr[] {
|
||||||
const expr: Expr[] = [];
|
const expr: Expr[] = [];
|
||||||
|
|
||||||
const protoChoice = values.protocolChoice ?? 'preset';
|
const protoChoice = values.protocolChoice ?? 'preset';
|
||||||
@@ -82,7 +95,6 @@ function buildExprFromValues(values: any): Expr[] {
|
|||||||
} else if (/udp/i.test(custom)) {
|
} else if (/udp/i.test(custom)) {
|
||||||
expr.push({ udp: {} });
|
expr.push({ udp: {} });
|
||||||
} else {
|
} else {
|
||||||
// allow user to paste a JSON token string (advanced)
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(custom);
|
const parsed = JSON.parse(custom);
|
||||||
expr.push(parsed);
|
expr.push(parsed);
|
||||||
@@ -112,30 +124,21 @@ function buildExprFromValues(values: any): Expr[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chooseUdp = (preset?: string, custom?: string) => preset === 'udp' || (custom && /(udp)/i.test(custom));
|
||||||
|
|
||||||
if (values.sport) {
|
if (values.sport) {
|
||||||
const useUdp =
|
const proto = chooseUdp(values.protocolPreset, values.protocolCustom) ? 'udp' : 'tcp';
|
||||||
values.protocolPreset === 'udp' ||
|
expr.push({ [proto]: { sport: Number(values.sport) } } as any);
|
||||||
(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) {
|
if (values.dport) {
|
||||||
const useUdp =
|
const proto = chooseUdp(values.protocolPreset, values.protocolCustom) ? 'udp' : 'tcp';
|
||||||
values.protocolPreset === 'udp' ||
|
expr.push({ [proto]: { dport: Number(values.dport) } } as any);
|
||||||
(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.advanced) {
|
if (values.advanced) {
|
||||||
const adv = values.advanced.trim();
|
const adv = (values.advanced || '').trim();
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(adv);
|
expr.push(JSON.parse(adv));
|
||||||
expr.push(parsed);
|
|
||||||
} catch {
|
} catch {
|
||||||
expr.push(adv);
|
expr.push(adv);
|
||||||
}
|
}
|
||||||
@@ -146,27 +149,27 @@ function buildExprFromValues(values: any): Expr[] {
|
|||||||
else if (action === 'accept') expr.push({ accept: null });
|
else if (action === 'accept') expr.push({ accept: null });
|
||||||
else if (action === 'reject') expr.push({ reject: null });
|
else if (action === 'reject') expr.push({ reject: null });
|
||||||
else if (action === 'nfqueue' || action === 'queue') {
|
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);
|
const qnum = Number(values.nfqueue || 0);
|
||||||
if (Number.isFinite(qnum) && qnum > 0) {
|
expr.push({ queue: Number.isFinite(qnum) && qnum > 0 ? qnum : 0 } as any);
|
||||||
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;
|
return expr;
|
||||||
}
|
}
|
||||||
|
|
||||||
function textFromExpr(expr: Expr): string {
|
export function textFromExpr(expr: Expr | null | undefined): string {
|
||||||
if (expr == null) return '';
|
if (!expr) return '';
|
||||||
if (Array.isArray(expr)) {
|
if (!Array.isArray(expr)) return String(expr);
|
||||||
|
|
||||||
const tokens: string[] = [];
|
const tokens: string[] = [];
|
||||||
for (const part of expr) {
|
for (const part of expr) {
|
||||||
if (typeof part === 'string') {
|
if (typeof part === 'string') {
|
||||||
tokens.push(part);
|
tokens.push(part);
|
||||||
} else if (part && typeof part === 'object') {
|
continue;
|
||||||
|
}
|
||||||
|
if (!part || typeof part !== 'object') {
|
||||||
|
tokens.push(String(part));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if ('match' in part) {
|
if ('match' in part) {
|
||||||
const m = (part as any).match;
|
const m = (part as any).match;
|
||||||
const left = m.left;
|
const left = m.left;
|
||||||
@@ -179,266 +182,231 @@ function textFromExpr(expr: Expr): string {
|
|||||||
tokens.push(`${prot} ${field} ${right}`);
|
tokens.push(`${prot} ${field} ${right}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
tokens.push('match');
|
tokens.push('match');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
tokens.push('match');
|
if ('payload' in part) {
|
||||||
} else if ('payload' in part) {
|
|
||||||
const p = (part as any).payload;
|
const p = (part as any).payload;
|
||||||
const prot = p.protocol;
|
tokens.push(`payload(${p.protocol}.${p.field})`);
|
||||||
const field = p.field;
|
continue;
|
||||||
tokens.push(`payload(${prot}.${field})`);
|
}
|
||||||
} else if ('tcp' in part) {
|
if ('tcp' in part) {
|
||||||
const v = (part as any).tcp;
|
const v = (part as any).tcp;
|
||||||
if (v && v.dport) tokens.push(`tcp dport ${v.dport}`);
|
if (v && v.dport) tokens.push(`tcp dport ${v.dport}`);
|
||||||
else if (v && v.sport) tokens.push(`tcp sport ${v.sport}`);
|
else if (v && v.sport) tokens.push(`tcp sport ${v.sport}`);
|
||||||
else tokens.push('tcp');
|
else tokens.push('tcp');
|
||||||
} else if ('udp' in part) {
|
continue;
|
||||||
|
}
|
||||||
|
if ('udp' in part) {
|
||||||
const v = (part as any).udp;
|
const v = (part as any).udp;
|
||||||
if (v && v.dport) tokens.push(`udp dport ${v.dport}`);
|
if (v && v.dport) tokens.push(`udp dport ${v.dport}`);
|
||||||
else if (v && v.sport) tokens.push(`udp sport ${v.sport}`);
|
else if (v && v.sport) tokens.push(`udp sport ${v.sport}`);
|
||||||
else tokens.push('udp');
|
else tokens.push('udp');
|
||||||
} else if ('drop' in part) {
|
continue;
|
||||||
|
}
|
||||||
|
if ('drop' in part) {
|
||||||
tokens.push('drop');
|
tokens.push('drop');
|
||||||
} else if ('accept' in part) {
|
continue;
|
||||||
|
}
|
||||||
|
if ('accept' in part) {
|
||||||
tokens.push('accept');
|
tokens.push('accept');
|
||||||
} else if ('reject' in part) {
|
continue;
|
||||||
|
}
|
||||||
|
if ('reject' in part) {
|
||||||
tokens.push('reject');
|
tokens.push('reject');
|
||||||
} else if ('counter' in part) {
|
continue;
|
||||||
|
}
|
||||||
|
if ('counter' in part) {
|
||||||
tokens.push('counter');
|
tokens.push('counter');
|
||||||
} else if ('queue' in part) {
|
continue;
|
||||||
// show NFQUEUE / queue
|
|
||||||
const q = (part as any).queue;
|
|
||||||
tokens.push(`queue ${q}`);
|
|
||||||
} else {
|
|
||||||
const keys = Object.keys(part).sort().join('+');
|
|
||||||
tokens.push(keys);
|
|
||||||
}
|
}
|
||||||
} else {
|
if ('queue' in part) {
|
||||||
tokens.push(String(part));
|
tokens.push(`queue ${(part as any).queue}`);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
tokens.push(Object.keys(part).sort().join('+'));
|
||||||
}
|
}
|
||||||
return tokens.join(' ');
|
return tokens.join(' ');
|
||||||
}
|
|
||||||
return String(expr);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- component ---------- */
|
/* Component */
|
||||||
|
export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps) => {
|
||||||
export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [tables, setTables] = useState<TableOut[]>([]);
|
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
const [cmdPreview, setCmdPreview] = useState<string>('');
|
|
||||||
const [requestJsonPreview, setRequestJsonPreview] = useState<any>(null);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
async function loadTables() {
|
const [cmdPreview, setCmdPreview] = useState('');
|
||||||
setRefreshing(true);
|
const [requestJsonPreview, setRequestJsonPreview] = useState<any>(null);
|
||||||
try {
|
|
||||||
const res = await fetchRuleset();
|
|
||||||
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 {
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetch on mount
|
// state for insert-before options (so we can recompute on both table change and form change)
|
||||||
useEffect(() => {
|
const [insertBeforeOptions, setInsertBeforeOptions] = useState<Array<{ value: any; label: string }>>([]);
|
||||||
loadTables();
|
|
||||||
|
// Resolve table selection convenience
|
||||||
|
const resolveTable = useCallback((values: FormValues) => {
|
||||||
|
const tableSelect = values.tableSelect;
|
||||||
|
if (!tableSelect || tableSelect === MANUAL_TABLE) {
|
||||||
|
return {
|
||||||
|
family: values.familyManual || 'inet',
|
||||||
|
table: values.tableManual || 'filter',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const [family = 'inet', table = 'filter'] = String(tableSelect).split(':');
|
||||||
|
return { family, table };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const tableOptions = useMemo(
|
const tableOptions = useMemo(
|
||||||
() => tables.map((t) => ({ family: t.family, name: t.name, label: `${t.family}:${t.name}` })),
|
() => (props.tables || []).map((t) => ({ value: `${t.family}:${t.name}`, label: `${t.family}:${t.name}` })),
|
||||||
[tables],
|
[props.tables],
|
||||||
);
|
);
|
||||||
|
|
||||||
// set sensible defaults once tables are known
|
// compute insert-before options from current form selection + props.tables
|
||||||
|
const updateInsertOptions = useCallback(() => {
|
||||||
|
const ts = form.getFieldValue('tableSelect');
|
||||||
|
const cs = form.getFieldValue('chainSelect');
|
||||||
|
if (!ts || ts === MANUAL_TABLE || !cs || cs === MANUAL_CHAIN) {
|
||||||
|
setInsertBeforeOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [family, table] = String(ts).split(':');
|
||||||
|
const tbl = props.tables?.find((t) => t.family === family && t.name === table);
|
||||||
|
if (!tbl) {
|
||||||
|
setInsertBeforeOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ch = (tbl.chains || []).find((c: ChainOut) => c.name === cs);
|
||||||
|
if (!ch || !Array.isArray(ch.rules)) {
|
||||||
|
setInsertBeforeOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = ch.rules
|
||||||
|
.filter((r: RuleOut) => r && r.handle != null)
|
||||||
|
.map((r: RuleOut) => ({
|
||||||
|
value: r.handle,
|
||||||
|
label: `#${r.handle} — ${r.text ?? (typeof r.expr === 'string' ? r.expr : JSON.stringify(r.expr || {}).slice(0, 120))}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setInsertBeforeOptions(opts);
|
||||||
|
}, [form, props.tables]);
|
||||||
|
|
||||||
|
// When the (parent-supplied) tables change, pick sensible defaults and update previews + insert options.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tableOptions.length > 0) {
|
if (tableOptions.length > 0) {
|
||||||
const first = tableOptions[0];
|
const first = tableOptions[0].value;
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
tableSelect: `${first.family}:${first.name}`,
|
tableSelect: first,
|
||||||
protocolChoice: 'preset',
|
protocolChoice: 'preset',
|
||||||
protocolPreset: 'icmp',
|
protocolPreset: 'icmp',
|
||||||
action: 'drop',
|
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);
|
const [f, n] = String(first).split(':');
|
||||||
|
const tbl = props.tables?.find((t) => t.family === f && t.name === n);
|
||||||
if (tbl && tbl.chains && tbl.chains.length > 0) {
|
if (tbl && tbl.chains && tbl.chains.length > 0) {
|
||||||
form.setFieldsValue({ chainSelect: tbl.chains[0].name });
|
form.setFieldsValue({ chainSelect: tbl.chains[0].name });
|
||||||
} else {
|
} else {
|
||||||
form.setFieldsValue({ chainSelect: '__manual_chain__' });
|
form.setFieldsValue({ chainSelect: MANUAL_CHAIN });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recompute insert-before options after defaults set
|
||||||
|
// give AntD a tick to flush form fields, then compute options & previews
|
||||||
|
setTimeout(() => {
|
||||||
|
updateInsertOptions();
|
||||||
|
updatePreviews();
|
||||||
|
}, 0);
|
||||||
} else {
|
} else {
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
tableSelect: '__manual__',
|
tableSelect: MANUAL_TABLE,
|
||||||
protocolChoice: 'preset',
|
protocolChoice: 'preset',
|
||||||
protocolPreset: 'icmp',
|
protocolPreset: 'icmp',
|
||||||
action: 'drop',
|
action: 'drop',
|
||||||
chainSelect: '__manual_chain__',
|
chainSelect: MANUAL_CHAIN,
|
||||||
});
|
});
|
||||||
|
setInsertBeforeOptions([]);
|
||||||
|
setTimeout(() => updatePreviews(), 0);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// only when parent tables change
|
||||||
}, [tableOptions]);
|
}, [props.tables, tableOptions.length, updateInsertOptions]);
|
||||||
|
|
||||||
// update previews when form changes
|
// preview update (reads form values)
|
||||||
const updatePreviews = useCallback(() => {
|
const updatePreviews = useCallback(() => {
|
||||||
const vals = form.getFieldsValue();
|
const values = form.getFieldsValue();
|
||||||
const expr = buildExprFromValues(vals);
|
const expr = buildExprFromValues(values);
|
||||||
|
|
||||||
|
const { family, table } = resolveTable(values);
|
||||||
|
|
||||||
// compute family/table/chain choices for cmd + request JSON
|
|
||||||
let family = 'inet';
|
|
||||||
let tableName = 'filter';
|
|
||||||
let chain = 'input';
|
let chain = 'input';
|
||||||
|
const chainVal = values.chainSelect;
|
||||||
|
if (!chainVal || chainVal === MANUAL_CHAIN) chain = values.chainManual || chain;
|
||||||
|
else chain = chainVal;
|
||||||
|
|
||||||
const tableSelect = vals.tableSelect;
|
const beforeHandle = values.insertBeforeHandle;
|
||||||
if (tableSelect && tableSelect !== '__manual__') {
|
|
||||||
const [f, n] = String(tableSelect).split(':');
|
|
||||||
family = f || family;
|
|
||||||
tableName = n || tableName;
|
|
||||||
} else {
|
|
||||||
family = vals.familyManual || family;
|
|
||||||
tableName = vals.tableManual || tableName;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chainVal = vals.chainSelect;
|
|
||||||
if (!chainVal || chainVal === '__manual_chain__') {
|
|
||||||
chain = vals.chainManual || chain;
|
|
||||||
} else {
|
|
||||||
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 short = textFromExpr(expr);
|
||||||
const cmd = `add rule ${family} ${tableName} ${chain}${positionToken} ${short}`.trim();
|
const cmdFriendly = beforeHandle
|
||||||
setCmdPreview(cmd);
|
? `add rule ${family} ${table} ${chain} position ${beforeHandle} ${short}`
|
||||||
|
: `add rule ${family} ${table} ${chain} ${short}`;
|
||||||
|
setCmdPreview(cmdFriendly);
|
||||||
|
|
||||||
const reqObj: any = { family, table: tableName, chain, expr };
|
const req: any = { family, table, chain, expr };
|
||||||
if (positionToken) {
|
if (beforeHandle != null && beforeHandle !== '') {
|
||||||
// extract numeric pos and include in request body
|
// backend expects "position"
|
||||||
const m = positionToken.match(/position\s+(\d+)/);
|
req.position = Number(beforeHandle);
|
||||||
if (m) {
|
|
||||||
reqObj.position = Number(m[1]);
|
|
||||||
}
|
}
|
||||||
}
|
setRequestJsonPreview(req);
|
||||||
setRequestJsonPreview(reqObj);
|
}, [form, resolveTable]);
|
||||||
}, [form, tables]);
|
|
||||||
|
|
||||||
|
// run once at mount to compute previews from initial values
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updatePreviews();
|
updatePreviews();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// keep preview updated on values change
|
// when user changes form fields
|
||||||
const onValuesChange = useCallback(() => {
|
const onValuesChange = useCallback(() => {
|
||||||
updatePreviews();
|
updatePreviews();
|
||||||
|
|
||||||
// when tableSelect changes, auto-select chain (first) if any
|
// keep chain in sync when tableSelect changes
|
||||||
const vals = form.getFieldsValue();
|
const values = form.getFieldsValue();
|
||||||
const ts = vals.tableSelect;
|
const ts = values.tableSelect;
|
||||||
if (ts && ts !== '__manual__') {
|
if (ts && ts !== MANUAL_TABLE) {
|
||||||
const [f, n] = String(ts).split(':');
|
const [f, n] = String(ts).split(':');
|
||||||
const tbl = tables.find((t) => t.family === f && t.name === n);
|
const tbl = props.tables?.find((t) => t.family === f && t.name === n);
|
||||||
if (tbl) {
|
if (tbl) {
|
||||||
if (tbl.chains && tbl.chains.length > 0) {
|
if (tbl.chains && tbl.chains.length > 0) {
|
||||||
// if currently no chain selected or manual, set to first available chain
|
const currentChain = form.getFieldValue('chainSelect');
|
||||||
const cs = form.getFieldValue('chainSelect');
|
if (!currentChain || currentChain === MANUAL_CHAIN) {
|
||||||
if (!cs || cs === '__manual_chain__') {
|
|
||||||
form.setFieldsValue({ chainSelect: tbl.chains[0].name });
|
form.setFieldsValue({ chainSelect: tbl.chains[0].name });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// if no chains, set chainSelect to manual so user can type
|
form.setFieldsValue({ chainSelect: MANUAL_CHAIN });
|
||||||
form.setFieldsValue({ chainSelect: '__manual_chain__' });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [form, tables, updatePreviews]);
|
|
||||||
|
|
||||||
// submit handler: build expr and call createRuleJson
|
// recompute insert-before options when the user changes table/chain
|
||||||
|
updateInsertOptions();
|
||||||
|
}, [form, props.tables, updatePreviews, updateInsertOptions]);
|
||||||
|
|
||||||
|
// create handler — component will call props.refreshRules if provided; otherwise it will NOT fetch rules itself.
|
||||||
const handleCreate = useCallback(
|
const handleCreate = useCallback(
|
||||||
async (values: any) => {
|
async (values: FormValues) => {
|
||||||
const tableSelect = values.tableSelect;
|
const { family, table } = resolveTable(values);
|
||||||
let family: string;
|
const chain =
|
||||||
let tableName: string;
|
!values.chainSelect || values.chainSelect === MANUAL_CHAIN ? values.chainManual || 'input' : values.chainSelect;
|
||||||
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);
|
const expr = buildExprFromValues(values);
|
||||||
|
|
||||||
// compute optional position from insertAfterHandle
|
const req: any = { family, table, chain, expr };
|
||||||
const reqObj: any = { family, table: tableName, chain, expr };
|
if (values.insertBeforeHandle != null && values.insertBeforeHandle !== '') {
|
||||||
if (
|
// backend expects "position" (per your request)
|
||||||
values.insertAfterHandle &&
|
req.position = Number(values.insertBeforeHandle);
|
||||||
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 textual = textFromExpr(expr);
|
||||||
const posStr = reqObj.position !== undefined ? ` position ${reqObj.position}` : '';
|
const previewHint = req.position ? ` ${req.position}` : '';
|
||||||
const cmd = `add rule ${family} ${tableName} ${chain}${posStr} ${textual}`.trim();
|
const cmd = `add rule ${family} ${table} ${chain}${previewHint} ${textual}`.trim();
|
||||||
|
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: 'Create rule (JSON)',
|
title: 'Create rule (JSON)',
|
||||||
@@ -447,7 +415,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
|
|||||||
<Text>
|
<Text>
|
||||||
About to create rule in{' '}
|
About to create rule in{' '}
|
||||||
<b>
|
<b>
|
||||||
{family}:{tableName} {chain}
|
{family}:{table} {chain}
|
||||||
</b>
|
</b>
|
||||||
</Text>
|
</Text>
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -455,20 +423,22 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
|
|||||||
<pre style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>{cmd || '(no textual preview available)'}</pre>
|
<pre style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>{cmd || '(no textual preview available)'}</pre>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Text strong>Full JSON NFT request:</Text>
|
<Text strong>Full JSON NFT request:</Text>
|
||||||
<pre style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>{JSON.stringify(reqObj, null, 2)}</pre>
|
<pre style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>{JSON.stringify(req, null, 2)}</pre>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
okText: 'Create',
|
okText: 'Create',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await createRuleJson(reqObj);
|
const res = await createRuleJson(req);
|
||||||
if (res && res.rc === 0) {
|
if (res && res.rc === 0) {
|
||||||
message.success('Rule created');
|
message.success('Rule created');
|
||||||
// refresh tables/chains after successful creation
|
if (props.refreshRules) {
|
||||||
await loadTables();
|
await props.refreshRules();
|
||||||
if (onCreated) await onCreated();
|
} else {
|
||||||
form.resetFields(['advanced', 'insertAfterHandle']);
|
message.info('Parent did not provide a refresh function; please refresh ruleset externally.');
|
||||||
|
}
|
||||||
|
if (props.onCreated) await props.onCreated();
|
||||||
} else {
|
} else {
|
||||||
message.error(`Create failed: ${res?.stderr ?? 'unknown error'}`);
|
message.error(`Create failed: ${res?.stderr ?? 'unknown error'}`);
|
||||||
}
|
}
|
||||||
@@ -480,12 +450,10 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
|
|||||||
const rc = Number(data.rc ?? -1);
|
const rc = Number(data.rc ?? -1);
|
||||||
const stderr = data.stderr ?? data;
|
const stderr = data.stderr ?? data;
|
||||||
if (rc === 0) {
|
if (rc === 0) {
|
||||||
message.warn(
|
message.warn('Rule may have been created; check ruleset.');
|
||||||
'Rule appears to have been created, but server returned an error status. Check output for details.',
|
if (props.refreshRules) {
|
||||||
);
|
await props.refreshRules();
|
||||||
await loadTables();
|
}
|
||||||
if (onCreated) await onCreated();
|
|
||||||
form.resetFields(['advanced', 'insertAfterHandle']);
|
|
||||||
} else {
|
} else {
|
||||||
const errMsg = typeof stderr === 'string' ? stderr : JSON.stringify(stderr);
|
const errMsg = typeof stderr === 'string' ? stderr : JSON.stringify(stderr);
|
||||||
message.error(`Create failed: ${errMsg}`);
|
message.error(`Create failed: ${errMsg}`);
|
||||||
@@ -504,56 +472,41 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[form, onCreated, tables],
|
[form, props.refreshRules, resolveTable],
|
||||||
);
|
);
|
||||||
|
|
||||||
// prepare chain options for currently selected table
|
|
||||||
const chainOptions = useMemo(() => {
|
const chainOptions = useMemo(() => {
|
||||||
const ts = form.getFieldValue('tableSelect');
|
const ts = form.getFieldValue('tableSelect');
|
||||||
if (ts && ts !== '__manual__') {
|
if (!ts || ts === MANUAL_TABLE) return null;
|
||||||
const [f, n] = String(ts).split(':');
|
const [f, n] = String(ts).split(':');
|
||||||
const tbl = tables.find((t) => t.family === f && t.name === n);
|
const tbl = props.tables?.find((t) => t.family === f && t.name === n);
|
||||||
if (tbl && tbl.chains.length > 0) {
|
if (!tbl) return null;
|
||||||
return (
|
return tbl.chains.map((c) => (
|
||||||
<>
|
|
||||||
{tbl.chains.map((c) => (
|
|
||||||
<Option key={c.name} value={c.name}>
|
<Option key={c.name} value={c.name}>
|
||||||
{c.name}
|
{c.name}
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
));
|
||||||
<Option key="__manual_chain__" value="__manual_chain__">
|
}, [form, props.tables]);
|
||||||
Manual chain...
|
|
||||||
</Option>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Option key="__manual_chain__" value="__manual_chain__">
|
|
||||||
Manual chain...
|
|
||||||
</Option>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}, [form, tables]);
|
|
||||||
|
|
||||||
// build insert-after options for currently selected table+chain
|
const handleRefresh = useCallback(async () => {
|
||||||
const insertAfterOptions = useMemo(() => {
|
setRefreshing(true);
|
||||||
const ts = form.getFieldValue('tableSelect');
|
try {
|
||||||
const cs = form.getFieldValue('chainSelect');
|
if (props.refreshRules) {
|
||||||
if (!ts || ts === '__manual__' || !cs || cs === '__manual_chain__') return [];
|
await props.refreshRules();
|
||||||
const [f, n] = String(ts).split(':');
|
message.success('Rules refresh requested');
|
||||||
const tbl = tables.find((t) => t.family === f && t.name === n);
|
} else {
|
||||||
if (!tbl) return [];
|
message.info('No refresh function provided by parent.');
|
||||||
const ch = tbl.chains.find((c) => c.name === cs);
|
}
|
||||||
if (!ch || !Array.isArray(ch.rules)) return [];
|
} catch (err) {
|
||||||
return ch.rules
|
console.warn('refresh failed', err);
|
||||||
.filter((r: any) => r && r.handle !== undefined && r.handle !== null)
|
message.error('Refresh failed');
|
||||||
.map((r: any) => ({
|
} finally {
|
||||||
value: r.handle,
|
// parent should update props.tables — update options once props.tables changes
|
||||||
label: `#${r.handle} — ${r.text ?? (typeof r.expr === 'string' ? r.expr : JSON.stringify(r.expr || r).slice(0, 120))}`,
|
// but ensure we recompute in case parent updated in place
|
||||||
}));
|
updateInsertOptions();
|
||||||
}, [form, tables]);
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [props.refreshRules, updateInsertOptions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card style={{ maxWidth: 980 }}>
|
<Card style={{ maxWidth: 980 }}>
|
||||||
@@ -566,9 +519,10 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
|
|||||||
Builds an nft JSON <code>expr</code> and sends it to the server. If rendering fails, use the Raw UI.
|
Builds an nft JSON <code>expr</code> and sends it to the server. If rendering fails, use the Raw UI.
|
||||||
</Text>
|
</Text>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col>
|
<Col>
|
||||||
<Space>
|
<Space>
|
||||||
<Button icon={<ReloadOutlined />} onClick={loadTables} loading={refreshing}>
|
<Button icon={<ReloadOutlined />} onClick={handleRefresh} loading={refreshing}>
|
||||||
Refresh tables
|
Refresh tables
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
@@ -584,7 +538,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
|
|||||||
protocolChoice: 'preset',
|
protocolChoice: 'preset',
|
||||||
protocolPreset: 'icmp',
|
protocolPreset: 'icmp',
|
||||||
action: 'drop',
|
action: 'drop',
|
||||||
tableSelect: tableOptions.length > 0 ? `${tableOptions[0].family}:${tableOptions[0].name}` : '__manual__',
|
tableSelect: tableOptions.length > 0 ? tableOptions[0].value : MANUAL_TABLE,
|
||||||
}}
|
}}
|
||||||
onFinish={handleCreate}
|
onFinish={handleCreate}
|
||||||
onValuesChange={onValuesChange}
|
onValuesChange={onValuesChange}
|
||||||
@@ -595,11 +549,11 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
|
|||||||
<Form.Item name="tableSelect" label="Table (family:name)" rules={[{ required: true }]}>
|
<Form.Item name="tableSelect" label="Table (family:name)" rules={[{ required: true }]}>
|
||||||
<Select>
|
<Select>
|
||||||
{tableOptions.map((t) => (
|
{tableOptions.map((t) => (
|
||||||
<Option key={`${t.family}:${t.name}`} value={`${t.family}:${t.name}`}>
|
<Option key={t.value} value={t.value}>
|
||||||
{t.label}
|
{t.label}
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
<Option key="__manual__" value="__manual__">
|
<Option key={MANUAL_TABLE} value={MANUAL_TABLE}>
|
||||||
Manual / new table...
|
Manual / new table...
|
||||||
</Option>
|
</Option>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -607,7 +561,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
|
|||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col xs={24} sm={12}>
|
<Col xs={24} sm={12}>
|
||||||
{form.getFieldValue('tableSelect') === '__manual__' ? (
|
{form.getFieldValue('tableSelect') === MANUAL_TABLE ? (
|
||||||
<Space style={{ width: '100%' }}>
|
<Space style={{ width: '100%' }}>
|
||||||
<Form.Item name="familyManual" label="Family" style={{ flex: 1 }}>
|
<Form.Item name="familyManual" label="Family" style={{ flex: 1 }}>
|
||||||
<Input placeholder="inet, bridge, ip, ip6 ..." />
|
<Input placeholder="inet, bridge, ip, ip6 ..." />
|
||||||
@@ -628,13 +582,19 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
|
|||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col xs={24} sm={12}>
|
<Col xs={24} sm={12}>
|
||||||
<Form.Item name="chainSelect" label="Chain (select)" rules={[{ required: true }]}>
|
<Form.Item name="chainSelect" label="Chain (select)" rules={[{ required: true }]}>
|
||||||
<Select>{chainOptions}</Select>
|
<Select>
|
||||||
|
{chainOptions ?? (
|
||||||
|
<Option key={MANUAL_CHAIN} value={MANUAL_CHAIN}>
|
||||||
|
Manual chain...
|
||||||
|
</Option>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col xs={24} sm={12}>
|
<Col xs={24} sm={12}>
|
||||||
{form.getFieldValue('chainSelect') === '__manual_chain__' ||
|
{form.getFieldValue('chainSelect') === MANUAL_CHAIN ||
|
||||||
form.getFieldValue('tableSelect') === '__manual__' ? (
|
form.getFieldValue('tableSelect') === MANUAL_TABLE ? (
|
||||||
<Form.Item name="chainManual" label="Chain (manual)">
|
<Form.Item name="chainManual" label="Chain (manual)">
|
||||||
<Input placeholder="input, forward, output" />
|
<Input placeholder="input, forward, output" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -646,24 +606,24 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* Insert-after control */}
|
{/* Insert-before control */}
|
||||||
{form.getFieldValue('tableSelect') !== '__manual__' &&
|
{form.getFieldValue('tableSelect') !== MANUAL_TABLE &&
|
||||||
form.getFieldValue('chainSelect') &&
|
form.getFieldValue('chainSelect') &&
|
||||||
form.getFieldValue('chainSelect') !== '__manual_chain__' && (
|
form.getFieldValue('chainSelect') !== MANUAL_CHAIN && (
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col xs={24} sm={12}>
|
<Col xs={24} sm={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="insertAfterHandle"
|
name="insertBeforeHandle"
|
||||||
label="Insert after (optional)"
|
label="Insert before (optional)"
|
||||||
help="Pick an existing rule handle to insert *after*. If left empty the rule will be appended."
|
help="Pick an existing rule handle to insert the new rule before. If left empty the rule will be appended."
|
||||||
>
|
>
|
||||||
<Select allowClear placeholder="Append (no insert-after)">
|
<Select allowClear placeholder="Append (no insert-before)">
|
||||||
{insertAfterOptions.length === 0 ? (
|
{insertBeforeOptions.length === 0 ? (
|
||||||
<Option value="__none__" disabled>
|
<Option value="__none__" disabled>
|
||||||
(no rules available)
|
(no rules available)
|
||||||
</Option>
|
</Option>
|
||||||
) : (
|
) : (
|
||||||
insertAfterOptions.map((o: any) => (
|
insertBeforeOptions.map((o) => (
|
||||||
<Option key={String(o.value)} value={o.value}>
|
<Option key={String(o.value)} value={o.value}>
|
||||||
{o.label}
|
{o.label}
|
||||||
</Option>
|
</Option>
|
||||||
@@ -672,12 +632,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
|
|||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} style={{ display: 'flex', alignItems: 'flex-end' }}>
|
|
||||||
<Text type="secondary">
|
|
||||||
Use when you want the new rule to appear right after a known handle. Refresh tables to see latest
|
|
||||||
handles.
|
|
||||||
</Text>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -695,11 +649,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
|
|||||||
</Space>
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{form.getFieldValue('protocolChoice') === 'custom' ? (
|
{isPresetProtocolChoice(form.getFieldsValue()) ? (
|
||||||
<Form.Item name="protocolCustom" label="Protocol (custom)">
|
|
||||||
<Input placeholder="e.g. icmp, icmpv6, tcp, udp, meta ..." />
|
|
||||||
</Form.Item>
|
|
||||||
) : (
|
|
||||||
<Form.Item name="protocolPreset" label="Protocol (preset)">
|
<Form.Item name="protocolPreset" label="Protocol (preset)">
|
||||||
<Select>
|
<Select>
|
||||||
<Option value="">(none)</Option>
|
<Option value="">(none)</Option>
|
||||||
@@ -709,6 +659,10 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
|
|||||||
<Option value="udp">UDP</Option>
|
<Option value="udp">UDP</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<Form.Item name="protocolCustom" label="Protocol (custom)">
|
||||||
|
<Input placeholder="e.g. icmp, icmpv6, tcp, udp, meta ..." />
|
||||||
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
@@ -732,11 +686,13 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
|
|||||||
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
|
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col xs={24} sm={6}>
|
<Col xs={24} sm={6}>
|
||||||
<Form.Item name="dport" label="Dest Port">
|
<Form.Item name="dport" label="Dest Port">
|
||||||
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
|
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col xs={24} sm={12}>
|
<Col xs={24} sm={12}>
|
||||||
<Form.Item name="action" label="Action" rules={[{ required: true }]} initialValue="drop">
|
<Form.Item name="action" label="Action" rules={[{ required: true }]} initialValue="drop">
|
||||||
<Radio.Group>
|
<Radio.Group>
|
||||||
@@ -796,12 +752,12 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!cmdPreview) return;
|
if (!cmdPreview) return;
|
||||||
// copy the textual preview as plain text
|
|
||||||
navigator.clipboard?.writeText(cmdPreview);
|
navigator.clipboard?.writeText(cmdPreview);
|
||||||
message.success('Command preview copied');
|
message.success('Command preview copied');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Button type="primary" htmlType="submit" loading={loading} icon={<PlusOutlined />}>
|
<Button type="primary" htmlType="submit" loading={loading} icon={<PlusOutlined />}>
|
||||||
Create rule
|
Create rule
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,123 +1,30 @@
|
|||||||
// src/components/FirewallTables.tsx
|
// src/components/FirewallTables.tsx
|
||||||
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined } from '@ant-design/icons';
|
import { ArrowDownOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
import { Alert, Button, Card, Divider, message, Modal, Space, Spin, Table, Typography } from 'antd';
|
import { Alert, Button, Card, Divider, message, Modal, Space, Table, Typography } from 'antd';
|
||||||
import { ColumnsType } from 'antd/lib/table';
|
import { ColumnsType } from 'antd/lib/table';
|
||||||
import { ReactElement, useEffect, useState } from 'react';
|
import { ReactElement, useCallback, useMemo, useState } from 'react';
|
||||||
import { execFirewallRaw, fetchRuleset, moveRule } from '../api/apiClient';
|
import { execFirewallRaw } from '../api/apiClient';
|
||||||
import type { CmdResult, ExecResult } from '../types/firewall';
|
import type { CmdResult, ExecResult, RuleOut, TableOut } from '../types/firewall';
|
||||||
import FirewallAddChainModal from './FireWallAddChainModal';
|
import FirewallAddChainModal from './FireWallAddChainModal';
|
||||||
import FirewallAddTableModal from './FireWallAddTableModal';
|
import FirewallAddTableModal from './FireWallAddTableModal';
|
||||||
|
|
||||||
const { Paragraph, Text, Title } = Typography;
|
const { Paragraph, Text, Title } = Typography;
|
||||||
|
|
||||||
/* Types */
|
/* ---------- Helpers ---------- */
|
||||||
type NFTRule = {
|
|
||||||
handle?: number | string;
|
|
||||||
expr?: any;
|
|
||||||
rule?: any;
|
|
||||||
text?: string;
|
|
||||||
[k: string]: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
type NFTChain = {
|
/**
|
||||||
name: string;
|
* Render a human friendly one-line representation of a rule.
|
||||||
type?: string | null;
|
* Accepts RuleOut (typed) but remains defensive against unexpected shapes.
|
||||||
hook?: string | null;
|
*/
|
||||||
priority?: number | null;
|
function renderRuleFriendly(rule: RuleOut | any): string {
|
||||||
policy?: string | null;
|
// prefer `text` if present
|
||||||
rules: NFTRule[];
|
if (rule?.text && typeof rule.text === 'string' && rule.text.trim() !== '') return rule.text;
|
||||||
};
|
|
||||||
|
|
||||||
type NFTTable = {
|
// if expr is a string, show it
|
||||||
family?: string | null;
|
if (rule?.expr && typeof rule.expr === 'string') return rule.expr;
|
||||||
name: string;
|
|
||||||
chains: NFTChain[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/* Extract ruleset -> tables (unchanged) */
|
// if expr is an array, try to pretty print common tokens
|
||||||
function extractTablesFromParsed(parsed: any): NFTTable[] {
|
const expr = rule?.expr ?? rule;
|
||||||
if (!parsed) return [];
|
|
||||||
|
|
||||||
if (Array.isArray(parsed.nftables)) {
|
|
||||||
const tablesMap = new Map<string, NFTTable>();
|
|
||||||
const chainsMap = new Map<string, NFTChain>();
|
|
||||||
|
|
||||||
for (const item of parsed.nftables) {
|
|
||||||
if (item.table) {
|
|
||||||
const t = item.table;
|
|
||||||
const key = `${t.family ?? 'n/a'}:${t.name}`;
|
|
||||||
if (!tablesMap.has(key)) {
|
|
||||||
tablesMap.set(key, { family: t.family ?? null, name: t.name, chains: [] });
|
|
||||||
}
|
|
||||||
} else if (item.chain) {
|
|
||||||
const c = item.chain;
|
|
||||||
const fam = c.family ?? 'n/a';
|
|
||||||
const table = c.table ?? 'n/a';
|
|
||||||
const tableKey = `${fam}:${table}`;
|
|
||||||
|
|
||||||
if (!tablesMap.has(tableKey)) {
|
|
||||||
tablesMap.set(tableKey, { family: c.family ?? null, name: table, chains: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
const chainKey = `${fam}:${table}:${c.name}`;
|
|
||||||
const chainObj: NFTChain = {
|
|
||||||
name: c.name,
|
|
||||||
type: c.type ?? null,
|
|
||||||
hook: c.hook ?? null,
|
|
||||||
priority: (c.priority as number) ?? null,
|
|
||||||
policy: c.policy ?? null,
|
|
||||||
rules: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
chainsMap.set(chainKey, chainObj);
|
|
||||||
tablesMap.get(tableKey)!.chains.push(chainObj);
|
|
||||||
} else if (item.rule) {
|
|
||||||
const r = item.rule;
|
|
||||||
const fam = r.family ?? r.table?.family ?? 'n/a';
|
|
||||||
const table = r.table ?? r.table?.name ?? r.table_name ?? 'n/a';
|
|
||||||
const chainName = r.chain ?? r.chain?.name ?? 'unknown';
|
|
||||||
|
|
||||||
const tableKey = `${fam}:${table}`;
|
|
||||||
const chainKey = `${fam}:${table}:${chainName}`;
|
|
||||||
|
|
||||||
if (!tablesMap.has(tableKey)) {
|
|
||||||
tablesMap.set(tableKey, { family: fam ?? null, name: table, chains: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!chainsMap.has(chainKey)) {
|
|
||||||
const newChain: NFTChain = { name: chainName, rules: [] };
|
|
||||||
chainsMap.set(chainKey, newChain);
|
|
||||||
tablesMap.get(tableKey)!.chains.push(newChain);
|
|
||||||
}
|
|
||||||
|
|
||||||
tablesMap
|
|
||||||
.get(tableKey)!
|
|
||||||
.chains.find((c) => c.name === chainName)!
|
|
||||||
.rules.push({ ...r });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(tablesMap.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(parsed.tables)) {
|
|
||||||
return parsed.tables.map((t: any) => ({
|
|
||||||
family: t.family ?? null,
|
|
||||||
name: t.name,
|
|
||||||
chains: t.chains ?? [],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
|
|
||||||
const expr = rule.expr ?? rule;
|
|
||||||
if (Array.isArray(expr)) {
|
if (Array.isArray(expr)) {
|
||||||
const tokens: string[] = [];
|
const tokens: string[] = [];
|
||||||
for (const part of expr) {
|
for (const part of expr) {
|
||||||
@@ -200,7 +107,8 @@ function renderRuleFriendly(rule: NFTRule): string {
|
|||||||
if (tokens.length > 0) return tokens.join(' ');
|
if (tokens.length > 0) return tokens.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rule.expr && typeof rule.expr === 'object') {
|
// fallback: if expr is an object, stringify a short excerpt
|
||||||
|
if (rule?.expr && typeof rule.expr === 'object') {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(rule.expr, (_k, v) => (v === undefined ? null : v)).slice(0, 500);
|
return JSON.stringify(rule.expr, (_k, v) => (v === undefined ? null : v)).slice(0, 500);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -208,6 +116,7 @@ function renderRuleFriendly(rule: NFTRule): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// final fallback: stringify whole rule
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(rule, null, 2);
|
return JSON.stringify(rule, null, 2);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -221,36 +130,24 @@ function isSuccessRc(out?: ExecResult | null): boolean {
|
|||||||
return out.rc === 0 || out.rc === -1;
|
return out.rc === 0 || out.rc === -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Component */
|
/* ---------- Props ---------- */
|
||||||
export default function FirewallTables(): ReactElement {
|
type Props = {
|
||||||
const [loading, setLoading] = useState(true);
|
tables: TableOut[]; // passed from parent
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
error?: Error | null;
|
||||||
const [error, setError] = useState<string | null>(null);
|
refreshRules: () => Promise<void>; // trigger to re-fetch ruleset
|
||||||
const [tables, setTables] = useState<NFTTable[]>([]);
|
};
|
||||||
const [results, setResults] = useState<CmdResult[]>([]);
|
|
||||||
|
|
||||||
|
/* ---------- Component ---------- */
|
||||||
|
|
||||||
|
export default function FirewallTables({ tables, error, refreshRules: refresh }: Props): ReactElement {
|
||||||
|
// local UI state, non-persistent
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [results, setResults] = useState<CmdResult[]>([]);
|
||||||
const [isOpenTableCreatorModal, setIsOpenTableCreatorModal] = useState(false);
|
const [isOpenTableCreatorModal, setIsOpenTableCreatorModal] = useState(false);
|
||||||
const [isOpenChainCreatorModal, setIsOpenChainCreatorModal] = useState(false);
|
const [isOpenChainCreatorModal, setIsOpenChainCreatorModal] = useState(false);
|
||||||
|
|
||||||
async function loadRuleset() {
|
// run raw nft commands sequentially and collect results (used for delete ops etc.)
|
||||||
setLoading(true);
|
const runCommands = useCallback(async (cmds: string[]) => {
|
||||||
try {
|
|
||||||
const res = await fetchRuleset();
|
|
||||||
const parsed = extractTablesFromParsed((res as any).ruleset);
|
|
||||||
setTables(parsed);
|
|
||||||
setError(null);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.message ?? String(err));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadRuleset();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function runCommands(cmds: string[]) {
|
|
||||||
const acc: CmdResult[] = [];
|
const acc: CmdResult[] = [];
|
||||||
for (const cmd of cmds) {
|
for (const cmd of cmds) {
|
||||||
try {
|
try {
|
||||||
@@ -261,17 +158,14 @@ export default function FirewallTables(): ReactElement {
|
|||||||
acc.push({ cmd, err: err?.message ?? String(err) });
|
acc.push({ cmd, err: err?.message ?? String(err) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// prepend new results to history
|
||||||
setResults((prev) => [...acc, ...prev]);
|
setResults((prev) => [...acc, ...prev]);
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
// Delete helpers (unchanged behavior)
|
// Delete rule
|
||||||
async function handleDeleteRule(
|
const handleDeleteRule = useCallback(
|
||||||
family: string | null | undefined,
|
async (family: string | null | undefined, table: string, chain: string, handle: number | string) => {
|
||||||
table: string,
|
|
||||||
chain: string,
|
|
||||||
handle: number | string,
|
|
||||||
) {
|
|
||||||
const cmd = `delete rule ${family ?? 'inet'} ${table} ${chain} handle ${handle}`;
|
const cmd = `delete rule ${family ?? 'inet'} ${table} ${chain} handle ${handle}`;
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: 'Delete Rule',
|
title: 'Delete Rule',
|
||||||
@@ -291,13 +185,17 @@ export default function FirewallTables(): ReactElement {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error('Delete failed: ' + (err?.message ?? String(err)));
|
message.error('Delete failed: ' + (err?.message ?? String(err)));
|
||||||
} finally {
|
} finally {
|
||||||
await loadRuleset();
|
await refresh();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
[runCommands, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
async function handleDeleteChain(family: string | null | undefined, table: string, chain: string) {
|
// Delete chain
|
||||||
|
const handleDeleteChain = useCallback(
|
||||||
|
async (family: string | null | undefined, table: string, chain: string) => {
|
||||||
const cmd = `delete chain ${family ?? 'inet'} ${table} ${chain}`;
|
const cmd = `delete chain ${family ?? 'inet'} ${table} ${chain}`;
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: 'Delete Chain',
|
title: 'Delete Chain',
|
||||||
@@ -319,13 +217,17 @@ export default function FirewallTables(): ReactElement {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error('Chain deletion failed: ' + (err?.message ?? String(err)));
|
message.error('Chain deletion failed: ' + (err?.message ?? String(err)));
|
||||||
} finally {
|
} finally {
|
||||||
await loadRuleset();
|
await refresh();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
[runCommands, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
async function handleDeleteTable(family: string | null | undefined, table: string) {
|
// Delete table
|
||||||
|
const handleDeleteTable = useCallback(
|
||||||
|
async (family: string | null | undefined, table: string) => {
|
||||||
const cmd = `delete table ${family ?? 'inet'} ${table}`;
|
const cmd = `delete table ${family ?? 'inet'} ${table}`;
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: 'Delete Table',
|
title: 'Delete Table',
|
||||||
@@ -347,144 +249,31 @@ export default function FirewallTables(): ReactElement {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error('Table deletion failed: ' + (err?.message ?? String(err)));
|
message.error('Table deletion failed: ' + (err?.message ?? String(err)));
|
||||||
} finally {
|
} finally {
|
||||||
await loadRuleset();
|
await refresh();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
[runCommands, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
async function handleRefresh() {
|
// manual refresh trigger (exposed to UI)
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
await loadRuleset();
|
await refresh();
|
||||||
message.success('Ruleset refreshed');
|
message.success('Ruleset refreshed');
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
}
|
}, [refresh]);
|
||||||
|
|
||||||
// Move using position (0-based). We still send source handle in the URL by setting before_handle =
|
const tablesToRender = useMemo(() => tables ?? [], [tables]);
|
||||||
// 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 (error)
|
||||||
|
return <Alert type="error" message="Failed to load firewall rules" description={error.message ?? String(error)} />;
|
||||||
// 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} />;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -500,16 +289,17 @@ export default function FirewallTables(): ReactElement {
|
|||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{tables.length === 0 && (
|
{tablesToRender.length === 0 && (
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
title="No firewall tables found"
|
message="No firewall tables found"
|
||||||
description="You can create a new table using the button above."
|
description="You can create a new table using the button above."
|
||||||
style={{ marginBottom: 12 }}
|
style={{ marginBottom: 12 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tables.map((table) => {
|
|
||||||
const totalRules = table.chains.reduce((acc, c) => acc + c.rules.length, 0);
|
{tablesToRender.map((table) => {
|
||||||
|
const totalRules = table.chains.reduce((acc, c) => acc + (c.rules?.length ?? 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -550,65 +340,40 @@ export default function FirewallTables(): ReactElement {
|
|||||||
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
{table.chains.map((chain) => {
|
{table.chains.map((chain) => {
|
||||||
const lastIndex = chain.rules.length - 1;
|
|
||||||
const columns: ColumnsType<any> = [
|
const columns: ColumnsType<any> = [
|
||||||
{ title: 'Rule #', dataIndex: 'idx', width: 80 },
|
{ title: 'Rule #', dataIndex: 'idx', width: 80 },
|
||||||
{
|
{
|
||||||
title: 'Handle',
|
title: 'Handle',
|
||||||
dataIndex: 'handle',
|
dataIndex: 'handle',
|
||||||
width: 120,
|
width: 80,
|
||||||
render: (v) => v ?? '-',
|
render: (v) => v ?? '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Rule',
|
title: 'Rule',
|
||||||
dataIndex: 'raw',
|
dataIndex: 'raw',
|
||||||
render: (v) => (
|
render: (v) => <Paragraph ellipsis={{ rows: 2, expandable: false }}>{v}</Paragraph>,
|
||||||
<Paragraph ellipsis={{ rows: 2, expandable: false }} copyable>
|
|
||||||
{v}
|
|
||||||
</Paragraph>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Actions',
|
title: 'Actions',
|
||||||
dataIndex: 'actions',
|
dataIndex: 'actions',
|
||||||
width: 160,
|
width: 80,
|
||||||
render: (_: any, rec: any) =>
|
render: (_: any, rec: any) =>
|
||||||
rec.handle ? (
|
rec.handle ? (
|
||||||
<Space>
|
|
||||||
<Button
|
<Button
|
||||||
danger
|
danger
|
||||||
size="small"
|
size="small"
|
||||||
icon={<DeleteOutlined />}
|
icon={<DeleteOutlined />}
|
||||||
onClick={() => handleDeleteRule(table.family, table.name, chain.name, rec.handle)}
|
onClick={() => handleDeleteRule(table.family, table.name, chain.name, rec.handle)}
|
||||||
/>
|
/>
|
||||||
<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>
|
<Space>
|
||||||
<Button size="small" disabled icon={<DeleteOutlined />} />
|
|
||||||
<Button size="small" disabled icon={<ArrowUpOutlined />} />
|
|
||||||
<Button size="small" disabled icon={<ArrowDownOutlined />} />
|
<Button size="small" disabled icon={<ArrowDownOutlined />} />
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const dataSource = chain.rules.map((r, idx) => ({
|
const dataSource = (chain.rules ?? []).map((r: RuleOut, idx: number) => ({
|
||||||
key: `${chain.name}:${idx}`,
|
key: `${chain.name}:${idx}`,
|
||||||
idx: idx + 1,
|
idx: idx + 1,
|
||||||
handle: r.handle ?? null,
|
handle: r.handle ?? null,
|
||||||
|
|||||||
@@ -1,11 +1,62 @@
|
|||||||
|
// src/pages/Firewall.tsx
|
||||||
|
import { Alert, Col, Row, Spin } from 'antd';
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { fetchRuleset } from '../api/apiClient';
|
||||||
import RuleBuilder from '../components/FirewallRuleBuilder';
|
import RuleBuilder from '../components/FirewallRuleBuilder';
|
||||||
import RulesView from '../components/FirewallRulesetViewer';
|
import RulesView from '../components/FirewallRulesetViewer';
|
||||||
|
import { TableOut } from '../types/firewall';
|
||||||
|
|
||||||
|
const EMPTY_TABLES: TableOut[] = [];
|
||||||
|
|
||||||
export const Firewall: React.FC = () => {
|
export const Firewall: React.FC = () => {
|
||||||
return (
|
const [tables, setTables] = useState<TableOut[]>(EMPTY_TABLES);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetchRuleset();
|
||||||
|
if (!res || res.ruleset == null || typeof res.ruleset === 'string') {
|
||||||
|
setTables(EMPTY_TABLES);
|
||||||
|
} else {
|
||||||
|
setTables(res.ruleset.tables ?? EMPTY_TABLES);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
setTables(EMPTY_TABLES);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// fetch once on mount
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// refresh() can be passed down to children to trigger a re-fetch
|
||||||
|
const refreshRules = useCallback(async () => {
|
||||||
|
await load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return loading ? (
|
||||||
|
<Spin tip="Loading firewall rules..." />
|
||||||
|
) : (
|
||||||
<div className="firewall-page">
|
<div className="firewall-page">
|
||||||
<RulesView />
|
<Row gutter={16}>
|
||||||
<RuleBuilder />
|
<Col xs={24}>
|
||||||
|
{error && <Alert type="error" message="Could not load ruleset" description={String(error)} showIcon />}
|
||||||
|
</Col>
|
||||||
|
<Col xs={24}>
|
||||||
|
<RulesView tables={tables} error={error} refreshRules={refreshRules} />
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} style={{ marginTop: 16 }}>
|
||||||
|
<RuleBuilder tables={tables} refreshRules={refreshRules} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export interface RuleOut {
|
|||||||
|
|
||||||
export interface ChainOut {
|
export interface ChainOut {
|
||||||
name: string;
|
name: string;
|
||||||
|
type?: string | null;
|
||||||
|
hook?: string | null;
|
||||||
|
priority?: number | null;
|
||||||
|
policy?: string | null;
|
||||||
rules: RuleOut[];
|
rules: RuleOut[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,55 +77,3 @@ export interface ExecResult {
|
|||||||
/** Standard error from nft (may be null/undefined). */
|
/** Standard error from nft (may be null/undefined). */
|
||||||
stderr?: string | null;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user