From 5ebe16b8540902c634ec260e260b1fff73b3b0ce Mon Sep 17 00:00:00 2001 From: malmert Date: Tue, 10 Feb 2026 21:01:43 +0100 Subject: [PATCH] added more types --- backend/src/api/nft_manager.py | 118 ++++++++------------- frontend/src/api/apiClient.ts | 182 +++++++++++++++++++++++++++++++-- 2 files changed, 214 insertions(+), 86 deletions(-) diff --git a/backend/src/api/nft_manager.py b/backend/src/api/nft_manager.py index 5056bc5..8e6058a 100644 --- a/backend/src/api/nft_manager.py +++ b/backend/src/api/nft_manager.py @@ -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) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 304ba77..be37f05 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -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 => { + const res = await api.get("/hello"); + return res.data; +}; + +export const fetchVersions = async (): Promise => { + const res = await api.get("/versions"); + return res.data; +}; + +/* ------------------------- + Network queries (existing) + ------------------------- */ + export const fetchInterfaces = async (): Promise => { const res = await api.get("/network/interfaces"); return res.data; @@ -60,19 +104,141 @@ export const fetchFullState = async (): Promise => { 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 => { const res = await api.get("/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 => { + const res = await api.post("/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 => { + const res = await api.post("/sniffer/stop"); return res.data; }; + */ +/* ------------------------- + Packets + ------------------------- */ + +export const fetchPackets = async (limit = 100): Promise => { + // limit default mirrors OpenAPI default + const res = await api.get("/packets/packets", { params: { limit } }); + return res.data; +}; + +/* ------------------------- + Firewall + ------------------------- */ + +export const listFirewallRules = async (): Promise => { + const res = await api.get("/firewall/rules"); + return res.data; +}; + +export const createFirewallRuleText = async (req: RawCmdRequest): Promise => { + // 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 => { + // returns 204 on success (no content) + const params: Record = {}; + 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 => { + // 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 => { + const res = await api.get("/scripts/scripts/status"); + return res.data; +}; + +export const fetchScriptStatusForName = async (name: string): Promise => { + const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}/status`); + return res.data; +}; + +export const listScripts = async (): Promise => { + const res = await api.get("/scripts/scripts"); + return res.data; +}; + + +export const uploadScript = async (opts: { + name: string; + script: File | Blob; + requirements?: File | Blob | null; +}): Promise => { + 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("/scripts/scripts", fd, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return res.data; +}; + +export const downloadScript = async (name: string): Promise => { + const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}`); + return res.data; +}; + +export const deleteScript = async (name: string, qnum?: number | null): Promise => { + const params: Record = {}; + 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 => { + const res = await api.post(`/scripts/scripts/${encodeURIComponent(name)}/enable`, req); + return res.data; +}; + +export const disableScript = async (name: string, qnum: number): Promise => { + const res = await api.post(`/scripts/scripts/${encodeURIComponent(name)}/disable`, null, { + params: { qnum }, + }); + return res.data; +}; + +export default api;