file structure and comments unified
Some checks failed
Build and Deploy MITM Webserver / build (push) Failing after 3s

This commit is contained in:
2026-03-06 19:03:26 +01:00
parent 7b9a7d3a4b
commit b60a1d3118
20 changed files with 697 additions and 1823 deletions

View File

@@ -1,13 +1,20 @@
"""Netplan schema models used by bridge/network configuration APIs."""
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
from typing import List, Dict, Optional
class Nameservers(BaseModel):
"""DNS nameserver configuration."""
addresses: List[str] = Field(default_factory=list)
search: List[str] = Field(default_factory=list)
class EthernetConfig(BaseModel):
"""Netplan ethernet interface configuration."""
dhcp4: Optional[bool] = None
dhcp6: Optional[bool] = None
addresses: Optional[List[str]] = None
@@ -18,44 +25,23 @@ class EthernetConfig(BaseModel):
class BridgeConfig(BaseModel):
interfaces: List[str] = Field(default_factory=list) # ["eth1", "eth2"]
"""Netplan bridge configuration."""
interfaces: List[str] = Field(default_factory=list)
dhcp4: Optional[bool] = None
dhcp6: Optional[bool] = None
addresses: Optional[List[str]] = None
gateway4: Optional[str] = None
gateway6: Optional[str] = None
nameservers: Optional[Nameservers] = None
parameters: Optional[dict] = None # allows spanning-tree, port-priority, forward-delay, etc.
parameters: Optional[dict] = None
optional: Optional[bool] = None
class NetworkConfig(BaseModel):
"""Top-level Netplan network object."""
version: int = 2
renderer: Optional[str] = "networkd"
ethernets: Dict[str, EthernetConfig] = Field(default_factory=dict)
bridges: Dict[str, BridgeConfig] = Field(default_factory=dict)
'''Example usage:
{
"version": 2,
"renderer": "networkd",
"ethernets": {
"eth0": {
"dhcp4": false,
"addresses": ["192.168.10.20/24"],
"gateway4": "192.168.10.1",
"nameservers": {
"addresses": ["1.1.1.1", "8.8.8.8"]
}
},
"eth1": {},
"eth2": {}
},
"bridges": {
"br0": {
"interfaces": ["eth1", "eth2"],
"dhcp4": true
}
}
}'''

View File

@@ -1,12 +1,16 @@
# src/models/packet.py
from datetime import datetime
"""Pydantic model for packet rows returned by the backend."""
from pydantic import BaseModel, Field, IPvAnyAddress
from datetime import datetime
from typing import Optional, Union
from pydantic import BaseModel, Field, IPvAnyAddress
class PacketDBModel(BaseModel):
"""Normalized packet representation used across DB and API layers."""
id: Union[int, str]
timestamp: datetime = Field(..., description="ISO timestamp")
timestamp: datetime = Field(..., description="Packet timestamp in ISO format.")
iface: str
src_mac: Optional[str] = None
dst_mac: Optional[str] = None
@@ -18,7 +22,7 @@ class PacketDBModel(BaseModel):
dst_port: Optional[int] = None
vlan_id: Optional[int] = None
length: Optional[int] = None
raw_b64: Optional[str] = Field(None, description="Base64-encoded raw bytes")
raw_b64: Optional[str] = Field(None, description="Base64-encoded packet bytes.")
direction: Optional[str] = None
packets: Optional[int] = None
@@ -40,4 +44,4 @@ class PacketDBModel(BaseModel):
"length": 128,
"raw_b64": "BASE64...",
}
}
}

View File

@@ -1,122 +1,95 @@
"""Network inspection and bridge management endpoints."""
from typing import List, Optional
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.
"""
"""IP address assigned to an 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).")
address: str = Field(..., description="IP address.")
prefixlen: int = Field(..., description="Subnet prefix length.")
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.")
"""Interface with link metadata and assigned addresses."""
ifindex: int = Field(..., description="Kernel interface index.")
name: str = Field(..., description="Interface name.")
state: str = Field(..., description="Operational state.")
mac: Optional[str] = Field(None, description="MAC address.")
mtu: int = Field(..., description="Maximum transmission unit.")
flags: List[str] = Field(..., description="Decoded interface flags.")
addresses: List[InterfaceAddress] = Field(..., description="Assigned IP addresses.")
class RouteInfo(BaseModel):
"""
Represents a single routing table entry.
"""
"""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."
)
dst: Optional[str] = Field(None, description="Destination CIDR; null means default route.")
gateway: Optional[str] = Field(None, description="Next-hop gateway.")
prefsrc: Optional[str] = Field(None, description="Preferred source IP.")
oif: Optional[int] = Field(None, description="Output interface index.")
ifname: Optional[str] = Field(None, description="Output interface name.")
table: int = Field(..., description="Route table ID.")
proto: Optional[int] = Field(None, description="Route protocol code.")
scope: Optional[int] = Field(None, description="Route scope code.")
type: Optional[int] = Field(None, description="Route type code.")
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")
"""Interface that belongs to a bridge."""
ifindex: int = Field(..., description="Interface index.")
ifname: str = Field(..., description="Interface name.")
state: Optional[str] = Field(None, description="Operational state.")
mtu: Optional[int] = Field(None, description="Interface MTU.")
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")
"""Bridge interface with member information."""
ifindex: int = Field(..., description="Bridge index.")
ifname: str = Field(..., description="Bridge name.")
state: Optional[str] = Field(None, description="Bridge state.")
mtu: Optional[int] = Field(None, description="Bridge MTU.")
stp_state: Optional[int] = Field(None, description="Spanning tree state.")
members: List[BridgeInterfaceInfo] = Field(default_factory=list, description="Bridge members.")
class BridgeCreateRequest(BaseModel):
"""Payload for creating a bridge and attaching interfaces."""
name: str
interfaces: List[str]
class BridgeRemoveRequest(BaseModel):
name: str
# ------------------------------
# Lazy Init Functions
# ------------------------------
"""Payload for removing a bridge."""
def init_network_api():
name: str
def init_network_api() -> None:
"""Initialize lazy pyroute2 clients."""
global ip, ndb
if ip is None:
ip = IPRoute()
if ndb is None:
ndb = NDB()
def shutdown_network_api():
def shutdown_network_api() -> None:
"""Close pyroute2 clients if they were initialized."""
global ip, ndb
if ip:
ip.close()
@@ -125,37 +98,38 @@ def shutdown_network_api():
ndb.close()
ndb = None
def get_iproute():
def get_iproute() -> IPRoute:
"""Dependency provider for the shared IPRoute instance."""
if ip is None:
init_network_api()
return ip
def get_ndb():
def get_ndb() -> NDB:
"""Dependency provider for the shared NDB instance."""
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(
def parse_addresses(addrs: list[dict]) -> list[InterfaceAddress]:
"""Convert pyroute2 address rows into `InterfaceAddress` models."""
result: list[InterfaceAddress] = []
for addr in addrs:
family = "ipv4" if addr.get("family") == 2 else "ipv6"
result.append(
InterfaceAddress(
family=family,
address=a.get("address"),
prefixlen=a.get("prefixlen"),
address=addr.get("address"),
prefixlen=addr.get("prefixlen"),
)
)
return res
return result
def parse_flags(flags_int: int) -> list[str]:
"""
Converts the integer flags from pyroute2 to human-readable list of strings.
"""
"""Decode Linux interface flag bitset to names."""
flags_map = {
0x1: "UP",
0x2: "BROADCAST",
@@ -177,37 +151,33 @@ def parse_flags(flags_int: int) -> list[str]:
0x20000: "DORMANT",
0x40000: "ECHO",
}
result = []
for bit, name in flags_map.items():
if flags_int & bit:
result.append(name)
return result
return [name for bit, name in flags_map.items() if flags_int & bit]
def iface_index(name: str, ip: IPRoute) -> int:
idx = ip.link_lookup(ifname=name)
def iface_index(name: str, ip_route: IPRoute) -> int:
"""Return interface index for a given interface name."""
idx = ip_route.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))
def bridge_exists(name: str, ip_route: IPRoute) -> bool:
"""Check whether a bridge/device with the given name exists."""
return bool(ip_route.link_lookup(ifname=name))
# ------------------------------
# Endpoints
# ------------------------------
@router.get("/interfaces", response_model=List[InterfaceInfo])
def get_interfaces(ip: IPRoute = Depends(get_iproute)):
result = []
def get_interfaces(ip: IPRoute = Depends(get_iproute)) -> List[InterfaceInfo]:
"""List host interfaces with addresses and decoded flags."""
result: list[InterfaceInfo] = []
links = ip.get_links()
addresses = ip.get_addr()
addr_map = {}
for a in addresses:
ifindex = a.get("index")
addr_map.setdefault(ifindex, []).append(a)
addr_map: dict[int, list] = {}
for addr in addresses:
ifindex = addr.get("index")
addr_map.setdefault(ifindex, []).append(addr)
for link in links:
attrs = dict(link["attrs"])
@@ -227,55 +197,53 @@ def get_interfaces(ip: IPRoute = Depends(get_iproute)):
)
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"])
def get_routes(ip: IPRoute = Depends(get_iproute)) -> List[RouteInfo]:
"""List routes from the kernel routing tables."""
routes: list[RouteInfo] = []
for route in ip.get_routes():
attrs = dict(route["attrs"])
dst = attrs.get("RTA_DST")
gateway = attrs.get("RTA_GATEWAY")
prefsrc = attrs.get("RTA_PREFSRC")
oif = r.get("oif")
oif = route.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,
dst=f"{dst}/{route.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"),
table=route.get("table", 254),
proto=route.get("proto"),
scope=route.get("scope"),
type=route.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 = []
def get_raw_links(ip: IPRoute = Depends(get_iproute)) -> List[InterfaceInfo]:
"""List links in a normalized structure for UI consumers."""
result: list[InterfaceInfo] = []
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)
addr_map: dict[int, list] = {}
for addr in addresses:
ifindex = addr.get("index")
addr_map.setdefault(ifindex, []).append(addr)
for link in links:
attrs = dict(link.get("attrs", [])) # convert list of tuples to dict
attrs = dict(link.get("attrs", []))
ifindex = link["index"]
addrs = addr_map.get(ifindex, [])
@@ -286,116 +254,95 @@ def get_raw_links(ip: IPRoute = Depends(get_iproute)):
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
flags=[],
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] = []
def get_bridges() -> List[BridgeInfo]:
"""List all bridges and their current member interfaces."""
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)
)
with NDB() as ndb_ctx:
for bridge in ndb_ctx.interfaces:
if getattr(bridge, "kind", None) != "bridge":
continue
members: list[BridgeInterfaceInfo] = []
for iface in ndb_ctx.interfaces:
if getattr(iface, "master", None) == bridge.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
)
bridges_list.append(
BridgeInfo(
ifindex=bridge.index,
ifname=bridge.ifname,
state=getattr(bridge, "operstate", None),
mtu=getattr(bridge, "mtu", None),
stp_state=getattr(bridge, "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
"""
def full_state(ip: IPRoute = Depends(get_iproute)) -> dict:
"""Return interfaces, routes, and bridges in one response."""
return {
"interfaces": get_interfaces(ip),
"routes": get_routes(ip),
"bridges": get_bridges(), # uses NDB internally
"bridges": get_bridges(),
}
@router.post("/bridge/create")
def create_bridge(req: BridgeCreateRequest, ip: IPRoute = Depends(get_iproute)):
if bridge_exists(req.name, ip):
raise HTTPException(400, detail=f"Bridge {req.name} already exists")
# Bridge erzeugen
@router.post("/bridge/create")
def create_bridge(req: BridgeCreateRequest, ip: IPRoute = Depends(get_iproute)) -> dict:
"""Create a bridge and attach listed interfaces."""
if bridge_exists(req.name, ip):
raise HTTPException(status_code=400, detail=f"Bridge {req.name} already exists")
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="down")
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
"interfaces": req.interfaces,
}
@router.post("/bridge/remove")
def remove_bridge(req: BridgeRemoveRequest, ip: IPRoute = Depends(get_iproute)):
def remove_bridge(req: BridgeRemoveRequest, ip: IPRoute = Depends(get_iproute)) -> dict:
"""Detach and remove a bridge by name."""
if not bridge_exists(req.name, ip):
raise HTTPException(404, f"Bridge {req.name} not found")
raise HTTPException(status_code=404, detail=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
}
"deleted": req.name,
}

View File

