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
|
||||
"""
|
||||
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 typing import Any, Dict, List, Optional, Union
|
||||
from fastapi import FastAPI, APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
import logging
|
||||
import re
|
||||
import json
|
||||
|
||||
# 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)
|
||||
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'.
|
||||
Uses the textual command path.
|
||||
@@ -95,71 +76,51 @@ mgr = NftManager()
|
||||
|
||||
# ---------- Request/Response models ----------
|
||||
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):
|
||||
# ruleset can be either a parsed JSON object (dict/list) or a string
|
||||
ruleset: 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
|
||||
ruleset: RulesetValue = Field(
|
||||
description="Parsed JSON ruleset (dict/list).",
|
||||
)
|
||||
|
||||
|
||||
# ---------- Routes ----------
|
||||
|
||||
# GET /firewall/rules -> textual nft ruleset output or parsed JSON when applicable
|
||||
@router.get("/rules", response_model=RulesetOut)
|
||||
@router.get("/rules", response_model=RulesetOut, summary="List ruleset")
|
||||
def list_rules():
|
||||
"""
|
||||
Returns the textual nft ruleset output (as a string) or native JSON if nft returned JSON.
|
||||
Clients should handle both cases.
|
||||
"""
|
||||
try:
|
||||
text = mgr.list_rules_text()
|
||||
text = mgr.list_rules()
|
||||
if text is None:
|
||||
return {"ruleset": None}
|
||||
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:
|
||||
parsed = json.loads(s)
|
||||
# Return parsed JSON (will be serialized as JSON by FastAPI)
|
||||
# Return parsed JSON (FastAPI will serialize)
|
||||
return {"ruleset": parsed}
|
||||
except json.JSONDecodeError:
|
||||
# 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:
|
||||
logger.exception("list_rules failed")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -168,18 +129,20 @@ def list_rules():
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# POST /firewall/rules -> execute a textual nft command (convenience)
|
||||
@router.post("/rules", status_code=status.HTTP_201_CREATED)
|
||||
@router.post("/rules", response_model=ExecResult, status_code=status.HTTP_201_CREATED, summary="Execute textual rule command")
|
||||
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
|
||||
"""
|
||||
try:
|
||||
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")}
|
||||
rc = int(res.get("rc", -1) or -1)
|
||||
if rc != 0:
|
||||
# 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:
|
||||
logger.warning("create_rule_text failed: %s", 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))
|
||||
|
||||
|
||||
# DELETE /firewall/rules/{handle} -> delete by handle using textual nft command
|
||||
@router.delete("/rules/{handle}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/rules/{handle}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete rule by handle")
|
||||
def delete_rule(handle: int, family: str = "inet", table: str = "filter", chain: str = "input"):
|
||||
"""
|
||||
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:
|
||||
mgr.delete_rule_by_handle_text(family=family, table=table, chain=chain, handle=handle)
|
||||
# 204 No Content — nothing to return
|
||||
except ValueError as e:
|
||||
logger.warning("delete_rule client error: %s", 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))
|
||||
|
||||
|
||||
# Execute arbitrary textual nft command
|
||||
@router.post("/raw")
|
||||
@router.post("/raw", response_model=ExecResult, summary="Execute raw textual nft command")
|
||||
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:
|
||||
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:
|
||||
logger.exception("exec_raw failed")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# include router
|
||||
app.include_router(router)
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
InterfaceInfo,
|
||||
RouteInfo,
|
||||
} from "../types/network";
|
||||
import { SnifferStatusResponse } from "../types/sniffer";
|
||||
import {
|
||||
SnifferStatusResponse,
|
||||
} from "../types/sniffer";
|
||||
|
||||
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[]> => {
|
||||
const res = await api.get<InterfaceInfo[]>("/network/interfaces");
|
||||
return res.data;
|
||||
@@ -60,19 +104,141 @@ export const fetchFullState = async (): Promise<FullState> => {
|
||||
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> => {
|
||||
const res = await api.get<SnifferStatusResponse>("/sniffer/status");
|
||||
return res.data;
|
||||
};
|
||||
|
||||
|
||||
// Mutations
|
||||
export const createBridge = async (req: BridgeCreateRequest) => {
|
||||
const res = await api.post("network/bridge/create", req);
|
||||
/* export const snifferStart = async (
|
||||
req: SnifferStartRequest
|
||||
): Promise<SnifferStartResponse> => {
|
||||
const res = await api.post<SnifferStartResponse>("/sniffer/start", req);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const removeBridge = async (req: BridgeRemoveRequest) => {
|
||||
const res = await api.post("network/bridge/remove", req);
|
||||
export const snifferStop = async (): Promise<SnifferStopResponse> => {
|
||||
const res = await api.post<SnifferStopResponse>("/sniffer/stop");
|
||||
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