From 04d644365a4d0602b62af85d99c77aa3736284d6 Mon Sep 17 00:00:00 2001 From: malmert Date: Sat, 10 Jan 2026 18:05:57 +0100 Subject: [PATCH] add nftables api --- backend/src/api/nftables_api.py | 287 ++++++++++++++++++++++++++++++++ backend/src/main.py | 11 +- 2 files changed, 290 insertions(+), 8 deletions(-) create mode 100644 backend/src/api/nftables_api.py diff --git a/backend/src/api/nftables_api.py b/backend/src/api/nftables_api.py new file mode 100644 index 0000000..ea6b327 --- /dev/null +++ b/backend/src/api/nftables_api.py @@ -0,0 +1,287 @@ +""" +FastAPI app to manage nftables bridge rules dynamically. +- Removed idempotent-by-id behavior: POST /rules always adds rules (generates id if missing) +- Added DELETE /rules endpoint to delete one or many rules by id + +Notes: +- Runs nft(8) commands; the process must have sufficient privileges (run as root or via sudo). +- This implementation stores the full rule JSON inside the nft rule comment as base64 to allow round-trip parsing. +- The API keeps sniffer separate; this module only manages kernel rules. + +Endpoints: +- GET /rules -> list active rules (read from nftables) +- POST /rules -> add one or many rules +- DELETE /rules -> delete one or many rules by id + +Rule schema (example): +{ + "id": "optional-uuid-if-you-want", + "table": "mitm_tbl", + "chain": "forward", + "family": "bridge", + "match": { + "iif": "br0", + "oif": "eth1", + "meta_length": "100-200", # or single int as str/number + "ip_proto": "tcp", + "tcp_dport": 80 + }, + "action": {"type": "drop"} +} + +Supported matches in this example: iif, oif, meta_length, ip_proto, tcp_dport, udp_dport +Supported actions: drop, accept, queue (num), redirect (port) +""" + +from fastapi import APIRouter, APIRouter, HTTPException +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any, Union +import subprocess +import uuid +import json +import base64 +import re + + +DEFAULT_TABLE = "mitm_tbl" +DEFAULT_CHAIN = "forward" +DEFAULT_FAMILY = "bridge" + +# ---------------------- Pydantic models ---------------------- +class MatchModel(BaseModel): + iif: Optional[str] + oif: Optional[str] + meta_length: Optional[Union[int, str]] # allow "100-200" + ip_proto: Optional[str] + tcp_dport: Optional[int] + udp_dport: Optional[int] + +class ActionModel(BaseModel): + type: str # drop | accept | queue | redirect + queue_num: Optional[int] + redirect_port: Optional[int] + +class RuleModel(BaseModel): + id: Optional[str] = Field(None, description="optional rule id; generated if missing") + family: Optional[str] = Field(DEFAULT_FAMILY) + table: Optional[str] = Field(DEFAULT_TABLE) + chain: Optional[str] = Field(DEFAULT_CHAIN) + match: MatchModel + action: ActionModel + +# ---------------------- Utilities ---------------------- + +def run_nft(args: List[str]) -> subprocess.CompletedProcess: + try: + return subprocess.run(["nft"] + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) + except subprocess.CalledProcessError as e: + # raise with stderr for easier debugging + raise RuntimeError(f"nft failed: {' '.join(e.cmd)} -- {e.stderr.strip()}") + + +def ensure_table_chain(family: str, table: str, chain: str): + # create table if missing (ignore error if exists) + try: + run_nft(["add", "table", family, table]) + except RuntimeError: + # already exists or failed; ignore existence error + pass + + # create chain if missing: forward chain with hook forward + try: + # Note: use type filter hook forward priority 0 + run_nft(["add", "chain", family, table, chain, "{", "type", "filter", "hook", "forward", "priority", "0", ";", "}"]) + except RuntimeError: + # ignore if exists + pass + + +def encode_rule_comment(rule: Dict[str, Any]) -> str: + # store the rule JSON as base64 to avoid quoting/escaping issues inside nft comment + j = json.dumps(rule, separators=(",", ":")) + b = base64.b64encode(j.encode()).decode() + rid = rule.get("id") or "" + return f"mitm_id:{rid} mitm_json:{b}" + + +def decode_comment_payload(comment: str) -> Optional[Dict[str, Any]]: + # expects comment like: mitm_id: mitm_json: + try: + parts = comment.split() + kv = {p.split(":", 1)[0]: p.split(":", 1)[1] for p in parts if ":" in p} + b64 = kv.get("mitm_json") + if not b64: + return None + j = base64.b64decode(b64.encode()).decode() + return json.loads(j) + except Exception: + return None + + +def build_nft_match_expr(match: MatchModel) -> List[str]: + expr: List[str] = [] + if match.iif: + expr += ["iif", match.iif] + if match.oif: + expr += ["oif", match.oif] + if match.meta_length is not None: + # accept either range string or int + expr += ["meta", "length", str(match.meta_length)] + if match.ip_proto: + expr += ["ip", "protocol", match.ip_proto] + if match.tcp_dport: + expr += ["tcp", "dport", str(match.tcp_dport)] + if match.udp_dport: + expr += ["udp", "dport", str(match.udp_dport)] + return expr + + +def build_nft_action_expr(action: ActionModel) -> List[str]: + if action.type == "drop": + return ["drop"] + if action.type == "accept": + return ["accept"] + if action.type == "queue": + num = action.queue_num if action.queue_num is not None else 0 + return ["queue", "num", str(num)] + if action.type == "redirect": + if action.redirect_port is None: + raise ValueError("redirect action requires redirect_port") + return ["redirect", "to", f":{action.redirect_port}"] + raise ValueError(f"unsupported action type: {action.type}") + + +def add_rule_to_nft(rule: RuleModel) -> Dict[str, Any]: + # ensure table/chain + ensure_table_chain(rule.family, rule.table, rule.chain) + + # ensure id + if not rule.id: + rule.id = str(uuid.uuid4()) + + # build nft command args + match_expr = build_nft_match_expr(rule.match) + action_expr = build_nft_action_expr(rule.action) + + comment = encode_rule_comment(rule.dict()) + + args: List[str] = ["add", "rule", rule.family, rule.table, rule.chain] + args += match_expr + args += action_expr + args += ["comment", comment] + + try: + run_nft(args) + return {"id": rule.id, "status": "added"} + except RuntimeError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +def list_rules_from_nft(family: str, table: str) -> List[Dict[str, Any]]: + try: + out = run_nft(["list", "table", family, table]).stdout + except RuntimeError: + return [] + + results: List[Dict[str, Any]] = [] + # naive parse: nft prints individual rules as lines; look for comment "mitm_json:" + for line in out.splitlines(): + line = line.strip() + if "comment" in line and "mitm_json:" in line: + # find comment payload part: comment "..." + # format often: "comment "mitm_id:... mitm_json:..."" + try: + # extract between first pair of double quotes + first_quote = line.index('\"') + last_quote = line.rindex('\"') + comment_str = line[first_quote + 1:last_quote] + except ValueError: + # fallback: take substring after comment + comment_str = line.split("comment", 1)[1].strip() + + parsed = decode_comment_payload(comment_str) + if parsed is not None: + results.append(parsed) + return results + + +def delete_rules_by_ids(ids: List[str], family: str, table: str, chain: str) -> List[str]: + """Delete rules whose comment contains mitm_id in ids. + Returns list of deleted ids. + """ + try: + out = run_nft(["list", "chain", family, table, chain, "-a"]).stdout + except RuntimeError: + return [] + + deleted: List[str] = [] + + # nft -a prints rules; each rule line may contain a comment and a trailing handle number: "... comment \\"mitm_id:...\\" ... handle 5" + for line in out.splitlines(): + if "comment" in line and "mitm_id:" in line: + # extract comment between quotes + try: + q1 = line.index('\"') + q2 = line.index('\"', q1 + 1) + comment_str = line[q1 + 1:q2] + except ValueError: + # fallback: substring + comment_str = line.split("comment", 1)[1] + + parsed = decode_comment_payload(comment_str) + if not parsed: + continue + rid = parsed.get("id") + if rid in ids: + # find handle number + m = re.search(r"handle\s+(\d+)", line) + if not m: + # try to find handle on the next token(s) - naive fallback + parts = line.split() + if "handle" in parts: + hi = parts.index("handle") + if hi + 1 < len(parts): + handle = parts[hi + 1] + else: + continue + else: + continue + else: + handle = m.group(1) + + try: + run_nft(["delete", "rule", family, table, chain, "handle", handle]) + deleted.append(rid) + except RuntimeError: + # ignore deletion errors for now + continue + return deleted + +# ---------------------- API endpoints ---------------------- + +router = APIRouter() + +@router.get("/rules") +def get_rules(family: Optional[str] = DEFAULT_FAMILY, table: Optional[str] = DEFAULT_TABLE): + rules = list_rules_from_nft(family, table) + return {"count": len(rules), "rules": rules} + +@router.post("/rules") +def post_rules(payload: Union[RuleModel, List[RuleModel]]): + # accept either single or list + rules = payload if isinstance(payload, list) else [payload] + results = [] + for r in rules: + res = add_rule_to_nft(r) + results.append(res) + return {"results": results} + +@router.delete("/rules") +def delete_rules(payload: Union[str, List[str]], family: Optional[str] = DEFAULT_FAMILY, table: Optional[str] = DEFAULT_TABLE, chain: Optional[str] = DEFAULT_CHAIN): + """Delete one or many rules by id. + Payload can be a single id string or a list of ids. + """ + ids = [payload] if isinstance(payload, str) else payload + deleted = delete_rules_by_ids(ids, family, table, chain) + results = [{"id": i, "deleted": i in deleted} for i in ids] + return {"results": results} diff --git a/backend/src/main.py b/backend/src/main.py index fa88f45..e26c728 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -11,6 +11,7 @@ import src.shared_objects as shared_objects from src.utilities.database import DatabasePool import src.api.network_api as network_api import src.api.sniffer_api as sniffer_api +import src.api.nftables_api as nftables_api # ---- Config ----------------------------------------------------------- DB_DSN = "postgresql://mitm_user:mitm_password@localhost:5432/mitm_db" @@ -123,17 +124,11 @@ def versions(): message = os.popen("python --version").read().strip() return {"message": message} - -@app.get("/nft/ruleset") -def nft_ruleset(): - message = os.popen("sudo nft --json list ruleset").read().strip() - return {"message": message} - - # --------------------- # Routers # --------------------- app.include_router(network_api.router, prefix="/network", tags=["network"]) app.include_router(sniffer_api.router, prefix="/sniffer", tags=["sniffer"]) -app.include_router(packet_api.router, prefix="/packets", tags=["packets"]) \ No newline at end of file +app.include_router(packet_api.router, prefix="/packets", tags=["packets"]) +app.include_router(nftables_api.router, prefix="/nftables", tags=["nftables"]) \ No newline at end of file