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.""" """Network inspection and bridge management endpoints."""
import asyncio
import logging
from typing import Any, Dict, List, Optional 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 pydantic import BaseModel, Field
from pyroute2 import IPRoute, NDB from pyroute2 import IPRoute, NDB
from starlette.websockets import WebSocketState
import src.shared_objects as shared
from src.config import settings from src.config import settings
from src.utilities.bridge_link_state_manager import bridge_link_state_manager 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() router = APIRouter()
logger = logging.getLogger("network_router")
ip: IPRoute | None = None ip: IPRoute | None = None
ndb: NDB | None = None ndb: NDB | None = None
@@ -33,6 +38,10 @@ class InterfaceInfo(BaseModel):
mac: Optional[str] = Field(None, description="MAC address.") mac: Optional[str] = Field(None, description="MAC address.")
mtu: int = Field(..., description="Maximum transmission unit.") mtu: int = Field(..., description="Maximum transmission unit.")
flags: List[str] = Field(..., description="Decoded interface flags.") 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.") addresses: List[InterfaceAddress] = Field(..., description="Assigned IP addresses.")
@@ -57,6 +66,10 @@ class BridgeInterfaceInfo(BaseModel):
ifname: str = Field(..., description="Interface name.") ifname: str = Field(..., description="Interface name.")
state: Optional[str] = Field(None, description="Operational state.") state: Optional[str] = Field(None, description="Operational state.")
mtu: Optional[int] = Field(None, description="Interface MTU.") 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): class BridgeInfo(BaseModel):
@@ -124,10 +137,23 @@ class BridgeLinkStateWatcherStatus(BaseModel):
None, None,
description="Low-rate fallback recheck interval while the bridge is degraded.", 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.") members: Dict[str, BridgeMemberLinkStateInfo] = Field(default_factory=dict, description="Per-member link-state snapshot.")
message: Optional[str] = Field(None, description="Optional informational message.") 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: def init_network_api() -> None:
"""Initialize lazy pyroute2 clients.""" """Initialize lazy pyroute2 clients."""
global ip, ndb global ip, ndb
@@ -222,6 +248,40 @@ def _watcher_status_response(payload: dict[str, Any]) -> BridgeLinkStateWatcherS
return BridgeLinkStateWatcherStatus.model_validate(payload) 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]) @router.get("/interfaces", response_model=List[InterfaceInfo])
def get_interfaces(ip: IPRoute = Depends(get_iproute)) -> List[InterfaceInfo]: def get_interfaces(ip: IPRoute = Depends(get_iproute)) -> List[InterfaceInfo]:
"""List host interfaces with addresses and decoded flags.""" """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"), mac=attrs.get("IFLA_ADDRESS"),
mtu=attrs.get("IFLA_MTU"), mtu=attrs.get("IFLA_MTU"),
flags=parse_flags(link.get("flags", 0)), flags=parse_flags(link.get("flags", 0)),
ethernet_profile=read_interface_ethernet_profile(attrs.get("IFLA_IFNAME")),
addresses=parse_addresses(addrs), addresses=parse_addresses(addrs),
) )
) )
@@ -310,6 +371,7 @@ def get_raw_links(ip: IPRoute = Depends(get_iproute)) -> List[InterfaceInfo]:
mac=attrs.get("IFLA_ADDRESS"), mac=attrs.get("IFLA_ADDRESS"),
mtu=attrs.get("IFLA_MTU", 0), mtu=attrs.get("IFLA_MTU", 0),
flags=[], flags=[],
ethernet_profile=read_interface_ethernet_profile(attrs.get("IFLA_IFNAME", "unknown")),
addresses=parse_addresses(addrs), addresses=parse_addresses(addrs),
) )
) )
@@ -336,6 +398,7 @@ def get_bridges() -> List[BridgeInfo]:
ifname=iface.ifname, ifname=iface.ifname,
state=getattr(iface, "operstate", None), state=getattr(iface, "operstate", None),
mtu=getattr(iface, "mtu", 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 return bridges_list
@router.get("/full-state") @router.get("/full-state", response_model=FullStateResponse)
def full_state(ip: IPRoute = Depends(get_iproute)) -> dict: def full_state(ip: IPRoute = Depends(get_iproute)) -> FullStateResponse:
"""Return interfaces, routes, and bridges in one response.""" """Return interfaces, routes, and bridges in one response."""
return { return _build_full_state_response(ip)
"interfaces": get_interfaces(ip),
"routes": get_routes(ip),
"bridges": get_bridges(),
}
@router.post("/bridge/create") @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, state="up")
ip.link("set", index=idx, master=br_idx) ip.link("set", index=idx, master=br_idx)
publish_network_state_update("bridge_created")
return { return {
"status": "ok", "status": "ok",
"bridge": req.name, "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("set", index=br_idx, state="down")
ip.link("del", index=br_idx) ip.link("del", index=br_idx)
publish_network_state_update("bridge_removed")
return { return {
"status": "ok", "status": "ok",
"deleted": req.name, "deleted": req.name,
@@ -449,6 +512,7 @@ def enable_bridge_link_state_watcher(
bridge_name=bridge_name, bridge_name=bridge_name,
recovery_holdoff_seconds=req.recovery_holdoff_seconds, recovery_holdoff_seconds=req.recovery_holdoff_seconds,
) )
publish_network_state_update("bridge_watcher_enabled")
return _watcher_status_response(status) return _watcher_status_response(status)
@@ -461,4 +525,70 @@ def disable_bridge_link_state_watcher(
if not bridge_exists(bridge_name, ip): if not bridge_exists(bridge_name, ip):
raise HTTPException(status_code=404, detail=f"Bridge {bridge_name} not found") 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

View File

@@ -61,6 +61,11 @@ async def on_startup() -> None:
except Exception: except Exception:
logging.exception("Failed to create/attach broadcaster") logging.exception("Failed to create/attach broadcaster")
try:
shared_objects.network_broadcaster = PacketBroadcaster(loop, queue_maxsize=settings.broadcaster_queue_maxsize)
except Exception:
logging.exception("Failed to create network broadcaster")
try: try:
from src import network_sniffer as sniffer from src import network_sniffer as sniffer
@@ -114,6 +119,12 @@ async def shutdown_event() -> None:
except Exception: except Exception:
logging.exception("Failed to close packet broadcaster during shutdown") logging.exception("Failed to close packet broadcaster during shutdown")
try:
if shared_objects.network_broadcaster is not None:
await shared_objects.network_broadcaster.close()
except Exception:
logging.exception("Failed to close network broadcaster during shutdown")
try: try:
web_db = getattr(shared_objects, "db", None) web_db = getattr(shared_objects, "db", None)
if web_db is not None: if web_db is not None:
@@ -123,6 +134,7 @@ async def shutdown_event() -> None:
shared_objects.db = None shared_objects.db = None
shared_objects.broadcaster = None shared_objects.broadcaster = None
shared_objects.network_broadcaster = None
shared_objects.web_loop = None shared_objects.web_loop = None

View File

@@ -6,3 +6,4 @@ from typing import Any, Optional
db: Any = None db: Any = None
web_loop: Optional[asyncio.AbstractEventLoop] = None web_loop: Optional[asyncio.AbstractEventLoop] = None
broadcaster: Any = None broadcaster: Any = None
network_broadcaster: Any = None

View File

@@ -19,6 +19,7 @@ from src.utilities.interface_bridge_helpers import (
get_bridge_ports_once, get_bridge_ports_once,
read_interface_admin_up, read_interface_admin_up,
read_interface_carrier, read_interface_carrier,
read_interface_ethernet_profile,
read_interface_mtu, read_interface_mtu,
read_interface_operstate, read_interface_operstate,
) )
@@ -255,9 +256,7 @@ class BridgeLinkStateWatcher:
matured_failing_members = self._track_failing_members(failing_members, now) matured_failing_members = self._track_failing_members(failing_members, now)
if not matured_failing_members: if not matured_failing_members:
self._set_last_action( self._set_last_action(f"waiting before suppressing transient failures on {failing_members}")
f"waiting {settings.bridge_link_state_failure_holdoff_seconds:.2f}s before suppressing due to failures={failing_members}"
)
return return
with self._lock: with self._lock:
@@ -270,14 +269,17 @@ class BridgeLinkStateWatcher:
with self._lock: with self._lock:
self._failing_since = {} self._failing_since = {}
should_restore = False should_restore = False
waiting_for_restore = False
with self._lock: with self._lock:
self._degraded = True self._degraded = True
if self._all_clear_since is None: if self._all_clear_since is None:
self._all_clear_since = now self._all_clear_since = now
should_restore = now - self._all_clear_since >= self.recovery_holdoff_seconds should_restore = now - self._all_clear_since >= self.recovery_holdoff_seconds
if not should_restore: if not should_restore:
remaining = max(0.0, self.recovery_holdoff_seconds - (now - self._all_clear_since)) waiting_for_restore = True
self._last_action = f"waiting {remaining:.2f}s before restoring suppressed members"
if waiting_for_restore:
self._set_last_action("waiting before restoring suppressed members")
if should_restore: if should_restore:
self._restore_suppressed_members(reason=reason) self._restore_suppressed_members(reason=reason)
@@ -413,69 +415,36 @@ class BridgeLinkStateWatcher:
def _read_ethernet_profile(self, ifname: str) -> Optional[EthernetProfile]: def _read_ethernet_profile(self, ifname: str) -> Optional[EthernetProfile]:
"""Read ethtool speed/duplex/autoneg for one interface if supported.""" """Read ethtool speed/duplex/autoneg for one interface if supported."""
try: profile = read_interface_ethernet_profile(ifname)
result = subprocess.run( if profile is None:
[_ETHTOOL_BIN, ifname],
capture_output=True,
text=True,
check=True,
)
except (FileNotFoundError, subprocess.CalledProcessError):
return None
values: dict[str, str] = {}
for line in result.stdout.splitlines():
if ":" not in line:
continue
key, value = line.split(":", 1)
values[key.strip()] = value.strip()
speed_value = values.get("Speed")
duplex_value = values.get("Duplex")
autoneg_value = values.get("Auto-negotiation")
speed_mbps: Optional[int] = None
if speed_value and speed_value.endswith("Mb/s"):
try:
speed_mbps = int(speed_value[:-4], 10)
except ValueError:
speed_mbps = None
duplex: Optional[str] = None
if duplex_value and duplex_value.lower() in {"full", "half"}:
duplex = duplex_value.lower()
autoneg: Optional[bool] = None
if autoneg_value:
lowered = autoneg_value.lower()
if lowered in {"on", "off"}:
autoneg = lowered == "on"
if speed_mbps is None and duplex is None and autoneg is None:
return None return None
return EthernetProfile( return EthernetProfile(
speed_mbps=speed_mbps, speed_mbps=profile.get("speed_mbps"),
duplex=duplex, duplex=profile.get("duplex"),
autoneg=autoneg, autoneg=profile.get("autoneg"),
) )
def _sync_member_configuration(self, source_ifname: str, states: dict[str, MemberLinkState]) -> None: def _sync_member_configuration(self, source_ifname: str, states: dict[str, MemberLinkState]) -> None:
"""Mirror MTU and ethtool link settings from one healthy member to its siblings.""" """Mirror MTU and ethtool link settings from one healthy member to its siblings."""
source = states[source_ifname] source = states[source_ifname]
changed_members: list[str] = [] changes: list[str] = []
for target_ifname, target in sorted(states.items()): for target_ifname, target in sorted(states.items()):
if target_ifname == source_ifname: if target_ifname == source_ifname:
continue continue
mtu_changed = self._sync_member_mtu(source_ifname, source, target_ifname, target) mtu_change = self._sync_member_mtu(source_ifname, source, target_ifname, target)
profile_changed = self._sync_member_ethernet_profile(source_ifname, source, target_ifname, target) profile_change = self._sync_member_ethernet_profile(source_ifname, source, target_ifname, target)
if mtu_changed or profile_changed: if mtu_change:
changed_members.append(target_ifname) changes.append(mtu_change)
if profile_change:
changes.append(profile_change)
if changed_members: if changes:
self._set_last_action(f"synchronized configuration from {source_ifname} to {changed_members}") self._set_last_action(f"synchronized from {source_ifname}: {'; '.join(changes)}")
else:
self._set_last_action(f"no configuration mismatch detected after event on {source_ifname}")
def _sync_member_mtu( def _sync_member_mtu(
self, self,
@@ -483,10 +452,10 @@ class BridgeLinkStateWatcher:
source: MemberLinkState, source: MemberLinkState,
target_ifname: str, target_ifname: str,
target: MemberLinkState, target: MemberLinkState,
) -> bool: ) -> Optional[str]:
"""Mirror MTU when the source member differs from the target.""" """Mirror MTU when the source member differs from the target."""
if source.mtu is None or target.mtu is None or source.mtu == target.mtu: if source.mtu is None or target.mtu is None or source.mtu == target.mtu:
return False return None
with IPRoute() as ipr: with IPRoute() as ipr:
indices = ipr.link_lookup(ifname=target_ifname) indices = ipr.link_lookup(ifname=target_ifname)
@@ -502,7 +471,7 @@ class BridgeLinkStateWatcher:
self.bridge_name, self.bridge_name,
source.mtu, source.mtu,
) )
return True return f"{target_ifname} mtu={source.mtu}"
def _sync_member_ethernet_profile( def _sync_member_ethernet_profile(
self, self,
@@ -510,12 +479,12 @@ class BridgeLinkStateWatcher:
source: MemberLinkState, source: MemberLinkState,
target_ifname: str, target_ifname: str,
target: MemberLinkState, target: MemberLinkState,
) -> bool: ) -> Optional[str]:
"""Mirror ethtool speed/duplex/autoneg from the source member to the target.""" """Mirror ethtool speed/duplex/autoneg from the source member to the target."""
source_profile = source.ethernet_profile source_profile = source.ethernet_profile
target_profile = target.ethernet_profile target_profile = target.ethernet_profile
if source_profile is None or target_profile is None or source_profile == target_profile: if source_profile is None or target_profile is None or source_profile == target_profile:
return False return None
cmd = [_ETHTOOL_BIN, "-s", target_ifname] cmd = [_ETHTOOL_BIN, "-s", target_ifname]
if source_profile.autoneg is True: if source_profile.autoneg is True:
@@ -532,7 +501,7 @@ class BridgeLinkStateWatcher:
] ]
) )
else: else:
return False return None
try: try:
subprocess.run(cmd, capture_output=True, text=True, check=True) subprocess.run(cmd, capture_output=True, text=True, check=True)
@@ -544,7 +513,7 @@ class BridgeLinkStateWatcher:
self.bridge_name, self.bridge_name,
exc, exc,
) )
return False return None
self._mark_managed_change(target_ifname) self._mark_managed_change(target_ifname)
logger.info( logger.info(
@@ -554,7 +523,11 @@ class BridgeLinkStateWatcher:
self.bridge_name, self.bridge_name,
source_profile, source_profile,
) )
return True return (
f"{target_ifname} link="
f"{source_profile.speed_mbps or 'unknown'}Mb/{source_profile.duplex or 'unknown'}/"
f"{'autoneg-on' if source_profile.autoneg else 'autoneg-off'}"
)
def _suppress_other_members(self, states: dict[str, MemberLinkState], failing_members: list[str]) -> None: def _suppress_other_members(self, states: dict[str, MemberLinkState], failing_members: list[str]) -> None:
desired_suppressed = set(states) - set(failing_members) desired_suppressed = set(states) - set(failing_members)
@@ -579,7 +552,7 @@ class BridgeLinkStateWatcher:
if changed_members if changed_members
else f"holding suppressed members because failing members={failing_members}" else f"holding suppressed members because failing members={failing_members}"
) )
self._last_action = action self._set_last_action(action)
def _restore_suppressed_members(self, reason: str) -> None: def _restore_suppressed_members(self, reason: str) -> None:
with self._lock: with self._lock:
@@ -600,7 +573,7 @@ class BridgeLinkStateWatcher:
with self._lock: with self._lock:
self._suppressed_members = {} self._suppressed_members = {}
self._all_clear_since = None self._all_clear_since = None
self._last_action = f"restored {restored_members} ({reason})" self._set_last_action(f"restored {restored_members} ({reason})")
def _set_interface_admin_state(self, ifname: str, target_up: bool) -> None: def _set_interface_admin_state(self, ifname: str, target_up: bool) -> None:
if not check_interface_exists(ifname): if not check_interface_exists(ifname):
@@ -625,9 +598,23 @@ class BridgeLinkStateWatcher:
def _set_last_action(self, action: str) -> None: def _set_last_action(self, action: str) -> None:
"""Update the watcher action text.""" """Update the watcher action text."""
changed = False
with self._lock: with self._lock:
changed = action != self._last_action
self._last_action = action self._last_action = action
if changed:
self._publish_network_state_update()
def _publish_network_state_update(self) -> None:
"""Publish a network snapshot after a meaningful watcher state change."""
try:
from src.api.network_api import publish_network_state_update
publish_network_state_update(f"bridge_watcher:{self.bridge_name}")
except Exception:
logger.exception("Failed to publish network state for bridge=%s", self.bridge_name)
class BridgeLinkStateManager: class BridgeLinkStateManager:
"""Track bridge link-state watchers keyed by bridge name.""" """Track bridge link-state watchers keyed by bridge name."""

