test
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s

This commit is contained in:
2026-02-28 22:37:59 +01:00
parent 7db2816660
commit f33c708cce

View File

@@ -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))