add nftables api
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
This commit is contained in:
287
backend/src/api/nftables_api.py
Normal file
287
backend/src/api/nftables_api.py
Normal 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}
|
||||
@@ -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"])
|
||||
app.include_router(packet_api.router, prefix="/packets", tags=["packets"])
|
||||
app.include_router(nftables_api.router, prefix="/nftables", tags=["nftables"])
|
||||
Reference in New Issue
Block a user