@@ -1,96 +1,72 @@
# src/routers/packets.py
"""Packet history and streaming endpoints."""
import asyncio
import base64
import json
import logging
from typing import Optional, Any, Dict, List, Union
from typing import Any, Dict, List, Optional, Union
from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from src.Models.packets import PacketDBModel
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.responses import JSONResponse
import src.shared_objects as shared
from src.Models.packets import PacketDBModel
logger = logging.getLogger("packets_router")
router = APIRouter()
def _serialize_row_for_json(row: Union[Dict[str, Any], PacketDBModel, BaseModel]) -> Dict[str, Any]:
"""
Convert a DB row or PacketDBModel into a JSON-serializable dict.
- If `row` is a Pydantic model (PacketDBModel or BaseModel), use `.dict()` to get a plain dict.
- If `raw` is bytes/bytearray, produce `raw_b64` and drop `raw`.
- If `raw_b64` already exists, do not re-encode.
- For values that cannot be JSON serialized, fall back to str(value).
"""
# If given a Pydantic model, convert to dict first
"""Convert one packet row to a JSON-safe dictionary."""
if isinstance(row, BaseModel):
d: Dict[str, Any] = row.dict(by_alias=True, exclude_none=True)
raw_dict: Dict[str, Any] = row.dict(by_alias=True, exclude_none=True)
else:
# copy to avoid mutating caller's dict
d = dict(row)
raw_dict = dict(row)
# If raw_b64 already present, prefer it. If raw present and bytes, convert.
raw_val = d.get("raw")
raw_val = raw_dict.get("raw")
if raw_val is not None and isinstance(raw_val, (bytes, bytearray)):
# convert to base64 string and remove raw
try:
d["raw_b64"] = base64.b64encode(raw_val).decode("ascii")
d.pop("raw", None)
raw_dict["raw_b64"] = base64.b64encode(raw_val).decode("ascii")
raw_dict.pop("raw", None)
except Exception:
# keep raw as str fallback
try:
d["raw_b64"] = base64.b64encode(bytes(raw_val)).decode("ascii")
d.pop("raw", None)
raw_dict["raw_b64"] = base64.b64encode(bytes(raw_val)).decode("ascii")
raw_dict.pop("raw", None)
except Exception:
logger.exception("Failed to base64-encode raw bytes for row id=%s", d.get("id"))
d["raw_b64"] = str(raw_val)
d.pop("raw", None)
logger.exception("Failed to base64-encode raw bytes for row id=%s", raw_dict.get("id"))
raw_dict["raw_b64"] = str(raw_val)
raw_dict.pop("raw", None)
# Ensure final dict is JSON-safe: try json.dumps on each value, fallback to str()
out: Dict[str, Any] = {}
for k, v in d.items():
# skip any private/internal keys if needed (optional)
# if k.startswith("_"):
# continue
# raw_b64: ensure it's a str
if k == "raw_b64" and isinstance(v, (bytes, bytearray)):
output: Dict[str, Any] = {}
for key, value in raw_dict.items():
if key == "raw_b64" and isinstance(value, (bytes, bytearray)):
try:
out["raw_b64"] = base64.b64encode(v).decode("ascii")
continue
output["raw_b64"] = base64.b64encode(value).decode("ascii")
except Exception:
out["raw_b64"] = str(v)
continue
output["raw_b64"] = str(value)
continue
# JSON-serializable check
try:
json.dumps({k: v})
out[k] = v
json.dumps({key: value})
output[key] = value
except (TypeError, ValueError):
# convert non-serializable to string representation
try:
out[k] = str(v)
output[key] = str(value)
except Exception:
out[k] = "<unserializable>"
return out
output[key] = "<unserializable>"
return output
async def _serialize_rows(rows: List[Union[Dict[str, Any], PacketDBModel]]) -> List[Dict[str, Any]]:
"""
Serialize a list of DB rows or PacketDBModel instances into JSON-ready dicts.
Keeps the same order as input.
"""
return [_serialize_row_for_json(r) for r in rows]
"""Convert packet rows to JSON-safe dictionaries, preserving order."""
return [_serialize_row_for_json(row) for row 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.
"""
async def get_packets(limit: int = Query(100, ge=1, le=10000)) -> JSONResponse:
"""Return the latest packets in reverse chronological order."""
db = shared.db
if db is None:
logger.warning("GET /packets called but DB is not available")
@@ -98,21 +74,16 @@ async def get_packets(limit: int = Query(100, ge=1, le=10000)):
try:
rows = await db.fetch_latest(limit)
serial = await _serialize_rows(rows)
return JSONResponse(content={"count": len(serial), "packets": serial})
except Exception:
serialized = await _serialize_rows(rows)
return JSONResponse(content={"count": len(serialized), "packets": serialized})
except Exception as exc:
logger.exception("Failed to fetch latest packets from DB")
raise HTTPException(status_code=500, detail="Failed to fetch packets")
raise HTTPException(status_code=500, detail="Failed to fetch packets") from exc
@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.
"""
async def websocket_packets(ws: WebSocket) -> None:
"""Stream live packets to a websocket client."""
await ws.accept()
logger.debug("WebSocket connection accepted: %s", ws.client)
@@ -131,86 +102,75 @@ async def websocket_packets(ws: WebSocket):
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
subscribe_recent = max(subscribe_recent, 0)
except Exception:
subscribe_recent = 0
q: Optional[asyncio.Queue] = None
queue: 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})
recent_serialized = await _serialize_rows(recent)
await ws.send_json({"type": "recent", "count": len(recent_serialized), "packets": recent_serialized})
# Subscribe to broadcaster to receive live packets
q = await broadcaster.subscribe()
logger.info("WebSocket subscribed client %s (queue maxsize=%d)", ws.client, q.maxsize)
queue = await broadcaster.subscribe()
logger.info("WebSocket subscribed client %s (queue maxsize=%d)", ws.client, queue.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)
message = await queue.get()
if isinstance(message, dict):
payload: Any = _serialize_row_for_json(message)
else:
# not a dict — try to json-serialize directly
try:
json.dumps(msg)
payload = msg
json.dumps(message)
payload = message
except Exception:
payload = {"data": str(msg)}
payload = {"data": str(message)}
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)
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:
if queue is not None:
try:
await broadcaster.unsubscribe(q)
await broadcaster.unsubscribe(queue)
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)
logger.debug("WebSocket connection cleaned up for client %s", ws.client)
@router.delete("/packets")
async def clear_packets(reset_id: bool = Query(True)):
"""
Clear all packet logs from the database.
Uses TRUNCATE internally for high performance.
"""
async def clear_packets(reset_id: bool = Query(True)) -> JSONResponse:
"""Remove all packet rows from the database."""
db = shared.db
if db is None:
raise HTTPException(status_code=503, detail="Database not available")
success = await db.clear_all_packets(reset_identity=reset_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to clear packet table")
logger.info("User initiated clear_packets (reset_id=%s)", reset_id)
return JSONResponse(
content={
"status": "success",
"status": "success",
"message": "All packets have been cleared",
"reset_id": reset_id
"reset_id": reset_id,
}
)

View File

@@ -1,7 +1,9 @@
# src/routers/sniffer.py
from fastapi import APIRouter, HTTPException, Query, Body
"""HTTP API for starting, stopping, and inspecting sniffer sessions."""
from typing import Any, Dict, Optional
from fastapi import APIRouter, Body, HTTPException, Query
from pydantic import BaseModel, Field
from typing import Dict, Any, Optional
from src.network_sniffer import (
get_sniffer_status,
@@ -12,103 +14,113 @@ from src.network_sniffer import (
router = APIRouter()
# ------------------------------
# Pydantic Models
# ------------------------------
class SnifferStartRequest(BaseModel):
"""
Request model for starting the sniffer on a specific bridge OR interface.
Exactly one of `bridge` or `interface` must be provided.
"""
bridge: Optional[str] = Field(None, example="br0", description="Name of the Linux bridge to sniff on")
interface: Optional[str] = Field(None, example="eth0", description="Name of the network interface to sniff on")
"""Request payload for starting a sniffer session."""
class SnifferStartResponse(BaseModel):
"""
Response model returned when sniffer starts successfully.
"""
started: bool = Field(..., description="Whether the sniffer was started successfully")
session_id: str = Field(..., description="Session identifier for this sniffer instance")
target: str = Field(..., description="Target that was started (bridge or interface)")
target_type: str = Field(..., description="Either 'bridge' or 'interface'")
class SnifferStopRequest(BaseModel):
"""
Optional body for stop — prefer session_id if you want to stop a specific session.
If omitted, stopping behavior will be determined by query params (bridge/interface) or global stop.
"""
session_id: Optional[str] = Field(None, description="Session id to stop")
class SnifferStopResponse(BaseModel):
stopped: bool = Field(..., description="Whether the sniffer was stopped successfully")
session_id: Optional[str] = Field(None, description="Session id stopped (if any)")
target: Optional[str] = Field(None, description="Target stopped; null if global stop")
target_type: Optional[str] = Field(None, description="'bridge' or 'interface' or None")
class InterfaceSnifferStatus(BaseModel):
running: bool = Field(..., description="Whether the sniffer thread/socket is active")
exists: bool = Field(..., description="Whether the interface exists in /sys/class/net")
up: bool = Field(..., description="Whether the interface is operationally UP")
session_id: Optional[str] = Field(None, description="Session id owning this interface")
session_label: Optional[str] = Field(None, description="Human label for the session")
class SnifferStatusResponse(BaseModel):
interfaces: Dict[str, InterfaceSnifferStatus] = Field(
..., description="Map of interface names to their sniffer status"
bridge: Optional[str] = Field(
None,
example="br0",
description="Bridge name to sniff.",
)
interface: Optional[str] = Field(
None,
example="eth0",
description="Interface name to sniff.",
)
class SnifferStartResponse(BaseModel):
"""Response payload for a successful sniffer start."""
started: bool = Field(..., description="True when a session was started.")
session_id: str = Field(..., description="Unique session identifier.")
target: str = Field(..., description="Started target name.")
target_type: str = Field(..., description="Either 'bridge' or 'interface'.")
class SnifferStopRequest(BaseModel):
"""Optional stop payload for targeting a specific session."""
session_id: Optional[str] = Field(None, description="Session ID to stop.")
class SnifferStopResponse(BaseModel):
"""Response payload for stop operations."""
stopped: bool = Field(..., description="True when stop completed.")
session_id: Optional[str] = Field(None, description="Stopped session ID if available.")
target: Optional[str] = Field(None, description="Stopped target name.")
target_type: Optional[str] = Field(None, description="'bridge', 'interface', or null.")
class InterfaceSnifferStatus(BaseModel):
"""Status details for a single network interface."""
running: bool = Field(..., description="Whether a sniffer is currently active.")
exists: bool = Field(..., description="Whether the interface exists on the host.")
up: bool = Field(..., description="Whether the interface is operationally up.")
session_id: Optional[str] = Field(None, description="Owning sniffer session ID.")
session_label: Optional[str] = Field(None, description="Human-readable session label.")
class SnifferStatusResponse(BaseModel):
"""Status response keyed by interface name."""
interfaces: Dict[str, InterfaceSnifferStatus] = Field(
...,
description="Map of interface names to status objects.",
)
# ------------------------------
# Endpoints
# ------------------------------
@router.post("/start", response_model=SnifferStartResponse)
def sniffer_start(req: SnifferStartRequest):
"""
Start a sniffer session for the given bridge OR interface.
Exactly one of `bridge` or `interface` must be provided.
Returns a session_id to manage the session.
"""
def sniffer_start(req: SnifferStartRequest) -> SnifferStartResponse:
"""Start one sniffer session for exactly one target."""
if bool(req.bridge) == bool(req.interface):
raise HTTPException(status_code=400, detail="Exactly one of 'bridge' or 'interface' must be provided")
try:
if req.interface:
session_id = start_afpacket_sniffer(req.interface, target_is_interface=True)
return SnifferStartResponse(started=True, session_id=session_id, target=req.interface, target_type="interface")
else:
session_id = start_afpacket_sniffer(req.bridge, target_is_interface=False)
return SnifferStartResponse(started=True, session_id=session_id, target=req.bridge, target_type="bridge")
return SnifferStartResponse(
started=True,
session_id=session_id,
target=req.interface,
target_type="interface",
)
session_id = start_afpacket_sniffer(req.bridge, target_is_interface=False)
return SnifferStartResponse(
started=True,
session_id=session_id,
target=req.bridge,
target_type="bridge",
)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to start sniffer: {exc}")
raise HTTPException(status_code=500, detail=f"Failed to start sniffer: {exc}") from exc
@router.post("/stop", response_model=SnifferStopResponse)
def sniffer_stop(
q_bridge: Optional[str] = Query(None, alias="bridge", description="If provided, stop sniffer sockets for this bridge"),
q_interface: Optional[str] = Query(None, alias="interface", description="If provided, stop sniffer socket for this interface"),
q_bridge: Optional[str] = Query(
None,
alias="bridge",
description="Stop sockets for this bridge.",
),
q_interface: Optional[str] = Query(
None,
alias="interface",
description="Stop sockets for this interface.",
),
body: SnifferStopRequest = Body(...),
):
"""
Stop sniffer sessions.
- If body.session_id is provided: stop that session (preferred).
- Else if query param `interface` provided: close socket for that interface across sessions.
- Else if query param `bridge` provided: remove snapshot / close sockets for that bridge across sessions.
- Else: stop all sessions (global stop).
"""
) -> SnifferStopResponse:
"""Stop by session ID, target query, or globally when no selector is given."""
if body and body.session_id:
try:
stop_afpacket_sniffer(session_id=body.session_id)
return SnifferStopResponse(stopped=True, session_id=body.session_id, target=None, target_type=None)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to stop session {body.session_id}: {exc}")
raise HTTPException(status_code=500, detail=f"Failed to stop session {body.session_id}: {exc}") from exc
# validate query params
if q_bridge and q_interface:
raise HTTPException(status_code=400, detail="Only one of 'bridge' or 'interface' may be provided")
@@ -116,29 +128,23 @@ def sniffer_stop(
if q_interface:
stop_afpacket_sniffer(target=q_interface, target_is_interface=True)
return SnifferStopResponse(stopped=True, session_id=None, target=q_interface, target_type="interface")
if q_bridge:
stop_afpacket_sniffer(target=q_bridge, target_is_interface=False)
return SnifferStopResponse(stopped=True, session_id=None, target=q_bridge, target_type="bridge")
# global stop
stop_afpacket_sniffer()
return SnifferStopResponse(stopped=True, session_id=None, target=None, target_type=None)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to stop sniffer: {exc}")
raise HTTPException(status_code=500, detail=f"Failed to stop sniffer: {exc}") from exc
@router.get("/status", response_model=SnifferStatusResponse)
def sniffer_status():
"""
Return the sniffer status information.
"""
def sniffer_status() -> SnifferStatusResponse:
"""Return current sniffer status per interface."""
try:
raw = get_sniffer_status()
# Convert raw dict → typed model
typed = {
k: InterfaceSnifferStatus(**v)
for k, v in raw.items()
}
raw: Dict[str, Dict[str, Any]] = get_sniffer_status()
typed = {key: InterfaceSnifferStatus(**value) for key, value in raw.items()}
return SnifferStatusResponse(interfaces=typed)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to query sniffer status: {exc}")
raise HTTPException(status_code=500, detail=f"Failed to query sniffer status: {exc}") from exc

View File

@@ -1,30 +1,24 @@
# src/main.py
"""FastAPI application entrypoint and runtime wiring."""
import asyncio
import logging
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from src.api import packet_scripting_api
from src.api import nft_manager
from src.utilities.packet_broadcaster import PacketBroadcaster
import src.shared_objects as shared_objects
from src.utilities.database import DatabasePool
import src.api.network_api as network_api
import src.api.sniffer_api as sniffer_api
from src.api import nft_api
import src.shared_objects as shared_objects
from src.api import nft_manager
from src.api import packet_api
import src.api.nftables_api as nftables_api
from src.api import packet_scripting_api
from src.utilities.database import DatabasePool
from src.utilities.packet_broadcaster import PacketBroadcaster
# ---- Config -----------------------------------------------------------
DB_DSN = "postgresql://mitm_user:mitm_password@localhost:5432/mitm_db"
logging.basicConfig(level=logging.DEBUG)
# ---- Globals -----------------------------------------------
# Create DatabasePool instance (pool created on startup)
shared_objects.db = DatabasePool(DB_DSN)
app = FastAPI(
@@ -45,62 +39,44 @@ app.add_middleware(
allow_headers=["*"],
)
# ---------------------
# Startup / Shutdown
# ---------------------
@app.on_event("startup")
async def on_startup():
"""
Initialize DB pool and broadcaster on the FastAPI event loop and
publish them into shared_objects so other modules (sniffer, routers)
can access them.
"""
async def on_startup() -> None:
"""Initialize shared runtime objects on the FastAPI event loop."""
loop = asyncio.get_running_loop()
shared_objects.web_loop = loop
# Initialize DB pool bound to this loop
try:
await shared_objects.db.init_pool()
except Exception:
logging.exception("Failed to initialize DB pool")
raise
# Create broadcaster and attach to DB so DB.insert_packet can publish updates
try:
shared_objects.broadcaster = PacketBroadcaster(loop)
shared_objects.db.broadcaster = shared_objects.broadcaster
except Exception:
logging.exception("Failed to create/attach broadcaster")
# continue — DB is primary; broadcaster optional
# Drain any buffered packets from the sniffer (if it started earlier)
try:
# import sniffer here to avoid circular imports at module import time
from src import network_sniffer as sniffer
# sniffer provides drain_buffer_to_shared_db()
try:
sniffer.drain_buffer_to_shared_db()
except Exception:
logging.exception("Failed to drain sniffer buffer")
except ImportError:
# sniffer not present or not importable; skip
logging.debug("sniffer module not importable at startup; skipping buffer drain")
logging.debug("Sniffer module not importable at startup; skipping buffer drain")
@app.on_event("shutdown")
async def shutdown_event():
"""
Shutdown actions: stop network API and close DB pool if present.
"""
# try to shut down network API components
async def shutdown_event() -> None:
"""Stop network resources and release shared runtime objects."""
try:
network_api.shutdown_network_api()
except Exception:
logging.exception("Error shutting down network API")
# close DB pool if available in shared_objects
try:
web_db = getattr(shared_objects, "db", None)
if web_db is not None:
@@ -108,37 +84,26 @@ async def shutdown_event():
except Exception:
logging.exception("Failed to close DB pool during shutdown")
# clear shared runtime objects (optional cleanup)
try:
shared_objects.db = None
shared_objects.broadcaster = None
shared_objects.web_loop = None
except Exception:
pass
shared_objects.db = None
shared_objects.broadcaster = None
shared_objects.web_loop = None
# ---------------------
# Basic Endpoints
# ---------------------
@app.get("/hello")
def hello():
def hello() -> dict[str, str]:
"""Simple health-check endpoint."""
return {"message": "Hello from FastAPI 🎉"}
@app.get("/versions")
def versions():
def versions() -> dict[str, str]:
"""Return runtime Python version."""
message = os.popen("python --version").read().strip()
return {"message": message}
# ---------------------
# Routers
# ---------------------
app.include_router(network_api.router, prefix="/network", tags=["network"])
app.include_router(sniffer_api.router, prefix="/sniffer", tags=["sniffer"])
app.include_router(packet_api.router, prefix="/packets", tags=["packets"])
#app.include_router(nftables_api.router, prefix="/nftables", tags=["nftables"])
#app.include_router(nft_api.router, prefix="/nft", tags=["nft"])
app.include_router(nft_manager.router, tags=["firewall"])
app.include_router(packet_scripting_api.router, prefix="/scripts", tags=["scripts"])
app.include_router(packet_scripting_api.router, prefix="/scripts", tags=["scripts"])

View File

@@ -1,8 +1,8 @@
from typing import Optional
import asyncio
"""Shared runtime objects initialized during FastAPI startup."""
# These are filled at FastAPI startup
# DB instance
db = None
import asyncio
from typing import Any, Optional
db: Any = None
web_loop: Optional[asyncio.AbstractEventLoop] = None
broadcaster = None
broadcaster: Any = None

View File

@@ -1,28 +1,21 @@
# src/utilities/database.py
import logging
import base64
import asyncio
from typing import Dict, List, Optional, Any
from pydantic import ValidationError
"""Database helper for packet persistence and retrieval."""
import asyncio
import base64
import logging
from typing import Any, Dict, List, Optional
import asyncpg
from asyncpg.pool import Pool
from pydantic import ValidationError
from src.Models.packets import PacketDBModel
# ---- Logging ----------------------------------------------------------
logger = logging.getLogger("af_packet_sniffer")
class DatabasePool:
"""
Lightweight asyncpg connection pool wrapper.
- Lazy pool creation via init_pool()
- Safe against concurrent init_pool() calls via an asyncio.Lock created on first use
- insert_packet() forwards the pkt_info to an optional broadcaster after successful insert
"""
"""Asyncpg connection pool wrapper used by the packet APIs."""
def __init__(self, dsn: str, min_size: int = 1, max_size: int = 5):
self._dsn = dsn
@@ -30,26 +23,26 @@ class DatabasePool:
self._min_size = min_size
self._max_size = max_size
self.broadcaster = None
# created on first init_pool() (must be created on an event loop)
self._init_lock: Optional[asyncio.Lock] = None
async def init_pool(self) -> None:
"""Initialize the asyncpg pool if not already initialized (idempotent)."""
"""Initialize the connection pool once per process."""
if self._pool is not None:
return
# Ensure a lock exists that is bound to the running event loop
if self._init_lock is None:
self._init_lock = asyncio.Lock()
async with self._init_lock:
# Double-check after acquiring lock
if self._pool is not None:
return
logger.info("Initializing DB pool (dsn=%s)", self._dsn)
try:
self._pool = await asyncpg.create_pool(
dsn=self._dsn, min_size=self._min_size, max_size=self._max_size
dsn=self._dsn,
min_size=self._min_size,
max_size=self._max_size,
)
logger.info("DB pool initialized")
except Exception:
@@ -57,9 +50,10 @@ class DatabasePool:
raise
async def close_pool(self) -> None:
"""Close the pool if it exists."""
"""Close the pool if present."""
if self._pool is None:
return
try:
await self._pool.close()
logger.info("DB pool closed")
@@ -69,13 +63,7 @@ class DatabasePool:
self._pool = None
async def insert_packet(self, pkt_info: Dict[str, Any]) -> None:
"""
Insert packet metadata into the `packets` table.
Preserves the same columns/values as before.
After a successful insert, if a broadcaster is attached it will be
notified via broadcaster.sync_publish(pkt_info).
"""
"""Insert one packet record and publish it to subscribers."""
if self._pool is None:
await self.init_pool()
@@ -115,13 +103,11 @@ class DatabasePool:
except Exception:
logger.exception("DB insert failed")
return
# Update the dictionary with the DB-generated values
if new_row:
pkt_info["id"] = new_row["id"]
# Convert timestamp to ISO string for JSON serialization in WebSockets
pkt_info["timestamp"] = new_row["timestamp"].isoformat()
# notify broadcaster (non-blocking). broadcaster is expected to be thread-safe.
if self.broadcaster:
try:
self.broadcaster.sync_publish(pkt_info)
@@ -129,11 +115,7 @@ class DatabasePool:
logger.exception("Failed to publish pkt_info to broadcaster")
async def fetch_latest(self, limit: int) -> List[PacketDBModel]:
"""
Fetch the latest `limit` packets (newest first).
Returns a list of PacketDBModel. Converts raw bytes -> raw_b64 for JSON-safe output.
"""
"""Fetch newest packet rows as validated `PacketDBModel` instances."""
if self._pool is None:
await self.init_pool()
@@ -148,45 +130,34 @@ class DatabasePool:
limit,
)
out: List[PacketDBModel] = []
result: List[PacketDBModel] = []
for row in rows:
data = dict(row)
for r in rows:
d = dict(r)
# convert byte raw -> base64 string (and remove raw)
raw_val = d.get("raw")
raw_val = data.get("raw")
if isinstance(raw_val, (bytes, bytearray)):
d["raw_b64"] = base64.b64encode(raw_val).decode("ascii")
d.pop("raw", None)
data["raw_b64"] = base64.b64encode(raw_val).decode("ascii")
data.pop("raw", None)
# Validate/construct Pydantic model
try:
packet_model = PacketDBModel(**d)
except ValidationError as ve:
# Log and skip invalid rows (or handle otherwise)
packet_model = PacketDBModel(**data)
except ValidationError as exc:
logger.warning(
"Skipping DB row that failed PacketDBModel validation (id=%s): %s",
d.get("id"),
ve,
data.get("id"),
exc,
)
continue
out.append(packet_model)
result.append(packet_model)
return result
return out
async def clear_all_packets(self, reset_identity: bool = True) -> bool:
"""
Deletes all rows from the `packets` table.
If reset_identity is True, the auto-increment ID counter is reset to 1.
Returns True if successful, False otherwise.
"""
"""Truncate the packet table and optionally reset identity counters."""
if self._pool is None:
await self.init_pool()
# TRUNCATE is faster than DELETE and resets the identity counter
restart_clause = "RESTART IDENTITY" if reset_identity else ""
query = f"TRUNCATE TABLE packets {restart_clause};"

View File

@@ -1,140 +1,100 @@
# src/utilities/packet_broadcaster.py
"""In-process packet broadcaster for websocket subscribers."""
import asyncio
import logging
from typing import Dict, Any, List, Optional
from typing import Any, Dict, List, Optional
logger = logging.getLogger("packet_broadcaster")
class PacketBroadcaster:
"""
Simple in-process broadcaster:
- Maintains a set of subscriber asyncio.Queues (one per websocket connection).
- publish(msg) is run on the broadcaster's event loop.
- sync_publish(msg) is thread-safe and can be called from other threads / loops.
Note: create this on the FastAPI event loop (e.g. in startup) so that its lock and
operations run on that same loop.
"""
"""Manage subscriber queues and publish packet events."""
def __init__(self, loop: asyncio.AbstractEventLoop, queue_maxsize: int = 1024):
self._loop = loop
self._queue_maxsize = queue_maxsize
# create lock and subscribers on the target loop to avoid cross-loop asyncio primitives
self._subscribers: List[asyncio.Queue] = []
# create lock bound to the same loop by scheduling its construction on that loop
self._lock: Optional[asyncio.Lock] = None
self._closed = False
try:
# ensure lock is created on the given loop
def _make_lock():
def _make_lock() -> None:
self._lock = asyncio.Lock()
loop.call_soon_threadsafe(_make_lock)
except Exception:
# fallback — create in current loop if call_soon_threadsafe fails
self._lock = asyncio.Lock()
self._closed = False
async def subscribe(self) -> asyncio.Queue:
"""
Create a subscriber queue and add it to the list.
Caller is expected to await on the returned queue to receive messages.
"""
"""Create and register a queue for one subscriber."""
if self._closed:
raise RuntimeError("PacketBroadcaster is closed")
q: asyncio.Queue = asyncio.Queue(maxsize=self._queue_maxsize)
# wait until lock exists
queue: asyncio.Queue = asyncio.Queue(maxsize=self._queue_maxsize)
while self._lock is None:
await asyncio.sleep(0) # yield to event loop briefly
await asyncio.sleep(0)
async with self._lock:
self._subscribers.append(q)
return q
self._subscribers.append(queue)
async def unsubscribe(self, q: asyncio.Queue) -> None:
"""
Remove a subscriber queue if present.
"""
return queue
async def unsubscribe(self, queue: asyncio.Queue) -> None:
"""Unregister a subscriber queue if it exists."""
if self._lock is None:
return
async with self._lock:
try:
self._subscribers.remove(q)
self._subscribers.remove(queue)
except ValueError:
pass
async def publish(self, msg: Dict[str, Any]) -> None:
"""
Publish msg to all subscribers (must be called on the broadcaster's loop).
We use put_nowait to avoid blocking. If a subscriber queue is full we drop
that subscriber's message to avoid backpressure.
"""
if self._closed:
return
if self._lock is None:
# not initialized yet; nothing to do
"""Publish one message to all current subscribers."""
if self._closed or self._lock is None:
return
async with self._lock:
subs = list(self._subscribers)
subscribers = list(self._subscribers)
for q in subs:
for queue in subscribers:
try:
q.put_nowait(msg)
queue.put_nowait(msg)
except asyncio.QueueFull:
# drop message for this subscriber
continue
except Exception as exc:
logger.exception("Unexpected error when publishing to subscriber: %s", exc)
# attempt to remove broken subscriber
logger.exception("Unexpected subscriber publish error: %s", exc)
try:
async with self._lock:
if q in self._subscribers:
self._subscribers.remove(q)
if queue in self._subscribers:
self._subscribers.remove(queue)
except Exception:
pass
def sync_publish(self, msg: Dict[str, Any]) -> None:
"""
Thread-safe publish method: schedule publish(msg) on the broadcaster's loop.
Safe to call from other threads / event loops.
We schedule creation of the publish task on the broadcaster loop using
call_soon_threadsafe so that publish() runs on the correct loop.
"""
"""Thread-safe wrapper that schedules `publish` on the broadcaster loop."""
if self._closed:
return
try:
# schedule the coroutine to run on the broadcaster loop
self._loop.call_soon_threadsafe(asyncio.create_task, self.publish(msg))
except Exception as exc:
# swallow errors but log for debugging
logger.exception("sync_publish failed to schedule publish: %s", exc)
async def close(self) -> None:
"""
Close the broadcaster: mark closed, clear subscribers, and drain queues.
"""
"""Close the broadcaster and clear queued messages."""
self._closed = True
if self._lock is None:
return
async with self._lock:
subs = list(self._subscribers)
subscribers = list(self._subscribers)
self._subscribers.clear()
for q in subs:
for queue in subscribers:
try:
# optionally notify subscribers of closure by putting None (client must handle)
# q.put_nowait(None)
while not q.empty():
try:
q.get_nowait()
except Exception:
break
while not queue.empty():
queue.get_nowait()
except Exception:
pass

View File

@@ -1,5 +1,5 @@
// src/apiClient.ts
import axios from 'axios';
import { CreateRuleRequest, ExecResult, RulesetModel } from '../types/firewall';
import {
BridgeCreateRequest,
@@ -36,22 +36,14 @@ export const api = axios.create({
timeout: 20000,
});
// Normalize FastAPI errors here
api.interceptors.response.use(
(response) => response,
(error) => {
// FastAPI HTTPException format
const detail = error?.response?.data?.detail ?? error?.response?.data?.message ?? error.message ?? 'Unknown error';
// Always reject with a standard Error
return Promise.reject(new Error(detail));
},
);
/* -------------------------
Basic endpoints
------------------------- */
export const fetchHello = async (): Promise<any> => {
const res = await api.get('/hello');
return res.data;
@@ -62,10 +54,6 @@ export const fetchVersions = async (): Promise<any> => {
return res.data;
};
/* -------------------------
Network queries (existing)
------------------------- */
export const fetchInterfaces = async (): Promise<InterfaceInfo[]> => {
const res = await api.get<InterfaceInfo[]>('/network/interfaces');
return res.data;
@@ -101,150 +89,84 @@ export const removeBridge = async (req: BridgeRemoveRequest) => {
return res.data;
};
/* -------------------------
Sniffer
------------------------- */
/**
* Start a sniffer session. Provide exactly one of { bridge, interface }.
* Returns a session_id that you can use to stop the session later.
*/
export const startSniffer = async (payload: SnifferStartRequest): Promise<SnifferStartResponse> => {
const res = await api.post<SnifferStartResponse>('/sniffer/start', payload);
return res.data;
};
/**
* Stop sniffer(s).
* - If you pass a body with { session_id }, it will stop that specific session.
* - If you call without body and without query params, it will stop all sessions.
*/
export const stopSniffer = async (body?: SnifferStopRequest): Promise<SnifferStopResponse> => {
const res = await api.post<SnifferStopResponse>('/sniffer/stop', body ?? {});
return res.data;
};
/**
* Stop sniffing for a specific interface across sessions.
* Calls: POST /sniffer/stop?interface=eth0 (empty body)
*/
export const stopSnifferByInterface = async (iface: string): Promise<SnifferStopResponse> => {
const res = await api.post<SnifferStopResponse>(`/sniffer/stop?interface=${encodeURIComponent(iface)}`, {});
return res.data;
};
/**
* Stop sniffing for a specific bridge across sessions.
* Calls: POST /sniffer/stop?bridge=br0 (empty body)
*/
export const stopSnifferByBridge = async (bridge: string): Promise<SnifferStopResponse> => {
const res = await api.post<SnifferStopResponse>(`/sniffer/stop?bridge=${encodeURIComponent(bridge)}`, {});
return res.data;
};
/**
* Fetch the sniffer status (per-interface).
*/
export const fetchSnifferStatus = async (): Promise<SnifferStatusResponse> => {
const res = await api.get<SnifferStatusResponse>('/sniffer/status');
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;
};
export const clearPackets = async (): Promise<any> => {
// limit default mirrors OpenAPI default
const res = await api.delete('/packets/packets');
return res.data;
};
/* -------------------------
Firewall
------------------------- */
/**
* GET /firewall/rules
* Returns: { ruleset: RulesetModel | string | null }
* - If the server returns a raw textual fallback (string), the caller should handle it.
*/
export const fetchRuleset = async (): Promise<{ ruleset: RulesetModel }> => {
const res = await api.get<{ ruleset: RulesetModel }>('/firewall/rules');
return res.data;
};
/**
* DELETE /firewall/rules/{handle}?family=...&table=...&chain=...
* On success the backend returns 204 No Content. This function resolves to void.
*/
export const deleteRule = async (handle: number, family: string, table: string, chain: string): Promise<void> => {
const res = await api.delete(`/firewall/rules/${encodeURIComponent(String(handle))}`, {
params: { family, table, chain },
});
// axios resolves non-2xx as reject; server uses 204 No Content so nothing to return
return res.data;
};
/**
* createRuleJson - POST /firewall/rules
* Body: CreateRuleRequest (must include expr)
*/
export const createRuleJson = async (req: CreateRuleRequest): Promise<ExecResult> => {
const res = await api.post<ExecResult>('/firewall/rules', req);
return res.data;
};
export const execFirewallRaw = async (cmd: string): Promise<ExecResult> => {
const res = await api.post<ExecResult>('/firewall/raw', { cmd: cmd });
const res = await api.post<ExecResult>('/firewall/raw', { cmd });
return res.data;
};
/* -------------------------
Scripts
------------------------- */
/**
* Fetch the combined scripts + status endpoint.
* Returns a list of ScriptWithStatus entries.
*/
export const fetchScriptsAll = async (): Promise<ScriptWithStatus[]> => {
const res = await api.get<ScriptWithStatus[]>('/scripts/scripts');
return res.data;
};
/**
* Convenience: list only the basic ScriptInfo items (no mappings).
* This uses the combined endpoint and maps to ScriptInfo[].
*/
export const listScripts = async (): Promise<ScriptInfo[]> => {
const all = await fetchScriptsAll();
return all.map((s) => ({ name: s.name, path: s.path }));
return all.map((script) => ({ name: script.name, path: script.path }));
};
/**
* Get status/mappings for a single script by name.
* Because the backend merged status into /scripts, we fetch that and filter.
*/
export const fetchScriptStatusForName = async (name: string): Promise<StatusForNameResponse> => {
const all = await fetchScriptsAll();
const found = all.find((s) => s.name === name);
const found = all.find((script) => script.name === name);
if (!found) {
// If script not found we still return an empty mapping structure
return { name, mappings: [] };
}
return { name: found.name, mappings: found.mappings || [] };
};
/**
* Upload a script (multipart). If a requirements file is provided the backend
* will run pip install and return pip output in the response (mandatory install).
*/
export const uploadScript = async (opts: {
name: string;
script: File | Blob;
@@ -253,7 +175,10 @@ export const uploadScript = async (opts: {
const fd = new FormData();
fd.append('name', opts.name);
fd.append('script', opts.script);
if (opts.requirements) fd.append('requirements', opts.requirements as Blob);
if (opts.requirements) {
fd.append('requirements', opts.requirements as Blob);
}
const res = await api.post<ScriptUploadResponse>('/scripts/scripts', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
@@ -261,71 +186,49 @@ export const uploadScript = async (opts: {
return res.data;
};
/**
* Download the script file as a Blob. Use this blob to create an object URL or read text.
*/
export const downloadScript = async (name: string): Promise<Blob> => {
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}`, { responseType: 'blob' });
return res.data as Blob;
};
/**
* Download the requirements.txt for a script as a Blob.
* Returns 404 if not present (axios will throw).
*/
export const downloadRequirements = async (name: string): Promise<Blob> => {
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}/requirements`, { responseType: 'blob' });
return res.data as Blob;
};
/**
* Replace / upload requirements for a script. This endpoint ALWAYS runs pip install
* and will return pip stdout/stderr on success. On pip failure the backend returns 500.
*
* Use multipart/form-data so Swagger UI shows a file picker (backend expects UploadFile).
*/
export const uploadRequirements = async (
name: string,
requirements: File | Blob,
): Promise<RequirementsUploadResult> => {
const fd = new FormData();
fd.append('requirements', requirements);
const res = await api.put<RequirementsUploadResult>(`/scripts/scripts/${encodeURIComponent(name)}/requirements`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
};
/**
* Delete only the requirements file and attempt to clean up the venv.
* Returns summary `{ removed: { requirements_removed, venv_removed }, errors? }`.
*/
export const deleteRequirements = async (name: string): Promise<RequirementsDeleteResult> => {
const res = await api.delete<RequirementsDeleteResult>(`/scripts/scripts/${encodeURIComponent(name)}/requirements`);
return res.data;
};
/**
* Delete a script (and its files / venv). If qnum provided only remove that unit.
*/
export const deleteScript = async (name: string, qnum?: number | null): Promise<DeleteResult> => {
const params: Record<string, any> = {};
if (typeof qnum !== 'undefined' && qnum !== null) params.qnum = qnum;
if (typeof qnum !== 'undefined' && qnum !== null) {
params.qnum = qnum;
}
const res = await api.delete<DeleteResult>(`/scripts/scripts/${encodeURIComponent(name)}`, { params });
return res.data;
};
/**
* Enable a script (creates + starts a systemd unit). Returns OperationResult including service name.
*/
export const enableScript = async (name: string, req: EnableRequest): Promise<OperationResult> => {
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/enable`, req);
return res.data;
};
/**
* Disable a script unit (POST with qnum as query param). Returns OperationResult.
*/
export const disableScript = async (name: string, qnum: number): Promise<OperationResult> => {
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/disable`, null, {
params: { qnum },

View File

@@ -1,6 +1,6 @@
// src/AppRouter.tsx
import { Navigate, Route, Routes } from 'react-router-dom';
import App from './App'; // your layout component (has <Outlet />)
import App from './App';
import { Firewall } from './pages/Firewall';
import Home from './pages/Home';
import Network from './pages/Network';
@@ -8,34 +8,27 @@ import Scripting from './pages/Scripting';
import Sniffing from './pages/Sniffing';
import { PATHS } from './routes';
function NotFound() {
return (
<div style={{ padding: 16 }}>
<h2>404 - Not Found</h2>
<p>The requested page does not exist.</p>
</div>
);
}
export default function AppRouter() {
return (
<Routes>
{/* App is the top-level layout; Outlet renders the active child route */}
<Route path={PATHS.ROOT} element={<App />}>
{/* When the user hits '/', redirect to '/home' */}
<Route index element={<Navigate to={PATHS.HOME} replace />} />
{/* Child routes - these render inside App's <Outlet /> */}
<Route path={PATHS.HOME.slice(1)} element={<Home />} />
<Route path={PATHS.NETWORK.slice(1)} element={<Network />} />
<Route path={PATHS.SNIFFING.slice(1)} element={<Sniffing />} />
<Route path={PATHS.SCRIPTING.slice(1)} element={<Scripting />} />
<Route path={PATHS.FIREWALL.slice(1)} element={<Firewall />} />
{/* Fallback (renders inside layout too) */}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
);
}
/** simple 404 rendered inside the layout */
function NotFound() {
return (
<div style={{ padding: 16 }}>
<h2>404 – Not Found</h2>
<p>The requested page does not exist.</p>
</div>
);
}

View File

@@ -1,4 +1,3 @@
// src/components/AddChainModal.tsx
import { CopyOutlined } from '@ant-design/icons';
import {
Alert,
@@ -67,12 +66,10 @@ export default function FirewallAddChainModal({
const isPrefilled = Boolean(table?.family && table?.name);
// init form values when modal opens or table prop changes
useEffect(() => {
form.setFieldsValue({
family: table?.family ?? 'bridge',
tableName: table?.name ?? 'filter',
// Do not override chainName if the user has typed it previously
type: 'filter',
hook: 'forward',
priority: 0,
@@ -111,7 +108,6 @@ export default function FirewallAddChainModal({
}
}
// Build add chain command — respects provided values or live form values
function buildCommands(values?: any): string[] {
const vals = values ?? form.getFieldsValue();
@@ -128,7 +124,6 @@ export default function FirewallAddChainModal({
: 0;
const policy = vals.policy ?? '';
// chain name: prefer explicit chainName else fallback to hook (less ideal) else 'mychain'
const chain = hook;
const policyPart = policy ? ` policy ${policy} ;` : '';
@@ -136,7 +131,6 @@ export default function FirewallAddChainModal({
return [cmd];
}
// Execute commands sequentially
async function executeCommands(cmds: string[]) {
setRunning(true);
setResults([]);
@@ -156,24 +150,19 @@ export default function FirewallAddChainModal({
setResults(acc);
setRunning(false);
// refresh ruleset shown in modal
try {
await refreshRuleset();
} catch {
/* ignored, refreshRuleset already notified on failure */
}
const hadError = acc.some((r) => r.err);
if (!hadError) {
// success notification inside the modal (not global refresh notification)
notification.success({
message: 'Chain created',
description: 'Chain created and ruleset refreshed locally in the modal.',
duration: 4,
});
// Inform parent that a resource was created.
// Parent can decide whether to refresh and whether to show a notification.
onClose?.(true);
if (onSuccess) onSuccess();
} else {
@@ -219,13 +208,11 @@ export default function FirewallAddChainModal({
type="primary"
onClick={async () => {
try {
// validate name fields: chainName required; if not prefilled, tableName & family required
const requiredFields = ['chainName'];
if (!isPrefilled) requiredFields.push('tableName', 'family');
await form.validateFields(requiredFields as any);
setStep(1);
} catch {
// AntD will show validation messages; no extra handling required
}
}}
>
@@ -264,7 +251,7 @@ export default function FirewallAddChainModal({
footer={renderFooter()}
destroyOnClose
>
{/* Step 0: Form */}
{step === 0 && (
<div>
{rulesetEmpty && (
@@ -284,7 +271,7 @@ export default function FirewallAddChainModal({
}}
>
<Row gutter={12}>
{/* family & table: show inputs only when not provided via props */}
<Col span={8}>
{isPrefilled ? (
<Form.Item label="Family">
@@ -363,7 +350,7 @@ export default function FirewallAddChainModal({
</div>
)}
{/* Step 1: Preview & Results */}
{step === 1 && (
<>
<Card title="Command preview" style={{ marginBottom: 12 }}>

View File

@@ -1,4 +1,3 @@
// src/components/FirewallManager.tsx
import { Button, Card, Col, Form, Input, Modal, Row, Select, Space, Spin, Typography, notification } from 'antd';
import { ReactElement, useEffect, useState } from 'react';
import { execFirewallRaw, fetchRuleset } from '../api/apiClient';
@@ -42,7 +41,6 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
const [results, setResults] = useState<CmdResult[]>([]);
const [selectedFamily, setSelectedFamily] = useState<string>('bridge');
// initialize and refresh when modal opens
useEffect(() => {
form.setFieldsValue({
family: 'bridge',
@@ -70,7 +68,6 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
setRulesetEmpty(false);
}
} catch (err: any) {
// use notification instead of message
notification.warning({
message: 'Could not load ruleset',
description: err?.message ?? String(err),
@@ -82,14 +79,12 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
}
}
// Build commands: only create table
function buildCommands(values: any): string[] {
const family = (values.family ?? 'bridge').trim();
const table = (values.tableName ?? 'filter').trim();
return [`add table ${family} ${table}`];
}
// Execute commands sequentially
async function executeCommands(cmds: string[]) {
setRunning(true);
setResults([]);
@@ -109,16 +104,13 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
setResults(acc);
setRunning(false);
// refresh ruleset after running
try {
await refreshRuleset();
} catch {
// ignore — refreshRuleset handles notifications on error
}
const hadError = acc.some((r) => r.err);
if (!hadError) {
// success -> notify briefly and inform parent that a resource was created
notification.success({
message: 'Table created',
description: 'Table was created and ruleset has been refreshed locally in the modal.',
@@ -126,7 +118,6 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
});
onClose?.(true); // signal parent to refresh and close modal
} else {
// error -> open results view and show notification
notification.error({
message: 'Some commands returned errors',
description: 'See execution results below for details.',
@@ -153,7 +144,6 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
<Button
type="primary"
onClick={() => {
// validate form before preview
form
.validateFields()
.then(() => setStep(1))
@@ -181,7 +171,6 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
);
}
// show spinner while checking ruleset
if (localLoadingRuleset) {
return (
<Modal title="Create Table" open={open} onCancel={() => onClose?.(false)} footer={null} width={700}>
@@ -201,7 +190,7 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
footer={renderFooter()}
destroyOnClose
>
{/* Step 0: minimal form */}
{step === 0 && (
<div>
<Form
@@ -238,7 +227,7 @@ export default function FirewallAddTableModal({ open, onClose, startOnPreview =
</div>
)}
{/* Step 1: preview + results */}
{step === 1 && (
<>
<Card title="Command preview" style={{ marginBottom: 12 }}>

View File

@@ -1,14 +1,3 @@
// src/components/RuleBuilder.tsx
//
// Extended RuleBuilder using the user's canonical match list:
// 1) Metadata & Connection Tracking (meta, ct)
// 2) Layer 3 Network Headers (ip, ip6)
// 3) Layer 4 Transport Headers (tcp, udp, icmp) — appear when chosen
// 4) Layer 2 Ethernet & VLAN (ether, vlan)
//
// The UI provides rich dropdowns / placeholders / short explanations for every token subfield.
//
// NOTE: This file replaces the token lists and per-field UI to strictly follow the user's canonical list.
import { PlusOutlined } from '@ant-design/icons';
import {
@@ -38,9 +27,6 @@ const { Title, Text } = Typography;
type FormValues = Record<string, any>;
/* ----------------------
Token types (canonical per user)
---------------------- */
type TokenType =
| 'meta'
| 'ct'
@@ -59,642 +45,10 @@ type TokenType =
| 'nat'
| 'queue';
/* ----------------------
TOKEN_FIELD_OPTIONS
Each token lists allowed subfields (exactly the fields from the user's canonical list).
The `kind` tells the UI which input widget to show (number, enum, string).
---------------------- */
const TOKEN_FIELD_OPTIONS: Record<
TokenType,
Array<{ value: string; label: string; kind?: 'string' | 'number' | 'enum' }>
> = {
/* 1) Metadata & Connection Tracking */
meta: [
{ value: 'iifname', label: 'iifname (input interface)', kind: 'string' },
{ value: 'oifname', label: 'oifname (output interface)', kind: 'string' },
{ value: 'l4proto', label: 'l4proto (protocol L4)', kind: 'enum' }, // tcp/udp/icmp/...
{ value: 'day', label: 'day (day of week)', kind: 'enum' },
{ value: 'hour', label: 'hour (hour of day/range)', kind: 'string' },
{ value: 'pkttype', label: 'pkttype (packet type)', kind: 'enum' },
{ value: 'mark', label: 'mark (packet mark)', kind: 'string' },
{ value: 'skuid', label: 'skuid (socket UID)', kind: 'number' },
{ value: 'skgid', label: 'skgid (socket GID)', kind: 'number' },
],
ct: [
{ value: 'state', label: 'state (ct state)', kind: 'enum' },
{ value: 'direction', label: 'direction (original/reply)', kind: 'enum' },
{ value: 'status', label: 'status', kind: 'string' },
{ value: 'mark', label: 'mark (conntrack mark)', kind: 'string' },
{ value: 'expiration', label: 'expiration', kind: 'string' },
{ value: 'helper', label: 'helper', kind: 'string' },
],
/* 2) Layer 3: Network Headers */
ip: [
{ value: 'saddr', label: 'saddr (source IPv4)', kind: 'string' },
{ value: 'daddr', label: 'daddr (destination IPv4)', kind: 'string' },
{ value: 'protocol', label: 'protocol (L4) — alias to l4proto', kind: 'enum' },
{ value: 'dscp', label: 'dscp (DSCP)', kind: 'enum' },
{ value: 'ttl', label: 'ttl (time to live)', kind: 'number' },
{ value: 'frag-off', label: 'frag-off (fragment bits)', kind: 'string' },
],
ip6: [
{ value: 'saddr', label: 'saddr (source IPv6)', kind: 'string' },
{ value: 'daddr', label: 'daddr (destination IPv6)', kind: 'string' },
{ value: 'nexthdr', label: 'nexthdr (protocol / next header)', kind: 'enum' },
{ value: 'dscp', label: 'dscp (DSCP)', kind: 'enum' },
{ value: 'hoplimit', label: 'hoplimit (IPv6 hop limit)', kind: 'number' },
{ value: 'flowlabel', label: 'flowlabel', kind: 'number' },
],
/* 3) Layer 4: Transport Headers (appear only when token type tcp/udp/icmp is chosen) */
tcp: [
{ value: 'sport', label: 'sport (source port)', kind: 'number' },
{ value: 'dport', label: 'dport (destination port)', kind: 'number' },
{ value: 'flags', label: 'flags (tcp flags bitmask)', kind: 'enum' },
],
udp: [
{ value: 'sport', label: 'sport (source port)', kind: 'number' },
{ value: 'dport', label: 'dport (destination port)', kind: 'number' },
],
icmp: [
{ value: 'type', label: 'type (icmp type)', kind: 'enum' },
{ value: 'code', label: 'code (icmp code)', kind: 'enum' },
],
/* 4) Layer 2: Ethernet & VLAN */
ether: [
{ value: 'saddr', label: 'saddr (src MAC)', kind: 'string' },
{ value: 'daddr', label: 'daddr (dst MAC)', kind: 'string' },
{ value: 'type', label: 'type (ethertype)', kind: 'enum' },
],
vlan: [
{ value: 'id', label: 'id (VLAN ID)', kind: 'number' },
// CFI/DEI and PCP exist but user's list specified only VLAN ID; add PCP & DEI as optional helpers:
{ value: 'pcp', label: 'pcp (priority code point)', kind: 'number' },
{ value: 'cfi', label: 'cfi / DEI (drop eligible)', kind: 'number' },
],
/* leftovers and statements */
payload: [{ value: 'payload', label: 'payload(protocol.field)', kind: 'string' }],
raw: [{ value: 'raw', label: 'raw text', kind: 'string' }],
counter: [{ value: 'counter', label: 'counter', kind: 'string' }],
limit: [{ value: 'limit', label: 'limit (rate)', kind: 'string' }],
log: [{ value: 'log', label: 'log', kind: 'string' }],
nat: [
{ value: 'dnat', label: 'dnat to', kind: 'string' },
{ value: 'snat', label: 'snat to', kind: 'string' },
{ value: 'masquerade', label: 'masquerade', kind: 'string' },
],
queue: [{ value: 'queue', label: 'queue num', kind: 'string' }],
};
/* ----------------------
ENUM_VALUES (dropdown contents)
Keep these aligned with the user's canonical lists.
---------------------- */
const ENUM_VALUES: Record<string, string[]> = {
l4proto: ['tcp', 'udp', 'icmp', 'icmpv6', 'igmp', 'esp', 'ah'],
days: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
pkttype: ['unicast', 'multicast', 'broadcast', 'other'],
ct_state: ['new', 'established', 'related', 'invalid', 'untracked'],
ct_direction: ['original', 'reply'],
// ICMP message *types* (used for e.g. echo-request/echo-reply)
icmp_types: ['echo-request', 'echo-reply', 'destination-unreachable'],
// IPv4 reject *reasons* (ICMPv4 codes / textual reasons used with `reject with icmp type <reason>`)
icmpv4_reasons: [
'net-unreachable',
'host-unreachable',
'prot-unreachable',
'port-unreachable', // default
'net-prohibited',
'host-prohibited',
'admin-prohibited',
],
// IPv6 reject reasons (ICMPv6 textual reasons)
icmpv6_reasons: ['no-route', 'admin-prohibited', 'addr-unreachable', 'port-unreachable'],
dscp_values: [
'cs0',
'cs1',
'cs2',
'cs3',
'cs4',
'cs5',
'cs6',
'cs7',
'af11',
'af12',
'af13',
'af21',
'af22',
'af23',
'af31',
'af32',
'af33',
'af41',
'af42',
'af43',
'ef',
],
tcp_flags: ['fin', 'syn', 'rst', 'psh', 'ack', 'urg', 'ece', 'cwr'],
ethertypes: ['ip', 'ip6', 'arp', 'vlan', 'loopback'],
// top-level reject types used in select control. Note `icmpv6` spelled out.
reject_types: ['icmp', 'icmpv6', 'icmpx', 'tcp-reset'],
};
/* ----------------------
tokenToText: produce nft textual representation from token value
(keeps command generation consistent with the UI)
---------------------- */
function tokenToText(token: any): string {
if (!token || !token.type) return '';
const t = token.type as TokenType;
const d = token.data || {};
// META
if (t === 'meta') {
const f = d.field;
if (!f) return '';
// special formatting: meta l4proto <proto>
if (f === 'l4proto') {
return `meta l4proto ${String(d.value ?? '')}`.trim();
}
if (f === 'iifname' || f === 'oifname') {
return `meta ${f} ${String(d.value ?? '')}`.trim();
}
if (f === 'day') {
return `meta day ${String(d.value ?? '')}`.trim();
}
if (f === 'hour') {
return `meta hour ${String(d.value ?? '')}`.trim();
}
if (f === 'pkttype') {
return `meta pkttype ${String(d.value ?? '')}`.trim();
}
if (f === 'mark') {
return `meta mark ${String(d.value ?? '')}`.trim();
}
if (f === 'skuid' || f === 'skgid') {
return `meta ${f} ${String(d.value ?? '')}`.trim();
}
return `meta ${f} ${String(d.value ?? '')}`.trim();
}
// CT
if (t === 'ct') {
const f = d.field;
if (!f) return '';
return `ct ${f} ${String(d.value ?? '')}`.trim();
}
// IP/IPv6
if (t === 'ip' || t === 'ip6') {
const f = d.field;
if (!f) return '';
// saddr/daddr: allow CIDR/list/range raw text
return `${t} ${f} ${String(d.value ?? '')}`.trim();
}
// Transport protocols
if (t === 'tcp' || t === 'udp') {
const f = d.field;
if (!f) return t;
if (f === 'dport' || f === 'sport') {
return `${t} ${f} ${String(d.value ?? '')}`.trim();
}
if (f === 'flags') {
// flags could be array or comma-separated
const vals = Array.isArray(d.value)
? d.value
: String(d.value ?? '')
.split(',')
.map((s: string) => s.trim())
.filter(Boolean);
if (vals.length === 0) return t;
// render as: tcp flags { syn, ack }
return `${t} flags { ${vals.join(', ')} }`;
}
return `${t} ${f} ${String(d.value ?? '')}`.trim();
}
if (t === 'icmp') {
const f = d.field;
if (!f) return 'icmp';
return `icmp ${f} ${String(d.value ?? '')}`.trim();
}
// ETHER
if (t === 'ether') {
const f = d.field;
if (!f) return '';
return `ether ${f} ${String(d.value ?? '')}`.trim();
}
// VLAN
if (t === 'vlan') {
const f = d.field;
if (!f) return 'vlan';
return `vlan ${f} ${String(d.value ?? '')}`.trim();
}
// Statements
if (t === 'counter') {
if (d.packets || d.bytes) {
return `counter${d.packets ? ` packets ${d.packets}` : ''}${d.bytes ? ` bytes ${d.bytes}` : ''}`.trim();
}
return 'counter';
}
if (t === 'limit') {
const r = d.rate ?? d.value;
return r ? `limit rate ${r}` : 'limit';
}
if (t === 'log') {
const parts: string[] = [];
if (d.level) parts.push(`level ${d.level}`);
if (d.group) parts.push(`group ${d.group}`);
if (d.snaplen) parts.push(`snaplen ${d.snaplen}`);
if (d.prefix) parts.push(`prefix "${d.prefix}"`);
return parts.length ? `log ${parts.join(' ')}` : 'log';
}
if (t === 'nat') {
if (d.kind === 'dnat' && d.to) return `dnat to ${d.to}`;
if (d.kind === 'snat' && d.to) return `snat to ${d.to}`;
if (d.kind === 'masquerade') return d.to ? `masquerade to ${d.to}` : 'masquerade';
return 'nat';
}
if (t === 'queue') {
if (d.num) {
// allow optional extra token words following queue num, e.g. "queue num 1 bypass"
const extra = d.extra ? ` ${String(d.extra)}` : '';
return `queue num ${d.num}${extra}`.trim();
}
return 'queue';
}
if (t === 'raw') {
return String(d.text ?? '').trim();
}
if (t === 'payload') {
if (d.value) return `payload(${d.value})`;
return 'payload';
}
return '';
}
/* ----------------------
generateCommandFromValues (build textual + final nft add/insert)
---------------------- */
function generateCommandFromValues(values: FormValues) {
const tokens = Array.isArray(values.tokens) ? values.tokens : [];
const parts: string[] = [];
for (const t of tokens) {
const txt = tokenToText(t);
if (txt) parts.push(txt);
}
if (values.advanced && typeof values.advanced === 'string' && values.advanced.trim() !== '') {
parts.push(values.advanced.trim());
}
// Build queue text for NFQUEUE action or queue token
if (values.action === 'nfqueue' || values.action === 'queue') {
const qnum = values.nfqueue ?? values.queue ?? 1;
const bypass = values.nfqueue_bypass ? ' bypass' : '';
const queueText = `queue num ${Number(qnum)}${bypass}`;
const combined = parts.join(' ');
if (!/\bqueue(?:\s+num)?\b/i.test(combined)) {
parts.push(queueText);
} else {
for (let i = 0; i < parts.length; i++) {
if (/\bqueue(?:\s+num)?\b/i.test(parts[i])) {
parts[i] = queueText;
break;
}
}
}
}
// Build action/reject/nfqueue textual suffix
let actionText: string | null = null;
if (values.action === 'accept' || values.action === 'drop') {
actionText = values.action;
} else if (values.action === 'reject') {
// reject requires a rejectType (form enforces it)
const rtype = values.rejectType;
if (!rtype) {
actionText = 'reject'; // fallback, though form validation should prevent this
} else if (rtype === 'tcp-reset') {
// nft "reject with tcp reset"
actionText = 'reject with tcp reset';
} else if (rtype === 'icmp') {
// IPv4: "reject with icmp type <reason>"
const reason = values.rejectIcmpReason || '';
actionText = reason ? `reject with icmp type ${reason}` : 'reject';
} else if (rtype === 'icmpv6') {
// IPv6: "reject with icmpv6 type <reason>"
const reason = values.rejectIcmp6Reason || '';
actionText = reason ? `reject with icmpv6 type ${reason}` : 'reject';
} else if (rtype === 'icmpx') {
// inet family abstraction (icmpx)
const reason = values.rejectIcmpxReason || '';
actionText = reason ? `reject with icmpx type ${reason}` : 'reject';
} else {
actionText = 'reject';
}
} else if (values.action === 'nfqueue') {
// NFQUEUE action is represented by queue token above; no extra action verb
actionText = null;
}
const textual = (parts.join(' ') + (actionText ? ` ${actionText}` : '')).trim();
const tableSelect = values.tableSelect;
const chain = values.chainSelect || 'input';
const [family = 'inet', table = 'filter'] = tableSelect ? String(tableSelect).split(':') : ['inet', 'filter'];
const before = values.insertBeforeHandle;
const hasBefore = before != null && String(before) !== '';
const verb = hasBefore ? 'insert' : 'add';
const positionPart = hasBefore ? ` position ${before}` : '';
const cmd = `${verb} rule ${family} ${table} ${chain}${positionPart} ${textual}`.replace(/\s+/g, ' ').trim();
return { cmd, textual, position: hasBefore ? Number(before) : undefined };
}
/* -------------------------
Component
------------------------- */
interface RuleBuilderProps {
onCreated?: () => Promise<void> | void;
tables?: TableOut[] | null;
rulesLoading?: boolean;
rulesError?: string | null;
refreshRules?: () => Promise<void>;
onRulesChange?: (tables: TableOut[]) => void;
}
export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps) => {
const [form] = Form.useForm();
const [cmdPreview, setCmdPreview] = useState('');
const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(false);
const tableOptions = useMemo(
() => (props.tables || []).map((t) => ({ value: `${t.family}:${t.name}`, label: `${t.family}:${t.name}` })),
[props.tables],
);
const noTables = !(props.tables && props.tables.length > 0);
const [insertBeforeOptions, setInsertBeforeOptions] = useState<Array<{ value: any; label: string }>>([]);
const updateInsertOptions = useCallback(() => {
const ts = form.getFieldValue('tableSelect');
const cs = form.getFieldValue('chainSelect');
if (!ts || !cs) {
setInsertBeforeOptions([]);
return;
}
const [family, table] = String(ts).split(':');
const tbl = props.tables?.find((t) => t.family === family && t.name === table);
if (!tbl) {
setInsertBeforeOptions([]);
return;
}
const ch = (tbl.chains || []).find((c: ChainOut) => c.name === cs);
if (!ch || !Array.isArray(ch.rules)) {
setInsertBeforeOptions([]);
return;
}
const opts = ch.rules
.filter((r: RuleOut) => r && r.handle != null)
.map((r: RuleOut) => ({
value: r.handle,
label: `#${r.handle} — ${r.text ?? (typeof r.expr === 'string' ? r.expr : JSON.stringify(r.expr || {}).slice(0, 120))}`,
}));
setInsertBeforeOptions(opts);
}, [form, props.tables]);
const previewTimerRef = useRef<number | null>(null);
const schedulePreviewUpdate = useCallback(() => {
if (previewTimerRef.current) window.clearTimeout(previewTimerRef.current);
previewTimerRef.current = window.setTimeout(() => {
const v = form.getFieldsValue();
const { cmd } = generateCommandFromValues(v);
setCmdPreview(cmd);
previewTimerRef.current = null;
}, 40);
}, [form]);
useEffect(() => {
if (tableOptions.length > 0) {
const first = tableOptions[0].value;
form.setFieldsValue({
tableSelect: first,
action: 'drop',
nfqueue: 1,
nfqueue_bypass: false,
tokens: [],
});
const [f, n] = String(first).split(':');
const tbl = props.tables?.find((t) => t.family === f && t.name === n);
if (tbl && tbl.chains && tbl.chains.length > 0) {
form.setFieldsValue({ chainSelect: tbl.chains[0].name });
} else {
form.setFieldsValue({ chainSelect: undefined });
}
setTimeout(() => {
updateInsertOptions();
schedulePreviewUpdate();
}, 0);
} else {
form.setFieldsValue({
action: 'drop',
nfqueue: 1,
nfqueue_bypass: false,
tableSelect: undefined,
chainSelect: undefined,
tokens: [],
});
setInsertBeforeOptions([]);
setTimeout(() => schedulePreviewUpdate(), 0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.tables, tableOptions.length]);
useEffect(() => {
const cur = form.getFieldsValue();
if (cur.nfqueue == null) form.setFieldsValue({ nfqueue: 1 });
schedulePreviewUpdate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onValuesChange = useCallback(
(_: any, allValues: FormValues) => {
if (allValues.action === 'nfqueue' && (allValues.nfqueue == null || allValues.nfqueue === '')) {
form.setFieldsValue({ nfqueue: 1 });
allValues.nfqueue = 1;
}
const ts = allValues.tableSelect;
if (ts) {
const [f, n] = String(ts).split(':');
const tbl = props.tables?.find((t) => t.family === f && t.name === n);
if (tbl) {
if (tbl.chains && tbl.chains.length > 0) {
if (!allValues.chainSelect) form.setFieldsValue({ chainSelect: tbl.chains[0].name });
} else {
form.setFieldsValue({ chainSelect: undefined });
}
}
}
updateInsertOptions();
schedulePreviewUpdate();
},
[form, props.tables, updateInsertOptions, schedulePreviewUpdate],
);
const handleCreate = useCallback(
async (values: FormValues) => {
try {
const validated = await form.validateFields();
const { cmd } = generateCommandFromValues(validated);
Modal.confirm({
title: 'Run raw nft command',
content: (
<div>
<Text>
About to run nft command in <b>{String(validated.tableSelect ?? 'inet:filter')}</b> (see preview).
</Text>
<Divider />
<Text strong>Command:</Text>
<pre style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>{cmd}</pre>
</div>
),
okText: 'Run',
onOk: async () => {
setLoading(true);
try {
const out: ExecResult = await execFirewallRaw(cmd);
const stderrText = out?.stderr ? String(out.stderr).trim() : '';
if (stderrText) {
notification.error({
message: 'Command produced Error',
description: stderrText,
});
} else if (out && (out.rc === 0 || out.rc === -1)) {
notification.success({
message: 'Command executed successfully',
});
if (props.refreshRules) await props.refreshRules();
if (props.onCreated) await props.onCreated();
} else {
const info = out
? `rc:${out.rc}` +
(out.stdout ? ` stdout:${out.stdout}` : '') +
(out.stderr ? ` stderr:${out.stderr}` : '')
: 'unknown result';
notification.error({
message: 'Command failed',
description: info,
});
}
} catch (err: any) {
notification.error({
message: 'Execution failed',
description: err?.message ?? String(err),
});
} finally {
setLoading(false);
}
},
});
} catch (err) {
schedulePreviewUpdate();
}
},
[form, props.refreshRules, props.onCreated, schedulePreviewUpdate],
);
const chainOptions = useMemo(() => {
const ts = form.getFieldValue('tableSelect');
if (!ts) return [];
const [f, n] = String(ts).split(':');
const tbl = props.tables?.find((t) => t.family === f && t.name === n);
if (!tbl) return [];
return tbl.chains.map((c) => (
<Option key={c.name} value={c.name}>
{c.name}
</Option>
));
}, [form, props.tables]);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
if (props.refreshRules) {
await props.refreshRules();
message.success('Rules refresh requested');
} else {
message.info('No refresh function provided by parent.');
}
} catch (err) {
console.warn('refresh failed', err);
message.error('Refresh failed');
} finally {
updateInsertOptions();
setRefreshing(false);
}
}, [props.refreshRules, updateInsertOptions]);
/* helper styles */
const tokenRowStyle: React.CSSProperties = {
display: 'flex',
gap: 8,
alignItems: 'center',
flexWrap: 'nowrap',
width: '100%',
};
const leftControlsStyle: React.CSSProperties = {
display: 'flex',
gap: 8,
alignItems: 'center',
minWidth: 72,
flex: '0 0 72px',
};
const typeSelectStyle: React.CSSProperties = { minWidth: 180, maxWidth: 260, flex: '0 0 220px' };
const fieldSelectStyle: React.CSSProperties = { minWidth: 160, maxWidth: 260, flex: '0 0 220px' };
const valueInputStyle: React.CSSProperties = { minWidth: 120, flex: '1 1 240px', maxWidth: '60%' };
const actionControlsStyle: React.CSSProperties = {
minWidth: 96,
flex: '0 0 96px',
display: 'flex',
justifyContent: 'flex-end',
};
return (
<Card title="Firewall Rule Builder" extra>
<Form
layout="vertical"
form={form}
initialValues={{
action: 'drop',
nfqueue: 1,
nfqueue_bypass: false,
tableSelect: tableOptions.length > 0 ? tableOptions[0].value : undefined,
tokens: [],
}}
onFinish={handleCreate}
onValuesChange={onValuesChange}
>
{/* Table / chain */}
> =
<Row gutter={16} align="middle">
<Col xs={24} sm={12}>
<Form.Item name="tableSelect" label="Table (family:name)" rules={[{ required: true }]}>
@@ -717,7 +71,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
</Col>
</Row>
{/* Insert before */}
<Row gutter={16} align="middle">
<Col xs={24} sm={12}>
<Form.Item
@@ -750,7 +104,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
<Divider />
{/* Token builder header + add control */}
<Row align="middle" justify="space-between" style={{ marginBottom: 8 }}>
<Col>
<Text strong>Token builder</Text>
@@ -766,7 +120,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
<Select
placeholder="Add token..."
onSelect={(val: TokenType) => {
// sensible defaults per token type
const defaultData =
val === 'meta'
? { field: 'iifname', value: '' }
@@ -823,7 +176,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
<Divider />
{/* Tokens Form.List rendering */}
<Form.List name="tokens">
{(fields, { remove, move }) =>
fields.length === 0 ? (
@@ -842,7 +195,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flex: 1, minWidth: 0 }}>
{/* Token type select */}
<Form.Item name={[field.name, 'type']} style={{ marginBottom: 0 }}>
<Select style={typeSelectStyle}>
{Object.keys(TOKEN_FIELD_OPTIONS).map((k) => (
@@ -853,7 +206,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
</Select>
</Form.Item>
{/* Token field + value UI (depends on token type and subfield) */}
<Form.Item
shouldUpdate={(prev, cur) =>
prev.tokens?.[field.name]?.type !== cur.tokens?.[field.name]?.type ||
@@ -865,7 +218,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
const tokenType = form.getFieldValue(['tokens', field.name, 'type']) as TokenType | undefined;
const options = tokenType ? TOKEN_FIELD_OPTIONS[tokenType] || [] : [];
// COUNTER special-case
if (tokenType === 'counter') {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
@@ -885,7 +237,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// LIMIT special-case
if (tokenType === 'limit') {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
@@ -903,7 +254,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// LOG special-case
if (tokenType === 'log') {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
@@ -941,7 +291,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// QUEUE special-case inside token list (separate from NFQUEUE action)
if (tokenType === 'queue') {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
@@ -961,7 +310,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// NAT special-case
if (tokenType === 'nat') {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
@@ -983,7 +331,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// Generic tokens with subfield dropdown
if (options.length > 0) {
return (
<div
@@ -1017,9 +364,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
const meta = opts.find((o) => o.value === selField);
const kind = meta?.kind ?? 'string';
/* --- Field-specific UIs & helpers (placeholders + explanatory text) --- */
// STRING typed helpers for interface names
if (tType === 'meta' && (selField === 'iifname' || selField === 'oifname')) {
return (
<div>
@@ -1034,7 +379,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// L4PROTO dropdown for meta.l4proto
if (tType === 'meta' && selField === 'l4proto') {
return (
<div>
@@ -1052,7 +396,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// Day of week (meta.day)
if (tType === 'meta' && selField === 'day') {
return (
<div>
@@ -1072,7 +415,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// Hour range (meta.hour) — free text but show placeholder/range hint
if (tType === 'meta' && selField === 'hour') {
return (
<div>
@@ -1087,7 +429,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// Packet type (meta.pkttype)
if (tType === 'meta' && selField === 'pkttype') {
return (
<div>
@@ -1107,7 +448,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// Packet/conn mark
if ((tType === 'meta' || tType === 'ct') && selField === 'mark') {
return (
<div>
@@ -1121,7 +461,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// skuid / skgid
if (tType === 'meta' && (selField === 'skuid' || selField === 'skgid')) {
return (
<div>
@@ -1140,7 +479,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// CT state
if (tType === 'ct' && selField === 'state') {
return (
<div>
@@ -1165,7 +503,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// CT direction
if (tType === 'ct' && selField === 'direction') {
return (
<div>
@@ -1185,8 +522,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
/* --- IP / IP6 address helpers --- */
if (
if (
(tType === 'ip' || tType === 'ip6') &&
(selField === 'saddr' || selField === 'daddr')
) {
@@ -1214,7 +550,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// protocol / nexthdr / ip.protocol (L4 protocol): show l4proto list
if (
(tType === 'ip' && selField === 'protocol') ||
(tType === 'ip6' && selField === 'nexthdr')
@@ -1238,7 +573,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// DSCP
if ((tType === 'ip' || tType === 'ip6') && selField === 'dscp') {
return (
<div>
@@ -1258,7 +592,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// TTL / hoplimit numeric
if (
(tType === 'ip' && selField === 'ttl') ||
(tType === 'ip6' && selField === 'hoplimit')
@@ -1278,7 +611,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// IP fragment bits (frag-off) — single string placeholder
if (tType === 'ip' && selField === 'frag-off') {
return (
<div>
@@ -1292,9 +624,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
/* --- Transport: TCP/UDP/ICMP --- */
// Ports: allow numeric or service name
if (
(tType === 'tcp' || tType === 'udp') &&
(selField === 'dport' || selField === 'sport')
@@ -1311,7 +641,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// TCP flags multi-select
if (tType === 'tcp' && selField === 'flags') {
return (
<div>
@@ -1335,7 +664,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// ICMP type/code dropdowns
if (tType === 'icmp' && selField === 'type') {
return (
<div>
@@ -1374,8 +702,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
/* --- Layer 2: Ethernet / VLAN --- */
if (tType === 'ether') {
if (selField === 'saddr' || selField === 'daddr') {
return (
@@ -1408,7 +735,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
}
}
// VLAN ID / PCP / CFI
if (tType === 'vlan') {
if (selField === 'id') {
return (
@@ -1459,8 +785,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
}
}
/* --- Payload / default free text input --- */
if (tType === 'payload' || (kind === 'string' && !selField)) {
if (tType === 'payload' || (kind === 'string' && !selField)) {
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
@@ -1474,7 +799,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
);
}
// Default fallback free-text with helpful examples
return (
<div>
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
@@ -1515,8 +839,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
<Divider />
{/* Action + NFQUEUE + Reject options: render action radios, then render
reject subform and nfqueue subform directly under it (same column) */}
<Row gutter={16} align="top">
<Col xs={24} sm={12}>
<Form.Item name="action" label="Action / verdict" rules={[{ required: true }]}>
@@ -1528,7 +851,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
</Radio.Group>
</Form.Item>
{/* Reject options (render under radios, same column) */}
<Form.Item shouldUpdate={(prev, cur) => prev.action !== cur.action} noStyle>
{() =>
form.getFieldValue('action') === 'reject' ? (
@@ -1547,7 +870,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
</Select>
</Form.Item>
{/* IPv4 reject reasons */}
<Form.Item shouldUpdate={(prev, cur) => prev.rejectType !== cur.rejectType} noStyle>
{() =>
form.getFieldValue('rejectType') === 'icmp' ? (
@@ -1572,7 +895,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
}
</Form.Item>
{/* IPv6 reject reasons */}
<Form.Item shouldUpdate={(prev, cur) => prev.rejectType !== cur.rejectType} noStyle>
{() =>
form.getFieldValue('rejectType') === 'icmpv6' ? (
@@ -1597,7 +920,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
}
</Form.Item>
{/* icmpx (inet) */}
<Form.Item shouldUpdate={(prev, cur) => prev.rejectType !== cur.rejectType} noStyle>
{() =>
form.getFieldValue('rejectType') === 'icmpx' ? (
@@ -1628,7 +951,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
}
</Form.Item>
{/* NFQUEUE options (now rendered under radios in same column) */}
<Form.Item shouldUpdate={(prev, cur) => prev.action !== cur.action} noStyle>
{() =>
form.getFieldValue('action') === 'nfqueue' ? (
@@ -1648,7 +971,6 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
<Form.Item name="nfqueue_bypass" valuePropName="checked">
<Checkbox
onChange={() => {
// update preview immediately
schedulePreviewUpdate();
}}
>
@@ -1665,7 +987,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
</Form.Item>
</Col>
{/* right column is free for notes / quick helpers */}
<Col xs={24} sm={12}>
<Text type="secondary">
Use NFQUEUE to hand packets to userspace. Full reject support requires kernel &gt;= 3.18 — when using
@@ -1674,7 +996,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
</Col>
</Row>
{/* advanced text */}
<Row>
<Col span={24}>
<Form.Item name="advanced" label="Advanced (optional)">
@@ -1688,7 +1010,7 @@ export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps)
<Divider />
{/* preview + run */}
<Form.Item>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', width: '100%' }}>
<div style={{ flex: 1 }}>

View File

@@ -1,4 +1,3 @@
// src/components/FirewallTables.tsx
import { ArrowDownOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
import { Alert, Button, Card, Divider, Modal, notification, Space, Spin, Table, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table';
@@ -10,7 +9,6 @@ import FirewallAddTableModal from './FireWallAddTableModal';
const { Paragraph, Text, Title } = Typography;
/* ---------- Helpers ---------- */
function renderRuleFriendly(rule: RuleOut | any): string {
if (rule?.text && typeof rule.text === 'string' && rule.text.trim() !== '') return rule.text;
@@ -103,7 +101,6 @@ function renderRuleFriendly(rule: RuleOut | any): string {
try {
return JSON.stringify(rule.expr, (_k, v) => (v === undefined ? null : v)).slice(0, 500);
} catch {
// fallthrough
}
}
@@ -120,22 +117,18 @@ function isSuccessRc(out?: ExecResult | null): boolean {
return out.rc === 0 || out.rc === -1;
}
/* ---------- Props ---------- */
type Props = {
tables: TableOut[]; // passed from parent
error?: Error | null;
refreshRules: () => Promise<void>; // trigger to re-fetch ruleset
};
/* ---------- Component ---------- */
export default function FirewallTables({ tables, error, refreshRules: refresh }: Props): ReactElement {
// local UI state, non-persistent
const [refreshing, setRefreshing] = useState(false);
const [isOpenTableCreatorModal, setIsOpenTableCreatorModal] = useState(false);
const [isOpenChainCreatorModal, setIsOpenChainCreatorModal] = useState(false);
// run raw nft commands sequentially and collect results (used for delete ops etc.)
const runCommands = useCallback(async (cmds: string[]) => {
const acc: CmdResult[] = [];
for (const cmd of cmds) {
@@ -150,7 +143,6 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
return acc;
}, []);
// Delete rule
const handleDeleteRule = useCallback(
async (family: string | null | undefined, table: string, chain: string, handle: number | string) => {
const cmd = `delete rule ${family ?? 'inet'} ${table} ${chain} handle ${handle}`;
@@ -175,12 +167,9 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
} catch (err: any) {
notification.error({ message: 'Delete failed', description: err?.message ?? String(err) });
} finally {
// auto-refresh after change (no refresh notification shown here)
try {
await refresh();
} catch {
/* ignore */
}
} catch
}
},
});
@@ -188,7 +177,6 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
[runCommands, refresh],
);
// Delete chain
const handleDeleteChain = useCallback(
async (family: string | null | undefined, table: string, chain: string) => {
const cmd = `delete chain ${family ?? 'inet'} ${table} ${chain}`;
@@ -215,12 +203,9 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
} catch (err: any) {
notification.error({ message: 'Chain deletion failed', description: err?.message ?? String(err) });
} finally {
// auto-refresh after change (no notification)
try {
await refresh();
} catch {
/* ignore */
}
} catch
}
},
});
@@ -228,7 +213,6 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
[runCommands, refresh],
);
// Delete table
const handleDeleteTable = useCallback(
async (family: string | null | undefined, table: string) => {
const cmd = `delete table ${family ?? 'inet'} ${table}`;
@@ -255,12 +239,9 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
} catch (err: any) {
notification.error({ message: 'Table deletion failed', description: err?.message ?? String(err) });
} finally {
// auto-refresh after change (no notification)
try {
await refresh();
} catch {
/* ignore */
}
} catch
}
},
});
@@ -268,12 +249,10 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
[runCommands, refresh],
);
// manual refresh trigger (exposed to UI)
const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
await refresh();
// Only show notification when user pressed the refresh button
notification.success({ message: 'Ruleset refreshed' });
} catch (err: any) {
notification.error({ message: 'Refresh failed', description: err?.message ?? String(err) });
@@ -289,14 +268,12 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
return (
<>
{/* Pass onClose that accepts optional 'created' boolean. If the modal
calls onClose(true) we will auto-refresh (no refresh notification). */}
<FirewallAddTableModal
open={isOpenTableCreatorModal}
onClose={(created?: boolean) => {
setIsOpenTableCreatorModal(false);
if (created) {
// auto-refresh after create (no notification)
void refresh().catch(() => {});
}
}}
@@ -361,7 +338,7 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
</div>
}
>
{/* Chain modal: same optional 'created' signal */}
<FirewallAddChainModal
open={isOpenChainCreatorModal}
onClose={(created?: boolean) => {

View File

@@ -1,4 +1,3 @@
// src/pages/ScriptsManager.tsx
import {
DeleteOutlined,
DownloadOutlined,
@@ -65,49 +64,36 @@ const { Paragraph } = Typography;
*/
export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (code: string) => void }) {
// --------------------
// State
// --------------------
const [scripts, setScripts] = useState<ScriptWithStatus[]>([]);
const [loading, setLoading] = useState(false);
// modals & editor values
const [uploadModalVisible, setUploadModalVisible] = useState(false);
const [editorModalVisible, setEditorModalVisible] = useState(false);
const [editorValue, setEditorValue] = useState<string>('');
const [currentEditingName, setCurrentEditingName] = useState<string | null>(null);
// inline requirements editor toggle + value for upload modal
const [useInlineReqEditor, setUseInlineReqEditor] = useState(false);
const [inlineReqValue, setInlineReqValue] = useState<string>('');
// pip output modal
const [pipModalVisible, setPipModalVisible] = useState(false);
const [pipOutput, setPipOutput] = useState<{ stdout?: string; stderr?: string } | null>(null);
// requirements editor modal (existing per-script editor)
const [reqModalVisible, setReqModalVisible] = useState(false);
const [reqEditorValue, setReqEditorValue] = useState<string>('');
const [reqEditingName, setReqEditingName] = useState<string | null>(null);
// add-requirements modal (for scripts that have no requirements)
const [addReqModalVisible, setAddReqModalVisible] = useState(false);
const [addReqTarget, setAddReqTarget] = useState<string | null>(null);
const [addReqUseInline, setAddReqUseInline] = useState(false);
const [addReqInlineValue, setAddReqInlineValue] = useState('');
const [addReqFile, setAddReqFile] = useState<File | null>(null);
// enable modal
const [enableModalVisible, setEnableModalVisible] = useState(false);
const [enableTarget, setEnableTarget] = useState<string | null>(null);
// forms
const [form] = Form.useForm();
const [enableForm] = Form.useForm();
// --------------------
// Data fetch
// --------------------
const refreshAll = useCallback(async () => {
setLoading(true);
try {
@@ -148,9 +134,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
refreshAll();
}, [refreshAll]);
// --------------------
// Helpers
// --------------------
const getMappingsForScript = useCallback(
(scriptName: string) => {
const s = scripts.find((x) => x.name === scriptName);
@@ -171,7 +154,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
[scripts],
);
// Focus helper: tries to find a textarea inside the modal and put caret at the end.
const focusEditorInModal = useCallback((modalSelector = '.ant-modal') => {
setTimeout(() => {
try {
@@ -184,7 +166,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
ta.setSelectionRange(val.length, val.length);
}
} catch {
// ignore
}
}, 80);
}, []);
@@ -205,7 +186,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
if (addReqModalVisible) focusEditorInModal('.ant-modal');
}, [addReqModalVisible, focusEditorInModal]);
// show pip modal if pip output is present
const showPipIfPresent = useCallback(
(resp?: ScriptUploadResponse | { pip?: { stdout?: string; stderr?: string } } | null) => {
const pip = (resp as any)?.pip;
@@ -217,9 +197,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
[],
);
// --------------------
// API Handlers
// --------------------
const handleDownload = useCallback(async (name: string) => {
try {
const blob = await downloadScript(name);
@@ -358,9 +335,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
[refreshAll],
);
// --------------------
// Upload handler (prefers file; falls back to inline editors)
// --------------------
const handleUpload = useCallback(
async (formValues: any) => {
const { name, scriptFile, requirementsFile } = formValues;
@@ -369,10 +343,8 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
return;
}
// script: prefer uploaded file; else use editorValue
const script = scriptFile || new Blob([editorValue], { type: 'text/x-python' });
// requirements: prefer uploaded file; else use inlineReqValue if toggle enabled; else null
let req: File | Blob | null = null;
if (requirementsFile) {
req = requirementsFile;
@@ -476,10 +448,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
[refreshAll],
);
// --------------------
// Upload form helpers
// --------------------
// Normalize Upload event to return single File object for Form storage
const normFile = (e: any) => {
if (!e) return undefined;
const list: UploadFile[] = e.fileList || [];
@@ -488,7 +456,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
return (last as any).originFileObj ?? last;
};
// Icon-only button wrapped in Tooltip
function IconButtonTooltip({
title,
onClick,
@@ -509,9 +476,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
);
}
// --------------------
// Add Requirements (for scripts missing requirements)
// --------------------
const openAddRequirements = useCallback((scriptName: string) => {
setAddReqTarget(scriptName);
setAddReqUseInline(false);
@@ -549,7 +513,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
}
}, [addReqFile, addReqInlineValue, addReqTarget, refreshAll, showPipIfPresent]);
// helper for Upload change inside add-req modal
const onAddReqUploadChange = useCallback((info: any) => {
const list: UploadFile[] = info.fileList || [];
if (list.length === 0) {
@@ -560,9 +523,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
setAddReqFile(last.originFileObj ?? last);
}, []);
// --------------------
// Table columns
// --------------------
const columns: ColumnsType<ScriptWithStatus> = useMemo(
() => [
{
@@ -715,9 +675,6 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
[getMappingsForScript, nestedColumns],
);
// --------------------
// Render
// --------------------
return (
<div>
<Row justify="space-between" style={{ marginBottom: 12 }}>
@@ -740,7 +697,7 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
expandable={expandable}
/>
{/* Upload Modal (wider) */}
<Modal
open={uploadModalVisible}
title="Upload or create script"
@@ -816,7 +773,7 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
</Form>
</Modal>
{/* Editor Modal */}
<Modal
open={editorModalVisible}
title={currentEditingName ? `Editing — ${currentEditingName}` : 'Editor'}
@@ -874,7 +831,7 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
<PythonEditor value={editorValue} onChange={setEditorValue} height={520} />
</Modal>
{/* Requirements Editor Modal (per-script) */}
<Modal
open={reqModalVisible}
title={reqEditingName ? `requirements.txt — ${reqEditingName}` : 'requirements.txt'}
@@ -913,7 +870,7 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
<PythonEditor value={reqEditorValue} onChange={setReqEditorValue} height={420} />
</Modal>
{/* Add Requirements Modal (for scripts without requirements) */}
<Modal
open={addReqModalVisible}
title={addReqTarget ? `Add requirements — ${addReqTarget}` : 'Add requirements'}
@@ -967,7 +924,7 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
</div>
</Modal>
{/* Pip output modal */}
<Modal
open={pipModalVisible}
title="pip install output"
@@ -986,7 +943,7 @@ export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (c
</div>
</Modal>
{/* Enable Modal */}
<Modal
open={enableModalVisible}
title={`Enable ${enableTarget ?? ''}`}

View File

@@ -1,4 +1,3 @@
// src/components/Sniffing.tsx
import {
CheckCircleOutlined,
ExclamationCircleOutlined,
@@ -23,6 +22,7 @@ import {
Typography,
} from 'antd';
import { ReactElement, useMemo, useState } from 'react';
import { startSniffer, stopSniffer, stopSnifferByInterface } from '../api/apiClient';
import { BridgeInfo, InterfaceInfo } from '../types/network';
import { InterfaceSnifferStatus } from '../types/sniffer';
@@ -41,69 +41,66 @@ interface SnifferManagerProps {
}
export default function SnifferManager(props: SnifferManagerProps): ReactElement {
// local UI state
// modal / form
const [isModalOpen, setIsModalOpen] = useState(false);
const [startMode, setStartMode] = useState<'interface' | 'bridge'>('interface');
const [form] = Form.useForm();
// derived entries
const statusEntries = useMemo(
() => Object.entries(props.statusMap) as [string, InterfaceSnifferStatus][],
[props.statusMap],
);
// open/close modal
const onOpenStartModal = () => {
form.resetFields();
setStartMode('interface');
setIsModalOpen(true);
};
const onCloseModal = () => setIsModalOpen(false);
// start submit
const handleStartSubmit = async (values: any) => {
const { target } = values;
const onCloseModal = () => {
setIsModalOpen(false);
};
const handleStartSubmit = async (values: { target?: string }) => {
const target = values.target;
if (!target) {
notification.warning({ message: 'Warning', description: 'Please select a target to start sniffing on.' });
return;
}
try {
const payload = startMode === 'interface' ? { interface: target } : { bridge: target };
const res = await startSniffer(payload);
const result = await startSniffer(payload);
notification.success({
message: 'Sniffer started',
description: `Sniffer started on ${target} (session ${res.session_id})`,
description: `Sniffer started on ${target} (session ${result.session_id})`,
});
await props.refreshAll();
setIsModalOpen(false);
} catch (err: any) {
console.error('startSniffer error', err);
notification.error(err?.message ?? 'Failed to start sniffer');
} finally {
} catch (error: any) {
console.error('startSniffer error', error);
notification.error({
message: 'Failed to start sniffer',
description: error?.message ?? 'Failed to start sniffer',
});
}
};
// stop all
const handleStopAll = async () => {
try {
await stopSniffer();
notification.success({
message: 'All sniffers stopped',
});
notification.success({ message: 'All sniffers stopped' });
await props.refreshStatus();
} catch (err: any) {
console.error('stopSniffer error', err);
notification.error(err?.message ?? 'Failed to stop sniffers');
} finally {
} catch (error: any) {
console.error('stopSniffer error', error);
notification.error({
message: 'Failed to stop sniffers',
description: error?.message ?? 'Failed to stop sniffers',
});
}
};
// stop per-interface
const handleStopFromList = async (ifaceName: string, session_id?: string | null) => {
const handleStopFromList = async (ifaceName: string, sessionId?: string | null) => {
try {
// prefer stop by interface
await stopSnifferByInterface(ifaceName);
notification.success({
message: 'Sniffer stopped',
@@ -111,28 +108,28 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
});
await props.refreshStatus();
return;
} catch (err: any) {
console.error('stopSnifferByInterface error', err);
// try stop by session as fallback
if (session_id) {
try {
await stopSniffer({ session_id });
notification.success({
message: 'Sniffer stopped',
description: `Sniffer stopped on interface ${ifaceName} (session ${session_id})`,
});
await props.refreshStatus();
return;
} catch (err2: any) {
console.error('stopSniffer by session fallback failed', err2);
}
}
notification.error({
message: 'Failed to stop sniffer',
description: err?.message ?? 'Failed to stop sniffer',
});
} finally {
} catch (error: any) {
console.error('stopSnifferByInterface error', error);
}
if (sessionId) {
try {
await stopSniffer({ session_id: sessionId });
notification.success({
message: 'Sniffer stopped',
description: `Sniffer stopped on interface ${ifaceName} (session ${sessionId})`,
});
await props.refreshStatus();
return;
} catch (fallbackError: any) {
console.error('stopSniffer by session fallback failed', fallbackError);
}
}
notification.error({
message: 'Failed to stop sniffer',
description: 'Could not stop sniffer for this interface.',
});
};
return (
@@ -145,11 +142,9 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
<Tooltip title="Start a new sniffer session">
<Button icon={<PlusOutlined />} onClick={onOpenStartModal} />
</Tooltip>
<Tooltip title="Refresh status">
<Button icon={<ReloadOutlined />} onClick={() => props.refreshStatus()} />
</Tooltip>
<Tooltip title="Stop all sniffers">
<Button danger icon={<StopOutlined />} onClick={handleStopAll} />
</Tooltip>
@@ -167,22 +162,19 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
) : (
<List
dataSource={statusEntries}
renderItem={([name, st]: [string, InterfaceSnifferStatus]) => {
const running = st.running;
const exists = st.exists;
const up = st.up;
const session_id = st.session_id ?? null;
const session_label = st.session_label ?? null;
renderItem={([name, status]: [string, InterfaceSnifferStatus]) => {
const sessionId = status.session_id ?? null;
const sessionLabel = status.session_label ?? null;
return (
<List.Item
actions={[
running ? (
status.running ? (
<Button
key="stop"
size="small"
icon={<StopOutlined />}
onClick={() => handleStopFromList(name, session_id)}
onClick={() => handleStopFromList(name, sessionId)}
disabled={props.loading}
>
Stop
@@ -194,21 +186,19 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
type="primary"
icon={<PlayCircleOutlined />}
onClick={async () => {
// start on this interface
try {
const res = await startSniffer({ interface: name });
const result = await startSniffer({ interface: name });
notification.success({
message: 'Sniffer started',
description: `Sniffer started on ${name} (session ${res.session_id})`,
description: `Sniffer started on ${name} (session ${result.session_id})`,
});
await props.refreshStatus();
} catch (err: any) {
console.error('startSniffer quick', err);
} catch (error: any) {
console.error('startSniffer quick', error);
notification.error({
message: 'Failed to start sniffer',
description: err?.message ?? 'Failed to start sniffer',
description: error?.message ?? 'Failed to start sniffer',
});
} finally {
}
}}
>
@@ -221,7 +211,7 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
title={
<Space>
<Text strong>{name}</Text>
{running ? (
{status.running ? (
<Tag icon={<CheckCircleOutlined />} color="success">
running
</Tag>
@@ -230,16 +220,14 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
stopped
</Tag>
)}
{!exists && <Tag color="error">missing</Tag>}
{exists && !up && <Tag color="warning">down</Tag>}
{exists && up && <Tag color="processing">up</Tag>}
{session_id && (
{!status.exists && <Tag color="error">missing</Tag>}
{status.exists && !status.up && <Tag color="warning">down</Tag>}
{status.exists && status.up && <Tag color="processing">up</Tag>}
{sessionId && (
<Tag>
{session_label ? `${session_label}` : 'session'}:{' '}
<Text code copyable={{ text: session_id }}>
{session_id.slice(0, 8)}
{sessionLabel ?? 'session'}:{' '}
<Text code copyable={{ text: sessionId }}>
{sessionId.slice(0, 8)}
</Text>
</Tag>
)}
@@ -254,7 +242,6 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
)}
</Card>
{/* Start sniffer modal */}
<Modal
title="Start sniffer session"
open={isModalOpen}
@@ -267,8 +254,8 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
<Form.Item label="Mode" name="mode">
<Radio.Group
value={startMode}
onChange={(e) => {
setStartMode(e.target.value);
onChange={(event) => {
setStartMode(event.target.value);
form.setFieldsValue({ target: undefined });
}}
>
@@ -291,14 +278,14 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
}
>
{startMode === 'interface'
? props.interfaces.map((i) => (
<Option key={`if:${i.name}`} value={i.name}>
{i.name}
? props.interfaces.map((iface) => (
<Option key={`if:${iface.name}`} value={iface.name}>
{iface.name}
</Option>
))
: props.bridges.map((b) => (
<Option key={`br:${b.ifname}`} value={b.ifname}>
{b.ifname} ({b.members.map((m) => m.ifname).join(', ')})
: props.bridges.map((bridge) => (
<Option key={`br:${bridge.ifname}`} value={bridge.ifname}>
{bridge.ifname} ({bridge.members.map((member) => member.ifname).join(', ')})
</Option>
))}
</Select>

View File

@@ -1,7 +1,7 @@
// src/hooks/useNetwork.ts
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import * as api from "../api/apiClient";
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useState } from 'react';
import * as api from '../api/apiClient';
import type {
BridgeCreateRequest,
BridgeInfo,
@@ -9,24 +9,15 @@ import type {
FullState,
InterfaceInfo,
RouteInfo,
} from "../types/network";
import { SnifferStatusResponse } from "../types/sniffer";
} from '../types/network';
import { SnifferStatusResponse } from '../types/sniffer';
const TEN_SECONDS = 1000 * 10;
const FIVE_SECONDS = 1000 * 5;
/**
* useNetwork
*
* - queries start disabled (no automatic network calls)
* - calling fetchInterfaces()/fetchBridges()/... will:
* 1) fetch and cache the data right away (queryClient.fetchQuery)
* 2) enable the corresponding useQuery so it becomes "active" and will
* auto-refetch based on the query options
*
* This gives "no initial auto-fetch" but "once fetched, auto-updates".
*/
export function useBackendAPI() {
const qc = useQueryClient();
const queryClient = useQueryClient();
// per-query enabled flags (start false => no automatic fetch)
const [interfacesEnabled, setInterfacesEnabled] = useState(false);
const [linksEnabled, setLinksEnabled] = useState(false);
const [routesEnabled, setRoutesEnabled] = useState(false);
@@ -34,132 +25,126 @@ export function useBackendAPI() {
const [fullStateEnabled, setFullStateEnabled] = useState(false);
const [snifferStatusEnabled, setSnifferStatusEnabled] = useState(false);
// common query options once enabled
const commonOptions = {
refetchOnWindowFocus: true,
staleTime: 1000 * 10, // 10s
staleTime: TEN_SECONDS,
};
// Queries (disabled initially)
const interfacesQuery = useQuery<InterfaceInfo[]>({
queryKey: ["interfaces"],
queryKey: ['interfaces'],
queryFn: api.fetchInterfaces,
enabled: interfacesEnabled,
...commonOptions,
});
const linksQuery = useQuery<InterfaceInfo[]>({
queryKey: ["links"],
queryKey: ['links'],
queryFn: api.fetchLinks,
enabled: linksEnabled,
...commonOptions,
});
const routesQuery = useQuery<RouteInfo[]>({
queryKey: ["routes"],
queryKey: ['routes'],
queryFn: api.fetchRoutes,
enabled: routesEnabled,
...commonOptions,
});
const bridgesQuery = useQuery<BridgeInfo[]>({
queryKey: ["bridges"],
queryKey: ['bridges'],
queryFn: api.fetchBridges,
enabled: bridgesEnabled,
...commonOptions,
});
const fullStateQuery = useQuery<FullState>({
queryKey: ["full-state"],
queryKey: ['full-state'],
queryFn: api.fetchFullState,
enabled: fullStateEnabled,
...commonOptions,
});
const snifferStatusQuery = useQuery<SnifferStatusResponse>({
queryKey: ["sniffer-status"],
queryKey: ['sniffer-status'],
queryFn: api.fetchSnifferStatus,
enabled: fullStateEnabled,
enabled: snifferStatusEnabled,
...commonOptions,
});
// Imperative fetch helpers that also enable auto-refetch behavior
const fetchInterfaces = useCallback(async () => {
const res = await qc.fetchQuery<InterfaceInfo[]>({
queryKey: ["interfaces"],
const result = await queryClient.fetchQuery<InterfaceInfo[]>({
queryKey: ['interfaces'],
queryFn: api.fetchInterfaces,
staleTime: 1000 * 10
staleTime: TEN_SECONDS,
});
setInterfacesEnabled(true);
return res;
}, [qc]);
return result;
}, [queryClient]);
const fetchLinks = useCallback(async () => {
const res = await qc.fetchQuery<InterfaceInfo[]>({
queryKey: ["links"],
const result = await queryClient.fetchQuery<InterfaceInfo[]>({
queryKey: ['links'],
queryFn: api.fetchLinks,
staleTime: 1000 * 10
staleTime: TEN_SECONDS,
});
setLinksEnabled(true);
return res;
}, [qc]);
return result;
}, [queryClient]);
const fetchRoutes = useCallback(async () => {
const res = await qc.fetchQuery<RouteInfo[]>({
queryKey: ["routes"],
const result = await queryClient.fetchQuery<RouteInfo[]>({
queryKey: ['routes'],
queryFn: api.fetchRoutes,
staleTime: 1000 * 10
staleTime: TEN_SECONDS,
});
setRoutesEnabled(true);
return res;
}, [qc]);
return result;
}, [queryClient]);
const fetchBridges = useCallback(async () => {
const res = await qc.fetchQuery<BridgeInfo[]>({
queryKey: ["bridges"],
const result = await queryClient.fetchQuery<BridgeInfo[]>({
queryKey: ['bridges'],
queryFn: api.fetchBridges,
staleTime: 1000 * 10
staleTime: TEN_SECONDS,
});
setBridgesEnabled(true);
return res;
}, [qc]);
return result;
}, [queryClient]);
const fetchFullState = useCallback(async () => {
const res = await qc.fetchQuery<FullState>({
queryKey: ["full-state"],
const result = await queryClient.fetchQuery<FullState>({
queryKey: ['full-state'],
queryFn: api.fetchFullState,
staleTime: 1000 * 5
staleTime: FIVE_SECONDS,
});
setFullStateEnabled(true);
return res;
}, [qc]);
return result;
}, [queryClient]);
const fetchSnifferStatus = useCallback(async () => {
const res = await qc.fetchQuery<SnifferStatusResponse>({
queryKey: ["sniffer-status"],
const result = await queryClient.fetchQuery<SnifferStatusResponse>({
queryKey: ['sniffer-status'],
queryFn: api.fetchSnifferStatus,
staleTime: 1000 * 5
staleTime: FIVE_SECONDS,
});
setFullStateEnabled(true);
return res;
}, [qc]);
setSnifferStatusEnabled(true);
return result;
}, [queryClient]);
// Local loading state for simple UI feedback
const [isCreating, setIsCreating] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
// Simple imperative functions that call the API and invalidate queries
async function createBridge(payload: BridgeCreateRequest) {
setIsCreating(true);
try {
await api.createBridge(payload);
// If the query is enabled it will refetch automatically after invalidation.
await qc.invalidateQueries({ queryKey: ["bridges"] });
await qc.invalidateQueries({ queryKey: ["full-state"] });
await qc.invalidateQueries({ queryKey: ["interfaces"] });
} catch (err) {
await queryClient.invalidateQueries({ queryKey: ['bridges'] });
await queryClient.invalidateQueries({ queryKey: ['full-state'] });
await queryClient.invalidateQueries({ queryKey: ['interfaces'] });
} catch (error) {
const message =
err instanceof Error ? err.message : typeof err === "string" ? err : "Create bridge failed";
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Create bridge failed';
throw new Error(message);
} finally {
setIsCreating(false);
@@ -170,45 +155,43 @@ export function useBackendAPI() {
setIsRemoving(true);
try {
await api.removeBridge(payload);
await qc.invalidateQueries({ queryKey: ["bridges"] });
await qc.invalidateQueries({ queryKey: ["full-state"] });
await qc.invalidateQueries({ queryKey: ["interfaces"] });
await qc.invalidateQueries({ queryKey: ["sniffer-status"] });
} catch (err) {
await queryClient.invalidateQueries({ queryKey: ['bridges'] });
await queryClient.invalidateQueries({ queryKey: ['full-state'] });
await queryClient.invalidateQueries({ queryKey: ['interfaces'] });
await queryClient.invalidateQueries({ queryKey: ['sniffer-status'] });
} catch (error) {
const message =
err instanceof Error ? err.message : typeof err === "string" ? err : "Remove bridge failed";
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Remove bridge failed';
throw new Error(message);
} finally {
setIsRemoving(false);
}
}
// Convenience: invalidate helpers
function refreshInterfaces() {
return qc.invalidateQueries({ queryKey: ["interfaces"] });
return queryClient.invalidateQueries({ queryKey: ['interfaces'] });
}
function refreshLinks() {
return qc.invalidateQueries({ queryKey: ["links"] });
return queryClient.invalidateQueries({ queryKey: ['links'] });
}
function refreshRoutes() {
return qc.invalidateQueries({ queryKey: ["routes"] });
return queryClient.invalidateQueries({ queryKey: ['routes'] });
}
function refreshBridges() {
return qc.invalidateQueries({ queryKey: ["bridges"] });
return queryClient.invalidateQueries({ queryKey: ['bridges'] });
}
function refreshFullState() {
return qc.invalidateQueries({ queryKey: ["full-state"] });
return queryClient.invalidateQueries({ queryKey: ['full-state'] });
}
function refreshSnifferStatus() {
return qc.invalidateQueries({ queryKey: ["sniffer-status"] });
return queryClient.invalidateQueries({ queryKey: ['sniffer-status'] });
}
// Convenience: refresh all queries
function refreshAll() {
refreshInterfaces();
refreshLinks();
@@ -219,39 +202,28 @@ export function useBackendAPI() {
}
return {
// queries
interfacesQuery,
linksQuery,
routesQuery,
bridgesQuery,
fullStateQuery,
snifferStatusQuery,
// manual fetchers (fetch+enable auto-updates)
fetchInterfaces,
fetchLinks,
fetchRoutes,
fetchBridges,
fetchFullState,
fetchSnifferStatus,
// simple mutation functions (imperative)
createBridge,
removeBridge,
// local loading flags
isCreating,
isRemoving,
// invalidate helpers
refreshInterfaces,
refreshLinks,
refreshRoutes,
refreshBridges,
refreshFullState,
refreshSnifferStatus,
// refresh all
refreshAll,
};
}

View File

@@ -1,4 +1,3 @@
// src/pages/BridgesManager.tsx
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import {
Button,
@@ -18,9 +17,9 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useEffect, useMemo, useState } from 'react';
import { createBridge, fetchFullState, removeBridge } from '../api/apiClient';
import type { BridgeInfo, InterfaceInfo } from '../types/network';
import { FullState } from '../types/network';
import type { BridgeInfo, FullState, InterfaceInfo } from '../types/network';
const { Title, Paragraph } = Typography;
@@ -29,19 +28,19 @@ export default function Network() {
const [bridgeForm] = Form.useForm();
const [networkState, setNetworkState] = useState<FullState>();
const getFullState = (auto: boolean = false) => {
const getFullState = (silent = false) => {
fetchFullState()
.then((interfaces) => {
setNetworkState(interfaces);
if (!auto) {
.then((state) => {
setNetworkState(state);
if (!silent) {
notification.success({
message: 'Success',
description: 'Network state updated.',
});
}
})
.catch((err) => {
console.error('Failed to fetch interfaces:', err);
.catch((error) => {
console.error('Failed to fetch network state:', error);
notification.error({
message: 'Error',
description: 'Failed to fetch network state.',
@@ -49,39 +48,36 @@ export default function Network() {
});
};
// fetch on mount (explicit, since queries are disabled by default)
useEffect(() => {
getFullState(true);
}, []);
// build select options from interfaces list
const interfaceOptions = useMemo(
() =>
networkState?.interfaces.map((it: InterfaceInfo) => ({
label: it.name,
value: it.name,
networkState?.interfaces.map((iface: InterfaceInfo) => ({
label: iface.name,
value: iface.name,
})) ?? [],
[networkState],
);
// Columns for interfaces table (read-only)
const interfaceColumns: ColumnsType<InterfaceInfo> = useMemo(
() => [
{ title: 'IfIndex', dataIndex: 'ifindex', key: 'ifindex', width: 90 },
{ title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'State', dataIndex: 'state', key: 'state', render: (s) => <Tag>{s}</Tag> },
{ title: 'MAC', dataIndex: 'mac', key: 'mac', render: (m) => m ?? '—' },
{ title: 'State', dataIndex: 'state', key: 'state', render: (state) => <Tag>{state}</Tag> },
{ title: 'MAC', dataIndex: 'mac', key: 'mac', render: (mac) => mac ?? '—' },
{ title: 'MTU', dataIndex: 'mtu', key: 'mtu', width: 90 },
{
title: 'Addresses',
dataIndex: 'addresses',
key: 'addresses',
render: (addrs: any[]) =>
addrs?.length ? (
<Space orientation="vertical">
{addrs.map((a) => (
<span key={`${a.address}/${a.prefixlen}`}>
{a.address}/{a.prefixlen} ({a.family})
render: (addresses: any[]) =>
addresses?.length ? (
<Space direction="vertical">
{addresses.map((address) => (
<span key={`${address.address}/${address.prefixlen}`}>
{address.address}/{address.prefixlen} ({address.family})
</span>
))}
</Space>
@@ -93,23 +89,22 @@ export default function Network() {
[],
);
// Columns for bridges table (with remove action)
const bridgeColumns: ColumnsType<BridgeInfo> = useMemo(
() => [
{ title: 'IfIndex', dataIndex: 'ifindex', key: 'ifindex', width: 90 },
{ title: 'Name', dataIndex: 'ifname', key: 'ifname' },
{ title: 'State', dataIndex: 'state', key: 'state', render: (s) => <Tag>{s ?? '—'}</Tag> },
{ title: 'State', dataIndex: 'state', key: 'state', render: (state) => <Tag>{state ?? '—'}</Tag> },
{
title: 'Members',
dataIndex: 'members',
key: 'members',
render: (members: any[]) => (members?.length ? members.map((m) => m.ifname).join(', ') : '—'),
render: (members: any[]) => (members?.length ? members.map((member) => member.ifname).join(', ') : '—'),
},
{
title: 'Actions',
key: 'actions',
width: 140,
render: (_: any, record: BridgeInfo) => (
render: (_, record: BridgeInfo) => (
<Popconfirm
title={`Remove bridge ${record.ifname}?`}
onConfirm={() => handleRemoveBridge(record.ifname)}
@@ -121,11 +116,12 @@ export default function Network() {
),
},
],
[networkState],
[],
);
async function handleCreateBridge(values: { name: string; interfaces?: string[] }) {
const ifaceList = values.interfaces ?? [];
createBridge({ name: values.name, interfaces: ifaceList })
function handleCreateBridge(values: { name: string; interfaces?: string[] }) {
const interfaces = values.interfaces ?? [];
createBridge({ name: values.name, interfaces })
.then(() => {
notification.success({
message: 'Success',
@@ -135,11 +131,11 @@ export default function Network() {
getFullState(true);
bridgeForm.resetFields();
})
.catch((err) => {
console.error(err);
.catch((error) => {
console.error(error);
notification.error({
message: 'Error',
description: (err as Error).message ?? 'Failed to create bridge',
description: (error as Error).message ?? 'Failed to create bridge',
});
});
}
@@ -153,11 +149,11 @@ export default function Network() {
});
getFullState(true);
})
.catch((err) => {
console.error(err);
.catch((error) => {
console.error(error);
notification.error({
message: 'Error',
description: (err as Error).message ?? 'Failed to remove bridge',
description: (error as Error).message ?? 'Failed to remove bridge',
});
});
}
@@ -166,28 +162,21 @@ export default function Network() {
<div style={{ padding: 16 }}>
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
<Col>
<Title level={2}> Network Management</Title>
<Title level={2}>Network Management</Title>
<Paragraph type="secondary">View system interfaces and manage network bridges.</Paragraph>
</Col>
<Col>
<Space>
<Button
icon={<ReloadOutlined />}
onClick={() => {
getFullState();
}}
>
Refresh
</Button>
</Space>
<Button icon={<ReloadOutlined />} onClick={() => getFullState()}>
Refresh
</Button>
</Col>
</Row>
<Row gutter={16}>
<Col span={14}>
<Card title={`Interfaces (${networkState?.interfaces.length})`} style={{ overflow: 'auto' }}>
<Card title={`Interfaces (${networkState?.interfaces.length ?? 0})`} style={{ overflow: 'auto' }}>
<Table
rowKey={(r: InterfaceInfo) => r.ifindex}
rowKey={(row: InterfaceInfo) => row.ifindex}
dataSource={networkState?.interfaces ?? []}
columns={interfaceColumns}
pagination={{ pageSize: 8 }}
@@ -198,14 +187,13 @@ export default function Network() {
<Col span={10}>
<Card
title={`Bridges (${networkState?.bridges.length})`}
title={`Bridges (${networkState?.bridges.length ?? 0})`}
style={{ overflow: 'auto' }}
extra={
<Button
icon={<PlusOutlined />}
type="primary"
onClick={() => {
// ensure up-to-date interface list when opening modal
getFullState(true);
setBridgeModalVisible(true);
}}
@@ -213,7 +201,7 @@ export default function Network() {
}
>
<Table
rowKey={(r: BridgeInfo) => String(r.ifindex)}
rowKey={(row: BridgeInfo) => String(row.ifindex)}
dataSource={networkState?.bridges ?? []}
columns={bridgeColumns}
pagination={{ pageSize: 6 }}
@@ -223,7 +211,6 @@ export default function Network() {
</Col>
</Row>
{/* Create Bridge Modal */}
<Modal
title="Create Bridge"
open={bridgeModalVisible}
@@ -238,7 +225,6 @@ export default function Network() {
<Input placeholder="e.g. br0" />
</Form.Item>
{/* Select field populated from interfaces endpoint */}
<Form.Item name="interfaces" label="Interfaces (select one or more)">
<Select
mode="multiple"

View File

@@ -1,6 +1,6 @@
// src/components/Sniffing.tsx
import { Col, message, Row, Select, Typography } from 'antd';
import { Col, message, Row, Typography } from 'antd';
import { ReactElement, useCallback, useEffect, useState } from 'react';
import { fetchBridges, fetchInterfaces, fetchSnifferStatus } from '../api/apiClient';
import PacketViewer from '../components/PacketViewer';
import SnifferManager from '../components/SnifferManager';
@@ -8,48 +8,48 @@ import { BridgeInfo, InterfaceInfo } from '../types/network';
import { InterfaceSnifferStatus } from '../types/sniffer';
const { Title, Text } = Typography;
const { Option } = Select;
export default function Sniffing(): ReactElement {
const [interfaces, setInterfaces] = useState<InterfaceInfo[]>([]);
const [bridges, setBridges] = useState<BridgeInfo[]>([]);
const [statusMap, setStatusMap] = useState<Record<string, InterfaceSnifferStatus>>({});
const [loading, setLoading] = useState(false);
const [statusLoading, setStatusLoading] = useState(false);
// initial load
useEffect(() => {
refreshAll().catch(() => {});
const refreshStatus = useCallback(async () => {
setStatusLoading(true);
try {
const status = await fetchSnifferStatus();
setStatusMap(status.interfaces ?? {});
} catch (error: any) {
console.error('fetchSnifferStatus error', error);
message.error(error?.message ?? 'Failed to fetch sniffer status');
} finally {
setStatusLoading(false);
}
}, []);
const refreshAll = useCallback(async () => {
setLoading(true);
try {
const [ifs, brs] = await Promise.allSettled([fetchInterfaces(), fetchBridges()]);
if (ifs.status === 'fulfilled') setInterfaces(ifs.value);
if (brs.status === 'fulfilled') setBridges(brs.value);
const [ifaces, bridgesResult] = await Promise.allSettled([fetchInterfaces(), fetchBridges()]);
if (ifaces.status === 'fulfilled') {
setInterfaces(ifaces.value);
}
if (bridgesResult.status === 'fulfilled') {
setBridges(bridgesResult.value);
}
await refreshStatus();
} catch (err) {
// ignore; errors handled in individual calls
} finally {
setLoading(false);
}
}, []);
}, [refreshStatus]);
useEffect(() => {
refreshAll().catch(() => undefined);
}, [refreshAll]);
const refreshStatus = useCallback(async () => {
setStatusLoading(true);
try {
const st = await fetchSnifferStatus();
setStatusMap(st.interfaces ?? {});
} catch (err: any) {
console.error('fetchSnifferStatus error', err);
message.error(err?.message ?? 'Failed to fetch sniffer status');
} finally {
setStatusLoading(false);
}
}, []);
return (
<div className="sniffing-page" style={{ padding: 16 }}>
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
@@ -57,9 +57,10 @@ export default function Sniffing(): ReactElement {
<Title level={2} style={{ margin: 0 }}>
Sniffing
</Title>
<Text type="secondary">Start, stop and view AF_PACKET sniffer sessions</Text>
<Text type="secondary">Start, stop and view AF_PACKET sniffer sessions.</Text>
</Col>
</Row>
<Row>
<Col span={24}>
<SnifferManager
@@ -73,9 +74,10 @@ export default function Sniffing(): ReactElement {
/>
</Col>
</Row>
<Row>
<Col span={24} style={{ marginTop: 24 }}>
<PacketViewer interfaces={interfaces.map((i) => i.name)} />
<PacketViewer interfaces={interfaces.map((iface) => iface.name)} />
</Col>
</Row>
</div>