nft rule post json
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:
@@ -181,7 +181,7 @@ class NftManager:
|
|||||||
|
|
||||||
|
|
||||||
# ---------- FastAPI + Router ----------
|
# ---------- FastAPI + Router ----------
|
||||||
app = FastAPI(title="Unrestricted nftables API (textual only)")
|
app = FastAPI(title="Unrestricted nftables API (json create)")
|
||||||
router = APIRouter(prefix="/firewall", tags=["firewall"])
|
router = APIRouter(prefix="/firewall", tags=["firewall"])
|
||||||
mgr = NftManager()
|
mgr = NftManager()
|
||||||
|
|
||||||
@@ -208,40 +208,40 @@ class RuleOut(BaseModel):
|
|||||||
position: Optional[Any] = Field(None, description="Optional position metadata from nft if present")
|
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")
|
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):
|
class ChainOut(BaseModel):
|
||||||
name: str = Field(..., description="Chain name", example="forward")
|
name: str = Field(..., description="Chain name", example="forward")
|
||||||
rules: List[RuleOut] = Field(..., description="Rules in this chain (ordered)")
|
rules: List[RuleOut] = Field(..., description="Rules in this chain (ordered)")
|
||||||
|
|
||||||
class Config:
|
|
||||||
schema_extra = {"example": {"name": "forward", "rules": []}}
|
|
||||||
|
|
||||||
|
|
||||||
class TableOut(BaseModel):
|
class TableOut(BaseModel):
|
||||||
family: str = Field(..., description="Table family (inet/bridge/ipv4/...)")
|
family: str = Field(..., description="Table family (inet/bridge/ipv4/...)")
|
||||||
name: str = Field(..., description="Table name", example="filter")
|
name: str = Field(..., description="Table name", example="filter")
|
||||||
chains: List[ChainOut] = Field(..., description="Chains in this table")
|
chains: List[ChainOut] = Field(..., description="Chains in this table")
|
||||||
|
|
||||||
class Config:
|
|
||||||
schema_extra = {"example": {"family": "bridge", "name": "filter", "chains": []}}
|
|
||||||
|
|
||||||
|
|
||||||
class RulesetModel(BaseModel):
|
class RulesetModel(BaseModel):
|
||||||
tables: List[TableOut] = Field(..., description="Top-level tables list")
|
tables: List[TableOut] = Field(..., description="Top-level tables list")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- New: CreateRuleRequest (JSON, expr required) ----------
|
||||||
|
class CreateRuleRequest(BaseModel):
|
||||||
|
family: str = Field(..., description="Table family (e.g. inet, bridge, ip, ip6)", example="bridge")
|
||||||
|
table: str = Field(..., description="Table name (e.g. filter)", example="filter")
|
||||||
|
chain: str = Field(..., description="Chain name (e.g. forward)", example="forward")
|
||||||
|
expr: Any = Field(..., description="nft JSON expression (machine-readable). This field is required for JSON rule creation.")
|
||||||
|
position: Optional[Any] = Field(None, description="Optional position metadata (if you want to specify insertion position)")
|
||||||
|
comment: Optional[str] = Field(None, description="Optional comment")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
schema_extra = {"example": {"tables": []}}
|
schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"family": "bridge",
|
||||||
|
"table": "filter",
|
||||||
|
"chain": "forward",
|
||||||
|
"expr": [{"match": {"left": {"payload": {"protocol": "ip", "field": "protocol"}}, "op": "==", "right": "icmp"}}, {"drop": None}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ruleset may be typed RulesetModel or raw textual string (fallback)
|
# ruleset may be typed RulesetModel or raw textual string (fallback)
|
||||||
@@ -263,6 +263,7 @@ 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.
|
||||||
Covers common constructs; falls back to JSON dump for unknown constructs.
|
Covers common constructs; falls back to JSON dump for unknown constructs.
|
||||||
|
(Used for display in GET /rules).
|
||||||
"""
|
"""
|
||||||
if expr is None:
|
if expr is None:
|
||||||
return ""
|
return ""
|
||||||
@@ -373,6 +374,101 @@ def build_predictable_ruleset(nft_json: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Helpers to render expr -> textual nft (best-effort) ----------
|
||||||
|
def expr_to_text(expr: Any) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Best-effort renderer that converts a typical nft JSON expr (list) into a textual
|
||||||
|
fragment suitable to append to 'add rule <family> <table> <chain> ...'.
|
||||||
|
Returns None when it cannot deterministically render the provided expr.
|
||||||
|
Supported cases (common):
|
||||||
|
- [{'match': {'left': {'payload': {'protocol':'ip','field':'protocol'}}, 'op':'==', 'right':'icmp'}}, {'drop': None}]
|
||||||
|
-> 'ip protocol icmp drop'
|
||||||
|
- payload / tcp / udp / counter / accept
|
||||||
|
- simple dicts where keys are 'drop' | 'accept' | 'counter'
|
||||||
|
This intentionally does not attempt to support every nft JSON construct.
|
||||||
|
"""
|
||||||
|
if expr is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(expr, str):
|
||||||
|
return expr
|
||||||
|
if not isinstance(expr, list):
|
||||||
|
# unsupported top-level type
|
||||||
|
return None
|
||||||
|
|
||||||
|
parts: List[str] = []
|
||||||
|
for element in expr:
|
||||||
|
if isinstance(element, dict):
|
||||||
|
# handle drop/accept/counter directly
|
||||||
|
if "drop" in element:
|
||||||
|
parts.append("drop")
|
||||||
|
continue
|
||||||
|
if "accept" in element:
|
||||||
|
parts.append("accept")
|
||||||
|
continue
|
||||||
|
if "counter" in element:
|
||||||
|
parts.append("counter")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# match left/right payload equals -> ip protocol icmp, or ip saddr/daddr
|
||||||
|
if "match" in element:
|
||||||
|
m = element["match"]
|
||||||
|
left = m.get("left")
|
||||||
|
right = m.get("right")
|
||||||
|
# payload matches
|
||||||
|
if isinstance(left, dict) and "payload" in left and isinstance(right, (str, int)):
|
||||||
|
p = left["payload"]
|
||||||
|
prot = p.get("protocol")
|
||||||
|
field = p.get("field")
|
||||||
|
# common: protocol field match (protocol == icmp)
|
||||||
|
if prot and field and isinstance(right, str):
|
||||||
|
# ip vs ip6 decision is left to the frontend; here we render 'ip protocol icmp' (works for many setups)
|
||||||
|
if field == "protocol":
|
||||||
|
parts.append(f"{prot} {field} {right}")
|
||||||
|
continue
|
||||||
|
# payload might be l4 ports etc; produce generic payload(...) token
|
||||||
|
parts.append(f"payload({prot}.{field}) {right}")
|
||||||
|
continue
|
||||||
|
# fallback for match: try to stringify right
|
||||||
|
parts.append("match")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# payload shorthand
|
||||||
|
if "payload" in element:
|
||||||
|
p = element["payload"]
|
||||||
|
prot = p.get("protocol")
|
||||||
|
field = p.get("field")
|
||||||
|
if prot and field:
|
||||||
|
parts.append(f"payload({prot}.{field})")
|
||||||
|
continue
|
||||||
|
parts.append("payload")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# tcp/udp as nested dicts sometimes appear
|
||||||
|
if "tcp" in element or "udp" in element:
|
||||||
|
proto = "tcp" if "tcp" in element else "udp"
|
||||||
|
val = element.get(proto)
|
||||||
|
# attempt to detect dport/sport keys
|
||||||
|
if isinstance(val, dict):
|
||||||
|
if "dport" in val:
|
||||||
|
parts.append(f"{proto} dport {val['dport']}")
|
||||||
|
continue
|
||||||
|
if "sport" in val:
|
||||||
|
parts.append(f"{proto} sport {val['sport']}")
|
||||||
|
continue
|
||||||
|
parts.append(proto)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# cmp/binary operators etc — not supported deterministically
|
||||||
|
# return None to indicate we can't safely render this expr
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
# non-dict token (string/number)
|
||||||
|
parts.append(str(element))
|
||||||
|
|
||||||
|
# join tokens
|
||||||
|
return " ".join(parts).strip()
|
||||||
|
|
||||||
|
|
||||||
# ---------- Routes ----------
|
# ---------- Routes ----------
|
||||||
|
|
||||||
@router.get("/rules", response_model=RulesetOut, summary="List ruleset")
|
@router.get("/rules", response_model=RulesetOut, summary="List ruleset")
|
||||||
@@ -395,7 +491,6 @@ def list_rules():
|
|||||||
return RulesetOut(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)
|
||||||
# Validate/construct Pydantic model so OpenAPI + client libs get accurate typing
|
|
||||||
ruleset_model = RulesetModel.parse_obj(custom)
|
ruleset_model = RulesetModel.parse_obj(custom)
|
||||||
return RulesetOut(ruleset=ruleset_model)
|
return RulesetOut(ruleset=ruleset_model)
|
||||||
except NftError as e:
|
except NftError as e:
|
||||||
@@ -406,25 +501,45 @@ def list_rules():
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rules", response_model=ExecResult, status_code=status.HTTP_201_CREATED, summary="Execute textual rule command")
|
@router.post("/rules", response_model=ExecResult, status_code=status.HTTP_201_CREATED, summary="Create rule (JSON, expr required)")
|
||||||
def create_rule_text(req: RawCmdRequest):
|
def create_rule_json(req: CreateRuleRequest):
|
||||||
"""
|
"""
|
||||||
Execute a textual nft command (convenience, returns structured exec result).
|
Create a rule from JSON.
|
||||||
Example: add rule inet filter input ip saddr 10.0.0.0/8 drop
|
Preferred usage: provide `expr` (nft JSON expr). Server attempts to render it to textual nft.
|
||||||
|
If `expr` can't be deterministically rendered, the server returns 400 instructing the client
|
||||||
|
to use POST /firewall/raw for raw textual commands.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
res = mgr.cmd(req.cmd)
|
family = req.family
|
||||||
|
table = req.table
|
||||||
|
chain = req.chain
|
||||||
|
|
||||||
|
if req.expr is None:
|
||||||
|
raise NftError("field 'expr' is required for JSON rule creation")
|
||||||
|
|
||||||
|
rendered = expr_to_text(req.expr)
|
||||||
|
if rendered is None:
|
||||||
|
# cannot render - instruct client to use textual API
|
||||||
|
raise NftError(
|
||||||
|
"cannot render provided 'expr' to textual nft syntax. "
|
||||||
|
"Please use POST /firewall/raw to execute the textual nft command."
|
||||||
|
)
|
||||||
|
expr_text = rendered
|
||||||
|
|
||||||
|
# construct final add rule command
|
||||||
|
cmd = f"add rule {family} {table} {chain} {expr_text}"
|
||||||
|
# execute
|
||||||
|
res = mgr.cmd(cmd)
|
||||||
rc = int(res.get("rc", -1) or -1)
|
rc = int(res.get("rc", -1) or -1)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
# Bad request: command failed (client-provided textual command)
|
# return 400 to indicate client-provided rule failed
|
||||||
# Return 400 with the stderr detail
|
raise NftError(f"create rule failed rc={rc}: {res.get('stderr')}")
|
||||||
raise NftError(f"cmd failed rc={rc}: {res.get('stderr')}")
|
|
||||||
return ExecResult(rc=rc, stdout=res.get("stdout"), stderr=res.get("stderr"))
|
return ExecResult(rc=rc, stdout=res.get("stdout"), stderr=res.get("stderr"))
|
||||||
except NftError as e:
|
except NftError as e:
|
||||||
logger.warning("create_rule_text failed: %s", e)
|
logger.warning("create_rule_json failed: %s", e)
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("create_rule_text internal error")
|
logger.exception("create_rule_json internal error")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@@ -437,7 +552,6 @@ def delete_rule(handle: int, family: str = "inet", table: str = "filter", chain:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
mgr.delete_rule_by_handle_text(family=family, table=table, chain=chain, handle=handle)
|
mgr.delete_rule_by_handle_text(family=family, table=table, chain=chain, handle=handle)
|
||||||
# 204 No Content — nothing to return
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning("delete_rule client error: %s", e)
|
logger.warning("delete_rule client error: %s", e)
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const menuItems = [
|
|||||||
icon: <TerminalIcon style={{ fontSize: '18px' }} width={18} height={18} />,
|
icon: <TerminalIcon style={{ fontSize: '18px' }} width={18} height={18} />,
|
||||||
label: 'Scripting',
|
label: 'Scripting',
|
||||||
},
|
},
|
||||||
|
{ key: PATHS.FIREWALL, icon: <ApiOutlined style={{ fontSize: '18px' }} />, label: 'Firewall' },
|
||||||
{ key: '/about', icon: <InfoCircleOutlined style={{ fontSize: '18px' }} />, label: 'About' },
|
{ key: '/about', icon: <InfoCircleOutlined style={{ fontSize: '18px' }} />, label: 'About' },
|
||||||
{ key: '/settings', icon: <SettingOutlined style={{ fontSize: '18px' }} />, label: 'Settings' },
|
{ key: '/settings', icon: <SettingOutlined style={{ fontSize: '18px' }} />, label: 'Settings' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// src/apiClient.ts
|
// src/apiClient.ts
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { RulesetModel } from "../types/firewall";
|
import { CreateRuleRequest, ExecResult, RulesetModel } from "../types/firewall";
|
||||||
import {
|
import {
|
||||||
BridgeCreateRequest,
|
BridgeCreateRequest,
|
||||||
BridgeInfo,
|
BridgeInfo,
|
||||||
@@ -155,6 +155,14 @@ export const deleteRule = async (
|
|||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* createRuleJson - POST /firewall/rules
|
||||||
|
* Body: CreateRuleRequest (must include expr)
|
||||||
|
*/
|
||||||
|
export const createRuleJson = async (req: CreateRuleRequest): Promise<ExecResult> => {
|
||||||
|
const res = await api.post<ExecResult>("/firewall/rules", req);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
/* -------------------------
|
/* -------------------------
|
||||||
Scripts
|
Scripts
|
||||||
------------------------- */
|
------------------------- */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// src/AppRouter.tsx
|
// src/AppRouter.tsx
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import App from './App'; // your layout component (has <Outlet />)
|
import App from './App'; // your layout component (has <Outlet />)
|
||||||
|
import { Firewall } from './pages/Firewall';
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
import Network from './pages/Network';
|
import Network from './pages/Network';
|
||||||
import Scripting from './pages/Scripting';
|
import Scripting from './pages/Scripting';
|
||||||
@@ -20,6 +21,7 @@ export default function AppRouter() {
|
|||||||
<Route path={PATHS.NETWORK.slice(1)} element={<Network />} />
|
<Route path={PATHS.NETWORK.slice(1)} element={<Network />} />
|
||||||
<Route path={PATHS.SNIFFING.slice(1)} element={<Sniffing />} />
|
<Route path={PATHS.SNIFFING.slice(1)} element={<Sniffing />} />
|
||||||
<Route path={PATHS.SCRIPTING.slice(1)} element={<Scripting />} />
|
<Route path={PATHS.SCRIPTING.slice(1)} element={<Scripting />} />
|
||||||
|
<Route path={PATHS.FIREWALL.slice(1)} element={<Firewall />} />
|
||||||
|
|
||||||
{/* Fallback (renders inside layout too) */}
|
{/* Fallback (renders inside layout too) */}
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
|
|||||||
576
frontend/src/components/FirewallRuleBuilder.tsx
Normal file
576
frontend/src/components/FirewallRuleBuilder.tsx
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
// src/components/RuleBuilder.tsx
|
||||||
|
import { CopyOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Divider,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
message,
|
||||||
|
Modal,
|
||||||
|
Radio,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { createRuleJson, fetchRuleset } from '../api/apiClient';
|
||||||
|
import { Expr, TableOut } from '../types/firewall';
|
||||||
|
|
||||||
|
const { Option } = Select;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
interface RuleBuilderProps {
|
||||||
|
onCreated?: () => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: create an nft JSON 'expr' array from form values.
|
||||||
|
* The produced expr is intentionally conservative and designed to be renderable by the backend's renderer.
|
||||||
|
*/
|
||||||
|
function buildExprFromValues(values: any): Expr[] {
|
||||||
|
const expr: Expr[] = [];
|
||||||
|
|
||||||
|
// protocol
|
||||||
|
const protoChoice = values.protocolChoice ?? 'preset';
|
||||||
|
if (protoChoice === 'preset') {
|
||||||
|
const preset = values.protocolPreset;
|
||||||
|
if (preset === 'icmp') {
|
||||||
|
expr.push({
|
||||||
|
match: {
|
||||||
|
left: { payload: { protocol: 'ip', field: 'protocol' } },
|
||||||
|
op: '==',
|
||||||
|
right: 'icmp',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (preset === 'icmpv6') {
|
||||||
|
expr.push({
|
||||||
|
match: {
|
||||||
|
left: { payload: { protocol: 'ip6', field: 'nexthdr' } },
|
||||||
|
op: '==',
|
||||||
|
right: 'icmpv6',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (preset === 'tcp') {
|
||||||
|
// meta l4proto tcp can be expressed as a 'match' fallback, but backend handles tcp dicts for ports
|
||||||
|
// include a simple token so renderer can show "tcp"
|
||||||
|
expr.push({ tcp: {} });
|
||||||
|
} else if (preset === 'udp') {
|
||||||
|
expr.push({ udp: {} });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// custom protocol — the UI accepts free text; attempt to produce a match if the user entered "icmp" etc.
|
||||||
|
const custom = (values.protocolCustom || '').trim();
|
||||||
|
if (custom) {
|
||||||
|
// simple heuristics
|
||||||
|
if (/^icmpv6$/i.test(custom)) {
|
||||||
|
expr.push({
|
||||||
|
match: {
|
||||||
|
left: { payload: { protocol: 'ip6', field: 'nexthdr' } },
|
||||||
|
op: '==',
|
||||||
|
right: 'icmpv6',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (/^icmp$/i.test(custom)) {
|
||||||
|
expr.push({
|
||||||
|
match: {
|
||||||
|
left: { payload: { protocol: 'ip', field: 'protocol' } },
|
||||||
|
op: '==',
|
||||||
|
right: 'icmp',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (/tcp/i.test(custom)) {
|
||||||
|
expr.push({ tcp: {} });
|
||||||
|
} else if (/udp/i.test(custom)) {
|
||||||
|
expr.push({ udp: {} });
|
||||||
|
} else {
|
||||||
|
// fallback: include as generic token (string) -- backend may not accept this
|
||||||
|
expr.push(custom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// source/destination addresses (encoded as payload matches)
|
||||||
|
if (values.saddr) {
|
||||||
|
expr.push({
|
||||||
|
match: {
|
||||||
|
left: { payload: { protocol: 'ip', field: 'saddr' } },
|
||||||
|
op: '==',
|
||||||
|
right: values.saddr,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (values.daddr) {
|
||||||
|
expr.push({
|
||||||
|
match: {
|
||||||
|
left: { payload: { protocol: 'ip', field: 'daddr' } },
|
||||||
|
op: '==',
|
||||||
|
right: values.daddr,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ports for tcp/udp - encode with tcp/udp dicts if provided
|
||||||
|
if (values.sport) {
|
||||||
|
// heuristics: if protocol preset is udp or custom mentions udp -> use udp
|
||||||
|
const useUdp =
|
||||||
|
values.protocolPreset === 'udp' ||
|
||||||
|
(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) {
|
||||||
|
const useUdp =
|
||||||
|
values.protocolPreset === 'udp' ||
|
||||||
|
(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanced free-text: we include as a string token so the backend can either render or reject
|
||||||
|
if (values.advanced) {
|
||||||
|
// try to include as raw JSON if looks like JSON, else include as string token
|
||||||
|
const adv = values.advanced.trim();
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(adv);
|
||||||
|
// if parsed is an object or array, append it directly
|
||||||
|
expr.push(parsed);
|
||||||
|
} catch {
|
||||||
|
// push as raw string token (backend may fail to render — user can use Raw)
|
||||||
|
expr.push(adv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// action: drop/accept/reject (we encode as dicts)
|
||||||
|
const action = values.action || 'drop';
|
||||||
|
if (action === 'drop') expr.push({ drop: null });
|
||||||
|
else if (action === 'accept') expr.push({ accept: null });
|
||||||
|
else if (action === 'reject') expr.push({ reject: null }); // nft supports 'reject' textual; JSON might differ, backend may reject
|
||||||
|
|
||||||
|
return expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic short textual serializer for expr (for preview)
|
||||||
|
* Mirrors backend's serializer heuristics so preview matches server-side text generation.
|
||||||
|
*/
|
||||||
|
function textFromExpr(expr: Expr): string {
|
||||||
|
if (expr == null) return '';
|
||||||
|
if (Array.isArray(expr)) {
|
||||||
|
const tokens: string[] = [];
|
||||||
|
for (const part of expr) {
|
||||||
|
if (typeof part === 'string') {
|
||||||
|
tokens.push(part);
|
||||||
|
} else if (part && typeof part === 'object') {
|
||||||
|
if ('match' in part) {
|
||||||
|
const m = (part as any).match;
|
||||||
|
const left = m.left;
|
||||||
|
const right = m.right;
|
||||||
|
if (left && left.payload && right !== undefined) {
|
||||||
|
const p = left.payload;
|
||||||
|
const prot = p.protocol;
|
||||||
|
const field = p.field;
|
||||||
|
if (prot && field && typeof right === 'string') {
|
||||||
|
tokens.push(`${prot} ${field} ${right}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tokens.push('match');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tokens.push('match');
|
||||||
|
} else if ('payload' in part) {
|
||||||
|
const p = (part as any).payload;
|
||||||
|
const prot = p.protocol;
|
||||||
|
const field = p.field;
|
||||||
|
tokens.push(`payload(${prot}.${field})`);
|
||||||
|
} else if ('tcp' in part) {
|
||||||
|
const v = (part as any).tcp;
|
||||||
|
if (v && v.dport) tokens.push(`tcp dport ${v.dport}`);
|
||||||
|
else if (v && v.sport) tokens.push(`tcp sport ${v.sport}`);
|
||||||
|
else tokens.push('tcp');
|
||||||
|
} else if ('udp' in part) {
|
||||||
|
const v = (part as any).udp;
|
||||||
|
if (v && v.dport) tokens.push(`udp dport ${v.dport}`);
|
||||||
|
else if (v && v.sport) tokens.push(`udp sport ${v.sport}`);
|
||||||
|
else tokens.push('udp');
|
||||||
|
} else if ('drop' in part) {
|
||||||
|
tokens.push('drop');
|
||||||
|
} else if ('accept' in part) {
|
||||||
|
tokens.push('accept');
|
||||||
|
} else if ('counter' in part) {
|
||||||
|
tokens.push('counter');
|
||||||
|
} else {
|
||||||
|
const keys = Object.keys(part).sort().join('+');
|
||||||
|
tokens.push(keys);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tokens.push(String(part));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tokens.join(' ');
|
||||||
|
}
|
||||||
|
return String(expr);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [tables, setTables] = useState<TableOut[]>([]);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [preview, setPreview] = useState('');
|
||||||
|
const [exprPreview, setExprPreview] = useState<Expr | null>(null);
|
||||||
|
|
||||||
|
// fetch tables/chains on mount
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetchRuleset()
|
||||||
|
.then((res) => {
|
||||||
|
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(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tableOptions = useMemo(
|
||||||
|
() => tables.map((t) => ({ family: t.family, name: t.name, label: `${t.family}:${t.name}` })),
|
||||||
|
[tables],
|
||||||
|
);
|
||||||
|
|
||||||
|
// set initial defaults
|
||||||
|
useEffect(() => {
|
||||||
|
if (tableOptions.length > 0) {
|
||||||
|
const first = tableOptions[0];
|
||||||
|
form.setFieldsValue({
|
||||||
|
tableSelect: `${first.family}:${first.name}`,
|
||||||
|
protocolChoice: 'preset',
|
||||||
|
protocolPreset: 'icmp',
|
||||||
|
action: 'drop',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
form.setFieldsValue({
|
||||||
|
tableSelect: '__manual__',
|
||||||
|
protocolChoice: 'preset',
|
||||||
|
protocolPreset: 'icmp',
|
||||||
|
action: 'drop',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [tableOptions]);
|
||||||
|
|
||||||
|
// update previews when form changes
|
||||||
|
const updatePreviews = useCallback(() => {
|
||||||
|
const vals = form.getFieldsValue();
|
||||||
|
const expr = buildExprFromValues(vals);
|
||||||
|
setExprPreview(expr);
|
||||||
|
setPreview(textFromExpr(expr));
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updatePreviews();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// keep preview updated on values change
|
||||||
|
const onValuesChange = useCallback(() => {
|
||||||
|
updatePreviews();
|
||||||
|
}, [updatePreviews]);
|
||||||
|
|
||||||
|
// submit handler: build expr and call createRuleJson
|
||||||
|
const handleCreate = useCallback(
|
||||||
|
async (values: any) => {
|
||||||
|
const tableSelect = values.tableSelect;
|
||||||
|
let family: string;
|
||||||
|
let tableName: string;
|
||||||
|
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);
|
||||||
|
// POST JSON
|
||||||
|
Modal.confirm({
|
||||||
|
title: 'Create rule (JSON)',
|
||||||
|
content: (
|
||||||
|
<div>
|
||||||
|
<Text>
|
||||||
|
About to create rule in{' '}
|
||||||
|
<b>
|
||||||
|
{family}:{tableName} {chain}
|
||||||
|
</b>
|
||||||
|
</Text>
|
||||||
|
<Divider />
|
||||||
|
<Text strong>Preview:</Text>
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>{preview || '(no textual preview available)'}</pre>
|
||||||
|
<Divider />
|
||||||
|
<Text strong>Expression (JSON):</Text>
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>{JSON.stringify(expr, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
okText: 'Create',
|
||||||
|
onOk: async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const req = { family, table: tableName, chain, expr };
|
||||||
|
const res = await createRuleJson(req);
|
||||||
|
if (res && res.rc === 0) {
|
||||||
|
message.success('Rule created');
|
||||||
|
if (onCreated) await onCreated();
|
||||||
|
form.resetFields(['advanced']);
|
||||||
|
} else {
|
||||||
|
message.error(`Create failed: ${res?.stderr ?? 'unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(`Create failed: ${err?.message ?? String(err)}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[form, preview, onCreated],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card style={{ maxWidth: 980 }}>
|
||||||
|
<Title level={4}>Add Firewall Rule (JSON)</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
This builder constructs a native nft JSON <code>expr</code> and sends it to the server. If the server cannot
|
||||||
|
render your expression to the system's nft syntax, use the Raw command UI instead.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Form
|
||||||
|
layout="vertical"
|
||||||
|
form={form}
|
||||||
|
initialValues={{
|
||||||
|
protocolChoice: 'preset',
|
||||||
|
protocolPreset: 'icmp',
|
||||||
|
action: 'drop',
|
||||||
|
tableSelect: tableOptions.length > 0 ? `${tableOptions[0].family}:${tableOptions[0].name}` : '__manual__',
|
||||||
|
}}
|
||||||
|
onFinish={handleCreate}
|
||||||
|
onValuesChange={onValuesChange}
|
||||||
|
>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} sm={12}>
|
||||||
|
<Form.Item name="tableSelect" label="Table (family:name)" rules={[{ required: true }]}>
|
||||||
|
<Select>
|
||||||
|
{tableOptions.map((t) => (
|
||||||
|
<Option key={`${t.family}:${t.name}`} value={`${t.family}:${t.name}`}>
|
||||||
|
{t.label}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
<Option key="__manual__" value="__manual__">
|
||||||
|
Manual / new table...
|
||||||
|
</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12}>
|
||||||
|
{form.getFieldValue('tableSelect') === '__manual__' ? (
|
||||||
|
<Space style={{ width: '100%' }}>
|
||||||
|
<Form.Item name="familyManual" label="Family" style={{ flex: 1 }}>
|
||||||
|
<Input placeholder="inet, bridge, ip, ip6 ..." />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="tableManual" label="Table" style={{ flex: 1 }}>
|
||||||
|
<Input placeholder="filter" />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Form.Item label="Table (preview)">
|
||||||
|
<Input value={form.getFieldValue('tableSelect')} readOnly />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} sm={12}>
|
||||||
|
<Form.Item name="chainSelect" label="Chain (select)">
|
||||||
|
<Select>
|
||||||
|
{(() => {
|
||||||
|
const ts = form.getFieldValue('tableSelect');
|
||||||
|
if (ts && ts !== '__manual__') {
|
||||||
|
const [f, n] = String(ts).split(':');
|
||||||
|
const tbl = tables.find((t) => t.family === f && t.name === n);
|
||||||
|
if (tbl && tbl.chains.length > 0) {
|
||||||
|
return tbl.chains.map((c) => (
|
||||||
|
<Option key={c.name} value={c.name}>
|
||||||
|
{c.name}
|
||||||
|
</Option>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
<Option key="__manual_chain__" value="__manual_chain__">
|
||||||
|
Manual chain...
|
||||||
|
</Option>,
|
||||||
|
];
|
||||||
|
})()}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={12}>
|
||||||
|
{form.getFieldValue('chainSelect') === '__manual_chain__' ||
|
||||||
|
form.getFieldValue('tableSelect') === '__manual__' ? (
|
||||||
|
<Form.Item name="chainManual" label="Chain (manual)">
|
||||||
|
<Input placeholder="input, forward, output" />
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<Form.Item label="Chain (preview)">
|
||||||
|
<Input value={form.getFieldValue('chainSelect') ?? ''} readOnly />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Form.Item label="Protocol selection" style={{ marginBottom: 0 }}>
|
||||||
|
<Space>
|
||||||
|
<Form.Item name="protocolChoice" noStyle>
|
||||||
|
<Radio.Group>
|
||||||
|
<Radio.Button value="preset">Preset</Radio.Button>
|
||||||
|
<Radio.Button value="custom">Custom</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{form.getFieldValue('protocolChoice') === 'custom' ? (
|
||||||
|
<Form.Item name="protocolCustom" label="Protocol (custom)">
|
||||||
|
<Input placeholder="e.g. icmp, icmpv6, meta l4proto tcp" />
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<Form.Item name="protocolPreset" label="Protocol (preset)">
|
||||||
|
<Select>
|
||||||
|
<Option value="">(none)</Option>
|
||||||
|
<Option value="icmp">ICMP (IPv4)</Option>
|
||||||
|
<Option value="icmpv6">ICMPv6</Option>
|
||||||
|
<Option value="tcp">TCP</Option>
|
||||||
|
<Option value="udp">UDP</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Form.Item name="saddr" label="Source IP / CIDR">
|
||||||
|
<Input placeholder="10.0.0.0/8 or 192.168.1.5" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Form.Item name="daddr" label="Destination IP / CIDR">
|
||||||
|
<Input placeholder="172.16.0.0/12" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} sm={6}>
|
||||||
|
<Form.Item name="sport" label="Source Port">
|
||||||
|
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={6}>
|
||||||
|
<Form.Item name="dport" label="Dest Port">
|
||||||
|
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12}>
|
||||||
|
<Form.Item name="action" label="Action" rules={[{ required: true }]} initialValue="drop">
|
||||||
|
<Radio.Group>
|
||||||
|
<Radio value="drop">Drop</Radio>
|
||||||
|
<Radio value="accept">Accept</Radio>
|
||||||
|
<Radio value="reject">Reject</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col span={24}>
|
||||||
|
<Form.Item name="advanced" label="Advanced (optional)">
|
||||||
|
<TextArea
|
||||||
|
rows={3}
|
||||||
|
placeholder="Append arbitrary JSON token or text (advanced). Use Raw endpoint for complex commands."
|
||||||
|
/>
|
||||||
|
<Text type="secondary">
|
||||||
|
Advanced is appended to the expr as a token. Prefer structured fields where possible.
|
||||||
|
</Text>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Form.Item label="Preview">
|
||||||
|
<Space style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div style={{ flex: 1, marginRight: 12 }}>
|
||||||
|
<Text strong>Short preview:</Text>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<Text code copyable={{ text: preview || '' }}>
|
||||||
|
{preview || '(no textual preview available)'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ width: 320 }}>
|
||||||
|
<Tooltip title="Copy JSON expr">
|
||||||
|
<Button
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
if (!exprPreview) return;
|
||||||
|
navigator.clipboard?.writeText(JSON.stringify(exprPreview, null, 2));
|
||||||
|
message.success('Expression JSON copied');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading} icon={<PlusOutlined />}>
|
||||||
|
Create rule
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Text strong>Expression (JSON):</Text>
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap', background: '#f6f6f6', padding: 12 }}>
|
||||||
|
{JSON.stringify(exprPreview, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RuleBuilder;
|
||||||
239
frontend/src/components/FirewallRulesetViewer.tsx
Normal file
239
frontend/src/components/FirewallRulesetViewer.tsx
Normal 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;
|
||||||
@@ -1,239 +1,13 @@
|
|||||||
// src/components/RulesView.tsx
|
import RuleBuilder from '../components/FirewallRuleBuilder';
|
||||||
import { DeleteOutlined, EyeOutlined, ReloadOutlined } from '@ant-design/icons';
|
import RulesView from '../components/FirewallRulesetViewer';
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export const Firewall: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<Card
|
<div className="firewall-page">
|
||||||
title="Firewall rules"
|
<RulesView />
|
||||||
extra={
|
<RuleBuilder />
|
||||||
<Space>
|
</div>
|
||||||
<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;
|
export default Firewall;
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export const PATHS = {
|
|||||||
NETWORK: '/network',
|
NETWORK: '/network',
|
||||||
SNIFFING: '/sniffing',
|
SNIFFING: '/sniffing',
|
||||||
SCRIPTING: '/scripting',
|
SCRIPTING: '/scripting',
|
||||||
|
FIREWALL: '/firewall',
|
||||||
};
|
};
|
||||||
@@ -22,3 +22,21 @@ export interface TableOut {
|
|||||||
export interface RulesetModel {
|
export interface RulesetModel {
|
||||||
tables: TableOut[];
|
tables: TableOut[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateRuleRequest {
|
||||||
|
family: string;
|
||||||
|
table: string;
|
||||||
|
chain: string;
|
||||||
|
expr: Expr;
|
||||||
|
position?: any | null;
|
||||||
|
comment?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExecResult matches backend ExecResult
|
||||||
|
*/
|
||||||
|
export interface ExecResult {
|
||||||
|
rc: number;
|
||||||
|
stdout?: string | null;
|
||||||
|
stderr?: string | null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user