firewall api and FE
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s

This commit is contained in:
2026-02-11 20:39:43 +01:00
parent d92c168e5c
commit c34b71f5d7
5 changed files with 357 additions and 59 deletions

View File

@@ -1,5 +1,5 @@
# app.py
from typing import Any, Dict, List, Optional, Union
from typing import Any, Dict, List, Optional, Tuple, Union
from fastapi import FastAPI, APIRouter, HTTPException, status
from pydantic import BaseModel, Field
import logging
@@ -119,7 +119,7 @@ class NftManager:
try:
parsed = json.loads(s)
# parsed may be the whole ruleset (nftables list) or a list; find any "rule" objects
rule_lines = []
rule_lines: List[str] = []
# parsed might be dict with "nftables" or a list of records
records = parsed.get("nftables") if isinstance(parsed, dict) else parsed
if not isinstance(records, list):
@@ -186,13 +186,13 @@ router = APIRouter(prefix="/firewall", tags=["firewall"])
mgr = NftManager()
# ---------- Request/Response models ----------
# ---------- Request/Response models (strongly typed) ----------
class RawCmdRequest(BaseModel):
cmd: str = Field(description="Textual nft command to execute", example="add rule inet filter input ip saddr 10.0.0.0/8 drop")
cmd: str = Field(..., description="Textual nft command to execute", example="add rule inet filter input ip saddr 10.0.0.0/8 drop")
class ExecResult(BaseModel):
rc: int = Field(description="Return code from nft execution", example=0)
rc: int = Field(..., description="Return code from nft execution", example=0)
stdout: Optional[str] = Field(None, description="Standard output from nft", example="")
stderr: Optional[str] = Field(None, description="Standard error from nft", example="")
@@ -200,19 +200,65 @@ class ExecResult(BaseModel):
schema_extra = {"example": {"rc": 0, "stdout": "ok", "stderr": ""}}
# ruleset may be a parsed JSON object (dict/list) OR a raw string (text) OR null
RulesetValue = Union[Dict[str, Any], List[Any], str, None]
# --- Strong models returned to frontend ---
class RuleOut(BaseModel):
handle: Optional[int] = Field(None, description="The rule handle (unique per rule), if available", example=3)
expr: Any = Field(..., description="Machine-readable nft expression (original nft JSON expr).")
text: str = Field(..., description="Deterministic short display string derived from expr", example="ip protocol icmp drop")
position: Optional[Any] = Field(None, description="Optional position metadata from nft if present")
comment: Optional[str] = Field(None, description="Optional comment attached to the rule")
class Config:
schema_extra = {
"example": {
"handle": 3,
"expr": [{"match": {"left": {"payload": {"protocol": "ip", "field": "protocol"}}, "op": "==", "right": "icmp"}}, {"drop": None}],
"text": "ip protocol icmp drop",
"position": None,
"comment": None,
}
}
class ChainOut(BaseModel):
name: str = Field(..., description="Chain name", example="forward")
rules: List[RuleOut] = Field(..., description="Rules in this chain (ordered)")
class Config:
schema_extra = {"example": {"name": "forward", "rules": []}}
class TableOut(BaseModel):
family: str = Field(..., description="Table family (inet/bridge/ipv4/...)")
name: str = Field(..., description="Table name", example="filter")
chains: List[ChainOut] = Field(..., description="Chains in this table")
class Config:
schema_extra = {"example": {"family": "bridge", "name": "filter", "chains": []}}
class RulesetModel(BaseModel):
tables: List[TableOut] = Field(..., description="Top-level tables list")
class Config:
schema_extra = {"example": {"tables": []}}
# ruleset may be typed RulesetModel or raw textual string (fallback)
RulesetValue = Optional[Union[RulesetModel, str]]
class RulesetOut(BaseModel):
ruleset: RulesetValue = Field(
description="parsed JSON ruleset (dict/list), or the raw textual ruleset as a string.",
None,
description="Parsed, strongly-typed ruleset (RulesetModel) or raw textual ruleset string if JSON is unavailable.",
)
# ---------- Helpers to convert to desired shape ----------
_handle_re = re.compile(r"\s+#\s*handle\s+\d+\s*$")
def rule_text_from_expr(expr: Any) -> str:
"""
Deterministic serializer to produce a compact UI-friendly string from expr list.
@@ -278,7 +324,7 @@ def build_predictable_ruleset(nft_json: Dict[str, Any]) -> Dict[str, Any]:
items = nft_json.get("nftables", []) if isinstance(nft_json, dict) else (nft_json or [])
# Build intermediate map: (family, table) -> {family, name, chains: {chain_name: {"name", "rules":[]}}}
tables: Dict[tuple, Dict[str, Any]] = {}
tables: Dict[Tuple[str, str], Dict[str, Any]] = {}
for rec in items:
if "table" in rec:
t = rec["table"]
@@ -332,7 +378,8 @@ def build_predictable_ruleset(nft_json: Dict[str, Any]) -> Dict[str, Any]:
@router.get("/rules", response_model=RulesetOut, summary="List ruleset")
def list_rules():
"""
Returns the ruleset in a stable, predictable JSON shape derived from `nft -j list ruleset`.
Returns the ruleset in a stable, strongly-typed JSON shape derived from `nft -j list ruleset`.
Structure:
{ "ruleset": { "tables": [ { "family": ..., "name": ..., "chains": [ { "name": ..., "rules": [ { "handle", "expr", "text" } ] } ] } ] } }
@@ -345,10 +392,12 @@ def list_rules():
except NftError as e:
logger.debug("could not obtain nft JSON ruleset: %s", e)
text = mgr.list_rules()
return {"ruleset": text.strip() if text is not None else None}
return RulesetOut(ruleset=text.strip() if text is not None else None)
custom = build_predictable_ruleset(nft_json)
return {"ruleset": custom}
# Validate/construct Pydantic model so OpenAPI + client libs get accurate typing
ruleset_model = RulesetModel.parse_obj(custom)
return RulesetOut(ruleset=ruleset_model)
except NftError as e:
logger.exception("list_rules failed")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,5 +1,6 @@
// src/apiClient.ts
import axios from "axios";
import { RulesetModel } from "../types/firewall";
import {
BridgeCreateRequest,
BridgeInfo,
@@ -8,6 +9,7 @@ import {
InterfaceInfo,
RouteInfo,
} from "../types/network";
import { EnableRequest, ScriptInfo } from "../types/scripting";
import {
SnifferStatusResponse,
} from "../types/sniffer";
@@ -36,30 +38,6 @@ api.interceptors.response.use(
}
);
/**
* Local lightweight types for a few endpoints where a dedicated project type
* wasn't imported above. If you already have these in your codebase, replace
* these with imports instead.
*/
export type RawCmdRequest = {
cmd: string;
};
export type RulesetOut = {
ruleset: string;
};
export type ScriptInfo = {
name: string;
path: string;
};
export type EnableRequest = {
qnum: number;
service_name?: string | null;
extra_args?: string | null;
enable_at_boot?: boolean | null;
};
/* -------------------------
Basic endpoints
@@ -150,33 +128,30 @@ export const fetchPackets = async (limit = 100): Promise<any> => {
Firewall
------------------------- */
export const listFirewallRules = async (): Promise<RulesetOut> => {
const res = await api.get<RulesetOut>("/firewall/rules");
/**
* GET /firewall/rules
* Returns: { ruleset: RulesetModel | string | null }
* - 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");
return res.data;
};
export const createFirewallRuleText = async (req: RawCmdRequest): Promise<any> => {
// Executes textual nft command, returns raw stdout (201 expected per spec)
const res = await api.post("/firewall/rules", req);
return res.data;
};
export const deleteFirewallRule = async (
/**
* 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,
options?: { family?: string; table?: string; chain?: string }
family: string,
table: string,
chain: string
): Promise<void> => {
// returns 204 on success (no content)
const params: Record<string, any> = {};
if (options?.family) params.family = options.family;
if (options?.table) params.table = options.table;
if (options?.chain) params.chain = options.chain;
await api.delete(`/firewall/rules/${handle}`, { params });
};
export const execFirewallRaw = async (req: RawCmdRequest): Promise<any> => {
// Execute arbitrary textual nft command and return {rc, stdout, stderr}
const res = await api.post("/firewall/raw", req);
const res = await api.delete(`/firewall/rules/${encodeURIComponent(String(handle))}`, {
params: { family, table, chain },
});
// axios resolves non-2xx as reject; server uses 204 No Content so nothing to return
return res.data;
};

View File

@@ -0,0 +1,239 @@
// src/components/RulesView.tsx
import { DeleteOutlined, EyeOutlined, ReloadOutlined } from '@ant-design/icons';
import { Button, Card, Collapse, Drawer, Empty, Popconfirm, Space, Spin, Table, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { deleteRule, fetchRuleset } from '../api/apiClient';
import { ChainOut, RulesetModel, TableOut } from '../types/firewall';
const { Panel } = Collapse;
const { Paragraph, Text } = Typography;
interface RuleRow {
key: string;
handle?: number | null;
text: string;
expr: any;
comment?: string | null;
position?: any;
// context for delete
family: string;
table: string;
chain: string;
}
/**
* Render rules inside an antd Table. The UI layout:
* - Collapse per table (title = family:name)
* - Inside each table -> collapse per chain (only chains that have rules)
* - Each chain shows a Table of rules with delete and inspect actions
*/
export const RulesView: React.FC<{ apiBase?: string }> = ({ apiBase = '' }) => {
const [loading, setLoading] = useState(false);
const [ruleset, setRuleset] = useState<RulesetModel | null>(null);
const [rawFallback, setRawFallback] = useState<string | null>(null);
const [exprDrawerVisible, setExprDrawerVisible] = useState(false);
const [exprForView, setExprForView] = useState<any>(null);
const [selectedRuleContext, setSelectedRuleContext] = useState<{
family: string;
table: string;
chain: string;
handle?: number | null;
} | null>(null);
const fetchRules = useCallback(async () => {
setLoading(true);
try {
const data = await fetchRuleset();
if (!data || data.ruleset === null) {
setRuleset({ tables: [] });
setRawFallback(null);
} else if (typeof data.ruleset === 'string') {
// fallback textual ruleset
setRawFallback(data.ruleset);
setRuleset({ tables: [] });
} else {
setRuleset(data.ruleset as RulesetModel);
setRawFallback(null);
}
} catch (err: any) {
message.error(`Failed to load ruleset: ${err?.message ?? err}`);
setRuleset({ tables: [] });
setRawFallback(null);
} finally {
setLoading(false);
}
}, [apiBase]);
useEffect(() => {
fetchRules();
}, [fetchRules]);
const onDelete = useCallback(
async (row: RuleRow) => {
if (!row.handle) {
message.error('Rule has no handle and cannot be deleted via API.');
return;
}
setLoading(true);
try {
await deleteRule(row.handle, row.family, row.table, row.chain);
message.success('Rule deleted');
await fetchRules();
} catch (err: any) {
message.error(`Delete failed: ${err?.message ?? err}`);
} finally {
setLoading(false);
}
},
[apiBase, fetchRules],
);
const openExprViewer = useCallback((expr: any) => {
setExprForView(expr);
setExprDrawerVisible(true);
}, []);
const closeExprViewer = useCallback(() => {
setExprForView(null);
setExprDrawerVisible(false);
}, []);
// columns for rule table
const columns: ColumnsType<RuleRow> = useMemo(
() => [
{
title: 'Handle',
dataIndex: 'handle',
key: 'handle',
width: 100,
render: (val) => val ?? '-',
},
{
title: 'Rule',
dataIndex: 'text',
key: 'text',
render: (txt: string, rec: RuleRow) => (
<Paragraph copyable={{ text: txt }} style={{ margin: 0 }}>
{txt}
</Paragraph>
),
},
{
title: 'Comment',
dataIndex: 'comment',
key: 'comment',
width: 200,
render: (c) => (c ? <Text>{c}</Text> : null),
},
{
title: 'Actions',
key: 'actions',
width: 160,
align: 'right',
render: (_, rec) => (
<Space>
<Button type="default" icon={<EyeOutlined />} size="small" onClick={() => openExprViewer(rec.expr)}>
View
</Button>
<Popconfirm title="Delete this rule?" onConfirm={() => onDelete(rec)} okText="Delete" cancelText="Cancel">
<Button danger icon={<DeleteOutlined />} size="small">
Delete
</Button>
</Popconfirm>
</Space>
),
},
],
[onDelete, openExprViewer],
);
// flatten rules for each chain into a table datasource
const renderChainTable = (table: TableOut, chain: ChainOut) => {
const data: RuleRow[] = chain.rules.map((r, idx) => ({
key: `${table.family}:${table.name}:${chain.name}:${String(r.handle ?? idx)}`,
handle: r.handle ?? null,
text: r.text,
expr: r.expr,
comment: r.comment ?? null,
position: r.position,
family: table.family,
table: table.name,
chain: chain.name,
}));
return (
<Table columns={columns} dataSource={data} pagination={{ pageSize: 8 }} size="small" rowKey={(rec) => rec.key} />
);
};
// UI when JSON is not available
if (!loading && rawFallback) {
return (
<Card>
<Space direction="vertical" style={{ width: '100%' }}>
<Text strong>Ruleset (raw)</Text>
<Paragraph style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}>{rawFallback}</Paragraph>
<Button icon={<ReloadOutlined />} onClick={() => fetchRules()}>
Refresh
</Button>
</Space>
</Card>
);
}
return (
<Card
title="Firewall rules"
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => fetchRules()} loading={loading}>
Refresh
</Button>
</Space>
}
>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
</div>
) : ruleset && ruleset.tables.length > 0 ? (
<Collapse accordion>
{ruleset.tables.map((t) => {
// only show chains that have rules
const chainsWithRules = t.chains.filter((c) => c.rules && c.rules.length > 0);
if (chainsWithRules.length === 0) return null;
return (
<Panel header={`${t.family}:${t.name}`} key={`${t.family}:${t.name}`}>
<Collapse>
{chainsWithRules.map((c) => (
<Panel header={`${c.name} — ${c.rules.length} rule(s)`} key={`${t.family}:${t.name}:${c.name}`}>
{renderChainTable(t, c)}
</Panel>
))}
</Collapse>
</Panel>
);
})}
</Collapse>
) : (
<Empty description="No rules found" />
)}
<Drawer
title="Rule expression (JSON)"
placement="right"
width={640}
onClose={closeExprViewer}
open={exprDrawerVisible}
>
<Paragraph>
<Text strong>Expression (raw JSON)</Text>
</Paragraph>
<pre style={{ whiteSpace: 'pre-wrap' }}>{JSON.stringify(exprForView, null, 2)}</pre>
</Drawer>
</Card>
);
};
export default RulesView;

View File

@@ -0,0 +1,24 @@
export type Expr = any; // raw nft expr JSON (opaque to frontend)
export interface RuleOut {
handle?: number | null;
expr: Expr;
text: string;
position?: any;
comment?: string | null;
}
export interface ChainOut {
name: string;
rules: RuleOut[];
}
export interface TableOut {
family: string;
name: string;
chains: ChainOut[];
}
export interface RulesetModel {
tables: TableOut[];
}

View File

@@ -0,0 +1,11 @@
export type ScriptInfo = {
name: string;
path: string;
};
export type EnableRequest = {
qnum: number;
service_name?: string | null;
extra_args?: string | null;
enable_at_boot?: boolean | null;
};