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

This commit is contained in:
2026-01-11 12:31:02 +01:00
parent 2ec714bd73
commit fa33f65dcb

View File

@@ -22,14 +22,13 @@ import uuid
from enum import Enum from enum import Enum
import asyncio import asyncio
import subprocess import subprocess
import re
# Try to import NFTables binding (various pyroute2 layouts) # Try to import NFTables binding (various pyroute2 layouts)
try: try:
# common location
from pyroute2.nftables.main import NFTables as NFTablesBinding # type: ignore from pyroute2.nftables.main import NFTables as NFTablesBinding # type: ignore
except Exception: except Exception:
try: try:
# alternate location
from pyroute2.nftables import NFTables as NFTablesBinding # type: ignore from pyroute2.nftables import NFTables as NFTablesBinding # type: ignore
except Exception: except Exception:
NFTablesBinding = None # will fall back to subprocess wrapper NFTablesBinding = None # will fall back to subprocess wrapper
@@ -102,12 +101,9 @@ class ReplaceResult(BaseModel):
# ---------------------- resilient NFT wrapper selection ---------------------- # ---------------------- resilient NFT wrapper selection ----------------------
class NFTSubprocessWrapper: class NFTSubprocessWrapper:
""" """Fallback wrapper calling the `nft` CLI via subprocess."""
Fallback wrapper calling the `nft` CLI via subprocess.
"""
def __init__(self): def __init__(self):
# log availability
try: try:
subprocess.run(["nft", "--version"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) subprocess.run(["nft", "--version"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
logger.debug("nft CLI available for subprocess wrapper") logger.debug("nft CLI available for subprocess wrapper")
@@ -115,7 +111,6 @@ class NFTSubprocessWrapper:
logger.warning("nft CLI not available or couldn't be invoked: %s", e) logger.warning("nft CLI not available or couldn't be invoked: %s", e)
def run(self, cmd: str) -> Dict[str, Any]: def run(self, cmd: str) -> Dict[str, Any]:
# naive split is acceptable for the limited command patterns used here
args = ["nft"] + cmd.split() args = ["nft"] + cmd.split()
try: try:
proc = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) proc = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)
@@ -177,7 +172,6 @@ class NFTBindingWrapper:
last_exc = e last_exc = e
if not self._constructed: if not self._constructed:
# try no-arg
try: try:
self._binding = binding_cls() self._binding = binding_cls()
self._constructed = True self._constructed = True
@@ -191,10 +185,8 @@ class NFTBindingWrapper:
# If the binding exposes an async setup coroutine, ensure we can run it synchronously. # If the binding exposes an async setup coroutine, ensure we can run it synchronously.
setup_coro = getattr(self._binding, "setup_endpoint", None) setup_coro = getattr(self._binding, "setup_endpoint", None)
if setup_coro and asyncio.iscoroutinefunction(setup_coro): if setup_coro and asyncio.iscoroutinefunction(setup_coro):
# If event loop running, we cannot await here — signal unsuitability.
if asyncio.get_event_loop().is_running(): if asyncio.get_event_loop().is_running():
raise RuntimeError("pyroute2 NFTables requires async setup but event loop is already running") raise RuntimeError("pyroute2 NFTables requires async setup but event loop is already running")
# otherwise run it to complete setup
try: try:
asyncio.get_event_loop().run_until_complete(setup_coro()) asyncio.get_event_loop().run_until_complete(setup_coro())
except Exception as e: except Exception as e:
@@ -231,7 +223,6 @@ def make_nft_wrapper():
""" """
if NFTablesBinding is not None: if NFTablesBinding is not None:
try: try:
# If an event loop is running (uvicorn), prefer subprocess to avoid async binding setup.
if asyncio.get_event_loop().is_running(): if asyncio.get_event_loop().is_running():
logger.info("asyncio loop is running; skipping binding and using subprocess wrapper") logger.info("asyncio loop is running; skipping binding and using subprocess wrapper")
raise RuntimeError("event loop running") raise RuntimeError("event loop running")
@@ -240,7 +231,6 @@ def make_nft_wrapper():
return w return w
except Exception as e: except Exception as e:
logger.warning("pyroute2 binding unavailable/synchronous construction failed: %s; falling back to nft CLI", e) logger.warning("pyroute2 binding unavailable/synchronous construction failed: %s; falling back to nft CLI", e)
# Fallback
logger.info("using nft CLI subprocess wrapper") logger.info("using nft CLI subprocess wrapper")
return NFTSubprocessWrapper() return NFTSubprocessWrapper()
@@ -309,6 +299,10 @@ def parse_comment_from_exprs(exprs: List[Dict[str, Any]]) -> Optional[str]:
def reconstruct_rule_from_rule_entry(entry: Dict[str, Any]) -> Dict[str, Any]: def reconstruct_rule_from_rule_entry(entry: Dict[str, Any]) -> Dict[str, Any]:
"""
Convert pyroute2/nft `rule` JSON entry into the simple dict format returned by the API.
Best-effort: handles common expression shapes; if not present returns defaults.
"""
exprs = [] exprs = []
if "rule" in entry: if "rule" in entry:
r = entry["rule"] r = entry["rule"]
@@ -423,6 +417,128 @@ def reconstruct_rule_from_rule_entry(entry: Dict[str, Any]) -> Dict[str, Any]:
} }
# ---------------------- helpers for normalized output ----------------------
def _normalize_nft_output(out: Any) -> List[Dict[str, Any]]:
"""
Convert various wrapper outputs into a list of nft 'entries'.
Handles shapes:
- {'nftables': [ ... ]}
- {'out': '<json text>'}
- list([...])
- dict (single entry)
- {'out': '<plain text>'} -> returned as [{'text': <...>}]
"""
if not out:
return []
if isinstance(out, dict):
if "nftables" in out and isinstance(out["nftables"], list):
return out["nftables"]
if "out" in out and isinstance(out["out"], str):
text = out["out"]
try:
parsed = json.loads(text)
if isinstance(parsed, dict) and "nftables" in parsed:
return parsed["nftables"]
if isinstance(parsed, list):
return parsed
return [parsed]
except Exception:
# textual fallback
return [{"text": text}]
# single dict -> wrap
return [out]
if isinstance(out, list):
return out
if isinstance(out, str):
return [{"text": out}]
return []
def _parse_textual_chain_output(text: str, family: str, table: str, chain: str) -> List[Dict[str, Any]]:
"""
Parse textual chain dump for simple rules as a last-resort fallback.
Looks for lines inside the chain and extracts simple patterns.
"""
results: List[Dict[str, Any]] = []
lines = text.splitlines()
for raw in lines:
line = raw.strip()
if not line:
continue
if line.startswith("type ") or line.startswith("policy ") or line.startswith("chain ") or line.startswith("table "):
continue
if line in ("{", "}"):
continue
comment = None
cm = re.search(r'comment\s+"([^"]+)"', line)
if cm:
comment = cm.group(1)
line_no_comment = re.sub(r'comment\s+"[^"]+"', '', line)
else:
line_no_comment = line
match: Dict[str, Any] = {}
action: Dict[str, Any] = {"type": "accept"}
m = re.search(r'\bip\s+protocol\s+([A-Za-z0-9_+-]+)\b', line_no_comment, flags=re.IGNORECASE)
if m:
proto = m.group(1).lower()
if proto == "icmp":
match["ip_proto"] = 1
elif proto == "tcp":
match["ip_proto"] = 6
elif proto == "udp":
match["ip_proto"] = 17
else:
try:
match["ip_proto"] = int(proto)
except Exception:
match["ip_proto"] = proto
m2 = re.search(r'\btcp\s+dport\s+(\d+)\b', line_no_comment, flags=re.IGNORECASE)
if m2:
match["tcp_dport"] = int(m2.group(1))
m3 = re.search(r'\budp\s+dport\s+(\d+)\b', line_no_comment, flags=re.IGNORECASE)
if m3:
match["udp_dport"] = int(m3.group(1))
m4 = re.search(r'\biif\s+"?([^"\s]+)"?\b', line_no_comment, flags=re.IGNORECASE)
if m4:
match["iif"] = m4.group(1)
m5 = re.search(r'\boif\s+"?([^"\s]+)"?\b', line_no_comment, flags=re.IGNORECASE)
if m5:
match["oif"] = m5.group(1)
if re.search(r'\bdrop\b', line_no_comment, flags=re.IGNORECASE):
action = {"type": "drop"}
elif re.search(r'\baccept\b', line_no_comment, flags=re.IGNORECASE):
action = {"type": "accept"}
elif re.search(r'\bqueue\b', line_no_comment, flags=re.IGNORECASE):
q = re.search(r'queue\s+num\s+(\d+)', line_no_comment, flags=re.IGNORECASE)
if q:
action = {"type": "queue", "queue_num": int(q.group(1))}
else:
action = {"type": "queue"}
elif re.search(r'\bredirect\b', line_no_comment, flags=re.IGNORECASE) or re.search(r'\bto\s+:\d+\b', line_no_comment, flags=re.IGNORECASE):
mredir = re.search(r':(\d+)', line_no_comment)
if mredir:
action = {"type": "redirect", "redirect_port": int(mredir.group(1))}
else:
action = {"type": "redirect"}
results.append({
"family": family,
"table": table,
"chain": chain,
"match": match,
"action": action,
"comment": comment,
})
return results
# ---------------------- high-level operations ---------------------- # ---------------------- high-level operations ----------------------
def ensure_table_chain(family: Union[str, Family], table: str, chain: str): def ensure_table_chain(family: Union[str, Family], table: str, chain: str):
fam = family.value if isinstance(family, Family) else family fam = family.value if isinstance(family, Family) else family
@@ -436,44 +552,64 @@ def ensure_table_chain(family: Union[str, Family], table: str, chain: str):
logger.debug("add_chain may have failed/exists: %s", e) logger.debug("add_chain may have failed/exists: %s", e)
def list_rules_from_nft(family: Union[str, Family], table: str) -> List[Dict[str, Any]]: def list_rules_from_nft(family: Union[str, Family], table: str, chain: str = DEFAULT_CHAIN) -> List[Dict[str, Any]]:
"""
Return reconstructed rules from kernel state.
Strategy:
1. Try `list chain <family> <table> <chain> -a` (best for rules + handles).
2. If that fails or returns textual output, fall back to `list ruleset` and extract all 'rule' entries.
3. If structured JSON is unavailable, parse textual output as last resort.
"""
fam = family.value if isinstance(family, Family) else family fam = family.value if isinstance(family, Family) else family
# primary: list chain -a
try: try:
out = NFTC.list_table(fam, table) raw = NFTC.list_chain(fam, table, chain)
logger.debug("raw list_chain output: %s", str(raw)[:2000])
except Exception as e: except Exception as e:
logger.debug("list_table failed: %s", e) logger.debug("list_chain failed (%s); falling back to list ruleset", e)
return []
entries = []
if isinstance(out, dict) and "nftables" in out:
entries = out["nftables"]
elif isinstance(out, dict) and "out" in out and isinstance(out["out"], str):
try: try:
parsed = json.loads(out["out"]) raw = NFTC.run("list ruleset")
if isinstance(parsed, dict) and "nftables" in parsed: logger.debug("raw list ruleset output: %s", str(raw)[:2000])
entries = parsed["nftables"] except Exception as e2:
elif isinstance(parsed, list): logger.error("list ruleset failed: %s", e2)
entries = parsed return []
except Exception:
logger.debug("could not parse textual nft output")
entries = []
elif isinstance(out, list):
entries = out
elif isinstance(out, dict) and out:
entries = [out]
else:
entries = []
entries = _normalize_nft_output(raw)
results: List[Dict[str, Any]] = [] results: List[Dict[str, Any]] = []
for item in entries: for item in entries:
if not item: if not item:
continue continue
# textual full-dump fallback
if isinstance(item, dict) and "text" in item and isinstance(item["text"], str):
results.extend(_parse_textual_chain_output(item["text"], fam, table, chain))
continue
# structured rule entry
if "rule" in item: if "rule" in item:
rule_entry = {"rule": item["rule"], "family": item.get("family"), "table": item.get("table")} rule_entry = {"rule": item["rule"], "family": item.get("family"), "table": item.get("table")}
else: reconstructed = reconstruct_rule_from_rule_entry(rule_entry)
rule_entry = item results.append(reconstructed)
reconstructed = reconstruct_rule_from_rule_entry(rule_entry) continue
results.append(reconstructed)
# some outputs embed table/chain with nested lists (from list ruleset)
# find nested 'rule' if present
if isinstance(item, dict):
# item may be { 'table': {...} } or { 'chain': {...} } etc.
# attempt to find inner rule keys recursively
if "nftables" in item and isinstance(item["nftables"], list):
for sub in item["nftables"]:
if isinstance(sub, dict) and "rule" in sub:
reconstructed = reconstruct_rule_from_rule_entry(sub)
results.append(reconstructed)
elif item.get("type") == "rule" or "expr" in item or "expressions" in item:
reconstructed = reconstruct_rule_from_rule_entry(item)
results.append(reconstructed)
else:
# nothing rule-like found here; skip
pass
return results return results
@@ -486,12 +622,11 @@ def add_rules_replace_all(rules: List[RuleModel], family: Union[str, Family], ta
except Exception as e: except Exception as e:
logger.debug("flush chain may have returned error: %s", e) logger.debug("flush chain may have returned error: %s", e)
# add rules in order
for r in rules: for r in rules:
fam_r = r.family.value if isinstance(r.family, Family) else r.family fam_r = r.family.value if isinstance(r.family, Family) else r.family
frag = nft_rule_fragment_from_model(r) frag = nft_rule_fragment_from_model(r)
try: try:
NFTC.add_rule(fam_r, r.table, r.chain, frag) NFTC.run(f"add rule {fam_r} {r.table} {r.chain} {frag}")
logger.info("added rule frag=%s", frag) logger.info("added rule frag=%s", frag)
except Exception as e: except Exception as e:
logger.exception("failed to add rule: %s", e) logger.exception("failed to add rule: %s", e)
@@ -500,7 +635,9 @@ def add_rules_replace_all(rules: List[RuleModel], family: Union[str, Family], ta
# ---------------------- API endpoints ---------------------- # ---------------------- API endpoints ----------------------
@router.get("/rules") @router.get("/rules")
def get_rules(family: Optional[Union[str, Family]] = DEFAULT_FAMILY, table: Optional[str] = DEFAULT_TABLE, chain: Optional[str] = DEFAULT_CHAIN): def get_rules(family: Optional[Union[str, Family]] = DEFAULT_FAMILY,
table: Optional[str] = DEFAULT_TABLE,
chain: Optional[str] = DEFAULT_CHAIN):
fam = family.value if isinstance(family, Family) else family fam = family.value if isinstance(family, Family) else family
logger.debug("GET /nft/rules called (family=%s table=%s chain=%s)", fam, table, chain) logger.debug("GET /nft/rules called (family=%s table=%s chain=%s)", fam, table, chain)
try: try:
@@ -509,7 +646,7 @@ def get_rules(family: Optional[Union[str, Family]] = DEFAULT_FAMILY, table: Opti
logger.error("failed to ensure table/chain: %s", e) logger.error("failed to ensure table/chain: %s", e)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
rules = list_rules_from_nft(fam, table) rules = list_rules_from_nft(fam, table, chain)
return {"count": len(rules), "rules": rules, "version": _current_version} return {"count": len(rules), "rules": rules, "version": _current_version}