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

This commit is contained in:
2026-01-27 17:37:45 +01:00
parent 8863152202
commit ae80e967ed

View File

@@ -1,201 +1,94 @@
# app.py
"""
Stateless nftables FastAPI service (libnftables only, no CLI fallback, no annotations).
Endpoints:
Unrestricted nftables FastAPI service (libnftables only) with a preview endpoint.
Endpoints (high level):
GET /firewall/rules -> list rules (kernel-provided rules with handles)
POST /firewall/rules -> add a constrained rule (returns 201)
DELETE /firewall/rules/{handle} -> delete rule by handle
POST /firewall/rules -> add rule (executes; accepts 'cmd' or 'json')
DELETE /firewall/rules/{handle} -> delete rule by handle (executes)
POST /firewall/raw -> execute textual nft command (executes)
POST /firewall/jsoncmd -> execute libnftables JSON command (executes)
POST /firewall/preview -> PREVIEW what would be executed (no changes)
Requirements:
python-nftables must be installed and usable:
pip install python-nftables fastapi uvicorn pydantic
The process must have CAP_NET_ADMIN (or be run as root) to modify nftables.
- python-nftables installed
- CAP_NET_ADMIN or root required to modify nftables
WARNING:
This service can run arbitrary nft commands. Run only in a trusted environment.
"""
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Union
from fastapi import FastAPI, APIRouter, HTTPException, status
from pydantic import BaseModel, Field
import ipaddress
import logging
import json
import re
# libnftables import (must be present)
# libnftables (must be installed)
from nftables import Nftables # type: ignore
# ---------- logging ----------
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("nft_api")
# ---------- NftManager (libnftables only) ----------
# ---------- Exceptions ----------
class NftError(RuntimeError):
pass
# ---------- NftManager (unrestricted) ----------
class NftManager:
"""
Minimal libnftables wrapper that performs JSON transactions only via python-nftables.
No CLI fallback, no comments/annotations.
Thin wrapper around python-nftables exposing:
- json_cmd execution (accepts libnftables JSON dict)
- cmd execution (accepts textual nft commands)
- convenience list_rules + delete_by_handle
This class intentionally does NOT validate or restrict nft commands.
"""
# Tighten these to your environment
ALLOWED_FAMILIES = {"inet"}
ALLOWED_TABLES = {"filter"}
ALLOWED_CHAINS = {"input", "output", "forward"}
ALLOWED_PROTOS = {"tcp", "udp"}
def __init__(self) -> None:
self.nft = Nftables()
# Request JSON output where applicable
# request json output where available
try:
self.nft.set_json_output(True)
except Exception:
# Some libnftables builds may ignore this; proceed anyway
logger.debug("set_json_output not available or failed")
logger.debug("set_json_output not available")
def _json_cmd(self, cmd: Dict[str, Any]) -> Any:
rc, out, err = self.nft.json_cmd(cmd)
def json_cmd(self, cmd_obj: Dict[str, Any]) -> Any:
"""
Execute a libnftables JSON command object via json_cmd and return parsed output.
"""
rc, out, err = self.nft.json_cmd(cmd_obj)
if rc != 0:
logger.error("libnftables error: %s", err)
raise NftError(err)
logger.error("json_cmd failed: %s", err)
raise NftError(err if err else f"json_cmd exit {rc}")
return out
def _validate_family_table_chain(self, family: str, table: str, chain: str) -> None:
if family not in self.ALLOWED_FAMILIES:
raise ValueError(f"family '{family}' not allowed")
if table not in self.ALLOWED_TABLES:
raise ValueError(f"table '{table}' not allowed")
if chain not in self.ALLOWED_CHAINS:
raise ValueError(f"chain '{chain}' not allowed")
def _validate_proto(self, proto: Optional[str]) -> None:
if proto is None:
return
if proto not in self.ALLOWED_PROTOS:
raise ValueError(f"protocol '{proto}' not allowed")
def _validate_src(self, src: Optional[str]) -> None:
if not src:
return
try:
# allow host or network
ipaddress.ip_network(src, strict=False)
except Exception as e:
raise ValueError(f"invalid src '{src}': {e}")
def _validate_port(self, port: Optional[int]) -> None:
if port is None:
return
if not (1 <= port <= 65535):
raise ValueError(f"invalid port: {port}")
def cmd(self, text_cmd: str) -> Dict[str, Optional[str]]:
"""
Execute a textual nft command via Nftables.cmd().
Returns dict { "stdout": out_str, "stderr": err_str, "rc": rc }.
"""
rc, out, err = self.nft.cmd(text_cmd)
if rc != 0:
logger.warning("cmd returned rc=%s, err=%s", rc, err)
return {"rc": rc, "stdout": out, "stderr": err}
def list_rules(self) -> List[Dict[str, Any]]:
"""
Return raw rule dicts extracted from libnftables JSON output.
Each item is the inner 'rule' dict returned by nftables JSON.
List the full ruleset and return collected 'rule' dicts (each includes handle when present).
"""
out = self._json_cmd({"nftables": [{"list": {"ruleset": None}}]})
out = self.json_cmd({"nftables": [{"list": {"ruleset": None}}]})
rules: List[Dict[str, Any]] = []
for item in out.get("nftables", []):
if "rule" in item:
rules.append(item["rule"])
return rules
def add_rule(
self,
family: str,
table: str,
chain: str,
proto: Optional[str],
src: Optional[str],
dst_port: Optional[int],
verdict: str,
) -> None:
def delete_by_handle(self, family: str, table: str, chain: str, handle: int) -> None:
"""
Add a constrained rule via libnftables JSON.
This constructs an 'expr' list that covers:
- ip saddr match (if src provided)
- l4 proto match (if proto provided)
- dport match (if dst_port provided)
- final verdict (accept/drop)
Delete rule by handle using JSON delete rule. No validation performed.
"""
# validate
self._validate_family_table_chain(family, table, chain)
self._validate_proto(proto)
self._validate_src(src)
self._validate_port(dst_port)
if verdict not in {"accept", "drop"}:
raise ValueError("verdict must be 'accept' or 'drop'")
expr: List[Dict[str, Any]] = []
# match source address (IP)
if src:
# Construct prefix match using nft JSON structure (ip payload -> saddr)
# The json shape used here is compatible with python-nftables expectations.
# We use ip payload field and prefix match for networks.
try:
net = ipaddress.ip_network(src, strict=False)
addr = str(net.network_address)
prefix_len = net.prefixlen
except Exception:
# fallback to treating as single IP
addr = src
prefix_len = 32 if ":" not in src else 128
expr.append({
"match": {
"left": {"payload": {"protocol": "ip", "field": "saddr"}},
"op": "==",
"right": {"prefix": {"addr": addr, "len": prefix_len}}
}
})
# match L4 protocol
if proto:
# meta.l4proto match is a common approach in JSON exprs
expr.append({
"match": {
"left": {"meta": {"key": "l4proto"}},
"op": "==",
"right": proto
}
})
# match destination port (only meaningful if proto provided)
if dst_port:
if not proto:
raise ValueError("dst_port requires proto to be set")
expr.append({
"match": {
"left": {"payload": {"protocol": proto, "field": "dport"}},
"op": "==",
"right": dst_port
}
})
# final verdict
expr.append({"verdict": verdict})
payload = {
"nftables": [{
"add": {
"rule": {
"family": family,
"table": table,
"chain": chain,
"expr": expr
}
}
}]
}
self._json_cmd(payload)
def delete_rule(self, family: str, table: str, chain: str, handle: int) -> None:
"""
Delete a rule by handle (authoritative).
"""
self._validate_family_table_chain(family, table, chain)
if not isinstance(handle, int) or handle <= 0:
raise ValueError("handle must be a positive integer")
raise ValueError("handle must be positive integer")
payload = {
"nftables": [{
"delete": {
@@ -208,22 +101,24 @@ class NftManager:
}
}]
}
self._json_cmd(payload)
self.json_cmd(payload)
# ---------- FastAPI application (router combined) ----------
# ---------- FastAPI + Router ----------
router = APIRouter(prefix="/firewall", tags=["firewall"])
manager = NftManager()
mgr = NftManager()
# Request models
class CreateRuleRequest(BaseModel):
family: str = Field("inet", description="family (whitelisted)")
table: str = Field("filter", description="table (whitelisted)")
chain: str = Field("input", description="chain (whitelisted)")
proto: Optional[str] = Field(None, description="tcp|udp")
src: Optional[str] = Field(None, description="source IP or CIDR")
dst_port: Optional[int] = Field(None, description="destination port")
verdict: str = Field("accept", description="accept|drop")
cmd: Optional[str] = Field(None, description="Textual nft command (e.g. 'add rule ...')")
json: Optional[Dict[str, Any]] = Field(None, description="libnftables JSON command object")
class RawCmdRequest(BaseModel):
cmd: str = Field(..., description="Textual nft command to execute")
class JsonCmdRequest(BaseModel):
json: Dict[str, Any] = Field(..., description="libnftables JSON command object")
class RuleOut(BaseModel):
@@ -234,15 +129,89 @@ class RuleOut(BaseModel):
expr: Optional[Any]
# ---------- PREVIEW helpers ----------
def parse_text_cmd_summary(cmd: str) -> Dict[str, Any]:
"""
Try to parse a short summary from a textual nft cmd.
We do NOT execute anything here — just regexp/token heuristics.
Returns a dict with possible keys: operation, family, table, chain, remainder.
"""
summary: Dict[str, Any] = {"operation": None, "family": None, "table": None, "chain": None, "remainder": cmd}
# normalize whitespace
s = cmd.strip()
tokens = s.split()
if len(tokens) >= 1:
summary["operation"] = tokens[0].lower()
# detect patterns like: add rule <family> <table> <chain> ...
m = re.match(r'^(add|insert|delete|replace)\s+rule\s+(\S+)\s+(\S+)\s+(\S+)\b', s, flags=re.I)
if m:
summary["operation"] = m.group(1).lower()
summary["family"] = m.group(2)
summary["table"] = m.group(3)
summary["chain"] = m.group(4)
summary["remainder"] = s[m.end():].strip()
return summary
# detect delete rule by handle: e.g. delete rule inet filter input handle 42
m2 = re.match(r'^(delete)\s+rule\s+(\S+)\s+(\S+)\s+(\S+)\s+handle\s+(\d+)', s, flags=re.I)
if m2:
summary["operation"] = m2.group(1).lower()
summary["family"] = m2.group(2)
summary["table"] = m2.group(3)
summary["chain"] = m2.group(4)
summary["remainder"] = f"handle {m2.group(5)}"
return summary
# fallback: try to find family/table/chain tokens near 'rule'
try:
idx = next(i for i,t in enumerate(tokens) if t.lower() == "rule")
if len(tokens) > idx + 3:
summary["family"] = tokens[idx+1]
summary["table"] = tokens[idx+2]
summary["chain"] = tokens[idx+3]
summary["remainder"] = " ".join(tokens[idx+4:]) if len(tokens) > idx+4 else ""
except StopIteration:
pass
return summary
def parse_json_cmd_summary(obj: Dict[str, Any]) -> Dict[str, Any]:
"""
Extract a short summary from a libnftables JSON command object.
Looks into top-level 'nftables' list for add/delete/replace/insert keys and extracts rule family/table/chain.
"""
summary = {"entries": []}
nft_entries = obj.get("nftables") if isinstance(obj, dict) else None
if not isinstance(nft_entries, list):
return {"error": "not a libnftables JSON object with 'nftables' list"}
for item in nft_entries:
# each item is like {"add": {"rule": {"family":...}}} or {"delete": {...}}
if not isinstance(item, dict):
continue
for k,v in item.items():
entry = {"op": k}
if isinstance(v, dict) and "rule" in v and isinstance(v["rule"], dict):
r = v["rule"]
entry["family"] = r.get("family")
entry["table"] = r.get("table")
entry["chain"] = r.get("chain")
# include handle if present
if "handle" in r:
entry["handle"] = r.get("handle")
else:
# some commands are different shapes (e.g., add table ...). Try to capture names
# look for 'table' or 'chain' nested keys
if isinstance(v, dict):
entry["info"] = {kk: vv for kk,vv in v.items() if kk in ("table","chain","family")}
summary["entries"].append(entry)
return summary
# ---------- End preview helpers ----------
# List rules
@router.get("/rules", response_model=List[RuleOut])
def list_rules():
"""
Returns a list of rules (raw rule dicts from nftables) that include kernel handles.
Clients should save handles if they want to delete rules later.
"""
try:
rules = manager.list_rules()
# Normalize fields for the response model: ensure expected keys exist
rules = mgr.list_rules()
out = []
for r in rules:
out.append({
@@ -250,7 +219,7 @@ def list_rules():
"table": r.get("table"),
"chain": r.get("chain"),
"handle": r.get("handle"),
"expr": r.get("expr")
"expr": r.get("expr"),
})
return out
except NftError as e:
@@ -258,46 +227,100 @@ def list_rules():
raise HTTPException(status_code=500, detail=str(e))
# Create rule: executes
@router.post("/rules", status_code=status.HTTP_201_CREATED)
def create_rule(req: CreateRuleRequest):
"""
Create a constrained rule. Returns 201 on success.
Use GET /firewall/rules to obtain the kernel-assigned handle.
Create a rule by sending either textual 'cmd' or libnftables 'json' object.
This endpoint executes the command.
"""
try:
manager.add_rule(
family=req.family,
table=req.table,
chain=req.chain,
proto=req.proto,
src=req.src,
dst_port=req.dst_port,
verdict=req.verdict,
)
return {"status": "created"}
except (ValueError, NftError) as e:
logger.warning("create_rule bad request: %s", e)
if req.cmd:
res = mgr.cmd(req.cmd)
if res["rc"] != 0:
raise NftError(f"cmd failed rc={res['rc']}: {res.get('stderr')}")
return {"status": "ok", "stdout": res.get("stdout")}
if req.json:
out = mgr.json_cmd(req.json)
return {"status": "ok", "output": out}
raise HTTPException(status_code=400, detail="either 'cmd' or 'json' must be provided")
except NftError as e:
logger.warning("create_rule failed: %s", e)
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.exception("create_rule internal error")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/rules/{handle}", status_code=status.HTTP_204_NO_CONTENT)
def delete_rule(
handle: int,
family: str = "inet",
table: str = "filter",
chain: str = "input",
):
# PREVIEW endpoint (does NOT execute anything)
@router.post("/preview")
def preview_rule(req: CreateRuleRequest):
"""
Delete a rule by handle. family/table/chain default to common values but must match.
Preview what would be applied if you executed the given 'cmd' or 'json'.
This endpoint only analyzes and returns: textual command (if present), pretty JSON (if present),
and a parsed summary (operation, family, table, chain, etc.). Nothing is executed.
"""
try:
manager.delete_rule(family=family, table=table, chain=chain, handle=handle)
if req.cmd:
text = req.cmd.strip()
summary = parse_text_cmd_summary(text)
return {
"type": "text",
"text_cmd": text,
"summary": summary,
"note": "This is a preview only. Nothing was executed."
}
if req.json:
pretty = json.dumps(req.json, indent=2, sort_keys=True)
summary = parse_json_cmd_summary(req.json)
return {
"type": "json",
"json_pretty": pretty,
"summary": summary,
"note": "This is a preview only. Nothing was executed."
}
raise HTTPException(status_code=400, detail="either 'cmd' or 'json' must be provided for preview")
except Exception as e:
logger.exception("preview_rule internal error")
raise HTTPException(status_code=500, detail=str(e))
# Delete by handle (authoritative)
@router.delete("/rules/{handle}", status_code=status.HTTP_204_NO_CONTENT)
def delete_rule(handle: int, family: str = "inet", table: str = "filter", chain: str = "input"):
"""
Delete a rule by handle. family/table/chain are passed through to the delete JSON.
"""
try:
mgr.delete_by_handle(family=family, table=table, chain=chain, handle=handle)
except ValueError as e:
logger.warning("delete_rule client error: %s", e)
raise HTTPException(status_code=400, detail=str(e))
except NftError as e:
logger.exception("delete_rule failed")
raise HTTPException(status_code=500, detail=str(e))
except Exception as e:
logger.exception("delete_rule internal error")
raise HTTPException(status_code=500, detail=str(e))
# Execute arbitrary textual nft command (convenience endpoint)
@router.post("/raw")
def exec_raw(req: RawCmdRequest):
try:
res = mgr.cmd(req.cmd)
return {"rc": res["rc"], "stdout": res["stdout"], "stderr": res["stderr"]}
except Exception as e:
logger.exception("exec_raw failed")
raise HTTPException(status_code=500, detail=str(e))
# Execute arbitrary JSON command
@router.post("/jsoncmd")
def exec_json(req: JsonCmdRequest):
try:
out = mgr.json_cmd(req.json)
return {"output": out}
except Exception as e:
logger.exception("exec_json failed")
raise HTTPException(status_code=400, detail=str(e))