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

This commit is contained in:
2026-01-11 17:51:28 +01:00
parent 96cf5d573c
commit 80030ec07c

View File

@@ -1,11 +1,22 @@
# fastapi_nft_router.py # fastapi_nft_router.py
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
FastAPI router for nftables (bridge family). Produces both JSON and FastAPI router that lists and manages nftables rules for family 'bridge'
human-readable textual representations for rules. (default table 'mitm_tbl', chain 'forward').
This version improves the JSON->text fallback so common shapes like Behavior:
'ip protocol icmp drop' are rendered as nft-style clauses. - Uses `nft --json list ruleset` to obtain authoritative rule metadata (handles).
- Uses `nft list chain <family> <table> <chain>` to extract the exact textual
rule lines. Mapping is done by matching `handle N` in the textual output.
- Returns for each rule:
- nft_rule_text_full: exact line from 'nft list chain ...' including 'handle N' (or None)
- nft_rule_text: same line trimmed to remove trailing 'handle N' (or None)
- add_command: "add rule <table> <chain> <nft_rule_text>" (or None)
- No JSON->text reconstruction is attempted. If text mapping is missing we return None.
Security note:
- Process must be run with privileges to run nft (root or appropriate capabilities).
- Consider adding auth before exposing these endpoints.
""" """
from typing import Any, Dict, List, Optional, Union, Literal from typing import Any, Dict, List, Optional, Union, Literal
import subprocess import subprocess
@@ -23,14 +34,14 @@ router = APIRouter()
logger = logging.getLogger("nftables") logger = logging.getLogger("nftables")
logger.debug("nftables router module loaded") logger.debug("nftables router module loaded")
# Defaults & nft binary locator # Defaults & nft binary
DEFAULT_TABLE = "mitm_tbl" DEFAULT_TABLE = "mitm_tbl"
DEFAULT_CHAIN = "forward" DEFAULT_CHAIN = "forward"
DEFAULT_FAMILY = "bridge" DEFAULT_FAMILY = "bridge"
NFT_BIN = shutil.which("nft") NFT_BIN = shutil.which("nft")
# ---------- Enums (for frontend dropdowns) ------------------------------ # ----------------- Enums (for frontend) -----------------
class Family(str, Enum): class Family(str, Enum):
bridge = DEFAULT_FAMILY bridge = DEFAULT_FAMILY
@@ -104,7 +115,7 @@ class LogGroup(int, Enum):
g6 = 6 g6 = 6
g7 = 7 g7 = 7
# ---------- Pydantic expression models --------------------------------- # ------------ Pydantic expression models (typed for frontend) ----------
class BaseExpr(BaseModel): class BaseExpr(BaseModel):
kind: str kind: str
class Config: class Config:
@@ -161,14 +172,14 @@ Expr = Union[
VerdictExpr, RejectExpr, LogExpr, RawExpr, VerdictExpr, RejectExpr, LogExpr, RawExpr,
] ]
# ---------- Rule model ------------------------------------------------- # ---------------- Rule model ----------------
class RuleModel(BaseModel): class RuleModel(BaseModel):
family: Family = Family.bridge family: Family = Family.bridge
table: Table = Table.table table: Table = Table.table
chain: Chain = Chain.forward chain: Chain = Chain.forward
expr: List[Expr] = Field(default_factory=list) expr: List[Expr] = Field(default_factory=list)
comment: Optional[str] = None comment: Optional[str] = None
position: Optional[int] = None position: Optional[int] = None # 1-based
handle: Optional[int] = None handle: Optional[int] = None
@validator("family") @validator("family")
@@ -177,35 +188,42 @@ class RuleModel(BaseModel):
raise ValueError("This router only manages family 'bridge'") raise ValueError("This router only manages family 'bridge'")
return v return v
# ---------- Low-level helpers ----------------------------------------- # ----------------- Helpers --------------------
def ensure_nft_available() -> None: def ensure_nft_available() -> None:
if not NFT_BIN: if not NFT_BIN:
logger.error("nft binary not found") logger.error("nft binary not found on server")
raise HTTPException(status_code=500, detail="nft binary not found on server") raise HTTPException(status_code=500, detail="nft binary not found on server")
def run_nft_cmd(cmd: str) -> Dict[str, Any]: def run_nft_cmd(cmd: str) -> Dict[str, Any]:
"""
Execute a single nft script line via `nft -f -`. Returns stdout/stderr.
"""
ensure_nft_available() ensure_nft_available()
full_cmd = [NFT_BIN, "-f", "-"] full_cmd = [NFT_BIN, "-f", "-"]
script = cmd.rstrip() + "\n" script = cmd.rstrip() + "\n"
logger.info("Running nft command: %s", cmd) logger.info("Running nft command: %s", cmd)
logger.debug("Executing: %s ; script: %s", full_cmd, script) logger.debug("Exec: %s ; script: %s", full_cmd, script)
try: try:
proc = subprocess.run(full_cmd, input=script.encode(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) proc = subprocess.run(full_cmd, input=script.encode(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
stdout = proc.stdout.decode() stdout = proc.stdout.decode()
stderr = proc.stderr.decode() stderr = proc.stderr.decode()
logger.info("nft command succeeded (%d bytes stdout, %d bytes stderr)", len(stdout), len(stderr)) logger.info("nft success (stdout %d bytes, stderr %d bytes)", len(stdout), len(stderr))
logger.debug("nft stdout: %s", stdout or "<empty>") logger.debug("nft stdout: %s", stdout or "<empty>")
if stderr: if stderr:
logger.debug("nft stderr: %s", stderr) logger.debug("nft stderr: %s", stderr)
return {"stdout": stdout, "stderr": stderr} return {"stdout": stdout, "stderr": stderr}
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
err = e.stderr.decode() if e.stderr else str(e) err = e.stderr.decode() if e.stderr else str(e)
logger.error("nft command failed: %s", err) logger.error("nft failed: %s", err)
raise HTTPException(status_code=500, detail=err) raise HTTPException(status_code=500, detail=err)
def ensure_table_and_chain_exist(family: str, table: str, chain: str) -> None: def ensure_table_and_chain_exist(family: str, table: str, chain: str) -> None:
logger.debug("Checking/existence for family=%s table=%s chain=%s", family, table, chain) """
Ensure the named table and chain exist; create them with conservative defaults if missing.
"""
logger.debug("Ensure table/chain exist family=%s table=%s chain=%s", family, table, chain)
ensure_nft_available() ensure_nft_available()
try: try:
out = subprocess.check_output([NFT_BIN, "--json", "list", "ruleset"], stderr=subprocess.PIPE) out = subprocess.check_output([NFT_BIN, "--json", "list", "ruleset"], stderr=subprocess.PIPE)
parsed = json.loads(out) parsed = json.loads(out)
@@ -219,13 +237,13 @@ def ensure_table_and_chain_exist(family: str, table: str, chain: str) -> None:
table_exists = False table_exists = False
chain_exists = False chain_exists = False
for item in items: for it in items:
if "table" in item: if "table" in it:
t = item["table"] t = it["table"]
if isinstance(t, dict) and t.get("name") == table and t.get("family") == family: if isinstance(t, dict) and t.get("name") == table and t.get("family") == family:
table_exists = True table_exists = True
if "chain" in item: if "chain" in it:
ch = item["chain"] ch = it["chain"]
if isinstance(ch, dict) and ch.get("name") == chain and ch.get("table") == table and ch.get("family") == family: if isinstance(ch, dict) and ch.get("name") == chain and ch.get("table") == table and ch.get("family") == family:
chain_exists = True chain_exists = True
@@ -240,17 +258,22 @@ def ensure_table_and_chain_exist(family: str, table: str, chain: str) -> None:
else: else:
run_nft_cmd(f"add chain {family} {table} {chain} {{ policy accept; }}") run_nft_cmd(f"add chain {family} {table} {chain} {{ policy accept; }}")
# ---------- textual chain parser & JSON->text fallback ----------------- # ----------------- Text mapping (strict) -----------------
HANDLE_RE = re.compile(r"\bhandle\s+(\d+)\b", flags=re.IGNORECASE) HANDLE_RE = re.compile(r"\bhandle\s+(\d+)\b", flags=re.IGNORECASE)
def nft_list_chain_text(family: str, table: str, chain: str) -> Dict[int, str]: def build_handle_text_map(family: str, table: str, chain: str) -> Dict[int, str]:
"""
Runs: nft list chain <family> <table> <chain>
Returns mapping handle -> full textual line containing 'handle N'.
If the textual output cannot be retrieved, raises HTTPException.
"""
ensure_nft_available() ensure_nft_available()
cmd = [NFT_BIN, "list", "chain", family, table, chain] cmd = [NFT_BIN, "list", "chain", family, table, chain]
logger.debug("Running textual chain list: %s", " ".join(cmd)) logger.debug("Listing chain text: %s", " ".join(cmd))
try: try:
out = subprocess.check_output(cmd, stderr=subprocess.PIPE).decode() out = subprocess.check_output(cmd, stderr=subprocess.PIPE).decode()
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.error("Failed textual chain list: %s", e.stderr.decode()) logger.error("Failed to list chain text: %s", e.stderr.decode())
raise HTTPException(status_code=500, detail=e.stderr.decode()) raise HTTPException(status_code=500, detail=e.stderr.decode())
mapping: Dict[int, str] = {} mapping: Dict[int, str] = {}
@@ -262,205 +285,52 @@ def nft_list_chain_text(family: str, table: str, chain: str) -> Dict[int, str]:
if not m: if not m:
continue continue
try: try:
handle = int(m.group(1)) h = int(m.group(1))
mapping[handle] = s # full textual line as-is
logger.debug("Text mapping: handle=%d -> %s", handle, s) mapping[h] = s
except Exception as ex: logger.debug("Found textual rule for handle %d: %s", h, s)
logger.debug("Failed parsing handle from line: %s (%s)", s, ex)
continue
return mapping
def extract_protocol_from_expr(expr: Any) -> Optional[str]:
"""
Attempt to detect a protocol (tcp/udp/icmp) from a single expr JSON object.
It searches common shapes ('payload', 'protocol', 'icmp') recursively.
Returns protocol string (e.g. 'icmp') or None.
"""
if not isinstance(expr, dict):
return None
# direct icmp key
if "icmp" in expr:
return "icmp"
# payload shapes: walk nested dicts looking for 'icmp' or 'tcp'/'udp'
def walk(d):
if isinstance(d, dict):
for k, v in d.items():
if isinstance(v, str):
if v.lower() in ("icmp", "tcp", "udp"):
return v.lower()
if isinstance(v, dict) or isinstance(v, list):
found = walk(v)
if found:
return found
elif isinstance(d, list):
for item in d:
found = walk(item)
if found:
return found
return None
# check common keys
for key in ("payload", "protocol", "ip", "meta"):
if key in expr:
found = walk(expr[key])
if found:
return found
# last resort: scan whole expr
return walk(expr)
def json_exprs_to_text(exprs: List[Any]) -> str:
"""
Best-effort conversion of nft JSON exprs to a human-readable nft-style
single-line clause. We aim to produce phrases like:
ip protocol icmp drop
meta iifname \"eth0\" accept
ct state established accept
The function collects match fragments then appends verdict/action at the end.
"""
matches: List[str] = []
verdicts: List[str] = []
for ex in exprs:
if not isinstance(ex, dict):
continue
# comment
if "comment" in ex:
c = ex["comment"]
if isinstance(c, str):
matches.append(f'comment "{c}"')
elif isinstance(c, dict):
txt = c.get("text") or c.get("str")
if txt:
matches.append(f'comment "{txt}"')
continue
# verdict shapes
if "verdict" in ex:
v = ex["verdict"]
if isinstance(v, dict):
k = next(iter(v.keys()), None)
if k:
verdicts.append(k)
else:
verdicts.append(str(v))
continue
if "drop" in ex:
verdicts.append("drop")
continue
if "accept" in ex:
verdicts.append("accept")
continue
if "reject" in ex:
verdicts.append("reject")
continue
# conntrack
if "ct" in ex:
ct = ex["ct"]
if isinstance(ct, dict):
# prefer printed form 'ct state established'
if "state" in ct:
matches.append(f"ct state {ct['state']}")
else:
for k, v in ct.items():
matches.append(f"ct {k} {v}")
continue
# meta
if "meta" in ex:
meta = ex["meta"]
if isinstance(meta, dict):
key = meta.get("key") or meta.get("name")
op = meta.get("op", "==")
val = meta.get("value")
if key and val is not None:
matches.append(f"meta {key} {op} {val}")
continue
# match object with left/op/right
if "match" in ex:
m = ex["match"]
left = m.get("left")
op = m.get("op")
right = m.get("right")
if left and op and (right is not None):
matches.append(f"{left} {op} {right}")
continue
# payload/protocol detection -> render 'ip protocol icmp'
proto = extract_protocol_from_expr(ex)
if proto:
# only render once per expr; protocol is a match, not action
matches.append(f"ip protocol {proto}")
continue
# log
if "log" in ex:
lg = ex["log"]
piece = "log"
if isinstance(lg, dict):
if lg.get("prefix"):
piece += f' prefix "{lg.get("prefix")}"'
if lg.get("group") is not None:
piece += f" group {lg.get('group')}"
matches.append(piece)
continue
# payload fallback: compact it
if "payload" in ex:
matches.append(json.dumps(ex["payload"]))
continue
# unknown: compact JSON
matches.append(json.dumps(ex))
# join matches then verdict(s)
clause = " ".join(matches).strip()
if clause and verdicts:
clause = f"{clause} {' '.join(verdicts)}"
elif not clause and verdicts:
clause = " ".join(verdicts)
return clause.strip()
# ---------- JSON rules parsing with textual injection ------------------
def nft_list_chain_text_map(family: str, table: str, chain: str) -> Dict[int, str]:
"""Thin wrapper to get textual mapping, returns empty mapping on failure."""
try:
return nft_list_chain_text(family, table, chain)
except HTTPException as e:
logger.debug("text map unavailable: %s", getattr(e, "detail", str(e)))
return {}
def nft_list_chain_text(family: str, table: str, chain: str) -> Dict[int, str]:
ensure_nft_available()
cmd = [NFT_BIN, "list", "chain", family, table, chain]
logger.debug("Running textual chain list: %s", " ".join(cmd))
try:
out = subprocess.check_output(cmd, stderr=subprocess.PIPE).decode()
except subprocess.CalledProcessError as e:
logger.error("Failed textual chain list: %s", e.stderr.decode())
raise HTTPException(status_code=500, detail=e.stderr.decode())
mapping: Dict[int, str] = {}
for line in out.splitlines():
s = line.strip()
if not s:
continue
m = HANDLE_RE.search(s)
if not m:
continue
try:
handle = int(m.group(1))
mapping[handle] = s
logger.debug("Text mapping: handle=%d -> %s", handle, s)
except Exception as ex: except Exception as ex:
logger.debug("Failed parsing handle from line: %s (%s)", s, ex) logger.debug("Failed parsing handle from line: %s (%s)", s, ex)
continue continue
return mapping return mapping
# ----------------- Rules listing (JSON + strict textual lookup) -----------------
def nft_list_rules(table: str = DEFAULT_TABLE, chain: str = DEFAULT_CHAIN) -> Dict[str, Any]: def nft_list_rules(table: str = DEFAULT_TABLE, chain: str = DEFAULT_CHAIN) -> Dict[str, Any]:
"""
Return rules parsed from nft --json list ruleset, augmented with textual lines
extracted from `nft list chain <family> <table> <chain>` via handle matching.
For each rule returned:
- family, table, chain
- handle
- position (1-based in chain)
- comment (best-effort from JSON exprs)
- verdict (best-effort)
- exprs (the JSON expr list)
- nft_rule_text_full: exact textual line from nft list chain ... INCLUDING 'handle N' (or None)
- nft_rule_text: textual line trimmed to remove trailing 'handle N' (or None)
- add_command: "add rule <table> <chain> <nft_rule_text>" (or None)
"""
ensure_nft_available() ensure_nft_available()
# 1) JSON dump: authoritative structure
try: try:
out = subprocess.check_output([NFT_BIN, "--json", "list", "ruleset"], stderr=subprocess.PIPE) out = subprocess.check_output([NFT_BIN, "--json", "list", "ruleset"], stderr=subprocess.PIPE)
parsed = json.loads(out) parsed = json.loads(out)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.error("Failed to list ruleset (json): %s", e.stderr.decode()) logger.error("Failed to get JSON ruleset: %s", e.stderr.decode())
raise HTTPException(status_code=500, detail=e.stderr.decode()) raise HTTPException(status_code=500, detail=e.stderr.decode())
# Try to obtain text map, but do not fail if unavailable # 2) textual map: strict mapping by handle
text_map = nft_list_chain_text_map(DEFAULT_FAMILY, table, chain) text_map: Dict[int, str] = {}
logger.debug("Text map entries: %d", len(text_map)) try:
text_map = build_handle_text_map(DEFAULT_FAMILY, table, chain)
logger.debug("Text map size: %d", len(text_map))
except HTTPException as e:
# bubble up the error: user asked to extract exact textual lines and we couldn't get them
logger.error("Failed to obtain textual chain dump: %s", getattr(e, "detail", str(e)))
# still continue — per your request we won't attempt reconstructions, but we can return None textual fields.
text_map = {}
results: List[Dict[str, Any]] = [] results: List[Dict[str, Any]] = []
counters: Dict[str, int] = {} counters: Dict[str, int] = {}
@@ -469,14 +339,15 @@ def nft_list_rules(table: str = DEFAULT_TABLE, chain: str = DEFAULT_CHAIN) -> Di
if not isinstance(items, list): if not isinstance(items, list):
items = [] items = []
for item in items: for it in items:
if "rule" not in item: if "rule" not in it:
continue continue
r = item["rule"] r = it["rule"]
family = r.get("family") family = r.get("family")
table_name = r.get("table") table_name = r.get("table")
chain_name = r.get("chain") chain_name = r.get("chain")
# only return rules for requested table/chain
if table_name != table or chain_name != chain: if table_name != table or chain_name != chain:
continue continue
@@ -488,56 +359,46 @@ def nft_list_rules(table: str = DEFAULT_TABLE, chain: str = DEFAULT_CHAIN) -> Di
handle = r.get("handle") handle = r.get("handle")
exprs = r.get("expr", []) exprs = r.get("expr", [])
# best-effort comment + verdict extraction from JSON exprs (keeps UI useful)
comment: Optional[str] = None comment: Optional[str] = None
verdict: Optional[str] = None verdict: Optional[str] = None
verdict_details: Optional[Any] = None for ex in exprs:
if not isinstance(ex, dict):
for expr in exprs:
if not isinstance(expr, dict):
continue continue
if "comment" in expr: if "comment" in ex:
c = expr.get("comment") c = ex.get("comment")
if isinstance(c, str): if isinstance(c, str):
comment = c comment = c
elif isinstance(c, dict): elif isinstance(c, dict):
comment = c.get("text") or c.get("str") comment = c.get("text") or c.get("str")
if "verdict" in expr: if "verdict" in ex:
v = expr["verdict"] v = ex["verdict"]
if isinstance(v, dict): if isinstance(v, dict):
verdict = next(iter(v.keys()), None) verdict = next(iter(v.keys()), None)
verdict_details = v.get(verdict)
else: else:
verdict = str(v) verdict = str(v)
if "drop" in expr and verdict is None: if "drop" in ex and verdict is None:
verdict = "drop" verdict = "drop"
if "accept" in expr and verdict is None: if "accept" in ex and verdict is None:
verdict = "accept" verdict = "accept"
if "reject" in expr and verdict is None: if "reject" in ex and verdict is None:
verdict = "reject" verdict = "reject"
# textual resolution: prefer exact mapping by handle, fallback to JSON->text # strict textual lookup: only use exact line if present in text_map
nft_rule_text_full: Optional[str] = None nft_rule_text_full: Optional[str] = None
nft_rule_text: Optional[str] = None nft_rule_text: Optional[str] = None
add_command: Optional[str] = None
if handle is not None and handle in text_map: if handle is not None and handle in text_map:
nft_rule_text_full = text_map[handle] nft_rule_text_full = text_map[handle]
# strip trailing ' handle N' to produce no-handle variant # remove trailing ' handle N' to get copy/paste clause
m = HANDLE_RE.search(nft_rule_text_full) m = HANDLE_RE.search(nft_rule_text_full)
if m: nft_rule_text = nft_rule_text_full[: m.start()].strip() if m else nft_rule_text_full
nft_rule_text = nft_rule_text_full[: m.start()].strip() add_command = f"add rule {table_name} {chain_name} {nft_rule_text}".strip() if nft_rule_text else None
else: logger.debug("Attached textual rule for handle %s", handle)
nft_rule_text = nft_rule_text_full
logger.debug("Using textual map for handle %s -> %s", handle, nft_rule_text_full)
else: else:
# fallback: build from exprs # If mapping missing, per your instruction do not attempt to reconstruct — leave textual fields None
clause = json_exprs_to_text(exprs) logger.debug("No textual mapping for handle %s — textual fields will be None", handle)
nft_rule_text = clause or None
nft_rule_text_full = (clause + (f" handle {handle}" if handle is not None else "")) if clause else None
logger.debug("Fallback clause for handle %s -> %s", handle, clause)
# build add_command: use add rule <table> <chain> <clause>
add_clause = nft_rule_text or ""
add_command = f"add rule {table_name} {chain_name} {add_clause}".strip()
results.append({ results.append({
"family": family, "family": family,
@@ -547,17 +408,17 @@ def nft_list_rules(table: str = DEFAULT_TABLE, chain: str = DEFAULT_CHAIN) -> Di
"position": position, "position": position,
"comment": comment, "comment": comment,
"verdict": verdict, "verdict": verdict,
"verdict_details": verdict_details,
"exprs": exprs, "exprs": exprs,
"nft_rule_text_full": nft_rule_text_full, # includes handle token if present "nft_rule_text_full": nft_rule_text_full,
"nft_rule_text": nft_rule_text, # no-handle clause "nft_rule_text": nft_rule_text,
"add_command": add_command, "add_command": add_command,
}) })
return {"rules": results} return {"rules": results}
# ---------- Expr -> nft snippet used for preview/add -------------------- # ------------ Expr -> nft snippet & command builder (preview/add) ------
def expr_to_nft_snippet(e: Expr) -> str: def expr_to_nft_snippet(e: Expr) -> str:
"""Build short nft snippet from typed Expr (used for preview/add)."""
if isinstance(e, MetaExpr): if isinstance(e, MetaExpr):
val = e.value val = e.value
key = e.key.value key = e.key.value
@@ -593,8 +454,8 @@ def expr_to_nft_snippet(e: Expr) -> str:
raise ValueError("Unsupported expression type") raise ValueError("Unsupported expression type")
def rule_to_nft_cmd(rule: RuleModel) -> str: def rule_to_nft_cmd(rule: RuleModel) -> str:
expr_snippets = [expr_to_nft_snippet(e) for e in rule.expr] expr_snips = [expr_to_nft_snippet(e) for e in rule.expr]
body = " ".join(s for s in expr_snippets if s) body = " ".join(s for s in expr_snips if s)
if rule.position is not None: if rule.position is not None:
cmd = f"insert rule {rule.table.value} {rule.chain.value} position {rule.position} {body}" cmd = f"insert rule {rule.table.value} {rule.chain.value} position {rule.position} {body}"
else: else:
@@ -603,9 +464,10 @@ def rule_to_nft_cmd(rule: RuleModel) -> str:
cmd += f' comment "{rule.comment}"' cmd += f' comment "{rule.comment}"'
return cmd return cmd
# ---------- Endpoints ------------------------------------------------- # ---------------- Endpoints -------------------
@router.get("/options") @router.get("/options")
def get_options() -> Dict[str, Any]: def get_options() -> Dict[str, Any]:
"""Return enum choices for frontend dropdowns."""
return { return {
"family": [f.value for f in Family], "family": [f.value for f in Family],
"table": [t.value for t in Table], "table": [t.value for t in Table],
@@ -624,32 +486,37 @@ def get_options() -> Dict[str, Any]:
@router.get("/rules") @router.get("/rules")
def list_rules(table: str = DEFAULT_TABLE, chain: str = DEFAULT_CHAIN) -> Dict[str, Any]: def list_rules(table: str = DEFAULT_TABLE, chain: str = DEFAULT_CHAIN) -> Dict[str, Any]:
"""List rules for the given table/chain (ensures table/chain exist first)."""
ensure_table_and_chain_exist(DEFAULT_FAMILY, table, chain) ensure_table_and_chain_exist(DEFAULT_FAMILY, table, chain)
return nft_list_rules(table=table, chain=chain) return nft_list_rules(table=table, chain=chain)
@router.post("/rules/preview") @router.post("/rules/preview")
def preview_rule(rule: RuleModel = Body(...)) -> Dict[str, str]: def preview_rule(rule: RuleModel = Body(...)) -> Dict[str, str]:
"""Return the nft command that would be executed for the provided rule (preview only)."""
try: try:
cmd = rule_to_nft_cmd(rule) cmd = rule_to_nft_cmd(rule)
except Exception as e: except Exception as e:
logger.error("Preview failed: %s", e) logger.error("Preview build failed: %s", e)
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
return {"cmd": cmd} return {"cmd": cmd}
@router.post("/rules") @router.post("/rules")
def add_rule(rule: RuleModel = Body(...)) -> Dict[str, Any]: def add_rule(rule: RuleModel = Body(...)) -> Dict[str, Any]:
"""Insert/append rule (creates table/chain if missing)."""
ensure_table_and_chain_exist(rule.family.value, rule.table.value, rule.chain.value) ensure_table_and_chain_exist(rule.family.value, rule.table.value, rule.chain.value)
cmd = rule_to_nft_cmd(rule) cmd = rule_to_nft_cmd(rule)
return run_nft_cmd(cmd) return run_nft_cmd(cmd)
@router.delete("/rules/{handle}") @router.delete("/rules/{handle}")
def delete_rule(handle: int, table: str = DEFAULT_TABLE, chain: str = DEFAULT_CHAIN) -> Dict[str, Any]: def delete_rule(handle: int, table: str = DEFAULT_TABLE, chain: str = DEFAULT_CHAIN) -> Dict[str, Any]:
"""Delete rule by nft handle."""
ensure_table_and_chain_exist(DEFAULT_FAMILY, table, chain) ensure_table_and_chain_exist(DEFAULT_FAMILY, table, chain)
cmd = f"delete rule {table} {chain} handle {handle}" cmd = f"delete rule {table} {chain} handle {handle}"
return run_nft_cmd(cmd) return run_nft_cmd(cmd)
@router.put("/rules/{handle}") @router.put("/rules/{handle}")
def update_rule(handle: int, rule: RuleModel = Body(...), table: str = DEFAULT_TABLE, chain: str = DEFAULT_CHAIN) -> Dict[str, Any]: def update_rule(handle: int, rule: RuleModel = Body(...), table: str = DEFAULT_TABLE, chain: str = DEFAULT_CHAIN) -> Dict[str, Any]:
"""Replace a rule by handle: delete by handle then insert replacement (attempt to preserve position)."""
ensure_table_and_chain_exist(rule.family.value, rule.table.value, rule.chain.value) ensure_table_and_chain_exist(rule.family.value, rule.table.value, rule.chain.value)
rules_info = nft_list_rules(table=table, chain=chain) rules_info = nft_list_rules(table=table, chain=chain)
position: Optional[int] = None position: Optional[int] = None