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 # 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 fastapi import FastAPI, APIRouter, HTTPException, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
import logging import logging
@@ -119,7 +119,7 @@ class NftManager:
try: try:
parsed = json.loads(s) parsed = json.loads(s)
# parsed may be the whole ruleset (nftables list) or a list; find any "rule" objects # 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 # parsed might be dict with "nftables" or a list of records
records = parsed.get("nftables") if isinstance(parsed, dict) else parsed records = parsed.get("nftables") if isinstance(parsed, dict) else parsed
if not isinstance(records, list): if not isinstance(records, list):
@@ -186,33 +186,79 @@ router = APIRouter(prefix="/firewall", tags=["firewall"])
mgr = NftManager() mgr = NftManager()
# ---------- Request/Response models ---------- # ---------- Request/Response models (strongly typed) ----------
class RawCmdRequest(BaseModel): 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): 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="") stdout: Optional[str] = Field(None, description="Standard output from nft", example="")
stderr: Optional[str] = Field(None, description="Standard error from nft", example="") stderr: Optional[str] = Field(None, description="Standard error from nft", example="")
class Config: class Config:
schema_extra = {"example": {"rc": 0, "stdout": "ok", "stderr": ""}} schema_extra = {"example": {"rc": 0, "stdout": "ok", "stderr": ""}}
# ruleset may be a parsed JSON object (dict/list) OR a raw string (text) OR null # --- Strong models returned to frontend ---
RulesetValue = Union[Dict[str, Any], List[Any], str, None] 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): class RulesetOut(BaseModel):
ruleset: RulesetValue = Field( 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 ---------- # ---------- Helpers to convert to desired shape ----------
_handle_re = re.compile(r"\s+#\s*handle\s+\d+\s*$") _handle_re = re.compile(r"\s+#\s*handle\s+\d+\s*$")
def rule_text_from_expr(expr: Any) -> str: def rule_text_from_expr(expr: Any) -> str:
""" """
Deterministic serializer to produce a compact UI-friendly string from expr list. 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 []) 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":[]}}} # 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: for rec in items:
if "table" in rec: if "table" in rec:
t = rec["table"] 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") @router.get("/rules", response_model=RulesetOut, summary="List ruleset")
def list_rules(): 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: Structure:
{ "ruleset": { "tables": [ { "family": ..., "name": ..., "chains": [ { "name": ..., "rules": [ { "handle", "expr", "text" } ] } ] } ] } } { "ruleset": { "tables": [ { "family": ..., "name": ..., "chains": [ { "name": ..., "rules": [ { "handle", "expr", "text" } ] } ] } ] } }
@@ -345,10 +392,12 @@ def list_rules():
except NftError as e: except NftError as e:
logger.debug("could not obtain nft JSON ruleset: %s", e) logger.debug("could not obtain nft JSON ruleset: %s", e)
text = mgr.list_rules() 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) 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: except NftError as e:
logger.exception("list_rules failed") logger.exception("list_rules failed")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,5 +1,6 @@
// src/apiClient.ts // src/apiClient.ts
import axios from "axios"; import axios from "axios";
import { RulesetModel } from "../types/firewall";
import { import {
BridgeCreateRequest, BridgeCreateRequest,
BridgeInfo, BridgeInfo,
@@ -8,6 +9,7 @@ import {
InterfaceInfo, InterfaceInfo,
RouteInfo, RouteInfo,
} from "../types/network"; } from "../types/network";
import { EnableRequest, ScriptInfo } from "../types/scripting";
import { import {
SnifferStatusResponse, SnifferStatusResponse,
} from "../types/sniffer"; } 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 Basic endpoints
@@ -150,33 +128,30 @@ export const fetchPackets = async (limit = 100): Promise<any> => {
Firewall 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; return res.data;
}; };
export const createFirewallRuleText = async (req: RawCmdRequest): Promise<any> => { /**
// Executes textual nft command, returns raw stdout (201 expected per spec) * DELETE /firewall/rules/{handle}?family=...&table=...&chain=...
const res = await api.post("/firewall/rules", req); * On success the backend returns 204 No Content. This function resolves to void.
return res.data; */
}; export const deleteRule = async (
export const deleteFirewallRule = async (
handle: number, handle: number,
options?: { family?: string; table?: string; chain?: string } family: string,
table: string,
chain: string
): Promise<void> => { ): Promise<void> => {
// returns 204 on success (no content) const res = await api.delete(`/firewall/rules/${encodeURIComponent(String(handle))}`, {
const params: Record<string, any> = {}; params: { family, table, chain },
if (options?.family) params.family = options.family; });
if (options?.table) params.table = options.table; // axios resolves non-2xx as reject; server uses 204 No Content so nothing to return
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);
return res.data; 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;
};