websockets and network interface datat added
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 9s

This commit is contained in:
2026-03-09 16:45:01 +01:00
parent e919853d07
commit 40d8b15417
8 changed files with 352 additions and 107 deletions

View File

@@ -1,16 +1,21 @@
"""Network inspection and bridge management endpoints."""
import asyncio
import logging
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, Field
from pyroute2 import IPRoute, NDB
from starlette.websockets import WebSocketState
import src.shared_objects as shared
from src.config import settings
from src.utilities.bridge_link_state_manager import bridge_link_state_manager
from src.utilities.interface_bridge_helpers import get_bridge_ports_once
from src.utilities.interface_bridge_helpers import get_bridge_ports_once, read_interface_ethernet_profile
router = APIRouter()
logger = logging.getLogger("network_router")
ip: IPRoute | None = None
ndb: NDB | None = None
@@ -33,6 +38,10 @@ class InterfaceInfo(BaseModel):
mac: Optional[str] = Field(None, description="MAC address.")
mtu: int = Field(..., description="Maximum transmission unit.")
flags: List[str] = Field(..., description="Decoded interface flags.")
ethernet_profile: Optional[Dict[str, Any]] = Field(
None,
description="Current speed/duplex/autoneg profile when available.",
)
addresses: List[InterfaceAddress] = Field(..., description="Assigned IP addresses.")
@@ -57,6 +66,10 @@ class BridgeInterfaceInfo(BaseModel):
ifname: str = Field(..., description="Interface name.")
state: Optional[str] = Field(None, description="Operational state.")
mtu: Optional[int] = Field(None, description="Interface MTU.")
ethernet_profile: Optional[Dict[str, Any]] = Field(
None,
description="Current speed/duplex/autoneg profile when available.",
)
class BridgeInfo(BaseModel):
@@ -124,10 +137,23 @@ class BridgeLinkStateWatcherStatus(BaseModel):
None,
description="Low-rate fallback recheck interval while the bridge is degraded.",
)
failure_holdoff_seconds: Optional[float] = Field(
None,
description="Transient failure debounce before suppressing siblings.",
)
members: Dict[str, BridgeMemberLinkStateInfo] = Field(default_factory=dict, description="Per-member link-state snapshot.")
message: Optional[str] = Field(None, description="Optional informational message.")
class FullStateResponse(BaseModel):
"""Interfaces, routes, bridges, and active watcher state."""
interfaces: List[InterfaceInfo] = Field(default_factory=list)
routes: List[RouteInfo] = Field(default_factory=list)
bridges: List[BridgeInfo] = Field(default_factory=list)
watchers: List[BridgeLinkStateWatcherStatus] = Field(default_factory=list)
def init_network_api() -> None:
"""Initialize lazy pyroute2 clients."""
global ip, ndb
@@ -222,6 +248,40 @@ def _watcher_status_response(payload: dict[str, Any]) -> BridgeLinkStateWatcherS
return BridgeLinkStateWatcherStatus.model_validate(payload)
def _build_full_state_response(ip_route: Optional[IPRoute] = None) -> FullStateResponse:
"""Build the current full network snapshot used by HTTP and websocket consumers."""
ip_instance = ip_route or get_iproute()
return FullStateResponse(
interfaces=get_interfaces(ip_instance),
routes=get_routes(ip_instance),
bridges=get_bridges(),
watchers=list_bridge_link_state_watchers(),
)
def build_full_state_payload(ip_route: Optional[IPRoute] = None) -> dict[str, Any]:
"""Return the current network snapshot as a JSON-safe dictionary."""
return _build_full_state_response(ip_route).model_dump(mode="json")
def publish_network_state_update(reason: str) -> None:
"""Publish the latest network snapshot to websocket subscribers."""
broadcaster = getattr(shared, "network_broadcaster", None)
if broadcaster is None:
return
try:
broadcaster.sync_publish(
{
"type": "network_state",
"reason": reason,
"snapshot": build_full_state_payload(),
}
)
except Exception:
logger.exception("Failed to publish network state update")
@router.get("/interfaces", response_model=List[InterfaceInfo])
def get_interfaces(ip: IPRoute = Depends(get_iproute)) -> List[InterfaceInfo]:
"""List host interfaces with addresses and decoded flags."""
@@ -247,6 +307,7 @@ def get_interfaces(ip: IPRoute = Depends(get_iproute)) -> List[InterfaceInfo]:
mac=attrs.get("IFLA_ADDRESS"),
mtu=attrs.get("IFLA_MTU"),
flags=parse_flags(link.get("flags", 0)),
ethernet_profile=read_interface_ethernet_profile(attrs.get("IFLA_IFNAME")),
addresses=parse_addresses(addrs),
)
)
@@ -310,6 +371,7 @@ def get_raw_links(ip: IPRoute = Depends(get_iproute)) -> List[InterfaceInfo]:
mac=attrs.get("IFLA_ADDRESS"),
mtu=attrs.get("IFLA_MTU", 0),
flags=[],
ethernet_profile=read_interface_ethernet_profile(attrs.get("IFLA_IFNAME", "unknown")),
addresses=parse_addresses(addrs),
)
)
@@ -336,6 +398,7 @@ def get_bridges() -> List[BridgeInfo]:
ifname=iface.ifname,
state=getattr(iface, "operstate", None),
mtu=getattr(iface, "mtu", None),
ethernet_profile=read_interface_ethernet_profile(iface.ifname),
)
)
@@ -353,14 +416,10 @@ def get_bridges() -> List[BridgeInfo]:
return bridges_list
@router.get("/full-state")
def full_state(ip: IPRoute = Depends(get_iproute)) -> dict:
@router.get("/full-state", response_model=FullStateResponse)
def full_state(ip: IPRoute = Depends(get_iproute)) -> FullStateResponse:
"""Return interfaces, routes, and bridges in one response."""
return {
"interfaces": get_interfaces(ip),
"routes": get_routes(ip),
"bridges": get_bridges(),
}
return _build_full_state_response(ip)
@router.post("/bridge/create")
@@ -380,6 +439,8 @@ def create_bridge(req: BridgeCreateRequest, ip: IPRoute = Depends(get_iproute))
ip.link("set", index=idx, state="up")
ip.link("set", index=idx, master=br_idx)
publish_network_state_update("bridge_created")
return {
"status": "ok",
"bridge": req.name,
@@ -397,6 +458,8 @@ def remove_bridge(req: BridgeRemoveRequest, ip: IPRoute = Depends(get_iproute))
ip.link("set", index=br_idx, state="down")
ip.link("del", index=br_idx)
publish_network_state_update("bridge_removed")
return {
"status": "ok",
"deleted": req.name,
@@ -449,6 +512,7 @@ def enable_bridge_link_state_watcher(
bridge_name=bridge_name,
recovery_holdoff_seconds=req.recovery_holdoff_seconds,
)
publish_network_state_update("bridge_watcher_enabled")
return _watcher_status_response(status)
@@ -461,4 +525,70 @@ def disable_bridge_link_state_watcher(
if not bridge_exists(bridge_name, ip):
raise HTTPException(status_code=404, detail=f"Bridge {bridge_name} not found")
return _watcher_status_response(bridge_link_state_manager.disable(bridge_name))
status = _watcher_status_response(bridge_link_state_manager.disable(bridge_name))
publish_network_state_update("bridge_watcher_disabled")
return status
@router.websocket("/ws/state")
async def websocket_network_state(ws: WebSocket) -> None:
"""Stream full network snapshots to websocket clients whenever the backend publishes updates."""
await ws.accept()
broadcaster = getattr(shared, "network_broadcaster", None)
if broadcaster is None:
await ws.send_json({"error": "network broadcaster not available"})
await ws.close()
return
queue: Optional[asyncio.Queue] = None
try:
await ws.send_json({"type": "network_state", "reason": "initial", "snapshot": build_full_state_payload()})
queue = await broadcaster.subscribe()
while True:
queue_task = asyncio.create_task(queue.get())
receive_task = asyncio.create_task(ws.receive())
done, pending = await asyncio.wait({queue_task, receive_task}, return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
if pending:
await asyncio.gather(*pending, return_exceptions=True)
if receive_task in done:
try:
inbound = receive_task.result()
except WebSocketDisconnect:
break
except Exception:
break
if inbound.get("type") == "websocket.disconnect":
break
if queue_task not in done:
if ws.client_state is not WebSocketState.CONNECTED:
break
continue
message = queue_task.result()
if isinstance(message, dict) and message.get("type") == "__broadcaster_shutdown__":
break
try:
await ws.send_json(message)
except Exception:
break
except WebSocketDisconnect:
pass
finally:
if queue is not None:
try:
await broadcaster.unsubscribe(queue)
except Exception:
logger.exception("Failed to unsubscribe network websocket queue")
try:
await ws.close()
except Exception:
pass