This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# app.py
|
# app.py
|
||||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
from fastapi import FastAPI, APIRouter, HTTPException, status
|
from fastapi import FastAPI, APIRouter, HTTPException, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
@@ -23,10 +23,11 @@ class NftManager:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.nft = Nftables()
|
self.nft = Nftables()
|
||||||
try:
|
try:
|
||||||
|
# prefer JSON output globally where available
|
||||||
self.nft.set_json_output(True)
|
self.nft.set_json_output(True)
|
||||||
self.nft.set_handle_output(True)
|
self.nft.set_handle_output(True)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("set_json_output not available or ignored")
|
logger.debug("set_json_output/set_handle_output not available")
|
||||||
|
|
||||||
def cmd(self, text_cmd: str) -> Dict[str, Optional[str]]:
|
def cmd(self, text_cmd: str) -> Dict[str, Optional[str]]:
|
||||||
rc, out, err = self.nft.cmd(text_cmd)
|
rc, out, err = self.nft.cmd(text_cmd)
|
||||||
@@ -47,6 +48,7 @@ class NftManager:
|
|||||||
raise NftError(f"json decode error: {e}")
|
raise NftError(f"json decode error: {e}")
|
||||||
|
|
||||||
def list_rules_text(self) -> str:
|
def list_rules_text(self) -> str:
|
||||||
|
# best-effort: temporarily disable JSON output so we get textual form
|
||||||
json_toggled = False
|
json_toggled = False
|
||||||
try:
|
try:
|
||||||
if hasattr(self.nft, "set_json_output"):
|
if hasattr(self.nft, "set_json_output"):
|
||||||
@@ -54,14 +56,14 @@ class NftManager:
|
|||||||
self.nft.set_json_output(False)
|
self.nft.set_json_output(False)
|
||||||
json_toggled = True
|
json_toggled = True
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("could not toggle set_json_output(False); will try command anyway")
|
logger.debug("could not toggle set_json_output(False)")
|
||||||
res = self.cmd("list ruleset")
|
res = self.cmd("list ruleset")
|
||||||
finally:
|
finally:
|
||||||
if json_toggled and hasattr(self.nft, "set_json_output"):
|
if json_toggled and hasattr(self.nft, "set_json_output"):
|
||||||
try:
|
try:
|
||||||
self.nft.set_json_output(True)
|
self.nft.set_json_output(True)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("failed to restore set_json_output(True)")
|
logger.debug("could not restore set_json_output(True)")
|
||||||
|
|
||||||
if res["rc"] != 0:
|
if res["rc"] != 0:
|
||||||
raise NftError(f"nft list ruleset failed: {res['stderr']}")
|
raise NftError(f"nft list ruleset failed: {res['stderr']}")
|
||||||
@@ -77,24 +79,26 @@ class NftManager:
|
|||||||
self.nft.set_json_output(False)
|
self.nft.set_json_output(False)
|
||||||
json_toggled = True
|
json_toggled = True
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("could not toggle set_json_output(False); will try command anyway")
|
logger.debug("could not toggle set_json_output(False)")
|
||||||
res = self.cmd(cmd)
|
res = self.cmd(cmd)
|
||||||
finally:
|
finally:
|
||||||
if json_toggled and hasattr(self.nft, "set_json_output"):
|
if json_toggled and hasattr(self.nft, "set_json_output"):
|
||||||
try:
|
try:
|
||||||
self.nft.set_json_output(True)
|
self.nft.set_json_output(True)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("failed to restore set_json_output(True)")
|
logger.debug("could not restore set_json_output(True)")
|
||||||
|
|
||||||
if res["rc"] != 0:
|
if res["rc"] != 0:
|
||||||
raise NftError(f"nft {cmd} failed: {res['stderr']}")
|
raise NftError(f"nft {cmd} failed: {res['stderr']}")
|
||||||
|
|
||||||
|
# If returned output is JSON-formatted (rare), we try to derive textual lines,
|
||||||
|
# otherwise return raw textual output.
|
||||||
out = res["stdout"] or ""
|
out = res["stdout"] or ""
|
||||||
s = out.strip()
|
s = out.strip()
|
||||||
if s.startswith("{") or s.startswith("["):
|
if s.startswith("{") or s.startswith("["):
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(s)
|
parsed = json.loads(s)
|
||||||
rule_lines: List[str] = []
|
lines: List[str] = []
|
||||||
records = parsed.get("nftables") if isinstance(parsed, dict) else parsed
|
records = parsed.get("nftables") if isinstance(parsed, dict) else parsed
|
||||||
if not isinstance(records, list):
|
if not isinstance(records, list):
|
||||||
records = []
|
records = []
|
||||||
@@ -103,58 +107,39 @@ class NftManager:
|
|||||||
r = rec["rule"]
|
r = rec["rule"]
|
||||||
expr = r.get("expr")
|
expr = r.get("expr")
|
||||||
if isinstance(expr, list):
|
if isinstance(expr, list):
|
||||||
tokens: List[str] = []
|
# summarized tokenization
|
||||||
|
toks: List[str] = []
|
||||||
for part in expr:
|
for part in expr:
|
||||||
if isinstance(part, dict) and "match" in part:
|
if isinstance(part, dict) and "match" in part:
|
||||||
m = part["match"]
|
m = part["match"]
|
||||||
left = m.get("left")
|
left = m.get("left")
|
||||||
right = m.get("right")
|
right = m.get("right")
|
||||||
if isinstance(left, dict) and "payload" in left and isinstance(right, str):
|
if isinstance(left, dict) and "payload" in left and isinstance(right, (str, int)):
|
||||||
p = left["payload"]
|
p = left["payload"]
|
||||||
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"{prot} {field} {right}")
|
toks.append(f"{prot} {field} {right}")
|
||||||
continue
|
continue
|
||||||
tokens.append("match")
|
toks.append("match")
|
||||||
elif isinstance(part, dict) and "payload" in part:
|
|
||||||
p = part["payload"]
|
|
||||||
prot = p.get("protocol")
|
|
||||||
field = p.get("field")
|
|
||||||
tokens.append(f"payload({prot}.{field})")
|
|
||||||
elif isinstance(part, dict) and "drop" in part:
|
elif isinstance(part, dict) and "drop" in part:
|
||||||
tokens.append("drop")
|
toks.append("drop")
|
||||||
elif isinstance(part, dict) and "accept" in part:
|
elif isinstance(part, dict) and "accept" in part:
|
||||||
tokens.append("accept")
|
toks.append("accept")
|
||||||
elif isinstance(part, dict) and "counter" in part:
|
elif isinstance(part, dict) and "counter" in part:
|
||||||
tokens.append("counter")
|
toks.append("counter")
|
||||||
elif isinstance(part, dict) and "queue" in part:
|
|
||||||
q = part["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")
|
|
||||||
tok = "queue"
|
|
||||||
if num is not None:
|
|
||||||
tok += f" num {num}"
|
|
||||||
if q.get("bypass"):
|
|
||||||
tok += " bypass"
|
|
||||||
tokens.append(tok)
|
|
||||||
else:
|
|
||||||
if isinstance(q, (int, float)):
|
|
||||||
tokens.append(f"queue num {int(q)}")
|
|
||||||
else:
|
|
||||||
tokens.append(f"queue num {q}")
|
|
||||||
else:
|
else:
|
||||||
if isinstance(part, dict):
|
if isinstance(part, dict):
|
||||||
tokens.append("+".join(part.keys()))
|
toks.append("+".join(part.keys()))
|
||||||
else:
|
else:
|
||||||
tokens.append(str(part))
|
toks.append(str(part))
|
||||||
rule_lines.append(" ".join(tokens))
|
lines.append(" ".join(toks))
|
||||||
else:
|
else:
|
||||||
rule_lines.append(json.dumps(r))
|
lines.append(json.dumps(r))
|
||||||
if rule_lines:
|
if lines:
|
||||||
return "\n".join(rule_lines)
|
return "\n".join(lines)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("fallback JSON parsing of chain output failed; returning raw output")
|
logger.debug("fallback json->text derivation failed; returning raw output")
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -258,6 +243,7 @@ def parse_priority(val: Any) -> Optional[int]:
|
|||||||
def rule_text_from_expr(expr: Any) -> str:
|
def rule_text_from_expr(expr: Any) -> str:
|
||||||
if expr is None:
|
if expr is None:
|
||||||
return ""
|
return ""
|
||||||
|
# try list first (common)
|
||||||
if isinstance(expr, list):
|
if isinstance(expr, list):
|
||||||
tokens: List[str] = []
|
tokens: List[str] = []
|
||||||
for part in expr:
|
for part in expr:
|
||||||
@@ -266,7 +252,7 @@ def rule_text_from_expr(expr: Any) -> str:
|
|||||||
m = part["match"]
|
m = part["match"]
|
||||||
left = m.get("left")
|
left = m.get("left")
|
||||||
right = m.get("right")
|
right = m.get("right")
|
||||||
if isinstance(left, dict) and "payload" in left and isinstance(right, str):
|
if isinstance(left, dict) and "payload" in left and isinstance(right, (str, int)):
|
||||||
p = left["payload"]
|
p = left["payload"]
|
||||||
prot = p.get("protocol")
|
prot = p.get("protocol")
|
||||||
field = p.get("field")
|
field = p.get("field")
|
||||||
@@ -282,8 +268,6 @@ def rule_text_from_expr(expr: Any) -> str:
|
|||||||
tokens.append(f"payload({prot}.{field})")
|
tokens.append(f"payload({prot}.{field})")
|
||||||
continue
|
continue
|
||||||
tokens.append("payload")
|
tokens.append("payload")
|
||||||
elif "cmp" in part or "binary" in part:
|
|
||||||
tokens.append("cmp")
|
|
||||||
elif "drop" in part:
|
elif "drop" in part:
|
||||||
tokens.append("drop")
|
tokens.append("drop")
|
||||||
elif "accept" in part:
|
elif "accept" in part:
|
||||||
@@ -313,12 +297,20 @@ def rule_text_from_expr(expr: Any) -> str:
|
|||||||
else:
|
else:
|
||||||
tokens.append(str(part))
|
tokens.append(str(part))
|
||||||
return " ".join(tokens)
|
return " ".join(tokens)
|
||||||
return str(expr)
|
# if expr is a dict or other type, give a compact repr
|
||||||
|
try:
|
||||||
|
return str(expr)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
|
"""
|
||||||
result: Dict[str, Any] = {"tables": []}
|
result: Dict[str, Any] = {"tables": []}
|
||||||
items = nft_json.get("nftables", []) if isinstance(nft_json, dict) else (nft_json or [])
|
items = nft_json.get("nftables", []) if isinstance(nft_json, dict) else (nft_json or [])
|
||||||
|
|
||||||
tables: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
tables: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
||||||
for rec in items:
|
for rec in items:
|
||||||
if "table" in rec:
|
if "table" in rec:
|
||||||
@@ -372,13 +364,16 @@ def build_predictable_ruleset(nft_json: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
tables.setdefault((fam, table_name), {"family": fam, "name": table_name, "chains": {}})
|
tables.setdefault((fam, table_name), {"family": fam, "name": table_name, "chains": {}})
|
||||||
chains_map = tables[(fam, table_name)]["chains"]
|
chains_map = tables[(fam, table_name)]["chains"]
|
||||||
chains_map.setdefault(chain_name, {"name": chain_name, "type": None, "hook": None, "priority": None, "policy": None, "rules": []})
|
chains_map.setdefault(chain_name, {"name": chain_name, "type": None, "hook": None, "priority": None, "policy": None, "rules": []})
|
||||||
# we keep expr exactly as provided from JSON; do not replace it
|
|
||||||
|
# do NOT change expr shape here; keep it exactly as NFT JSON provided
|
||||||
rule_obj: Dict[str, Any] = {"handle": handle, "expr": expr, "text": rule_text_from_expr(expr)}
|
rule_obj: Dict[str, Any] = {"handle": handle, "expr": expr, "text": rule_text_from_expr(expr)}
|
||||||
if "position" in r:
|
if "position" in r:
|
||||||
rule_obj["position"] = r["position"]
|
rule_obj["position"] = r["position"]
|
||||||
if "comment" in r:
|
if "comment" in r:
|
||||||
rule_obj["comment"] = r["comment"]
|
rule_obj["comment"] = r["comment"]
|
||||||
chains_map[chain_name]["rules"].append(rule_obj)
|
chains_map[chain_name]["rules"].append(rule_obj)
|
||||||
|
|
||||||
|
# salvage chain-level metadata from rule record if present
|
||||||
if isinstance(r.get("chain"), dict):
|
if isinstance(r.get("chain"), dict):
|
||||||
csub = r.get("chain")
|
csub = r.get("chain")
|
||||||
if chains_map[chain_name].get("priority") is None:
|
if chains_map[chain_name].get("priority") is None:
|
||||||
@@ -391,6 +386,8 @@ def build_predictable_ruleset(nft_json: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
chains_map[chain_name]["hook"] = csub.get("hook")
|
chains_map[chain_name]["hook"] = csub.get("hook")
|
||||||
if chains_map[chain_name].get("policy") is None and csub.get("policy") is not None:
|
if chains_map[chain_name].get("policy") is None and csub.get("policy") is not None:
|
||||||
chains_map[chain_name]["policy"] = csub.get("policy")
|
chains_map[chain_name]["policy"] = csub.get("policy")
|
||||||
|
|
||||||
|
# convert to lists (deterministic order)
|
||||||
for (fam, tname) in sorted(tables.keys(), key=lambda k: (k[0], k[1])):
|
for (fam, tname) in sorted(tables.keys(), key=lambda k: (k[0], k[1])):
|
||||||
tdata = tables[(fam, tname)]
|
tdata = tables[(fam, tname)]
|
||||||
chains_list: List[Dict[str, Any]] = []
|
chains_list: List[Dict[str, Any]] = []
|
||||||
@@ -407,6 +404,7 @@ def build_predictable_ruleset(nft_json: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
result["tables"].append({"family": fam, "name": tname, "chains": chains_list})
|
result["tables"].append({"family": fam, "name": tname, "chains": chains_list})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -419,6 +417,7 @@ def parse_ruleset_text(nft_text: str) -> Dict[Tuple[str, str, str], List[Dict[st
|
|||||||
table_re = re.compile(r"^\s*table\s+(\S+)\s+(\S+)\s*\{")
|
table_re = re.compile(r"^\s*table\s+(\S+)\s+(\S+)\s*\{")
|
||||||
chain_re = re.compile(r"^\s*chain\s+(\S+)\s*\{")
|
chain_re = re.compile(r"^\s*chain\s+(\S+)\s*\{")
|
||||||
handle_re = re.compile(r"#\s*handle\s*(\d+)\b")
|
handle_re = re.compile(r"#\s*handle\s*(\d+)\b")
|
||||||
|
# only skip semicolon-terminated chain metadata lines (type/hook/priority/policy)
|
||||||
chain_meta_re = re.compile(r"^\s*(type\b|hook\b|priority\b|policy\b)\b.*;")
|
chain_meta_re = re.compile(r"^\s*(type\b|hook\b|priority\b|policy\b)\b.*;")
|
||||||
|
|
||||||
current_family = None
|
current_family = None
|
||||||
@@ -447,6 +446,7 @@ def parse_ruleset_text(nft_text: str) -> Dict[Tuple[str, str, str], List[Dict[st
|
|||||||
if s == "" or s == "{" or s == "}":
|
if s == "" or s == "{" or s == "}":
|
||||||
continue
|
continue
|
||||||
if chain_meta_re.match(s):
|
if chain_meta_re.match(s):
|
||||||
|
# skip chain metadata lines only
|
||||||
continue
|
continue
|
||||||
|
|
||||||
m_handle = handle_re.search(s)
|
m_handle = handle_re.search(s)
|
||||||
@@ -464,11 +464,14 @@ def parse_ruleset_text(nft_text: str) -> Dict[Tuple[str, str, str], List[Dict[st
|
|||||||
|
|
||||||
|
|
||||||
def populate_text_from_ruleset_text(custom: Dict[str, Any], nft_text: str) -> None:
|
def populate_text_from_ruleset_text(custom: Dict[str, Any], nft_text: str) -> None:
|
||||||
|
"""
|
||||||
|
Enrich JSON-derived 'custom' structure in-place by setting only rule['text']
|
||||||
|
when a reliable textual mapping is found. Do not change expr or other types.
|
||||||
|
"""
|
||||||
if not nft_text:
|
if not nft_text:
|
||||||
return
|
return
|
||||||
|
|
||||||
parsed = parse_ruleset_text(nft_text)
|
parsed = parse_ruleset_text(nft_text)
|
||||||
|
|
||||||
for table in custom.get("tables", []):
|
for table in custom.get("tables", []):
|
||||||
fam = table.get("family")
|
fam = table.get("family")
|
||||||
tname = table.get("name")
|
tname = table.get("name")
|
||||||
@@ -481,7 +484,7 @@ def populate_text_from_ruleset_text(custom: Dict[str, Any], nft_text: str) -> No
|
|||||||
key = (fam, tname, cname)
|
key = (fam, tname, cname)
|
||||||
textual_entries = parsed.get(key, [])
|
textual_entries = parsed.get(key, [])
|
||||||
if not textual_entries:
|
if not textual_entries:
|
||||||
continue # nothing to enrich for this chain
|
continue
|
||||||
|
|
||||||
handle_map: Dict[int, str] = {}
|
handle_map: Dict[int, str] = {}
|
||||||
ordered_lines: List[str] = []
|
ordered_lines: List[str] = []
|
||||||
@@ -494,37 +497,41 @@ def populate_text_from_ruleset_text(custom: Dict[str, Any], nft_text: str) -> No
|
|||||||
|
|
||||||
rules = chain.get("rules", [])
|
rules = chain.get("rules", [])
|
||||||
for idx, rule in enumerate(rules):
|
for idx, rule in enumerate(rules):
|
||||||
replaced = False
|
# ONLY update 'text' when we can map a textual line
|
||||||
# ensure we don't remove/overwrite expr
|
|
||||||
h = rule.get("handle")
|
h = rule.get("handle")
|
||||||
|
mapped: Optional[str] = None
|
||||||
if isinstance(h, int) and h in handle_map:
|
if isinstance(h, int) and h in handle_map:
|
||||||
rule["text"] = handle_map[h]
|
mapped = handle_map[h]
|
||||||
replaced = True
|
else:
|
||||||
|
|
||||||
if not replaced:
|
|
||||||
pos = rule.get("position")
|
pos = rule.get("position")
|
||||||
if isinstance(pos, int) and 0 <= pos < len(ordered_lines):
|
if isinstance(pos, int) and 0 <= pos < len(ordered_lines):
|
||||||
rule["text"] = ordered_lines[pos]
|
mapped = ordered_lines[pos]
|
||||||
replaced = True
|
elif idx < len(ordered_lines):
|
||||||
|
mapped = ordered_lines[idx]
|
||||||
|
|
||||||
if not replaced and idx < len(ordered_lines):
|
# final substring probe (safe)
|
||||||
rule["text"] = ordered_lines[idx]
|
if mapped is None:
|
||||||
replaced = True
|
|
||||||
|
|
||||||
if not replaced:
|
|
||||||
probe = rule.get("text") or rule_text_from_expr(rule.get("expr"))
|
probe = rule.get("text") or rule_text_from_expr(rule.get("expr"))
|
||||||
if probe:
|
if probe:
|
||||||
for ln in ordered_lines:
|
for ln in ordered_lines:
|
||||||
if probe in ln:
|
if probe in ln:
|
||||||
rule["text"] = ln
|
mapped = ln
|
||||||
replaced = True
|
|
||||||
break
|
break
|
||||||
# only update 'text' when a match was found; otherwise leave existing rule['text']
|
|
||||||
|
if mapped is not None:
|
||||||
|
# ensure we only write a str into 'text'
|
||||||
|
try:
|
||||||
|
rule["text"] = str(mapped)
|
||||||
|
except Exception:
|
||||||
|
rule["text"] = mapped # should be str already
|
||||||
|
|
||||||
|
|
||||||
def populate_text_from_chain_text(custom: Dict[str, Any]) -> None:
|
def populate_text_from_chain_text(custom: Dict[str, Any]) -> None:
|
||||||
tables = custom.get("tables") or []
|
"""
|
||||||
for t in tables:
|
Per-chain fallback that queries nft for the chain's textual listing and
|
||||||
|
updates only rule['text'] when a reliable mapping is found.
|
||||||
|
"""
|
||||||
|
for t in custom.get("tables", []):
|
||||||
fam = t.get("family")
|
fam = t.get("family")
|
||||||
tname = t.get("name")
|
tname = t.get("name")
|
||||||
if not fam or not tname:
|
if not fam or not tname:
|
||||||
@@ -545,88 +552,84 @@ def populate_text_from_chain_text(custom: Dict[str, Any]) -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for rule in ch.get("rules", []):
|
rules = ch.get("rules", [])
|
||||||
replaced = False
|
for idx, rule in enumerate(rules):
|
||||||
|
mapped: Optional[str] = None
|
||||||
h = rule.get("handle")
|
h = rule.get("handle")
|
||||||
if isinstance(h, int) and h in handle_map:
|
if isinstance(h, int) and h in handle_map:
|
||||||
rule["text"] = handle_map[h]
|
mapped = handle_map[h]
|
||||||
replaced = True
|
else:
|
||||||
|
probe = rule.get("text") or rule_text_from_expr(rule.get("expr"))
|
||||||
if not replaced:
|
|
||||||
expr = rule.get("expr")
|
|
||||||
probe = rule.get("text") or rule_text_from_expr(expr)
|
|
||||||
if probe:
|
if probe:
|
||||||
for ln in lines:
|
for ln in lines:
|
||||||
if probe in ln:
|
if probe in ln:
|
||||||
rule["text"] = ln.strip()
|
mapped = ln.strip()
|
||||||
replaced = True
|
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if mapped is not None:
|
||||||
|
try:
|
||||||
|
rule["text"] = str(mapped)
|
||||||
|
except Exception:
|
||||||
|
rule["text"] = mapped
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(
|
logger.debug("populate_text_from_chain_text failed for %s %s %s: %s", fam, tname, cname, e)
|
||||||
"populate_text_from_chain_text: failed to get textual chain for %s %s %s: %s",
|
|
||||||
fam,
|
|
||||||
tname,
|
|
||||||
cname,
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
# ---------- Normalization helper to satisfy Pydantic model ----------
|
# ---------- Normalization helper (lightweight and safe) ----------
|
||||||
def normalize_custom_for_model(custom: Dict[str, Any]) -> None:
|
def normalize_custom_for_model(custom: Dict[str, Any]) -> None:
|
||||||
"""
|
"""
|
||||||
Ensure every rule has required keys/types for RulesetModel parsing:
|
Make minimal, safe guarantees required by Pydantic:
|
||||||
- 'expr' must exist (if missing/None -> set to [])
|
- rule['expr'] must exist (if None -> set to empty list)
|
||||||
- 'text' must exist and be a str (if missing -> derive via rule_text_from_expr or "")
|
- rule['text'] must be a str (if missing -> derived string)
|
||||||
- 'handle' coerced to int or None
|
- rule['handle'] coerced to int or None
|
||||||
This mutates `custom` in-place.
|
Do NOT change any other shapes.
|
||||||
"""
|
"""
|
||||||
tables = custom.get("tables") or []
|
for t in custom.get("tables", []):
|
||||||
for t in tables:
|
|
||||||
for ch in t.get("chains", []):
|
for ch in t.get("chains", []):
|
||||||
rules = ch.get("rules", []) or []
|
rules = ch.get("rules", []) or []
|
||||||
for r in rules:
|
for r in rules:
|
||||||
# expr: required by model; prefer existing value, but don't leave None
|
# expr: if missing or None => set to [] (preserves Any)
|
||||||
if "expr" not in r or r.get("expr") is None:
|
if "expr" not in r or r.get("expr") is None:
|
||||||
# default to empty list (acceptable Any value and consistent with prior shape)
|
|
||||||
r["expr"] = []
|
r["expr"] = []
|
||||||
|
|
||||||
# text: must be str
|
# text: ensure string
|
||||||
txt = r.get("text")
|
if "text" not in r or r.get("text") is None:
|
||||||
if txt is None:
|
|
||||||
try:
|
try:
|
||||||
r["text"] = rule_text_from_expr(r.get("expr"))
|
r["text"] = rule_text_from_expr(r.get("expr"))
|
||||||
except Exception:
|
except Exception:
|
||||||
r["text"] = ""
|
r["text"] = ""
|
||||||
else:
|
else:
|
||||||
# coerce to str if not string
|
if not isinstance(r["text"], str):
|
||||||
if not isinstance(txt, str):
|
|
||||||
try:
|
try:
|
||||||
r["text"] = str(txt)
|
r["text"] = str(r["text"])
|
||||||
except Exception:
|
except Exception:
|
||||||
r["text"] = ""
|
r["text"] = ""
|
||||||
|
|
||||||
# handle: ensure int or None
|
# handle: coerce to int or None
|
||||||
h = r.get("handle")
|
h = r.get("handle")
|
||||||
if isinstance(h, str):
|
if isinstance(h, str):
|
||||||
try:
|
try:
|
||||||
r["handle"] = int(h)
|
r["handle"] = int(h)
|
||||||
except Exception:
|
except Exception:
|
||||||
r["handle"] = None
|
r["handle"] = None
|
||||||
elif not isinstance(h, int):
|
elif isinstance(h, float):
|
||||||
# if it's e.g. float, bool, etc. convert if reasonable
|
|
||||||
try:
|
try:
|
||||||
if isinstance(h, (float,)):
|
r["handle"] = int(h)
|
||||||
r["handle"] = int(h)
|
|
||||||
else:
|
|
||||||
r["handle"] = None
|
|
||||||
except Exception:
|
except Exception:
|
||||||
r["handle"] = None
|
r["handle"] = None
|
||||||
|
elif not isinstance(h, int):
|
||||||
|
r["handle"] = None
|
||||||
|
|
||||||
|
|
||||||
# ---------- Routes ----------
|
# ---------- Routes ----------
|
||||||
@router.get("/rules", response_model=RulesetOut, summary="List ruleset")
|
@router.get("/rules", response_model=RulesetOut, summary="List ruleset")
|
||||||
def list_rules():
|
def list_rules():
|
||||||
|
"""
|
||||||
|
Returns JSON-derived ruleset (RulesetModel) and enriches each rule['text']
|
||||||
|
with the textual nft rule line when possible. This function will not replace
|
||||||
|
JSON-derived 'expr' or other data types — enrichment is additive only.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
nft_json = mgr.list_rules_json()
|
nft_json = mgr.list_rules_json()
|
||||||
@@ -634,41 +637,51 @@ def list_rules():
|
|||||||
logger.debug("could not obtain nft JSON ruleset: %s", e)
|
logger.debug("could not obtain nft JSON ruleset: %s", e)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to obtain nft JSON ruleset: {e}")
|
raise HTTPException(status_code=500, detail=f"Failed to obtain nft JSON ruleset: {e}")
|
||||||
|
|
||||||
# get textual snapshot (best-effort), used only to enrich 'text' values
|
# best-effort textual snapshot for enrichment
|
||||||
nft_text = ""
|
nft_text = ""
|
||||||
try:
|
try:
|
||||||
nft_text = mgr.list_rules_text()
|
nft_text = mgr.list_rules_text()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("could not obtain textual nft ruleset (list_rules_text failed)")
|
logger.debug("could not obtain textual nft ruleset snapshot")
|
||||||
|
|
||||||
# build predictable JSON-derived structure (must be the source of truth)
|
# Build canonical JSON-derived shape (source of truth)
|
||||||
custom = build_predictable_ruleset(nft_json)
|
custom = build_predictable_ruleset(nft_json)
|
||||||
|
|
||||||
# enrich existing JSON rules with textual lines (handle -> line; position/index fallback)
|
# Enrich only the 'text' field in-place using the textual snapshot
|
||||||
try:
|
try:
|
||||||
if nft_text:
|
if nft_text:
|
||||||
populate_text_from_ruleset_text(custom, nft_text)
|
populate_text_from_ruleset_text(custom, nft_text)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("list_rules: populate_text_from_ruleset_text failed: %s", e)
|
logger.debug("populate_text_from_ruleset_text failed: %s", e)
|
||||||
|
|
||||||
# per-chain fallback that queries nft for chain-level textual lines
|
# Per-chain fallback using `nft list chain ...`
|
||||||
try:
|
try:
|
||||||
populate_text_from_chain_text(custom)
|
populate_text_from_chain_text(custom)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("list_rules: populate_text_from_chain_text failed: %s", e)
|
logger.debug("populate_text_from_chain_text failed: %s", e)
|
||||||
|
|
||||||
# Normalize to satisfy Pydantic model (do this last)
|
# Normalize minimally for model validation
|
||||||
normalize_custom_for_model(custom)
|
normalize_custom_for_model(custom)
|
||||||
|
|
||||||
# Debug: log counts
|
# Debug counts
|
||||||
num_tables = len(custom.get("tables", []))
|
num_tables = len(custom.get("tables", []))
|
||||||
num_rules = sum(len(ch.get("rules", [])) for t in custom.get("tables", []) for ch in t.get("chains", []))
|
num_rules = sum(len(ch.get("rules", [])) for t in custom.get("tables", []) for ch in t.get("chains", []))
|
||||||
logger.info("list_rules: returning tables=%d rules=%d", num_tables, num_rules)
|
logger.info("list_rules: returning tables=%d rules=%d", num_tables, num_rules)
|
||||||
|
|
||||||
return RulesetModel.parse_obj(custom)
|
# RETURN a shape matching response_model=RulesetOut
|
||||||
|
try:
|
||||||
|
ruleset_model = RulesetModel.parse_obj(custom)
|
||||||
|
except ValidationError as ve:
|
||||||
|
# log full validation error for debugging and return 500 with message
|
||||||
|
logger.exception("RulesetModel validation failed: %s", ve)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Internal: ruleset validation failed: {ve}")
|
||||||
|
|
||||||
|
return {"ruleset": ruleset_model}
|
||||||
except NftError as e:
|
except NftError as e:
|
||||||
logger.exception("list_rules failed")
|
logger.exception("list_rules failed")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("list_rules internal error")
|
logger.exception("list_rules internal error")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -689,16 +702,10 @@ def create_rule_json(req: CreateRuleRequest):
|
|||||||
if req.expr is None:
|
if req.expr is None:
|
||||||
raise NftError("field 'expr' is required for JSON rule creation")
|
raise NftError("field 'expr' is required for JSON rule creation")
|
||||||
|
|
||||||
rendered = None
|
rendered = rule_text_from_expr(req.expr)
|
||||||
try:
|
if rendered is None or rendered == "":
|
||||||
rendered = rule_text_from_expr(req.expr)
|
|
||||||
except Exception:
|
|
||||||
rendered = None
|
|
||||||
|
|
||||||
if rendered is None:
|
|
||||||
raise NftError(
|
raise NftError(
|
||||||
"cannot render provided 'expr' to textual nft syntax. "
|
"cannot render provided 'expr' to textual nft syntax. Use POST /firewall/raw"
|
||||||
"Please use POST /firewall/raw to execute the textual nft command."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
expr_text = rendered.strip()
|
expr_text = rendered.strip()
|
||||||
@@ -714,8 +721,7 @@ def create_rule_json(req: CreateRuleRequest):
|
|||||||
else:
|
else:
|
||||||
cmd = f"add rule {family} {table} {chain} {expr_text}"
|
cmd = f"add rule {family} {table} {chain} {expr_text}"
|
||||||
|
|
||||||
logger.info("create_rule_json executing command: %s", cmd)
|
logger.info("create_rule_json executing: %s", cmd)
|
||||||
|
|
||||||
res = mgr.cmd(cmd)
|
res = mgr.cmd(cmd)
|
||||||
raw_rc = res.get("rc")
|
raw_rc = res.get("rc")
|
||||||
stdout = res.get("stdout") or ""
|
stdout = res.get("stdout") or ""
|
||||||
@@ -733,22 +739,19 @@ def create_rule_json(req: CreateRuleRequest):
|
|||||||
if rc == 0:
|
if rc == 0:
|
||||||
return exec_res
|
return exec_res
|
||||||
|
|
||||||
|
# If non-zero rc but empty stderr, try to verify presence
|
||||||
if (rc < 0 or rc != 0) and stderr.strip() == "":
|
if (rc < 0 or rc != 0) and stderr.strip() == "":
|
||||||
logger.debug("create_rule_json: rc indicates failure but stderr empty; verifying rule presence")
|
|
||||||
try:
|
try:
|
||||||
chain_text = mgr.list_chain_text(family, table, chain) or ""
|
chain_text = mgr.list_chain_text(family, table, chain) or ""
|
||||||
if expr_text and expr_text in chain_text:
|
if expr_text and expr_text in chain_text:
|
||||||
logger.info("create_rule_json: detected rule in chain after add; treating as success")
|
logger.info("create_rule_json: rule detected after add; treating as success")
|
||||||
return ExecResult(rc=0, stdout=stdout or None, stderr=stderr or None)
|
return ExecResult(rc=0, stdout=stdout or None, stderr=stderr or None)
|
||||||
else:
|
|
||||||
logger.debug("create_rule_json: rule not found in chain text; chain_text=%r", chain_text)
|
|
||||||
except Exception as e_chain:
|
except Exception as e_chain:
|
||||||
logger.warning("create_rule_json: failed to list chain for verification: %s", e_chain)
|
logger.warning("create_rule_json verification failed: %s", e_chain)
|
||||||
|
|
||||||
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)
|
||||||
raise HTTPException(status_code=400, detail=detail)
|
raise HTTPException(status_code=400, detail=detail)
|
||||||
|
|
||||||
except NftError as e:
|
except NftError as e:
|
||||||
logger.warning("create_rule_json NftError: %s", e)
|
logger.warning("create_rule_json NftError: %s", e)
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|||||||
Reference in New Issue
Block a user