View File

@@ -1,9 +1,11 @@
from typing import List, Optional from typing import Any, Dict, List, Optional
import logging import logging
import os import os
import subprocess
# ---- Logging ---------------------------------------------------------- # ---- Logging ----------------------------------------------------------
logger = logging.getLogger("af_packet_sniffer") logger = logging.getLogger("af_packet_sniffer")
_ETHTOOL_BIN = "/usr/sbin/ethtool" if os.path.exists("/usr/sbin/ethtool") else "ethtool"
# ------------------------- # -------------------------
# Interface / bridge helpers # Interface / bridge helpers
@@ -86,6 +88,55 @@ def read_interface_mtu(iface: str) -> Optional[int]:
return None return None
def read_interface_ethernet_profile(iface: str) -> Optional[Dict[str, Any]]:
"""Return the current speed/duplex/autoneg profile when ethtool supports it."""
try:
result = subprocess.run(
[_ETHTOOL_BIN, iface],
capture_output=True,
text=True,
check=True,
)
except (FileNotFoundError, subprocess.CalledProcessError):
return None
values: dict[str, str] = {}
for line in result.stdout.splitlines():
if ":" not in line:
continue
key, value = line.split(":", 1)
values[key.strip()] = value.strip()
speed_mbps: Optional[int] = None
speed_value = values.get("Speed")
if speed_value and speed_value.endswith("Mb/s"):
try:
speed_mbps = int(speed_value[:-4], 10)
except ValueError:
speed_mbps = None
duplex: Optional[str] = None
duplex_value = values.get("Duplex")
if duplex_value and duplex_value.lower() in {"full", "half"}:
duplex = duplex_value.lower()
autoneg: Optional[bool] = None
autoneg_value = values.get("Auto-negotiation")
if autoneg_value:
lowered = autoneg_value.lower()
if lowered in {"on", "off"}:
autoneg = lowered == "on"
if speed_mbps is None and duplex is None and autoneg is None:
return None
return {
"speed_mbps": speed_mbps,
"duplex": duplex,
"autoneg": autoneg,
}
def _read_bridge_ports_from_sysfs(bridge: str) -> List[str]: def _read_bridge_ports_from_sysfs(bridge: str) -> List[str]:
""" """
Read bridge member interfaces from sysfs. Internal helper that always reads. Read bridge member interfaces from sysfs. Internal helper that always reads.

