added more types
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s

This commit is contained in:
2026-02-10 21:01:43 +01:00
parent 317946c7b7
commit 5ebe16b854
2 changed files with 214 additions and 86 deletions

View File

@@ -1,27 +1,8 @@
# app.py # app.py
""" from typing import Any, Dict, List, Optional, Union
Unrestricted nftables FastAPI service using textual nft commands only.
Endpoints (high level):
GET /firewall/rules -> return textual ruleset (raw nft output or parsed JSON when nft returned JSON)
POST /firewall/rules -> execute a textual nft command (convenience)
DELETE /firewall/rules/{handle} -> delete rule by handle via textual nft command
POST /firewall/raw -> execute arbitrary textual nft command (executes)
POST /firewall/preview -> PREVIEW what textual command would do (no changes)
Requirements:
- 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
WARNING:
This service can run arbitrary nft commands. Run only in a trusted environment.
"""
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 re
import json import json
# libnftables (we call textual commands through its .cmd() method) # libnftables (we call textual commands through its .cmd() method)
@@ -63,7 +44,7 @@ class NftManager:
logger.warning("nft cmd rc=%s stderr=%s cmd=%s", rc, err, text_cmd) 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_text(self) -> str: def list_rules(self) -> str:
""" """
Return the textual ruleset as produced by 'nft list ruleset'. Return the textual ruleset as produced by 'nft list ruleset'.
Uses the textual command path. Uses the textual command path.
@@ -95,71 +76,51 @@ mgr = NftManager()
# ---------- Request/Response models ---------- # ---------- Request/Response models ----------
class RawCmdRequest(BaseModel): class RawCmdRequest(BaseModel):
cmd: str = Field(..., description="Textual nft command to execute") cmd: str = Field(description="Textual nft command to execute", example="add rule inet filter input ip saddr 10.0.0.0/8 drop")
class ExecResult(BaseModel):
rc: int = Field(description="Return code from nft execution", example=0)
stdout: Optional[str] = Field(None, description="Standard output from nft", example="")
stderr: Optional[str] = Field(None, description="Standard error from nft", example="")
class Config:
schema_extra = {"example": {"rc": 0, "stdout": "ok", "stderr": ""}}
# ruleset may be a parsed JSON object (dict/list) OR a raw string (text) OR null
RulesetValue = Dict
class RulesetOut(BaseModel): class RulesetOut(BaseModel):
# ruleset can be either a parsed JSON object (dict/list) or a string ruleset: RulesetValue = Field(
ruleset: Any description="Parsed JSON ruleset (dict/list).",
)
# ---------- 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
# ---------- Routes ---------- # ---------- Routes ----------
# GET /firewall/rules -> textual nft ruleset output or parsed JSON when applicable @router.get("/rules", response_model=RulesetOut, summary="List ruleset")
@router.get("/rules", response_model=RulesetOut)
def list_rules(): def list_rules():
""" """
Returns the textual nft ruleset output (as a string) or native JSON if nft returned JSON. Returns the textual nft ruleset output (as a string) or native JSON if nft returned JSON.
Clients should handle both cases. Clients should handle both cases.
""" """
try: try:
text = mgr.list_rules_text() text = mgr.list_rules()
if text is None: if text is None:
return {"ruleset": None} return {"ruleset": None}
s = text.strip() s = text.strip()
# Try to interpret as JSON. nft will produce JSON when set_json_output(True) is active. # Try to interpret as JSON. nft may produce JSON when set_json_output(True).
try: try:
parsed = json.loads(s) parsed = json.loads(s)
# Return parsed JSON (will be serialized as JSON by FastAPI) # Return parsed JSON (FastAPI will serialize)
return {"ruleset": parsed} return {"ruleset": parsed}
except json.JSONDecodeError: except json.JSONDecodeError:
# Not JSON — return raw text # Not JSON — return raw text
return {"ruleset": text} # Important: we do NOT treat non-json as an internal error; return raw string.
logger.debug("nft output is not JSON; returning raw text")
return {"ruleset": s}
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))
@@ -168,18 +129,20 @@ def list_rules():
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# POST /firewall/rules -> execute a textual nft command (convenience) @router.post("/rules", response_model=ExecResult, status_code=status.HTTP_201_CREATED, summary="Execute textual rule command")
@router.post("/rules", status_code=status.HTTP_201_CREATED)
def create_rule_text(req: RawCmdRequest): def create_rule_text(req: RawCmdRequest):
""" """
Execute a textual nft command (convenience, returns raw stdout). Execute a textual nft command (convenience, returns structured exec result).
Example: add rule inet filter input ip saddr 10.0.0.0/8 drop Example: add rule inet filter input ip saddr 10.0.0.0/8 drop
""" """
try: try:
res = mgr.cmd(req.cmd) res = mgr.cmd(req.cmd)
if res["rc"] != 0: rc = int(res.get("rc", -1) or -1)
raise NftError(f"cmd failed rc={res['rc']}: {res.get('stderr')}") if rc != 0:
return {"status": "ok", "stdout": res.get("stdout")} # Bad request: command failed (client-provided textual command)
# Return 400 with the stderr detail
raise NftError(f"cmd failed rc={rc}: {res.get('stderr')}")
return ExecResult(rc=rc, stdout=res.get("stdout"), stderr=res.get("stderr"))
except NftError as e: except NftError as e:
logger.warning("create_rule_text 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))
@@ -188,8 +151,7 @@ def create_rule_text(req: RawCmdRequest):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# DELETE /firewall/rules/{handle} -> delete by handle using textual nft command @router.delete("/rules/{handle}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete rule by handle")
@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 using textual nft command. Delete a rule by handle using textual nft command.
@@ -198,6 +160,7 @@ def delete_rule(handle: int, family: str = "inet", table: str = "filter", chain:
""" """
try: try:
mgr.delete_rule_by_handle_text(family=family, table=table, chain=chain, handle=handle) mgr.delete_rule_by_handle_text(family=family, table=table, chain=chain, handle=handle)
# 204 No Content — nothing to return
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))
@@ -209,19 +172,18 @@ 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 @router.post("/raw", response_model=ExecResult, summary="Execute raw textual nft command")
@router.post("/raw")
def exec_raw(req: RawCmdRequest): def exec_raw(req: RawCmdRequest):
""" """
Execute an arbitrary textual nft command and return {rc, stdout, stderr}. Execute an arbitrary textual nft command and return structured {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"]} rc = int(res.get("rc", -1) or -1)
return ExecResult(rc=rc, stdout=res.get("stdout"), stderr=res.get("stderr"))
except Exception as e: except Exception as e:
logger.exception("exec_raw failed") logger.exception("exec_raw failed")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# include router
app.include_router(router) app.include_router(router)

View File

@@ -8,7 +8,9 @@ import {
InterfaceInfo, InterfaceInfo,
RouteInfo, RouteInfo,
} from "../types/network"; } from "../types/network";
import { SnifferStatusResponse } from "../types/sniffer"; import {
SnifferStatusResponse,
} from "../types/sniffer";
const BASE = "http://mitm.lan/api"; const BASE = "http://mitm.lan/api";
@@ -34,7 +36,49 @@ api.interceptors.response.use(
} }
); );
// Queries /**
* Local lightweight types for a few endpoints where a dedicated project type
* wasn't imported above. If you already have these in your codebase, replace
* these with imports instead.
*/
export type RawCmdRequest = {
cmd: string;
};
export type RulesetOut = {
ruleset: string;
};
export type ScriptInfo = {
name: string;
path: string;
};
export type EnableRequest = {
qnum: number;
service_name?: string | null;
extra_args?: string | null;
enable_at_boot?: boolean | null;
};
/* -------------------------
Basic endpoints
------------------------- */
export const fetchHello = async (): Promise<any> => {
const res = await api.get("/hello");
return res.data;
};
export const fetchVersions = async (): Promise<any> => {
const res = await api.get("/versions");
return res.data;
};
/* -------------------------
Network queries (existing)
------------------------- */
export const fetchInterfaces = async (): Promise<InterfaceInfo[]> => { export const fetchInterfaces = async (): Promise<InterfaceInfo[]> => {
const res = await api.get<InterfaceInfo[]>("/network/interfaces"); const res = await api.get<InterfaceInfo[]>("/network/interfaces");
return res.data; return res.data;
@@ -60,19 +104,141 @@ export const fetchFullState = async (): Promise<FullState> => {
return res.data; return res.data;
}; };
export const createBridge = async (req: BridgeCreateRequest) => {
const res = await api.post("/network/bridge/create", req);
return res.data;
};
export const removeBridge = async (req: BridgeRemoveRequest) => {
const res = await api.post("/network/bridge/remove", req);
return res.data;
};
/* -------------------------
Sniffer
------------------------- */
export const fetchSnifferStatus = async (): Promise<SnifferStatusResponse> => { export const fetchSnifferStatus = async (): Promise<SnifferStatusResponse> => {
const res = await api.get<SnifferStatusResponse>("/sniffer/status"); const res = await api.get<SnifferStatusResponse>("/sniffer/status");
return res.data; return res.data;
}; };
/* export const snifferStart = async (
// Mutations req: SnifferStartRequest
export const createBridge = async (req: BridgeCreateRequest) => { ): Promise<SnifferStartResponse> => {
const res = await api.post("network/bridge/create", req); const res = await api.post<SnifferStartResponse>("/sniffer/start", req);
return res.data; return res.data;
}; };
export const removeBridge = async (req: BridgeRemoveRequest) => { export const snifferStop = async (): Promise<SnifferStopResponse> => {
const res = await api.post("network/bridge/remove", req); const res = await api.post<SnifferStopResponse>("/sniffer/stop");
return res.data; return res.data;
}; };
*/
/* -------------------------
Packets
------------------------- */
export const fetchPackets = async (limit = 100): Promise<any> => {
// limit default mirrors OpenAPI default
const res = await api.get("/packets/packets", { params: { limit } });
return res.data;
};
/* -------------------------
Firewall
------------------------- */
export const listFirewallRules = async (): Promise<RulesetOut> => {
const res = await api.get<RulesetOut>("/firewall/rules");
return res.data;
};
export const createFirewallRuleText = async (req: RawCmdRequest): Promise<any> => {
// Executes textual nft command, returns raw stdout (201 expected per spec)
const res = await api.post("/firewall/rules", req);
return res.data;
};
export const deleteFirewallRule = async (
handle: number,
options?: { family?: string; table?: string; chain?: string }
): Promise<void> => {
// returns 204 on success (no content)
const params: Record<string, any> = {};
if (options?.family) params.family = options.family;
if (options?.table) params.table = options.table;
if (options?.chain) params.chain = options.chain;
await api.delete(`/firewall/rules/${handle}`, { params });
};
export const execFirewallRaw = async (req: RawCmdRequest): Promise<any> => {
// Execute arbitrary textual nft command and return {rc, stdout, stderr}
const res = await api.post("/firewall/raw", req);
return res.data;
};
/* -------------------------
Scripts
------------------------- */
export const fetchScriptsStatusAll = async (): Promise<any> => {
const res = await api.get("/scripts/scripts/status");
return res.data;
};
export const fetchScriptStatusForName = async (name: string): Promise<any> => {
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}/status`);
return res.data;
};
export const listScripts = async (): Promise<ScriptInfo[]> => {
const res = await api.get<ScriptInfo[]>("/scripts/scripts");
return res.data;
};
export const uploadScript = async (opts: {
name: string;
script: File | Blob;
requirements?: File | Blob | null;
}): Promise<ScriptInfo> => {
const fd = new FormData();
fd.append("name", opts.name);
fd.append("script", opts.script);
if (opts.requirements) fd.append("requirements", opts.requirements as Blob);
// axios will set multipart/form-data boundary automatically when FormData passed
const res = await api.post<ScriptInfo>("/scripts/scripts", fd, {
headers: { "Content-Type": "multipart/form-data" },
});
return res.data;
};
export const downloadScript = async (name: string): Promise<any> => {
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}`);
return res.data;
};
export const deleteScript = async (name: string, qnum?: number | null): Promise<any> => {
const params: Record<string, any> = {};
if (typeof qnum !== "undefined") params.qnum = qnum;
const res = await api.delete(`/scripts/scripts/${encodeURIComponent(name)}`, { params });
return res.data;
};
export const enableScript = async (name: string, req: EnableRequest): Promise<any> => {
const res = await api.post(`/scripts/scripts/${encodeURIComponent(name)}/enable`, req);
return res.data;
};
export const disableScript = async (name: string, qnum: number): Promise<any> => {
const res = await api.post(`/scripts/scripts/${encodeURIComponent(name)}/disable`, null, {
params: { qnum },
});
return res.data;
};
export default api;