This commit is contained in:
@@ -18,29 +18,26 @@ class NftError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ---------- NftManager (textual-only) ----------
|
# ---------- NftManager (textual vs JSON) ----------
|
||||||
class NftManager:
|
class NftManager:
|
||||||
"""
|
"""
|
||||||
Thin wrapper around python-nftables exposing:
|
Thin wrapper around python-nftables exposing:
|
||||||
- cmd execution (textual nft commands via Nftables.cmd())
|
- cmd execution via Nftables.cmd() for textual output
|
||||||
- json execution via Nftables.json_cmd() when available
|
- json_cmd execution when available for JSON output (or fallback to cmd with -j)
|
||||||
- convenience list_rules_text / list_rules_json / list_chain_text
|
- helpers to list rules / chain text
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.nft = Nftables()
|
self.nft = Nftables()
|
||||||
# Try to prefer JSON for general listing; we'll toggle off for chain-list calls.
|
# set_json_output is optional; don't rely on it for JSON path.
|
||||||
try:
|
try:
|
||||||
# If set_json_output exists it's a convenience; we won't rely on it for JSON path.
|
if hasattr(self.nft, "set_json_output"):
|
||||||
self.nft.set_json_output(True)
|
self.nft.set_json_output(True)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("set_json_output not available or ignored")
|
logger.debug("set_json_output not available or ignored")
|
||||||
|
|
||||||
def cmd(self, text_cmd: str) -> Dict[str, Optional[str]]:
|
def cmd(self, text_cmd: str) -> Dict[str, Optional[str]]:
|
||||||
"""
|
"""Execute textual nft command via Nftables.cmd()."""
|
||||||
Execute a textual nft command via Nftables.cmd().
|
|
||||||
Returns dict { "rc": rc, "stdout": out_str, "stderr": err_str }.
|
|
||||||
"""
|
|
||||||
rc, out, err = self.nft.cmd(text_cmd)
|
rc, out, err = self.nft.cmd(text_cmd)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
logger.warning("nft cmd rc=%s stderr=%s cmd=%s", rc, err, text_cmd)
|
logger.warning("nft cmd rc=%s stderr=%s cmd=%s", rc, err, text_cmd)
|
||||||
@@ -48,23 +45,20 @@ class NftManager:
|
|||||||
|
|
||||||
def json_cmd(self, text_cmd: str) -> Tuple[int, str, str]:
|
def json_cmd(self, text_cmd: str) -> Tuple[int, str, str]:
|
||||||
"""
|
"""
|
||||||
Execute a JSON-output nft command. Prefer Nftables.json_cmd if available
|
Execute nft command expecting JSON output.
|
||||||
(returns tuple (rc, stdout, stderr)). Otherwise, call `nft <cmd> -j` using cmd()
|
Prefer Nftables.json_cmd when available (returns (rc, out, err)).
|
||||||
and attempt to return the same tuple shape.
|
Otherwise call cmd() with a '-j' suffix and return a similar tuple.
|
||||||
"""
|
"""
|
||||||
# Prefer built-in json_cmd if present
|
|
||||||
if hasattr(self.nft, "json_cmd"):
|
if hasattr(self.nft, "json_cmd"):
|
||||||
try:
|
try:
|
||||||
res = self.nft.json_cmd(text_cmd)
|
res = self.nft.json_cmd(text_cmd)
|
||||||
# Expecting (rc, out, err)
|
|
||||||
if isinstance(res, (list, tuple)) and len(res) >= 3:
|
if isinstance(res, (list, tuple)) and len(res) >= 3:
|
||||||
return int(res[0]), res[1], res[2]
|
return int(res[0]), res[1], res[2]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("json_cmd failed, falling back to cmd with -j: %s", e)
|
logger.debug("nft.json_cmd failed, falling back to cmd -j: %s", e)
|
||||||
|
|
||||||
# Fallback: call cmd with -j variant and parse output
|
# fallback: append -j if not present and call textual cmd()
|
||||||
# Use 'list ruleset -j' or similar command suffixes as caller provides the whole command
|
cmd_with_j = text_cmd if "-j" in text_cmd else f"{text_cmd} -j"
|
||||||
cmd_with_j = f"{text_cmd} -j" if "-j" not in text_cmd else text_cmd
|
|
||||||
r = self.cmd(cmd_with_j)
|
r = self.cmd(cmd_with_j)
|
||||||
rc = int(r.get("rc", -1) or -1)
|
rc = int(r.get("rc", -1) or -1)
|
||||||
out = r.get("stdout") or ""
|
out = r.get("stdout") or ""
|
||||||
@@ -72,19 +66,14 @@ class NftManager:
|
|||||||
return rc, out, err
|
return rc, out, err
|
||||||
|
|
||||||
def list_rules(self) -> str:
|
def list_rules(self) -> str:
|
||||||
"""
|
"""Return textual ruleset from `nft list ruleset`."""
|
||||||
Return the textual ruleset as produced by 'nft list ruleset'.
|
|
||||||
"""
|
|
||||||
res = self.cmd("list ruleset")
|
res = self.cmd("list ruleset")
|
||||||
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']}")
|
||||||
return res["stdout"] or ""
|
return res["stdout"] or ""
|
||||||
|
|
||||||
def list_rules_json(self) -> Dict[str, Any]:
|
def list_rules_json(self) -> Dict[str, Any]:
|
||||||
"""
|
"""Return parsed JSON from `nft -j list ruleset`."""
|
||||||
Try to obtain nft -j list ruleset (JSON). Returns parsed JSON dict on success.
|
|
||||||
Raises NftError on failure or when output cannot be parsed as JSON.
|
|
||||||
"""
|
|
||||||
rc, out, err = self.json_cmd("list ruleset")
|
rc, out, err = self.json_cmd("list ruleset")
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
raise NftError(f"nft list ruleset failed: {err}")
|
raise NftError(f"nft list ruleset failed: {err}")
|
||||||
@@ -99,108 +88,97 @@ class NftManager:
|
|||||||
def list_chain_text(self, family: str, table: str, chain: str) -> str:
|
def list_chain_text(self, family: str, table: str, chain: str) -> str:
|
||||||
"""
|
"""
|
||||||
Return textual output of `nft list chain <family> <table> <chain>`.
|
Return textual output of `nft list chain <family> <table> <chain>`.
|
||||||
We prefer calling nft in textual mode (nft.cmd). If the wrapper can't return textual
|
Prefer textual cmd(); if it fails and JSON is returned, attempt a best-effort
|
||||||
output and returns JSON, we attempt a best-effort reconstruction of the textual lines.
|
reconstruction of textual lines from JSON.
|
||||||
"""
|
"""
|
||||||
cmd = f"list chain {family} {table} {chain}"
|
cmd = f"list chain {family} {table} {chain}"
|
||||||
# Attempt to call textual cmd (this uses self.nft.cmd)
|
|
||||||
res = self.cmd(cmd)
|
res = self.cmd(cmd)
|
||||||
if res["rc"] != 0:
|
if res["rc"] == 0:
|
||||||
# if the raw textual command failed, try JSON and attempt to reconstruct textual
|
return res["stdout"] or ""
|
||||||
logger.debug("list_chain_text textual cmd rc!=0; trying JSON fallback: %s", res["stderr"])
|
|
||||||
# try json_cmd
|
# textual call failed -> try JSON fallback and reconstruct
|
||||||
rc, out, err = self.json_cmd(cmd)
|
logger.debug("list_chain_text: textual cmd failed (%s), attempting JSON fallback", res["stderr"])
|
||||||
if rc != 0:
|
rc, out, err = self.json_cmd(cmd)
|
||||||
raise NftError(f"nft {cmd} failed: {err}")
|
if rc != 0:
|
||||||
# attempt to build textual lines from JSON
|
raise NftError(f"nft {cmd} failed: {err}")
|
||||||
try:
|
|
||||||
parsed = json.loads(out)
|
try:
|
||||||
except Exception:
|
parsed = json.loads(out)
|
||||||
# give up, return raw text (could be empty)
|
except Exception:
|
||||||
return res.get("stdout") or ""
|
# give up and return textual stdout (maybe empty)
|
||||||
# build textual representation from JSON (best-effort)
|
return res.get("stdout") or ""
|
||||||
lines: List[str] = []
|
|
||||||
records = parsed.get("nftables") if isinstance(parsed, dict) else (parsed or [])
|
lines: List[str] = []
|
||||||
if not isinstance(records, list):
|
records = parsed.get("nftables") if isinstance(parsed, dict) else (parsed or [])
|
||||||
records = []
|
if not isinstance(records, list):
|
||||||
for rec in records:
|
records = []
|
||||||
if "rule" in rec:
|
for rec in records:
|
||||||
# try to use 'line' if present
|
if "rule" in rec:
|
||||||
r = rec["rule"]
|
r = rec["rule"]
|
||||||
# sometimes json includes 'expr' or 'handle' — we try to recompose a line
|
handle = r.get("handle")
|
||||||
line_parts: List[str] = []
|
expr = r.get("expr")
|
||||||
handle = r.get("handle")
|
parts: List[str] = []
|
||||||
# attempt to render expr to short textual form
|
if isinstance(expr, list):
|
||||||
expr = r.get("expr")
|
for part in expr:
|
||||||
if isinstance(expr, list):
|
if isinstance(part, dict):
|
||||||
# best-effort: reuse simple tokens
|
if "payload" in part:
|
||||||
for part in expr:
|
p = part["payload"]
|
||||||
if isinstance(part, dict):
|
prot = p.get("protocol")
|
||||||
if "payload" in part:
|
field = p.get("field")
|
||||||
p = part["payload"]
|
if prot and field:
|
||||||
|
parts.append(f"payload({prot}.{field})")
|
||||||
|
continue
|
||||||
|
if "match" in part:
|
||||||
|
m = part["match"]
|
||||||
|
left = m.get("left")
|
||||||
|
right = m.get("right")
|
||||||
|
if isinstance(left, dict) and "payload" in left and isinstance(right, (str, int)):
|
||||||
|
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:
|
||||||
line_parts.append(f"payload({prot}.{field})")
|
parts.append(f"{prot} {field} {right}")
|
||||||
continue
|
continue
|
||||||
if "match" in part:
|
parts.append("match")
|
||||||
m = part["match"]
|
continue
|
||||||
left = m.get("left")
|
if "drop" in part:
|
||||||
right = m.get("right")
|
parts.append("drop")
|
||||||
if isinstance(left, dict) and "payload" in left and isinstance(right, (str, int)):
|
continue
|
||||||
p = left["payload"]
|
if "accept" in part:
|
||||||
prot = p.get("protocol")
|
parts.append("accept")
|
||||||
field = p.get("field")
|
continue
|
||||||
if prot and field:
|
if "counter" in part:
|
||||||
line_parts.append(f"{prot} {field} {right}")
|
parts.append("counter")
|
||||||
continue
|
continue
|
||||||
line_parts.append("match")
|
if "queue" in part:
|
||||||
|
q = part["queue"]
|
||||||
|
tok = "queue"
|
||||||
|
if isinstance(q, dict):
|
||||||
|
num = q.get("num") or q.get("number") or q.get("queue_number")
|
||||||
|
if num is not None:
|
||||||
|
tok += f" num {num}"
|
||||||
|
if q.get("bypass") or q.get("flags") == "bypass":
|
||||||
|
tok += " bypass"
|
||||||
|
parts.append(tok)
|
||||||
continue
|
continue
|
||||||
if "drop" in part:
|
if isinstance(q, (int, float)):
|
||||||
line_parts.append("drop")
|
parts.append(f"queue num {int(q)}")
|
||||||
continue
|
continue
|
||||||
if "accept" in part:
|
parts.append("queue")
|
||||||
line_parts.append("accept")
|
continue
|
||||||
continue
|
# fallback: join keys
|
||||||
if "counter" in part:
|
parts.append("+".join(sorted(part.keys())))
|
||||||
line_parts.append("counter")
|
else:
|
||||||
continue
|
parts.append(str(part))
|
||||||
if "queue" in part:
|
else:
|
||||||
q = part["queue"]
|
parts.append(json.dumps(r))
|
||||||
if isinstance(q, dict):
|
text_line = " ".join(parts).strip()
|
||||||
num = q.get("num") or q.get("number") or q.get("queue_number")
|
if handle is not None:
|
||||||
token = "queue"
|
text_line = f"{text_line} # handle {handle}"
|
||||||
if num is not None:
|
lines.append(text_line)
|
||||||
token += f" num {num}"
|
return "\n".join(lines) if lines else (res.get("stdout") or "")
|
||||||
if q.get("bypass"):
|
|
||||||
token += " bypass"
|
|
||||||
line_parts.append(token)
|
|
||||||
elif isinstance(q, (int, float)):
|
|
||||||
line_parts.append(f"queue num {int(q)}")
|
|
||||||
else:
|
|
||||||
line_parts.append("queue")
|
|
||||||
continue
|
|
||||||
# fallback to keys
|
|
||||||
line_parts.append("+".join(sorted(part.keys())))
|
|
||||||
else:
|
|
||||||
line_parts.append(str(part))
|
|
||||||
else:
|
|
||||||
# expr not list -> fallback to JSON dump of rule
|
|
||||||
line_parts.append(json.dumps(r))
|
|
||||||
# combine into single textual line; append handle if present
|
|
||||||
text_line = " ".join(line_parts).strip()
|
|
||||||
if handle is not None:
|
|
||||||
text_line = f"{text_line} # handle {handle}"
|
|
||||||
lines.append(text_line)
|
|
||||||
return "\n".join(lines) if lines else (res.get("stdout") or "")
|
|
||||||
# textual cmd succeeded
|
|
||||||
return res.get("stdout") or ""
|
|
||||||
|
|
||||||
def delete_rule_by_handle_text(self, family: str, table: str, chain: str, handle: int) -> None:
|
def delete_rule_by_handle_text(self, family: str, table: str, chain: str, handle: int) -> None:
|
||||||
"""
|
|
||||||
Delete a rule by handle using textual nft command:
|
|
||||||
delete rule <family> <table> <chain> handle <handle>
|
|
||||||
"""
|
|
||||||
if not isinstance(handle, int) or handle <= 0:
|
if not isinstance(handle, int) or handle <= 0:
|
||||||
raise ValueError("handle must be a positive integer")
|
raise ValueError("handle must be a positive integer")
|
||||||
cmd = f"delete rule {family} {table} {chain} handle {handle}"
|
cmd = f"delete rule {family} {table} {chain} handle {handle}"
|
||||||
@@ -215,81 +193,62 @@ router = APIRouter(prefix="/firewall", tags=["firewall"])
|
|||||||
mgr = NftManager()
|
mgr = NftManager()
|
||||||
|
|
||||||
|
|
||||||
# ---------- Request/Response models (strongly typed) ----------
|
# ---------- Request/Response models ----------
|
||||||
class RawCmdRequest(BaseModel):
|
class RawCmdRequest(BaseModel):
|
||||||
cmd: str = Field(..., description="Textual nft command to execute", example="add rule inet filter input ip saddr 10.0.0.0/8 drop")
|
cmd: str = Field(..., example="add rule inet filter input ip saddr 10.0.0.0/8 drop")
|
||||||
|
|
||||||
|
|
||||||
class ExecResult(BaseModel):
|
class ExecResult(BaseModel):
|
||||||
rc: int = Field(..., description="Return code from nft execution", example=0)
|
rc: int
|
||||||
stdout: Optional[str] = Field(None, description="Standard output from nft", example="")
|
stdout: Optional[str] = None
|
||||||
stderr: Optional[str] = Field(None, description="Standard error from nft", example="")
|
stderr: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
|
||||||
schema_extra = {"example": {"rc": 0, "stdout": "ok", "stderr": ""}}
|
|
||||||
|
|
||||||
|
|
||||||
# --- Strong models returned to frontend ---
|
|
||||||
class RuleOut(BaseModel):
|
class RuleOut(BaseModel):
|
||||||
handle: Optional[int] = Field(None, description="The rule handle (unique per rule), if available", example=3)
|
handle: Optional[int] = None
|
||||||
expr: Any = Field(..., description="Machine-readable nft expression (original nft JSON expr).")
|
expr: Any
|
||||||
text: str = Field(..., description="Deterministic short display string derived from expr", example="ip protocol icmp drop")
|
text: str
|
||||||
position: Optional[Any] = Field(None, description="Optional position metadata from nft if present")
|
position: Optional[Any] = None
|
||||||
comment: Optional[str] = Field(None, description="Optional comment attached to the rule")
|
comment: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ChainOut(BaseModel):
|
class ChainOut(BaseModel):
|
||||||
name: str = Field(..., description="Chain name", example="forward")
|
name: str
|
||||||
type: Optional[str] = Field(None, description="Chain type (e.g. filter, nat, route)")
|
type: Optional[str] = None
|
||||||
hook: Optional[str] = Field(None, description="Hook (input/forward/output/ingress/egress) if present")
|
hook: Optional[str] = None
|
||||||
priority: Optional[int] = Field(None, description="Hook priority if present")
|
priority: Optional[int] = None
|
||||||
policy: Optional[str] = Field(None, description="Chain policy (accept/drop) if present")
|
policy: Optional[str] = None
|
||||||
rules: List[RuleOut] = Field(..., description="Rules in this chain (ordered)")
|
rules: List[RuleOut]
|
||||||
|
|
||||||
|
|
||||||
class TableOut(BaseModel):
|
class TableOut(BaseModel):
|
||||||
family: str = Field(..., description="Table family (inet/bridge/ipv4/...)")
|
family: str
|
||||||
name: str = Field(..., description="Table name", example="filter")
|
name: str
|
||||||
chains: List[ChainOut] = Field(..., description="Chains in this table")
|
chains: List[ChainOut]
|
||||||
|
|
||||||
|
|
||||||
class RulesetModel(BaseModel):
|
class RulesetModel(BaseModel):
|
||||||
tables: List[TableOut] = Field(..., description="Top-level tables list")
|
tables: List[TableOut]
|
||||||
|
|
||||||
|
|
||||||
# ---------- New: CreateRuleRequest (JSON, expr required) ----------
|
|
||||||
class CreateRuleRequest(BaseModel):
|
class CreateRuleRequest(BaseModel):
|
||||||
family: str = Field(..., description="Table family (e.g. inet, bridge, ip, ip6)", example="bridge")
|
family: str
|
||||||
table: str = Field(..., description="Table name (e.g. filter)", example="filter")
|
table: str
|
||||||
chain: str = Field(..., description="Chain name (e.g. forward)", example="forward")
|
chain: str
|
||||||
expr: Any = Field(..., description="nft JSON expression (machine-readable). This field is required for JSON rule creation.")
|
expr: Any
|
||||||
position: Optional[int] = Field(None, description="Optional insertion position (zero-based). If provided endpoint will insert at that position.")
|
position: Optional[int] = None
|
||||||
comment: Optional[str] = Field(None, description="Optional comment")
|
comment: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
|
||||||
schema_extra = {
|
|
||||||
"example": {
|
|
||||||
"family": "bridge",
|
|
||||||
"table": "filter",
|
|
||||||
"chain": "forward",
|
|
||||||
"expr": [{"match": {"left": {"payload": {"protocol": "ip", "field": "protocol"}}, "op": "==", "right": "icmp"}}, {"drop": None}],
|
|
||||||
"position": 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ruleset may be typed RulesetModel or raw textual string (fallback)
|
# ruleset may be typed RulesetModel or raw textual string (fallback)
|
||||||
RulesetValue = Optional[Union[RulesetModel, str]]
|
RulesetValue = Optional[Union[Dict[str, Any], str]]
|
||||||
|
|
||||||
|
|
||||||
class RulesetOut(BaseModel):
|
class RulesetOut(BaseModel):
|
||||||
ruleset: RulesetValue = Field(
|
ruleset: RulesetValue = None
|
||||||
None,
|
|
||||||
description="Parsed, strongly-typed ruleset (RulesetModel) or raw textual ruleset string if JSON is unavailable.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- Helpers to convert to desired shape ----------
|
# ---------- Helpers ----------
|
||||||
_handle_re = re.compile(r"\s+#\s*handle\s+\d+\s*$")
|
_handle_re = re.compile(r"\s+#\s*handle\s+\d+\s*$")
|
||||||
|
|
||||||
|
|
||||||
@@ -319,9 +278,6 @@ def parse_priority(val: Any) -> Optional[int]:
|
|||||||
|
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
if expr is None:
|
if expr is None:
|
||||||
return ""
|
return ""
|
||||||
if isinstance(expr, list):
|
if isinstance(expr, list):
|
||||||
@@ -348,8 +304,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:
|
||||||
@@ -366,7 +320,7 @@ def rule_text_from_expr(expr: Any) -> str:
|
|||||||
num = q.get("num") or q.get("number") or q.get("queue_number") or q.get("from") or q.get("range")
|
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:
|
if num is not None:
|
||||||
token += f" num {num}"
|
token += f" num {num}"
|
||||||
if q.get("bypass"):
|
if q.get("bypass") or q.get("flags") == "bypass":
|
||||||
token += " bypass"
|
token += " bypass"
|
||||||
elif isinstance(q, (int, float)):
|
elif isinstance(q, (int, float)):
|
||||||
token += f" num {int(q)}"
|
token += f" num {int(q)}"
|
||||||
@@ -374,8 +328,7 @@ def rule_text_from_expr(expr: Any) -> str:
|
|||||||
token += f" num {q}"
|
token += f" num {q}"
|
||||||
tokens.append(token)
|
tokens.append(token)
|
||||||
else:
|
else:
|
||||||
keys = "+".join(sorted(part.keys()))
|
tokens.append("+".join(sorted(part.keys())))
|
||||||
tokens.append(keys)
|
|
||||||
else:
|
else:
|
||||||
tokens.append(str(part))
|
tokens.append(str(part))
|
||||||
return " ".join(tokens)
|
return " ".join(tokens)
|
||||||
@@ -483,7 +436,6 @@ def build_predictable_ruleset(nft_json: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ---------- New helper: populate_text_from_chain_text ----------
|
|
||||||
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 []
|
tables = custom.get("tables") or []
|
||||||
for t in tables:
|
for t in tables:
|
||||||
@@ -523,23 +475,13 @@ def populate_text_from_chain_text(custom: Dict[str, Any]) -> None:
|
|||||||
replaced = True
|
replaced = True
|
||||||
break
|
break
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
# ---------- 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 the ruleset in a stable, strongly-typed JSON shape derived from `nft -j list ruleset`.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
nft_json = mgr.list_rules_json()
|
nft_json = mgr.list_rules_json()
|
||||||
@@ -555,7 +497,7 @@ def list_rules():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("list_rules: populate_text_from_chain_text failed: %s", e)
|
logger.debug("list_rules: populate_text_from_chain_text failed: %s", e)
|
||||||
|
|
||||||
# IMPORTANT: return a plain dict (not a JSON string) so FastAPI/Pydantic serializes it naturally.
|
# IMPORTANT: return native Python structure (dict) — not a JSON string.
|
||||||
ruleset_model = RulesetModel.parse_obj(custom)
|
ruleset_model = RulesetModel.parse_obj(custom)
|
||||||
return RulesetOut(ruleset=ruleset_model.dict())
|
return RulesetOut(ruleset=ruleset_model.dict())
|
||||||
except NftError as e:
|
except NftError as e:
|
||||||
@@ -570,7 +512,7 @@ def list_rules():
|
|||||||
"/rules",
|
"/rules",
|
||||||
response_model=ExecResult,
|
response_model=ExecResult,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="Create rule (JSON, expr required; returns ExecResult with rc/stdout/stderr)",
|
summary="Create rule (JSON, expr required; returns ExecResult)",
|
||||||
)
|
)
|
||||||
def create_rule_json(req: CreateRuleRequest):
|
def create_rule_json(req: CreateRuleRequest):
|
||||||
try:
|
try:
|
||||||
@@ -583,13 +525,9 @@ def create_rule_json(req: CreateRuleRequest):
|
|||||||
|
|
||||||
rendered = expr_to_text(req.expr)
|
rendered = expr_to_text(req.expr)
|
||||||
if rendered is None:
|
if rendered is None:
|
||||||
raise NftError(
|
raise NftError("cannot render provided 'expr' to textual nft syntax; use /raw to run text command")
|
||||||
"cannot render provided 'expr' to textual nft syntax. "
|
|
||||||
"Please use POST /firewall/raw to execute the textual nft command."
|
|
||||||
)
|
|
||||||
|
|
||||||
expr_text = rendered.strip()
|
expr_text = rendered.strip()
|
||||||
|
|
||||||
if req.position is not None:
|
if req.position is not None:
|
||||||
try:
|
try:
|
||||||
pos = int(req.position)
|
pos = int(req.position)
|
||||||
@@ -601,41 +539,33 @@ 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 ""
|
||||||
stderr = res.get("stderr") or ""
|
stderr = res.get("stderr") or ""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
rc = int(raw_rc)
|
rc = int(raw_rc)
|
||||||
except Exception:
|
except Exception:
|
||||||
rc = -1
|
rc = -1
|
||||||
|
|
||||||
logger.info("nft cmd rc=%s stdout=%r stderr=%r cmd=%s", rc, stdout, stderr, cmd)
|
|
||||||
|
|
||||||
exec_res = ExecResult(rc=rc, stdout=stdout or None, stderr=stderr or None)
|
exec_res = ExecResult(rc=rc, stdout=stdout or None, stderr=stderr or None)
|
||||||
|
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
return exec_res
|
return exec_res
|
||||||
|
|
||||||
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: detected rule 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(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