added more types
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 10s
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user