use shared objects like loops db access etc
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s

This commit is contained in:
2025-12-03 20:49:22 +01:00
parent 50225a6dec
commit a18576a61e
8 changed files with 526 additions and 46 deletions

View File

@@ -0,0 +1,401 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import List, Optional
from pyroute2 import IPRoute, NDB
router = APIRouter()
# Globals for lazy initialization
ip: IPRoute | None = None
ndb: NDB | None = None
# ------------------------------
# Pydantic models
# ------------------------------
class InterfaceAddress(BaseModel):
"""
Represents an IP address assigned to a network interface.
"""
family: str = Field(..., description="IP family: 'ipv4' or 'ipv6'.")
address: str = Field(..., description="The IP address assigned to the interface.")
prefixlen: int = Field(..., description="Subnet prefix length (e.g., 24 for 255.255.255.0).")
class InterfaceInfo(BaseModel):
"""
Represents a network interface with all its properties.
"""
ifindex: int = Field(..., description="Interface index (unique identifier assigned by the kernel).")
name: str = Field(..., description="Interface name (e.g., 'eth0', 'enp38s0').")
state: str = Field(..., description="Operational state (e.g., 'UP', 'DOWN', 'UNKNOWN').")
mac: Optional[str] = Field(None, description="MAC address of the interface, if applicable.")
mtu: int = Field(..., description="Maximum Transmission Unit for the interface.")
flags: List[str] = Field(..., description="List of interface flags (e.g., ['BROADCAST', 'MULTICAST']).")
addresses: List[InterfaceAddress] = Field(..., description="List of IP addresses assigned to the interface.")
class RouteInfo(BaseModel):
"""
Represents a single routing table entry.
"""
dst: Optional[str] = Field(
None, description="Destination network in CIDR notation (e.g., '192.168.1.0/24'). None means default route."
)
gateway: Optional[str] = Field(
None, description="Next-hop gateway IP address for this route. None if the route is directly connected."
)
prefsrc: Optional[str] = Field(
None, description="Preferred source IP to use when sending packets via this route."
)
oif: Optional[int] = Field(
None, description="Output interface index (ifindex) for this route. Can be used to look up the interface name."
)
ifname: Optional[str] = Field(
None, description="Name of the interface corresponding to `oif` (e.g., 'eth0')."
)
table: int = Field(
..., description="Routing table ID (e.g., 254 = main, 255 = local)."
)
proto: Optional[int] = Field(
None,
description="Protocol of the route (numeric Linux codes, e.g., 2=kernel, 16=static)."
)
scope: Optional[int] = Field(
None,
description="Scope of the route: 0=global, 253=link, 254=host, 255=nowhere."
)
type: Optional[int] = Field(
None,
description="Type of the route (numeric code): 1=unicast, 2=local, 3=broadcast, 5=multicast."
)
class BridgeInterfaceInfo(BaseModel):
"""
Represents a network interface which is a member of an bridge.
"""
ifindex: int = Field(..., description="Interface index of a bridge member")
ifname: str = Field(..., description="Interface name of a bridge member")
state: Optional[str] = Field(None, description="Operational state of the interface")
mtu: Optional[int] = Field(None, description="MTU of the interface")
class BridgeInfo(BaseModel):
"""
Represents a network bridge interface with all its properties.
"""
ifindex: int = Field(..., description="Interface index of the bridge")
ifname: str = Field(..., description="Bridge interface name")
state: Optional[str] = Field(None, description="Operational state of the bridge")
mtu: Optional[int] = Field(None, description="MTU of the bridge")
stp_state: Optional[int] = Field(None, description="STP (Spanning Tree Protocol) state of the bridge")
members: List[BridgeInterfaceInfo] = Field(default_factory=list, description="List of member interfaces of the bridge")
class BridgeCreateRequest(BaseModel):
name: str
interfaces: List[str]
class BridgeRemoveRequest(BaseModel):
name: str
# ------------------------------
# Lazy Init Functions
# ------------------------------
def init_network_api():
global ip, ndb
if ip is None:
ip = IPRoute()
if ndb is None:
ndb = NDB()
def shutdown_network_api():
global ip, ndb
if ip:
ip.close()
ip = None
if ndb:
ndb.close()
ndb = None
def get_iproute():
if ip is None:
init_network_api()
return ip
def get_ndb():
if ndb is None:
init_network_api()
return ndb
# ------------------------------
# Utility functions
# ------------------------------
def parse_addresses(addrs):
res = []
for a in addrs:
family = "ipv4" if a.get("family") == 2 else "ipv6"
res.append(
InterfaceAddress(
family=family,
address=a.get("address"),
prefixlen=a.get("prefixlen"),
)
)
return res
def parse_flags(flags_int: int) -> list[str]:
"""
Converts the integer flags from pyroute2 to human-readable list of strings.
"""
flags_map = {
0x1: "UP",
0x2: "BROADCAST",
0x4: "DEBUG",
0x8: "LOOPBACK",
0x10: "POINTOPOINT",
0x20: "NOTRAILERS",
0x40: "RUNNING",
0x80: "NOARP",
0x100: "PROMISC",
0x200: "ALLMULTI",
0x400: "MASTER",
0x800: "SLAVE",
0x1000: "MULTICAST",
0x2000: "PORTSEL",
0x4000: "AUTOMEDIA",
0x8000: "DYNAMIC",
0x10000: "LOWER_UP",
0x20000: "DORMANT",
0x40000: "ECHO",
}
result = []
for bit, name in flags_map.items():
if flags_int & bit:
result.append(name)
return result
def iface_index(name: str, ip: IPRoute) -> int:
idx = ip.link_lookup(ifname=name)
if not idx:
raise HTTPException(status_code=404, detail=f"Interface {name} not found")
return idx[0]
def bridge_exists(name: str, ip: IPRoute) -> bool:
return bool(ip.link_lookup(ifname=name))
# ------------------------------
# Endpoints
# ------------------------------
@router.get("/interfaces", response_model=List[InterfaceInfo])
def get_interfaces(ip: IPRoute = Depends(get_iproute)):
result = []
links = ip.get_links()
addresses = ip.get_addr()
addr_map = {}
for a in addresses:
ifindex = a.get("index")
addr_map.setdefault(ifindex, []).append(a)
for link in links:
attrs = dict(link["attrs"])
ifindex = link["index"]
addrs = addr_map.get(ifindex, [])
result.append(
InterfaceInfo(
ifindex=ifindex,
name=attrs.get("IFLA_IFNAME"),
state=attrs.get("IFLA_OPERSTATE", "unknown"),
mac=attrs.get("IFLA_ADDRESS"),
mtu=attrs.get("IFLA_MTU"),
flags=parse_flags(link.get("flags", 0)),
addresses=parse_addresses(addrs),
)
)
return result
@router.get("/routes", response_model=List[RouteInfo])
def get_routes(ip: IPRoute = Depends(get_iproute)):
routes = []
for r in ip.get_routes():
attrs = dict(r["attrs"])
dst = attrs.get("RTA_DST")
gateway = attrs.get("RTA_GATEWAY")
prefsrc = attrs.get("RTA_PREFSRC")
oif = r.get("oif")
ifname = None
if oif is not None:
# translate ifindex → name
link = ip.get_links(oif)[0]
ifname = dict(link["attrs"]).get("IFLA_IFNAME")
routes.append(
RouteInfo(
dst=f"{dst}/{r.get('dst_len')}" if dst else None,
gateway=gateway,
prefsrc=prefsrc,
oif=oif,
ifname=ifname,
table=r.get("table", 254),
proto=r.get("proto"),
scope=r.get("scope"),
type=r.get("type"),
)
)
return routes
@router.get("/links", response_model=List[InterfaceInfo])
def get_raw_links(ip: IPRoute = Depends(get_iproute)):
"""
Returns all interfaces in a clean Pydantic format.
This is similar to /interfaces but avoids additional processing if needed.
"""
result = []
links = ip.get_links()
addresses = ip.get_addr()
# group addresses by interface index
addr_map = {}
for a in addresses:
ifindex = a.get("index")
addr_map.setdefault(ifindex, []).append(a)
for link in links:
attrs = dict(link.get("attrs", [])) # convert list of tuples to dict
ifindex = link["index"]
addrs = addr_map.get(ifindex, [])
result.append(
InterfaceInfo(
ifindex=ifindex,
name=attrs.get("IFLA_IFNAME", "unknown"),
state=attrs.get("IFLA_OPERSTATE", "unknown"),
mac=attrs.get("IFLA_ADDRESS"),
mtu=attrs.get("IFLA_MTU", 0),
flags=[], # latest pyroute2 removed ifi_flags, leave empty
addresses=parse_addresses(addrs),
)
)
return result
@router.get("/bridges", response_model=List[BridgeInfo])
def get_bridges():
"""
Get all bridge interfaces on the system, including their member interfaces.
Returns detailed information:
- Bridge index, name, state, MTU
- STP state
- Member interfaces with index, name, state, and MTU
"""
bridges_list: List[BridgeInfo] = []
with NDB() as ndb:
for br in ndb.interfaces:
# Only bridges
if getattr(br, "kind", None) == "bridge":
members: List[BridgeInterfaceInfo] = []
# Find member interfaces
for iface in ndb.interfaces:
if getattr(iface, "master", None) == br.index:
members.append(
BridgeInterfaceInfo(
ifindex=iface.index,
ifname=iface.ifname,
state=getattr(iface, "operstate", None),
mtu=getattr(iface, "mtu", None)
)
)
bridges_list.append(
BridgeInfo(
ifindex=br.index,
ifname=br.ifname,
state=getattr(br, "operstate", None),
mtu=getattr(br, "mtu", None),
stp_state=getattr(br, "stp_state", None),
members=members
)
)
return bridges_list
@router.get("/full-state")
def full_state(
ip: IPRoute = Depends(get_iproute),
):
"""
Returns the full network state:
- Interfaces with IP addresses and flags
- Routes
- Bridges with member interfaces
"""
return {
"interfaces": get_interfaces(ip),
"routes": get_routes(ip),
"bridges": get_bridges(), # uses NDB internally
}
@router.post("/bridge/create")
def create_bridge(req: BridgeCreateRequest, ip: IPRoute = Depends(get_iproute)):
if bridge_exists(req.name, ip):
raise HTTPException(400, f"Bridge {req.name} already exists")
# Bridge erzeugen
ip.link("add", ifname=req.name, kind="bridge")
br_idx = iface_index(req.name, ip)
# Bridge konfigurieren
# TODO Parameter anpassen (STP, etc.)
ip.link("set", index=br_idx, kind="bridge", br_stp_state=0)
ip.link("set", index=br_idx, state="up")
# Interfaces hinzufügen + aktivieren
for iface in req.interfaces:
idx = iface_index(iface, ip)
# interface hochfahren
ip.link("set", index=idx, state="down") # optional - sicherer
ip.link("set", index=idx, state="up")
# interface in die bridge hängen
ip.link("set", index=idx, master=br_idx)
return {
"status": "ok",
"bridge": req.name,
"interfaces": req.interfaces
}
@router.post("/bridge/remove")
def remove_bridge(req: BridgeRemoveRequest, ip: IPRoute = Depends(get_iproute)):
if not bridge_exists(req.name, ip):
raise HTTPException(404, f"Bridge {req.name} not found")
br_idx = iface_index(req.name, ip)
# Bridge runterfahren
ip.link("set", index=br_idx, state="down")
# Bridge löschen
ip.link("del", index=br_idx)
return {
"status": "ok",
"deleted": req.name
}

