nfqueue added
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s

This commit is contained in:
2026-02-28 14:25:08 +01:00
parent 397ec24875
commit 87d950e2ab
3 changed files with 204 additions and 73 deletions

View File

@@ -154,6 +154,19 @@ class NftManager:
tokens.append("accept") tokens.append("accept")
elif "counter" in part: elif "counter" in part:
tokens.append("counter") tokens.append("counter")
elif "queue" in part:
# handle fallback queue textualization
q = part["queue"]
if isinstance(q, dict):
num = q.get("num") or q.get("number") or q.get("range") or q.get("from")
tok = "queue"
if num is not None:
tok += f" num{num}"
if q.get("bypass"):
tok += " bypass"
tokens.append(tok)
else:
tokens.append(f"queue{q}")
else: else:
tokens.append("+".join(part.keys())) tokens.append("+".join(part.keys()))
rule_lines.append(" ".join(tokens)) rule_lines.append(" ".join(tokens))
@@ -240,10 +253,13 @@ class CreateRuleRequest(BaseModel):
class Config: class Config:
schema_extra = { schema_extra = {
"example": { "example": {
"family": "bridge", "family": "inet",
"table": "filter", "table": "filter",
"chain": "forward", "chain": "input",
"expr": [{"match": {"left": {"payload": {"protocol": "ip", "field": "protocol"}}, "op": "==", "right": "icmp"}}, {"drop": None}], "expr": [
{"match": {"left": {"payload": {"protocol": "ip", "field": "protocol"}}, "op": "==", "right": "icmp"}},
{"queue": {"num": 0, "bypass": True}}
],
} }
} }
@@ -302,6 +318,7 @@ def parse_priority(val: Any) -> Optional[int]:
return p return p
return None return None
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.
@@ -314,6 +331,23 @@ def rule_text_from_expr(expr: Any) -> str:
tokens: List[str] = [] tokens: List[str] = []
for part in expr: for part in expr:
if isinstance(part, dict): if isinstance(part, dict):
# queue handling: support {'queue': 0}, {'queue': '0-3'}, {'queue': {'num': 0, 'bypass': True}}
if "queue" in part:
q = part["queue"]
token = "queue"
if isinstance(q, dict):
num = q.get("num") or q.get("number") or q.get("queue_number") or q.get("from") or q.get("range")
if num is not None:
token += f" num{num}"
if q.get("bypass"):
token += " bypass"
elif isinstance(q, (int, float)):
token += f" num{int(q)}"
elif isinstance(q, str):
token += f" num{q}"
tokens.append(token)
continue
# common tokens # common tokens
if "match" in part: if "match" in part:
m = part["match"] m = part["match"]
@@ -393,10 +427,11 @@ def build_predictable_ruleset(nft_json: Dict[str, Any]) -> Dict[str, Any]:
# extract metadata robustly # extract metadata robustly
ch_type = ch.get("type") ch_type = ch.get("type")
ch_hook = ch.get("hook") ch_hook = ch.get("hook")
ch_priority = parse_priority(ch.get("priority") if "priority" in ch else ch.get("prio") if "prio" in ch else ch.get("prio", None)) # priority may be provided in several forms; try them
# also attempt to parse nested shapes if present (some nft JSON variations) ch_priority = parse_priority(ch.get("priority") if "priority" in ch else ch.get("prio") if "prio" in ch else ch.get("priority", None))
if ch_priority is None: # further attempt if ch_hook is dict
ch_priority = parse_priority(ch.get("hook") if isinstance(ch.get("hook"), dict) else None) if ch_priority is None and isinstance(ch.get("hook"), dict):
ch_priority = parse_priority(ch.get("hook").get("priority") if ch.get("hook") else None)
ch_policy = ch.get("policy") ch_policy = ch.get("policy")
@@ -447,16 +482,12 @@ def build_predictable_ruleset(nft_json: Dict[str, Any]) -> Dict[str, Any]:
chains_map[chain_name]["rules"].append(rule_obj) chains_map[chain_name]["rules"].append(rule_obj)
# Attempt to salvage chain metadata from rule record if present # Attempt to salvage chain metadata from rule record if present
# some nft JSON may include 'chain' subfields inside rule record
# e.g. r.get('chain') might be an object - handle that defensively
if isinstance(r.get("chain"), dict): if isinstance(r.get("chain"), dict):
csub = r.get("chain") csub = r.get("chain")
# try to parse nested priority
if chains_map[chain_name].get("priority") is None: if chains_map[chain_name].get("priority") is None:
parsed_prio = parse_priority(csub.get("priority") if "priority" in csub else csub.get("prio")) parsed_prio = parse_priority(csub.get("priority") if "priority" in csub else csub.get("prio"))
if parsed_prio is not None: if parsed_prio is not None:
chains_map[chain_name]["priority"] = parsed_prio chains_map[chain_name]["priority"] = parsed_prio
# type/hook/policy from nested if present
if chains_map[chain_name].get("type") is None and csub.get("type") is not None: if chains_map[chain_name].get("type") is None and csub.get("type") is not None:
chains_map[chain_name]["type"] = csub.get("type") chains_map[chain_name]["type"] = csub.get("type")
if chains_map[chain_name].get("hook") is None and csub.get("hook") is not None: if chains_map[chain_name].get("hook") is None and csub.get("hook") is not None:
@@ -491,12 +522,10 @@ def expr_to_text(expr: Any) -> Optional[str]:
Best-effort renderer that converts a typical nft JSON expr (list) into a textual Best-effort renderer that converts a typical nft JSON expr (list) into a textual
fragment suitable to append to 'add rule <family> <table> <chain> ...'. fragment suitable to append to 'add rule <family> <table> <chain> ...'.
Returns None when it cannot deterministically render the provided expr. Returns None when it cannot deterministically render the provided expr.
Supported cases (common): Supports queue + bypass:
- [{'match': {'left': {'payload': {'protocol':'ip','field':'protocol'}}, 'op':'==', 'right':'icmp'}}, {'drop': None}] {'queue': 0} -> "queue num0"
-> 'ip protocol icmp drop' {'queue': '0-3'} -> "queue num0-3"
- payload / tcp / udp / counter / accept {'queue': {'num': 0, 'bypass': True}} -> "queue num0 bypass"
- simple dicts where keys are 'drop' | 'accept' | 'counter'
This intentionally does not attempt to support every nft JSON construct.
""" """
if expr is None: if expr is None:
return "" return ""
@@ -520,6 +549,23 @@ def expr_to_text(expr: Any) -> Optional[str]:
parts.append("counter") parts.append("counter")
continue continue
# queue support (NEW)
if "queue" in element:
q = element["queue"]
token = "queue"
if isinstance(q, dict):
num = q.get("num") or q.get("number") or q.get("queue_number") or q.get("from") or q.get("range")
if num is not None:
token += f" num{num}"
if q.get("bypass"):
token += " bypass"
elif isinstance(q, (int, float)):
token += f" num{int(q)}"
elif isinstance(q, str):
token += f" num{q}"
parts.append(token)
continue
# match left/right payload equals -> ip protocol icmp, or ip saddr/daddr # match left/right payload equals -> ip protocol icmp, or ip saddr/daddr
if "match" in element: if "match" in element:
m = element["match"] m = element["match"]
@@ -532,11 +578,9 @@ def expr_to_text(expr: Any) -> Optional[str]:
field = p.get("field") field = p.get("field")
# common: protocol field match (protocol == icmp) # common: protocol field match (protocol == icmp)
if prot and field and isinstance(right, str): 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": if field == "protocol":
parts.append(f"{prot} {field} {right}") parts.append(f"{prot} {field} {right}")
continue continue
# payload might be l4 ports etc; produce generic payload(...) token
parts.append(f"payload({prot}.{field}) {right}") parts.append(f"payload({prot}.{field}) {right}")
continue continue
# fallback for match: try to stringify right # fallback for match: try to stringify right
@@ -687,11 +731,8 @@ def create_rule_json(req: CreateRuleRequest):
logger.warning("create_rule_json: failed to list chain for verification: %s", e_chain) logger.warning("create_rule_json: failed to list chain for verification: %s", e_chain)
# If we reach here -> treat as error: return 400 with exec_res in body. # If we reach here -> treat as error: return 400 with exec_res in body.
# FastAPI cannot both raise HTTPException and include ExecResult as body easily, so raise HTTPException
# with detail that includes stderr and the executed cmd.
detail = f"nft command failed rc={rc}. stderr: {stderr!r}. cmd: {cmd}" detail = f"nft command failed rc={rc}. stderr: {stderr!r}. cmd: {cmd}"
logger.warning("create_rule_json failed: %s", detail) logger.warning("create_rule_json failed: %s", detail)
# Return an HTTPException with the detail (frontend can still inspect error.response.data if ExecResult was included)
raise HTTPException(status_code=400, detail=detail) raise HTTPException(status_code=400, detail=detail)
except NftError as e: except NftError as e:
@@ -705,7 +746,6 @@ def create_rule_json(req: CreateRuleRequest):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.delete("/rules/{handle}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete rule by handle") @router.delete("/rules/{handle}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete rule by handle")
def delete_rule(handle: int, family: str = "inet", table: str = "filter", chain: str = "input"): def delete_rule(handle: int, family: str = "inet", table: str = "filter", chain: str = "input"):
""" """