View File

@@ -82,6 +82,13 @@ export const fetchFullState = async (): Promise<FullState> => {
return res.data; return res.data;
}; };
export const getNetworkStateWebSocketUrl = (): string => {
const url = new URL(BASE);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.pathname = `${url.pathname.replace(/\/$/, '')}/network/ws/state`;
return url.toString();
};
export const createBridge = async (req: BridgeCreateRequest) => { export const createBridge = async (req: BridgeCreateRequest) => {
const res = await api.post('/network/bridge/create', req); const res = await api.post('/network/bridge/create', req);
return res.data; return res.data;
@@ -92,11 +99,6 @@ export const removeBridge = async (req: BridgeRemoveRequest) => {
return res.data; return res.data;
}; };
export const fetchBridgeLinkStateWatchers = async (): Promise<BridgeLinkStateWatcherStatus[]> => {
const res = await api.get<BridgeLinkStateWatcherStatus[]>('/network/bridge/link-state-watchers');
return res.data;
};
export const enableBridgeLinkStateWatcher = async ( export const enableBridgeLinkStateWatcher = async (
bridgeName: string, bridgeName: string,
req: BridgeLinkStateEnableRequest, req: BridgeLinkStateEnableRequest,

View File

@@ -24,11 +24,12 @@ import {
createBridge, createBridge,
disableBridgeLinkStateWatcher, disableBridgeLinkStateWatcher,
enableBridgeLinkStateWatcher, enableBridgeLinkStateWatcher,
fetchBridgeLinkStateWatchers,
fetchFullState, fetchFullState,
getNetworkStateWebSocketUrl,
removeBridge, removeBridge,
} from '../api/apiClient'; } from '../api/apiClient';
import type { import type {
EthernetProfile,
BridgeInfo, BridgeInfo,
BridgeLinkStateWatcherStatus, BridgeLinkStateWatcherStatus,
FullState, FullState,
@@ -48,15 +49,10 @@ export default function Network() {
const [watcherBusyBridge, setWatcherBusyBridge] = useState<string>(); const [watcherBusyBridge, setWatcherBusyBridge] = useState<string>();
const getNetworkPageState = (silent = false) => { const getNetworkPageState = (silent = false) => {
Promise.all([fetchFullState(), fetchBridgeLinkStateWatchers()]) fetchFullState()
.then(([state, watchers]) => { .then((state) => {
setNetworkState(state); setNetworkState(state);
setWatcherStatuses( setWatcherStatuses(indexWatchers(state.watchers ?? []));
watchers.reduce<Record<string, BridgeLinkStateWatcherStatus>>((acc, watcher) => {
acc[watcher.bridge] = watcher;
return acc;
}, {}),
);
if (!silent) { if (!silent) {
notification.success({ notification.success({
message: 'Success', message: 'Success',
@@ -73,26 +69,70 @@ export default function Network() {
}); });
}; };
function indexWatchers(watchers: BridgeLinkStateWatcherStatus[]) {
return watchers.reduce<Record<string, BridgeLinkStateWatcherStatus>>((acc, watcher) => {
acc[watcher.bridge] = watcher;
return acc;
}, {});
}
function applySnapshot(state: FullState) {
setNetworkState(state);
setWatcherStatuses(indexWatchers(state.watchers ?? []));
}
useEffect(() => { useEffect(() => {
getNetworkPageState(true); getNetworkPageState(true);
}, []); }, []);
const hasActiveWatchers = useMemo(
() => Object.values(watcherStatuses).some((watcher) => watcher.active),
[watcherStatuses],
);
useEffect(() => { useEffect(() => {
if (!hasActiveWatchers) { let reconnectTimer: number | undefined;
return undefined; let socket: WebSocket | undefined;
let cancelled = false;
const connect = () => {
socket = new WebSocket(getNetworkStateWebSocketUrl());
socket.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload?.type === 'network_state' && payload.snapshot) {
applySnapshot(payload.snapshot as FullState);
}
} catch (error) {
console.error('Failed to parse network websocket payload:', error);
}
};
socket.onclose = () => {
if (cancelled) {
return;
}
reconnectTimer = window.setTimeout(connect, 2000);
};
};
connect();
return () => {
cancelled = true;
if (reconnectTimer) {
window.clearTimeout(reconnectTimer);
}
socket?.close();
};
}, []);
function renderEthernetProfile(profile?: EthernetProfile | null) {
if (!profile) {
return '—';
} }
const intervalId = window.setInterval(() => { const speed = profile.speed_mbps ? `${profile.speed_mbps} Mb/s` : 'speed ?';
getNetworkPageState(true); const duplex = profile.duplex ?? 'duplex ?';
}, 2000); const autoneg = typeof profile.autoneg === 'boolean' ? (profile.autoneg ? 'autoneg on' : 'autoneg off') : 'autoneg ?';
return `${speed}, ${duplex}, ${autoneg}`;
return () => window.clearInterval(intervalId); }
}, [hasActiveWatchers]);
const interfaceOptions = useMemo( const interfaceOptions = useMemo(
() => () =>
@@ -129,6 +169,13 @@ export default function Network() {
width: 100, width: 100,
render: (value: boolean | null | undefined) => <Tag color={value ? 'green' : 'default'}>{value ? 'up' : 'down'}</Tag>, render: (value: boolean | null | undefined) => <Tag color={value ? 'green' : 'default'}>{value ? 'up' : 'down'}</Tag>,
}, },
{ title: 'MTU', dataIndex: 'mtu', key: 'mtu', width: 90, render: (value: number | null | undefined) => value ?? '—' },
{
title: 'Link',
dataIndex: 'ethernet_profile',
key: 'ethernet_profile',
render: (value: EthernetProfile | null | undefined) => renderEthernetProfile(value),
},
{ {
title: 'Operstate', title: 'Operstate',
dataIndex: 'operstate', dataIndex: 'operstate',
@@ -157,6 +204,12 @@ export default function Network() {
{ title: 'State', dataIndex: 'state', key: 'state', render: (state) => <Tag>{state}</Tag> }, { title: 'State', dataIndex: 'state', key: 'state', render: (state) => <Tag>{state}</Tag> },
{ title: 'MAC', dataIndex: 'mac', key: 'mac', render: (mac) => mac ?? '—' }, { title: 'MAC', dataIndex: 'mac', key: 'mac', render: (mac) => mac ?? '—' },
{ title: 'MTU', dataIndex: 'mtu', key: 'mtu', width: 90 }, { title: 'MTU', dataIndex: 'mtu', key: 'mtu', width: 90 },
{
title: 'Link',
dataIndex: 'ethernet_profile',
key: 'ethernet_profile',
render: (value: EthernetProfile | null | undefined) => renderEthernetProfile(value),
},
{ {
title: 'Addresses', title: 'Addresses',
dataIndex: 'addresses', dataIndex: 'addresses',
@@ -435,6 +488,9 @@ export default function Network() {
<Descriptions size="small" column={4} style={{ marginBottom: members.length ? 12 : 0 }}> <Descriptions size="small" column={4} style={{ marginBottom: members.length ? 12 : 0 }}>
<Descriptions.Item label="Members">{bridge.members.map((member) => member.ifname).join(', ') || '—'}</Descriptions.Item> <Descriptions.Item label="Members">{bridge.members.map((member) => member.ifname).join(', ') || '—'}</Descriptions.Item>
<Descriptions.Item label="Mode">{watcher?.event_driven ? 'netlink events' : '—'}</Descriptions.Item> <Descriptions.Item label="Mode">{watcher?.event_driven ? 'netlink events' : '—'}</Descriptions.Item>
<Descriptions.Item label="Failure holdoff">
{watcher?.failure_holdoff_seconds ? `${watcher.failure_holdoff_seconds}s` : '—'}
</Descriptions.Item>
<Descriptions.Item label="Recovery holdoff"> <Descriptions.Item label="Recovery holdoff">
{watcher?.recovery_holdoff_seconds ? `${watcher.recovery_holdoff_seconds}s` : '—'} {watcher?.recovery_holdoff_seconds ? `${watcher.recovery_holdoff_seconds}s` : '—'}
</Descriptions.Item> </Descriptions.Item>

View File

@@ -4,6 +4,12 @@ export interface InterfaceAddress {
prefixlen: number; prefixlen: number;
} }
export interface EthernetProfile {
speed_mbps?: number | null;
duplex?: string | null;
autoneg?: boolean | null;
}
export interface InterfaceInfo { export interface InterfaceInfo {
ifindex: number; ifindex: number;
name: string; name: string;
@@ -11,6 +17,7 @@ export interface InterfaceInfo {
mac?: string | null; mac?: string | null;
mtu: number; mtu: number;
flags: string[]; flags: string[];
ethernet_profile?: EthernetProfile | null;
addresses: InterfaceAddress[]; addresses: InterfaceAddress[];
} }
@@ -31,6 +38,7 @@ export interface BridgeInterfaceInfo {
ifname: string; ifname: string;
state?: string | null; state?: string | null;
mtu?: number | null; mtu?: number | null;
ethernet_profile?: EthernetProfile | null;
} }
export interface BridgeInfo { export interface BridgeInfo {
@@ -61,11 +69,7 @@ export interface BridgeMemberLinkStateInfo {
carrier_up?: boolean | null; carrier_up?: boolean | null;
operstate?: string | null; operstate?: string | null;
mtu?: number | null; mtu?: number | null;
ethernet_profile?: { ethernet_profile?: EthernetProfile | null;
speed_mbps?: number | null;
duplex?: string | null;
autoneg?: boolean | null;
} | null;
link_ready: boolean; link_ready: boolean;
suppressed: boolean; suppressed: boolean;
} }
@@ -78,6 +82,7 @@ export interface BridgeLinkStateWatcherStatus {
last_error?: string | null; last_error?: string | null;
last_action?: string | null; last_action?: string | null;
suppressed_members: string[]; suppressed_members: string[];
failure_holdoff_seconds?: number | null;
recovery_holdoff_seconds?: number | null; recovery_holdoff_seconds?: number | null;
degraded_recheck_seconds?: number | null; degraded_recheck_seconds?: number | null;
members: Record<string, BridgeMemberLinkStateInfo>; members: Record<string, BridgeMemberLinkStateInfo>;
@@ -89,4 +94,5 @@ export interface FullState {
interfaces: InterfaceInfo[]; interfaces: InterfaceInfo[];
routes: RouteInfo[]; routes: RouteInfo[];
bridges: BridgeInfo[]; bridges: BridgeInfo[];
watchers: BridgeLinkStateWatcherStatus[];
} }