View File

@@ -0,0 +1,144 @@
# src/routers/packets.py
import asyncio
import base64
import json
import logging
from typing import Optional, Any, Dict, List
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.responses import JSONResponse
import src.shared_objects as shared
logger = logging.getLogger("packets_router")
router = APIRouter()
def _serialize_row_for_json(row: Dict[str, Any]) -> Dict[str, Any]:
"""
Convert DB row / pkt_info to a JSON-serializable dict.
- If 'raw' is bytes, produce 'raw_b64' and drop 'raw'.
- Fallback to str() for unknown/unserializable values.
"""
out: Dict[str, Any] = {}
for k, v in row.items():
if k == "raw" and isinstance(v, (bytes, bytearray)):
out["raw_b64"] = base64.b64encode(v).decode("ascii")
continue
# try to JSON serialize the value directly
try:
json.dumps({k: v})
out[k] = v
except (TypeError, ValueError):
out[k] = str(v)
return out
async def _serialize_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
return [_serialize_row_for_json(r) for r in rows]
@router.get("/packets")
async def get_packets(limit: int = Query(100, ge=1, le=10000)):
"""
Return latest `limit` packets (newest first). The DB helper already converts
`raw` to `raw_b64` in fetch_latest, but we defensively re-serialize here.
"""
db = shared.db
if db is None:
logger.warning("GET /packets called but DB is not available")
raise HTTPException(status_code=503, detail="Database not available")
try:
rows = await db.fetch_latest(limit)
serial = await _serialize_rows(rows)
return JSONResponse(content={"count": len(serial), "packets": serial})
except Exception:
logger.exception("Failed to fetch latest packets from DB")
raise HTTPException(status_code=500, detail="Failed to fetch packets")
@router.websocket("/ws/packets")
async def websocket_packets(ws: WebSocket):
"""
WebSocket live feed endpoint.
Accepts optional query param `subscribe_recent` (e.g. ?subscribe_recent=20)
which will deliver the last N packets immediately on connect.
"""
await ws.accept()
logger.debug("WebSocket connection accepted: %s", ws.client)
db = shared.db
broadcaster = shared.broadcaster
if db is None:
await ws.send_json({"error": "database not available"})
await ws.close()
logger.warning("WebSocket closed: DB not available")
return
if broadcaster is None:
await ws.send_json({"error": "broadcaster not available"})
await ws.close()
logger.warning("WebSocket closed: broadcaster not available")
return
# Parse subscribe_recent from query params (defensive)
try:
subscribe_recent_raw = ws.query_params.get("subscribe_recent", "0")
subscribe_recent = int(subscribe_recent_raw)
if subscribe_recent < 0:
subscribe_recent = 0
except Exception:
subscribe_recent = 0
q: Optional[asyncio.Queue] = None
try:
# Optionally send recent history first
if subscribe_recent > 0:
recent = await db.fetch_latest(subscribe_recent)
recent_serial = await _serialize_rows(recent)
await ws.send_json({"type": "recent", "count": len(recent_serial), "packets": recent_serial})
# Subscribe to broadcaster to receive live packets
q = await broadcaster.subscribe()
logger.info("WebSocket subscribed client %s (queue maxsize=%d)", ws.client, q.maxsize)
# Simple heartbeat: periodically ensure client is responsive (optional)
# We'll implement by awaiting q.get() which blocks until a message is published.
while True:
msg = await q.get()
# Normalize message to JSON-able dict
if isinstance(msg, dict):
payload = _serialize_row_for_json(msg)
else:
# not a dict — try to json-serialize directly
try:
json.dumps(msg)
payload = msg
except Exception:
payload = {"data": str(msg)}
try:
await ws.send_json(payload)
except Exception:
# sending failed (client disconnected or write error)
logger.info("WebSocket send failed for client %s — unsubscribing", ws.client)
break
except WebSocketDisconnect:
logger.info("WebSocket client disconnected: %s", ws.client)
except Exception:
logger.exception("Unexpected error in websocket_packets")
finally:
# Clean up subscriber queue
if q is not None:
try:
await broadcaster.unsubscribe(q)
except Exception:
logger.exception("Failed to unsubscribe websocket queue")
try:
await ws.close()
except Exception:
pass
logger.debug("WebSocket connection closed and cleaned up for client %s", ws.client)

