nft improv 3
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
This commit is contained in:
@@ -1,19 +1,16 @@
|
|||||||
# app.py
|
# app.py
|
||||||
"""
|
"""
|
||||||
Unrestricted nftables FastAPI service (libnftables only) with separate endpoints for
|
Unrestricted nftables FastAPI service using textual nft commands only.
|
||||||
textual commands (/firewall/raw) and JSON commands (/firewall/jsoncmd and /firewall/rules),
|
|
||||||
plus a preview endpoint that does NOT execute.
|
|
||||||
|
|
||||||
Endpoints (high level):
|
Endpoints (high level):
|
||||||
GET /firewall/rules -> list rules (kernel-provided rules with handles)
|
GET /firewall/rules -> return textual ruleset (raw nft output)
|
||||||
POST /firewall/rules -> add/execute libnftables JSON command (executes)
|
POST /firewall/rules -> execute a textual nft command (convenience)
|
||||||
DELETE /firewall/rules/{handle} -> delete rule by handle (executes)
|
DELETE /firewall/rules/{handle} -> delete rule by handle via textual nft command
|
||||||
POST /firewall/raw -> execute textual nft command (executes)
|
POST /firewall/raw -> execute arbitrary textual nft command (executes)
|
||||||
POST /firewall/jsoncmd -> execute libnftables JSON command (executes)
|
POST /firewall/preview -> PREVIEW what textual command would do (no changes)
|
||||||
POST /firewall/preview -> PREVIEW what would be executed (no changes)
|
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
- python-nftables installed
|
- python-nftables must be installed for the Nftables wrapper (we still use python-nftables to call .cmd())
|
||||||
- CAP_NET_ADMIN or root required to modify nftables
|
- CAP_NET_ADMIN or root required to modify nftables
|
||||||
|
|
||||||
WARNING:
|
WARNING:
|
||||||
@@ -24,263 +21,135 @@ from typing import Any, Dict, List, Optional
|
|||||||
from fastapi import FastAPI, APIRouter, HTTPException, status
|
from fastapi import FastAPI, APIRouter, HTTPException, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
import logging
|
import logging
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
# libnftables (must be installed)
|
# libnftables (we call textual commands through its .cmd() method)
|
||||||
from nftables import Nftables # type: ignore
|
from nftables import Nftables # type: ignore
|
||||||
|
|
||||||
# ---------- logging ----------
|
# ---------- logging ----------
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger("nft_api")
|
logger = logging.getLogger("nft_api_raw_only")
|
||||||
|
|
||||||
# ---------- Exceptions ----------
|
# ---------- Exceptions ----------
|
||||||
class NftError(RuntimeError):
|
class NftError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# ---------- NftManager (unrestricted) ----------
|
|
||||||
|
# ---------- NftManager (textual-only) ----------
|
||||||
class NftManager:
|
class NftManager:
|
||||||
"""
|
"""
|
||||||
Thin wrapper around python-nftables exposing:
|
Thin wrapper around python-nftables exposing:
|
||||||
- json_cmd execution (accepts libnftables JSON dict)
|
- cmd execution (textual nft commands via Nftables.cmd())
|
||||||
- cmd execution (accepts textual nft commands)
|
- convenience list_rules_text
|
||||||
- convenience list_rules + delete_by_handle
|
This class intentionally avoids json transactions: everything is textual 'nft' commands.
|
||||||
This class intentionally does NOT validate or restrict nft commands.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.nft = Nftables()
|
self.nft = Nftables()
|
||||||
# request json output where available
|
|
||||||
try:
|
try:
|
||||||
|
# prefer seeing JSON when python-nftables prints it, but we only use .cmd() below
|
||||||
self.nft.set_json_output(True)
|
self.nft.set_json_output(True)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("set_json_output not available")
|
logger.debug("set_json_output not available or ignored")
|
||||||
|
|
||||||
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("json_cmd failed: %s", err)
|
|
||||||
raise NftError(err if err else f"json_cmd exit {rc}")
|
|
||||||
return out
|
|
||||||
|
|
||||||
def cmd(self, text_cmd: str) -> Dict[str, Optional[str]]:
|
def cmd(self, text_cmd: str) -> Dict[str, Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Execute a textual nft command via Nftables.cmd().
|
Execute a textual nft command via Nftables.cmd().
|
||||||
Returns dict { "stdout": out_str, "stderr": err_str, "rc": rc }.
|
Returns dict { "rc": rc, "stdout": out_str, "stderr": err_str }.
|
||||||
"""
|
"""
|
||||||
rc, out, err = self.nft.cmd(text_cmd)
|
rc, out, err = self.nft.cmd(text_cmd)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
logger.warning("cmd returned rc=%s, err=%s", rc, err)
|
logger.warning("nft cmd rc=%s stderr=%s cmd=%s", rc, err, text_cmd)
|
||||||
return {"rc": rc, "stdout": out, "stderr": err}
|
return {"rc": rc, "stdout": out, "stderr": err}
|
||||||
|
|
||||||
def list_rules(self) -> List[Dict[str, Any]]:
|
def list_rules_text(self) -> str:
|
||||||
"""
|
"""
|
||||||
List the full ruleset and return collected 'rule' dicts (each includes handle when present).
|
Return the textual ruleset as produced by 'nft list ruleset'.
|
||||||
|
Uses the textual command path.
|
||||||
"""
|
"""
|
||||||
out = self.json_cmd({"nftables": [{"list": {"ruleset": None}}]})
|
res = self.cmd("list ruleset")
|
||||||
rules: List[Dict[str, Any]] = []
|
if res["rc"] != 0:
|
||||||
for item in out.get("nftables", []):
|
raise NftError(f"nft list ruleset failed: {res['stderr']}")
|
||||||
if "rule" in item:
|
return res["stdout"]
|
||||||
rules.append(item["rule"])
|
|
||||||
return rules
|
|
||||||
|
|
||||||
def delete_by_handle(self, family: str, table: str, chain: str, handle: int) -> None:
|
def delete_rule_by_handle_text(self, family: str, table: str, chain: str, handle: int) -> None:
|
||||||
"""
|
"""
|
||||||
Delete rule by handle using JSON delete rule. No validation performed.
|
Delete a rule by handle using textual nft command:
|
||||||
|
delete rule <family> <table> <chain> handle <handle>
|
||||||
"""
|
"""
|
||||||
if not isinstance(handle, int) or handle <= 0:
|
if not isinstance(handle, int) or handle <= 0:
|
||||||
raise ValueError("handle must be positive integer")
|
raise ValueError("handle must be a positive integer")
|
||||||
payload = {
|
# construct textual command
|
||||||
"nftables": [{
|
cmd = f"delete rule {family} {table} {chain} handle {handle}"
|
||||||
"delete": {
|
res = self.cmd(cmd)
|
||||||
"rule": {
|
if res["rc"] != 0:
|
||||||
"family": family,
|
raise NftError(f"delete rule failed: {res['stderr']}")
|
||||||
"table": table,
|
|
||||||
"chain": chain,
|
|
||||||
"handle": handle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
self.json_cmd(payload)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- FastAPI + Router ----------
|
# ---------- FastAPI + Router ----------
|
||||||
app = FastAPI(title="Unrestricted nftables API (libnftables only)")
|
app = FastAPI(title="Unrestricted nftables API (textual only)")
|
||||||
router = APIRouter(prefix="/firewall", tags=["firewall"])
|
router = APIRouter(prefix="/firewall", tags=["firewall"])
|
||||||
mgr = NftManager()
|
mgr = NftManager()
|
||||||
|
|
||||||
|
|
||||||
# Request models
|
# ---------- Request/Response models ----------
|
||||||
class CreateRuleJsonRequest(BaseModel):
|
|
||||||
json: Dict[str, Any] = Field(..., description="libnftables JSON command object")
|
|
||||||
|
|
||||||
|
|
||||||
class RawCmdRequest(BaseModel):
|
class RawCmdRequest(BaseModel):
|
||||||
cmd: str = Field(..., description="Textual nft command to execute")
|
cmd: str = Field(..., description="Textual nft command to execute")
|
||||||
|
|
||||||
|
|
||||||
class JsonCmdRequest(BaseModel):
|
class RulesetOut(BaseModel):
|
||||||
json: Dict[str, Any] = Field(..., description="libnftables JSON command object")
|
ruleset: str
|
||||||
|
|
||||||
|
|
||||||
class PreviewRequest(BaseModel):
|
|
||||||
# preview accepts either textual cmd or json object
|
|
||||||
cmd: Optional[str] = Field(None, description="Textual nft command (preview only)")
|
|
||||||
json: Optional[Dict[str, Any]] = Field(None, description="libnftables JSON command object (preview only)")
|
|
||||||
|
|
||||||
|
|
||||||
class RuleOut(BaseModel):
|
|
||||||
family: Optional[str]
|
|
||||||
table: Optional[str]
|
|
||||||
chain: Optional[str]
|
|
||||||
handle: Optional[int]
|
|
||||||
expr: Optional[Any]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- PREVIEW helpers (unchanged) ----------
|
|
||||||
def parse_text_cmd_summary(cmd: str) -> Dict[str, Any]:
|
|
||||||
summary: Dict[str, Any] = {"operation": None, "family": None, "table": None, "chain": None, "remainder": cmd}
|
|
||||||
s = cmd.strip()
|
|
||||||
tokens = s.split()
|
|
||||||
if len(tokens) >= 1:
|
|
||||||
summary["operation"] = tokens[0].lower()
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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]:
|
|
||||||
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:
|
|
||||||
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")
|
|
||||||
if "handle" in r:
|
|
||||||
entry["handle"] = r.get("handle")
|
|
||||||
else:
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- Routes ----------
|
# ---------- Routes ----------
|
||||||
|
|
||||||
# List rules
|
# GET /firewall/rules -> textual nft ruleset output
|
||||||
@router.get("/rules", response_model=List[RuleOut])
|
@router.get("/rules", response_model=RulesetOut)
|
||||||
def list_rules():
|
def list_rules():
|
||||||
|
"""
|
||||||
|
Returns the textual nft ruleset output (as a single string).
|
||||||
|
Use clients to parse as needed.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
rules = mgr.list_rules()
|
text = mgr.list_rules_text()
|
||||||
out = []
|
return {"ruleset": text}
|
||||||
for r in rules:
|
|
||||||
out.append({
|
|
||||||
"family": r.get("family"),
|
|
||||||
"table": r.get("table"),
|
|
||||||
"chain": r.get("chain"),
|
|
||||||
"handle": r.get("handle"),
|
|
||||||
"expr": r.get("expr"),
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
except NftError as e:
|
except NftError as e:
|
||||||
logger.exception("list_rules failed")
|
logger.exception("list_rules failed")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("list_rules internal error")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
# Create/execute rule via JSON only (structured)
|
# POST /firewall/rules -> execute a textual nft command (convenience)
|
||||||
@router.post("/rules", status_code=status.HTTP_201_CREATED)
|
@router.post("/rules", status_code=status.HTTP_201_CREATED)
|
||||||
def create_rule_json(req: CreateRuleJsonRequest):
|
def create_rule_text(req: RawCmdRequest):
|
||||||
"""
|
"""
|
||||||
Execute a libnftables JSON command object.
|
Execute a textual nft command (convenience, returns raw stdout).
|
||||||
Use this endpoint to add structured rules or multi-op transactions.
|
Example: add rule inet filter input ip saddr 10.0.0.0/8 drop
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
out = mgr.json_cmd(req.json)
|
res = mgr.cmd(req.cmd)
|
||||||
return {"status": "ok", "output": out}
|
if res["rc"] != 0:
|
||||||
|
raise NftError(f"cmd failed rc={res['rc']}: {res.get('stderr')}")
|
||||||
|
return {"status": "ok", "stdout": res.get("stdout")}
|
||||||
except NftError as e:
|
except NftError as e:
|
||||||
logger.warning("create_rule_json failed: %s", e)
|
logger.warning("create_rule_text failed: %s", e)
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("create_rule_json internal error")
|
logger.exception("create_rule_text internal error")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
# PREVIEW endpoint (does NOT execute anything)
|
# DELETE /firewall/rules/{handle} -> delete by handle using textual nft command
|
||||||
@router.post("/preview")
|
|
||||||
def preview_rule(req: PreviewRequest):
|
|
||||||
"""
|
|
||||||
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:
|
|
||||||
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)
|
@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"):
|
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.
|
Delete a rule by handle using textual nft command.
|
||||||
|
Command executed:
|
||||||
|
delete rule <family> <table> <chain> handle <handle>
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
mgr.delete_by_handle(family=family, table=table, chain=chain, handle=handle)
|
mgr.delete_rule_by_handle_text(family=family, table=table, chain=chain, handle=handle)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning("delete_rule client error: %s", e)
|
logger.warning("delete_rule client error: %s", e)
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -292,9 +161,12 @@ def delete_rule(handle: int, family: str = "inet", table: str = "filter", chain:
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
# Execute arbitrary textual nft command (convenience endpoint)
|
# Execute arbitrary textual nft command
|
||||||
@router.post("/raw")
|
@router.post("/raw")
|
||||||
def exec_raw(req: RawCmdRequest):
|
def exec_raw(req: RawCmdRequest):
|
||||||
|
"""
|
||||||
|
Execute an arbitrary textual nft command and return {rc, stdout, stderr}.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
res = mgr.cmd(req.cmd)
|
res = mgr.cmd(req.cmd)
|
||||||
return {"rc": res["rc"], "stdout": res["stdout"], "stderr": res["stderr"]}
|
return {"rc": res["rc"], "stdout": res["stdout"], "stderr": res["stderr"]}
|
||||||
@@ -303,3 +175,5 @@ def exec_raw(req: RawCmdRequest):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# include router
|
||||||
|
app.include_router(router)
|
||||||
|
|||||||
Reference in New Issue
Block a user