nft tables api pretty text
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s

This commit is contained in:
2026-02-28 22:04:13 +01:00
parent 90da926415
commit 5930794a4e
2 changed files with 271 additions and 170 deletions

View File

@@ -325,73 +325,285 @@ def parse_priority(val: Any) -> Optional[int]:
return None return None
def rule_text_from_expr(expr: Any) -> str: # -------------------------
# Expr serializer helpers
# -------------------------
def _compact_json_fragment(obj: Any) -> str:
""" """
Deterministic serializer to produce a compact UI-friendly string from expr list. Return a very compact JSON fragment for unknown tokens to include inline in text
Covers common constructs; falls back to JSON dump for unknown constructs. (keeps text deterministic and safe).
(Used for display in GET /rules). """
try:
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
except Exception:
return str(obj)
def _stringify_value(v: Any) -> str:
"""
Convert RHS values to a deterministic textual form:
- strings -> raw
- numbers -> str
- list/sets -> "{a,b,c}"
- dict with 'start'/'end' -> "start-end" (range style)
- boolean -> "true"/"false"
"""
if v is None:
return "None"
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, (int, float)):
# preserve integer appearance if possible
if isinstance(v, int) or float(v).is_integer():
return str(int(v))
return str(v)
if isinstance(v, str):
return v
if isinstance(v, (list, tuple, set)):
inner = ",".join(sorted(map(str, v))) if not isinstance(v, set) else ",".join(sorted(map(str, v)))
return "{" + inner + "}"
if isinstance(v, dict):
# common NFT range shape: {"start": "10.0.0.1", "end":"10.0.0.255"} or numeric equivalent
if "start" in v and "end" in v:
return f"{_stringify_value(v['start'])}-{_stringify_value(v['end'])}"
# fallback: compact fragment
return _compact_json_fragment(v)
return str(v)
def _render_match(m: Dict[str, Any]) -> Optional[str]:
"""
Render a 'match' dict into textual piece, best-effort.
Supports:
- payload left/right equality: {'left': {'payload': {'protocol':'ip','field':'protocol'}}, 'op':'==', 'right': 'icmp'}
- payload field equals port or address
- IN / not in via op 'in' or 'not in'
- ranges expressed as dict or as right 'range'
Returns None if completely unknown.
"""
if not isinstance(m, dict):
return None
left = m.get("left")
right = m.get("right")
op = m.get("op") or m.get("operator") or m.get("type") or "=="
# Helper: payload left
if isinstance(left, dict) and "payload" in left:
p = left["payload"]
prot = p.get("protocol")
field = p.get("field")
# if right is dict/list/str/number, stringify deterministically
rtxt = _stringify_value(right)
if prot and field:
# typical: protocol field
if field == "protocol":
# e.g. "ip protocol icmp" (right usually string)
return f"{prot} {field} {rtxt}"
# address/port fields
return f"payload({prot}.{field}) {op} {rtxt}"
# left could be dict with 'meta' or 'ct' selectors or direct field names
if isinstance(left, dict) and "meta" in left:
# e.g. meta l4proto
mdata = left["meta"]
if isinstance(mdata, dict):
key = next(iter(mdata.keys()), None)
val = mdata.get(key) if key else None
return f"meta {key} {_stringify_value(val)}"
# left as simple string (rare) or numeric field name
if isinstance(left, str):
return f"{left} {op} {_stringify_value(right)}"
# Left may be a two-sided cmp e.g., {'left': {'payload':...}, 'right': {'payload':...}}
if isinstance(left, dict) and isinstance(right, dict):
# try to render both sides if they contain payloads
if "payload" in left and "payload" in right:
lp = left["payload"]
rp = right["payload"]
ltxt = f"{lp.get('protocol')}.{lp.get('field')}" if lp else _compact_json_fragment(left)
rtxt = f"{rp.get('protocol')}.{rp.get('field')}" if rp else _compact_json_fragment(right)
return f"{ltxt} {op} {rtxt}"
# Fallback: include compact JSON fragment if we cannot deterministically render
return f"match {op} {_compact_json_fragment({'left': left, 'right': right})}"
def _serialize_expr(expr: Any) -> Optional[str]:
"""
Robust serializer for an nft JSON expr (list) -> textual fragment.
Returns a string (possibly verbose) or None if totally unsupported.
The intent is to produce deterministic, readable text for the UI.
""" """
if expr is None: if expr is None:
return "" return ""
if isinstance(expr, list): if isinstance(expr, str):
tokens: List[str] = [] return expr
for part in expr: if not isinstance(expr, list):
if isinstance(part, dict): # unsupported top-level type -> pretty-print compact
# common tokens return _compact_json_fragment(expr)
if "match" in part:
m = part["match"] tokens: List[str] = []
left = m.get("left") for el in expr:
right = m.get("right") # handle simple dict tokens
if isinstance(left, dict) and "payload" in left and isinstance(right, str): if isinstance(el, dict):
p = left["payload"] # direct known keywords
prot = p.get("protocol") if "drop" in el:
field = p.get("field") tokens.append("drop")
if prot and field: continue
tokens.append(f"{prot} {field} {right}") if "accept" in el:
continue tokens.append("accept")
tokens.append("match") continue
elif "payload" in part: if "counter" in el:
p = part["payload"] tokens.append("counter")
continue
if "return" in el:
tokens.append("return")
continue
if "reject" in el:
# reject may be a string reason or dict
rv = el.get("reject")
if isinstance(rv, str):
tokens.append(f"reject {rv}")
elif isinstance(rv, dict):
tokens.append(f"reject {_compact_json_fragment(rv)}")
else:
tokens.append("reject")
continue
# queue may be int/string or dict
if "queue" in el:
q = el["queue"]
tok = "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:
tok += f" num {num}"
if q.get("bypass"):
tok += " bypass"
elif isinstance(q, (int, float)):
tok += f" num {int(q)}"
elif isinstance(q, str):
tok += f" num {q}"
tokens.append(tok)
continue
# match token
if "match" in el:
try:
rendered = _render_match(el["match"])
if rendered is None:
tokens.append(_compact_json_fragment(el))
else:
tokens.append(rendered)
except Exception:
tokens.append(_compact_json_fragment(el))
continue
# payload shorthand
if "payload" in el:
p = el["payload"]
if isinstance(p, dict):
prot = p.get("protocol") prot = p.get("protocol")
field = p.get("field") field = p.get("field")
if prot and field: if prot and field:
tokens.append(f"payload({prot}.{field})") tokens.append(f"payload({prot}.{field})")
continue continue
tokens.append("payload") tokens.append(_compact_json_fragment(el))
elif "cmp" in part or "binary" in part: continue
tokens.append("cmp")
elif "drop" in part: # tcp/udp nested objects e.g. {"tcp": {"dport": 22}} or {"tcp": {"flags":{"syn": True}}}
tokens.append("drop") if "tcp" in el or "udp" in el:
elif "accept" in part: proto = "tcp" if "tcp" in el else "udp"
tokens.append("accept") val = el.get(proto)
elif "counter" in part: if isinstance(val, dict):
tokens.append("counter") # dport/sport
elif "tcp" in part or "udp" in part: if "dport" in val:
proto = "tcp" if "tcp" in part else "udp" tokens.append(f"{proto} dport {_stringify_value(val['dport'])}")
tokens.append(proto) continue
elif "queue" in part: if "sport" in val:
q = part["queue"] tokens.append(f"{proto} sport {_stringify_value(val['sport'])}")
token = "queue" continue
if isinstance(q, dict): # flags
num = q.get("num") or q.get("number") or q.get("queue_number") or q.get("from") or q.get("range") if "flags" in val:
if num is not None: flags = val.get("flags")
token += f" num {num}" if isinstance(flags, (list, tuple)):
if q.get("bypass"): tokens.append(f"{proto} flags {{{','.join(map(str, flags))}}}")
token += " bypass" else:
elif isinstance(q, (int, float)): tokens.append(f"{proto} { _compact_json_fragment(val) }")
token += f" num {int(q)}" continue
elif isinstance(q, str): tokens.append(proto)
token += f" num {q}" continue
tokens.append(token)
else: # cmp / binary / bitwise — attempt to render if shape known
keys = "+".join(sorted(part.keys())) if "cmp" in el or "binary" in el or "bitwise" in el:
tokens.append(keys) # try to compose a readable fragment
else: tokens.append(_compact_json_fragment(el))
tokens.append(str(part)) continue
return " ".join(tokens)
return str(expr) # named set membership e.g. {"in": {"left": ..., "right": ...}} or op in match
# fallback: include compact JSON fragment
tokens.append(_compact_json_fragment(el))
continue
# non-dict tokens (strings/numbers)
tokens.append(str(el))
return " ".join(tokens).strip()
# -------------------------
# Existing helpers (now use _serialize_expr)
# -------------------------
def rule_text_from_expr(expr: Any) -> str:
"""
Deterministic serializer to produce a compact UI-friendly string from expr list.
Uses the robust _serialize_expr and guarantees a string result (never None).
"""
try:
rendered = _serialize_expr(expr)
if rendered is None:
# as a last resort, dump compact JSON
return _compact_json_fragment(expr)
return rendered
except Exception:
return _compact_json_fragment(expr)
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 only when the expr is clearly unsupported for textual insertion.
"""
# For create_rule_json we should be slightly stricter: if serialization produces
# a totally opaque fragment (compact JSON), we prefer to return None to force
# the caller to use the raw textual endpoint.
try:
rendered = _serialize_expr(expr)
if rendered is None:
return None
# Heuristic: if our rendering is just a compact JSON object (meaning we couldn't parse it),
# consider it unsupported (return None).
if isinstance(rendered, str) and rendered.startswith("{") and rendered.endswith("}"):
# try to be conservative: maybe it's a queue/counter JSON we can accept; allow specific tokens
try:
parsed = json.loads(rendered)
# if parsed is dict with single known action, allow it:
if any(k in parsed for k in ("drop", "accept", "queue", "counter")):
return rendered
except Exception:
pass
return None
return rendered
except Exception:
return None
# -------------------------
# Build predictable ruleset (unchanged except it still uses rule_text_from_expr)
# -------------------------
def build_predictable_ruleset(nft_json: Dict[str, Any]) -> Dict[str, Any]: def build_predictable_ruleset(nft_json: Dict[str, Any]) -> Dict[str, Any]:
""" """
Convert nft -j list ruleset parsed JSON into a deterministic, predictable JSON: Convert nft -j list ruleset parsed JSON into a deterministic, predictable JSON:
@@ -523,118 +735,6 @@ 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
- queue tokens and optional bypass support
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
# queue support: allow {"queue": 1} or {"queue": {"num":1, "bypass": True}} etc.
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
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")

View File

@@ -350,7 +350,7 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
}, },
{ {
title: 'Rule', title: 'Rule',
dataIndex: 'raw', dataIndex: 'frontendParsed',
render: (v) => <Paragraph ellipsis={{ rows: 2, expandable: false }}>{v}</Paragraph>, render: (v) => <Paragraph ellipsis={{ rows: 2, expandable: false }}>{v}</Paragraph>,
}, },
{ {
@@ -377,7 +377,8 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
key: `${chain.name}:${idx}`, key: `${chain.name}:${idx}`,
idx: idx + 1, idx: idx + 1,
handle: r.handle ?? null, handle: r.handle ?? null,
raw: renderRuleFriendly(r), frontendParsed: renderRuleFriendly(r),
backendtext: r.text,
})); }));
return ( return (