View File

@@ -0,0 +1,99 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from typing import Dict, Any, List, Optional
from src.network_sniffer import (
get_sniffer_status,
start_afpacket_sniffer,
stop_afpacket_sniffer,
)
router = APIRouter()
# ------------------------------
# Pydantic Models
# ------------------------------
class SnifferStartRequest(BaseModel):
"""
Request model for starting the sniffer on a specific bridge.
"""
bridge: str = Field(..., example="br0", description="Name of the Linux bridge to sniff on")
class SnifferStartResponse(BaseModel):
"""
Response model returned when sniffer starts successfully.
"""
started: bool = Field(..., description="Whether the sniffer was started successfully")
bridge: str = Field(..., description="Bridge where the sniffer was started")
class SnifferStopResponse(BaseModel):
"""
Response model returned when the sniffer stops successfully.
"""
stopped: bool = Field(..., description="Whether the sniffer was stopped successfully")
class InterfaceSnifferStatus(BaseModel):
"""
Status of an individual interface monitored by the AF_PACKET sniffer.
"""
running: bool = Field(..., description="Whether the sniffer thread is active")
exists: bool = Field(..., description="Whether the interface exists in /sys/class/net")
up: bool = Field(..., description="Whether the interface is operationally UP")
class SnifferStatusResponse(BaseModel):
"""
Response model for the sniffer status endpoint.
"""
interfaces: Dict[str, InterfaceSnifferStatus] = Field(
..., description="Map of interface names to their sniffer status"
)
# ------------------------------
# Endpoints
# ------------------------------
@router.post("/sniffer/start", response_model=SnifferStartResponse)
def sniffer_start(req: SnifferStartRequest):
"""
Start the AF_PACKET sniffer for the given bridge.
"""
try:
start_afpacket_sniffer(req.bridge)
return SnifferStartResponse(started=True, bridge=req.bridge)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to start sniffer: {exc}")
@router.post("/sniffer/stop", response_model=SnifferStopResponse)
def sniffer_stop():
"""
Stop the AF_PACKET sniffer (if running).
"""
try:
stop_afpacket_sniffer()
return SnifferStopResponse(stopped=True)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to stop sniffer: {exc}")
@router.get("/sniffer/status", response_model=SnifferStatusResponse)
def sniffer_status():
"""
Return the sniffer status information.
"""
try:
raw = get_sniffer_status()
# Convert raw dict → typed model
typed = {
k: InterfaceSnifferStatus(**v)
for k, v in raw.items()
}
return SnifferStatusResponse(interfaces=typed)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to query sniffer status: {exc}")