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

This commit is contained in:
2026-01-10 18:05:57 +01:00
parent e3a555089e
commit 04d644365a
2 changed files with 290 additions and 8 deletions

View File

@@ -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:<id> mitm_json:<base64>
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}

View File

@@ -11,6 +11,7 @@ import src.shared_objects as shared_objects
from src.utilities.database import DatabasePool from src.utilities.database import DatabasePool
import src.api.network_api as network_api import src.api.network_api as network_api
import src.api.sniffer_api as sniffer_api import src.api.sniffer_api as sniffer_api
import src.api.nftables_api as nftables_api
# ---- Config ----------------------------------------------------------- # ---- Config -----------------------------------------------------------
DB_DSN = "postgresql://mitm_user:mitm_password@localhost:5432/mitm_db" DB_DSN = "postgresql://mitm_user:mitm_password@localhost:5432/mitm_db"
@@ -123,13 +124,6 @@ def versions():
message = os.popen("python --version").read().strip() message = os.popen("python --version").read().strip()
return {"message": message} return {"message": message}
@app.get("/nft/ruleset")
def nft_ruleset():
message = os.popen("sudo nft --json list ruleset").read().strip()
return {"message": message}
# --------------------- # ---------------------
# Routers # Routers
# --------------------- # ---------------------
@@ -137,3 +131,4 @@ def nft_ruleset():
app.include_router(network_api.router, prefix="/network", tags=["network"]) app.include_router(network_api.router, prefix="/network", tags=["network"])
app.include_router(sniffer_api.router, prefix="/sniffer", tags=["sniffer"]) app.include_router(sniffer_api.router, prefix="/sniffer", tags=["sniffer"])
app.include_router(packet_api.router, prefix="/packets", tags=["packets"]) app.include_router(packet_api.router, prefix="/packets", tags=["packets"])
app.include_router(nftables_api.router, prefix="/nftables", tags=["nftables"])