View File

@@ -1,5 +1,5 @@
// src/components/RuleBuilder.tsx // src/components/RuleBuilder.tsx
import { CopyOutlined, PlusOutlined } from '@ant-design/icons'; import { CopyOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { import {
Button, Button,
Card, Card,
@@ -82,7 +82,13 @@ 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 {
expr.push(custom); // allow user to paste a JSON token string (advanced)
try {
const parsed = JSON.parse(custom);
expr.push(parsed);
} catch {
expr.push(custom);
}
} }
} }
} }
@@ -139,6 +145,16 @@ function buildExprFromValues(values: any): Expr[] {
if (action === 'drop') expr.push({ drop: null }); if (action === 'drop') expr.push({ drop: null });
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') {
// include a numeric queue token in the expr. backend should accept this shape.
const qnum = Number(values.nfqueue || 0);
if (Number.isFinite(qnum) && qnum > 0) {
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;
} }
@@ -186,8 +202,14 @@ function textFromExpr(expr: Expr): string {
tokens.push('drop'); tokens.push('drop');
} else if ('accept' in part) { } else if ('accept' in part) {
tokens.push('accept'); tokens.push('accept');
} else if ('reject' in part) {
tokens.push('reject');
} else if ('counter' in part) { } else if ('counter' in part) {
tokens.push('counter'); tokens.push('counter');
} else if ('queue' in part) {
// show NFQUEUE / queue
const q = (part as any).queue;
tokens.push(`queue ${q}`);
} else { } else {
const keys = Object.keys(part).sort().join('+'); const keys = Object.keys(part).sort().join('+');
tokens.push(keys); tokens.push(keys);
@@ -210,26 +232,31 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
const [cmdPreview, setCmdPreview] = useState<string>(''); const [cmdPreview, setCmdPreview] = useState<string>('');
const [requestJsonPreview, setRequestJsonPreview] = useState<any>(null); const [requestJsonPreview, setRequestJsonPreview] = useState<any>(null);
const [refreshing, setRefreshing] = useState(false);
// fetch tables/chains on mount async function loadTables() {
useEffect(() => { setRefreshing(true);
setLoading(true); try {
fetchRuleset() const res = await fetchRuleset();
.then((res) => { if (!res || res.ruleset === null) {
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([]); setTables([]);
}) } else if (typeof res.ruleset === 'string') {
.finally(() => setLoading(false)); 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
useEffect(() => {
loadTables();
}, []); }, []);
const tableOptions = useMemo( const tableOptions = useMemo(
@@ -237,7 +264,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
[tables], [tables],
); );
// set initial defaults // set sensible defaults once tables are known
useEffect(() => { useEffect(() => {
if (tableOptions.length > 0) { if (tableOptions.length > 0) {
const first = tableOptions[0]; const first = tableOptions[0];
@@ -247,12 +274,20 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
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);
if (tbl && tbl.chains && tbl.chains.length > 0) {
form.setFieldsValue({ chainSelect: tbl.chains[0].name });
} else {
form.setFieldsValue({ chainSelect: '__manual_chain__' });
}
} else { } else {
form.setFieldsValue({ form.setFieldsValue({
tableSelect: '__manual__', tableSelect: '__manual__',
protocolChoice: 'preset', protocolChoice: 'preset',
protocolPreset: 'icmp', protocolPreset: 'icmp',
action: 'drop', action: 'drop',
chainSelect: '__manual_chain__',
}); });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -301,7 +336,27 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
// keep preview updated on values change // keep preview updated on values change
const onValuesChange = useCallback(() => { const onValuesChange = useCallback(() => {
updatePreviews(); updatePreviews();
}, [updatePreviews]);
// when tableSelect changes, auto-select chain (first) if any
const vals = form.getFieldsValue();
const ts = vals.tableSelect;
if (ts && ts !== '__manual__') {
const [f, n] = String(ts).split(':');
const tbl = tables.find((t) => t.family === f && t.name === n);
if (tbl) {
if (tbl.chains && tbl.chains.length > 0) {
// if currently no chain selected or manual, set to first available chain
const cs = form.getFieldValue('chainSelect');
if (!cs || cs === '__manual_chain__') {
form.setFieldsValue({ chainSelect: tbl.chains[0].name });
}
} else {
// if no chains, set chainSelect to manual so user can type
form.setFieldsValue({ chainSelect: '__manual_chain__' });
}
}
}
}, [form, tables, updatePreviews]);
// submit handler: build expr and call createRuleJson // submit handler: build expr and call createRuleJson
const handleCreate = useCallback( const handleCreate = useCallback(
@@ -322,7 +377,8 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
} }
const expr = buildExprFromValues(values); const expr = buildExprFromValues(values);
const cmd = `add rule ${family} ${tableName} ${chain} ${textFromExpr(expr)}`.trim(); const textual = textFromExpr(expr);
const cmd = `add rule ${family} ${tableName} ${chain} ${textual}`.trim();
const reqObj = { family, table: tableName, chain, expr }; const reqObj = { family, table: tableName, chain, expr };
Modal.confirm({ Modal.confirm({
@@ -350,6 +406,8 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
const res = await createRuleJson(reqObj); const res = await createRuleJson(reqObj);
if (res && res.rc === 0) { if (res && res.rc === 0) {
message.success('Rule created'); message.success('Rule created');
// refresh tables/chains after successful creation
await loadTables();
if (onCreated) await onCreated(); if (onCreated) await onCreated();
form.resetFields(['advanced']); form.resetFields(['advanced']);
} else { } else {
@@ -366,6 +424,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
message.warn( message.warn(
'Rule appears to have been created, but server returned an error status. Check output for details.', 'Rule appears to have been created, but server returned an error status. Check output for details.',
); );
await loadTables();
if (onCreated) await onCreated(); if (onCreated) await onCreated();
form.resetFields(['advanced']); form.resetFields(['advanced']);
} else { } else {
@@ -396,27 +455,48 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
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 = tables.find((t) => t.family === f && t.name === n);
if (tbl && tbl.chains.length > 0) { if (tbl && tbl.chains.length > 0) {
return tbl.chains.map((c) => ( return (
<Option key={c.name} value={c.name}> <>
{c.name} {tbl.chains.map((c) => (
</Option> <Option key={c.name} value={c.name}>
)); {c.name}
</Option>
))}
<Option key="__manual_chain__" value="__manual_chain__">
Manual chain...
</Option>
</>
);
} }
} }
return [ return (
<Option key="__manual_chain__" value="__manual_chain__"> <>
Manual chain... <Option key="__manual_chain__" value="__manual_chain__">
</Option>, Manual chain...
]; </Option>
</>
);
}, [form, tables]); }, [form, tables]);
return ( return (
<Card style={{ maxWidth: 980 }}> <Card style={{ maxWidth: 980 }}>
<Title level={4}>Add Firewall Rule (JSON)</Title> <Row justify="space-between" align="middle">
<Text type="secondary"> <Col>
This builder constructs a native nft JSON <code>expr</code> and sends it to the server. If the server cannot <Title level={4} style={{ margin: 0 }}>
render your expression to the system's nft syntax, use the Raw command UI instead. Add Firewall Rule (JSON)
</Text> </Title>
<Text type="secondary">
Builds an nft JSON <code>expr</code> and sends it to the server. If rendering fails, use the Raw UI.
</Text>
</Col>
<Col>
<Space>
<Button icon={<ReloadOutlined />} onClick={loadTables} loading={refreshing}>
Refresh tables
</Button>
</Space>
</Col>
</Row>
<Divider /> <Divider />
@@ -470,7 +550,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
{/* Chain selection */} {/* Chain selection */}
<Row gutter={16}> <Row gutter={16}>
<Col xs={24} sm={12}> <Col xs={24} sm={12}>
<Form.Item name="chainSelect" label="Chain (select)"> <Form.Item name="chainSelect" label="Chain (select)" rules={[{ required: true }]}>
<Select>{chainOptions}</Select> <Select>{chainOptions}</Select>
</Form.Item> </Form.Item>
</Col> </Col>
@@ -505,7 +585,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
{form.getFieldValue('protocolChoice') === 'custom' ? ( {form.getFieldValue('protocolChoice') === 'custom' ? (
<Form.Item name="protocolCustom" label="Protocol (custom)"> <Form.Item name="protocolCustom" label="Protocol (custom)">
<Input placeholder="e.g. icmp, icmpv6, meta l4proto tcp" /> <Input placeholder="e.g. icmp, icmpv6, tcp, udp, meta ..." />
</Form.Item> </Form.Item>
) : ( ) : (
<Form.Item name="protocolPreset" label="Protocol (preset)"> <Form.Item name="protocolPreset" label="Protocol (preset)">
@@ -551,8 +631,15 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
<Radio value="drop">Drop</Radio> <Radio value="drop">Drop</Radio>
<Radio value="accept">Accept</Radio> <Radio value="accept">Accept</Radio>
<Radio value="reject">Reject</Radio> <Radio value="reject">Reject</Radio>
<Radio value="nfqueue">NFQUEUE</Radio>
</Radio.Group> </Radio.Group>
</Form.Item> </Form.Item>
{form.getFieldValue('action') === 'nfqueue' && (
<Form.Item name="nfqueue" label="Queue number" initialValue={1}>
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item>
)}
</Col> </Col>
</Row> </Row>
@@ -562,7 +649,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
<Form.Item name="advanced" label="Advanced (optional)"> <Form.Item name="advanced" label="Advanced (optional)">
<TextArea <TextArea
rows={3} rows={3}
placeholder="Append arbitrary JSON token or text (advanced). Use Raw endpoint for complex commands." placeholder='Append arbitrary JSON token or text (advanced). Example: {"limit": {"rate":"5/second"}}'
/> />
<Text type="secondary"> <Text type="secondary">
Advanced is appended to the expr as a token. Prefer structured fields where possible. Advanced is appended to the expr as a token. Prefer structured fields where possible.
@@ -585,20 +672,21 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
</div> </div>
<div style={{ width: 320 }}> <div style={{ width: 320 }}>
<Tooltip title="Click to copy expr JSON. Shift+click to copy full request JSON."> <Tooltip title="Click to copy expr JSON. Shift+Click copies full request JSON.">
<Button <Button
icon={<CopyOutlined />} icon={<CopyOutlined />}
size="small" size="small"
onClick={(e) => { style={{ marginRight: 8 }}
// shiftKey => copy full request JSON onClick={(e: any) => {
if (e.shiftKey && requestJsonPreview) { if (e.shiftKey && requestJsonPreview) {
navigator.clipboard?.writeText(JSON.stringify(requestJsonPreview, null, 2)); navigator.clipboard?.writeText(JSON.stringify(requestJsonPreview, null, 2));
message.success('Full request JSON copied'); message.success('Full request JSON copied');
return; return;
} }
if (!cmdPreview) return; if (!cmdPreview) return;
navigator.clipboard?.writeText(JSON.stringify(cmdPreview, null, 2)); // copy the textual preview as plain text
message.success('Expression JSON copied'); navigator.clipboard?.writeText(cmdPreview);
message.success('Command preview copied');
}} }}
/> />
</Tooltip> </Tooltip>
@@ -612,9 +700,11 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = ({ onCreated }) => {
<Divider /> <Divider />
<Text strong>Full JSON NFT request:</Text> <Text strong>Full JSON NFT request:</Text>
<pre style={{ whiteSpace: 'pre-wrap', background: '#f6f6f6', padding: 12 }}> <div style={{ marginTop: 8 }}>
{JSON.stringify(requestJsonPreview, null, 2)} <Card size="small" type="inner" style={{ background: '#fafafa' }}>
</pre> <pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{JSON.stringify(requestJsonPreview, null, 2)}</pre>
</Card>
</div>
</Card> </Card>
); );
}; };

View File

@@ -416,7 +416,8 @@ export default function FirewallTables(): ReactElement {
<div> <div>
<Text strong>{chain.name}</Text> <Text strong>{chain.name}</Text>
<Text type="secondary" style={{ marginLeft: 12 }}> <Text type="secondary" style={{ marginLeft: 12 }}>
<b>Hook:</b> {chain.hook ?? '-'} &nbsp; <b>Policy:</b> {chain.policy ?? '-'} <b>Hook:</b> {chain.hook ?? '-'} &nbsp; &nbsp; <b>Type:</b> {chain.type ?? '-'} &nbsp;{' '}
<b>Policy:</b> {chain.policy ?? '-'} &nbsp; <b>Priority:</b> {chain.priority ?? '-'}
</Text> </Text>
</div> </div>