test backend codec ndpi
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m40s
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m40s
This commit is contained in:
@@ -23,6 +23,14 @@ class PacketDBModel(BaseModel):
|
|||||||
vlan_id: Optional[int] = None
|
vlan_id: Optional[int] = None
|
||||||
length: Optional[int] = None
|
length: Optional[int] = None
|
||||||
raw_b64: Optional[str] = Field(None, description="Base64-encoded packet bytes.")
|
raw_b64: Optional[str] = Field(None, description="Base64-encoded packet bytes.")
|
||||||
|
app_protocol: Optional[str] = Field(None, description="Detected application protocol.")
|
||||||
|
app_master_protocol: Optional[str] = Field(None, description="Detected application master protocol.")
|
||||||
|
app_category: Optional[str] = Field(None, description="nDPI category, if available.")
|
||||||
|
app_confidence: Optional[str] = Field(None, description="nDPI confidence level, if available.")
|
||||||
|
app_hostname: Optional[str] = Field(None, description="Detected hostname/SNI, if available.")
|
||||||
|
app_is_encrypted: Optional[bool] = Field(None, description="Whether detected protocol appears encrypted.")
|
||||||
|
app_risk_score: Optional[int] = Field(None, description="Count/score of detected nDPI risks.")
|
||||||
|
dpi_metadata: Optional[dict] = Field(None, description="Raw DPI metadata from nDPI.")
|
||||||
direction: Optional[str] = None
|
direction: Optional[str] = None
|
||||||
packets: Optional[int] = None
|
packets: Optional[int] = None
|
||||||
|
|
||||||
@@ -43,5 +51,13 @@ class PacketDBModel(BaseModel):
|
|||||||
"vlan_id": None,
|
"vlan_id": None,
|
||||||
"length": 128,
|
"length": 128,
|
||||||
"raw_b64": "BASE64...",
|
"raw_b64": "BASE64...",
|
||||||
|
"app_protocol": "HTTP",
|
||||||
|
"app_master_protocol": "HTTP",
|
||||||
|
"app_category": "Web",
|
||||||
|
"app_confidence": "high",
|
||||||
|
"app_hostname": "example.org",
|
||||||
|
"app_is_encrypted": False,
|
||||||
|
"app_risk_score": 0,
|
||||||
|
"dpi_metadata": {"method": "GET"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from src.utilities.interface_bridge_helpers import (
|
|||||||
check_interface_up,
|
check_interface_up,
|
||||||
get_bridge_ports_once,
|
get_bridge_ports_once,
|
||||||
)
|
)
|
||||||
|
from src.utilities.ndpi_classifier import ndpi_classifier
|
||||||
from src.Models.etherType import EtherTypeEnum, ethertype_from_int
|
from src.Models.etherType import EtherTypeEnum, ethertype_from_int
|
||||||
from src.Models.ip_protocol import IPProtocolEnum, protocol_from_number
|
from src.Models.ip_protocol import IPProtocolEnum, protocol_from_number
|
||||||
|
|
||||||
@@ -73,6 +74,14 @@ class PacketInfo(TypedDict, total=False):
|
|||||||
dst_ip: Optional[str]
|
dst_ip: Optional[str]
|
||||||
src_port: Optional[int]
|
src_port: Optional[int]
|
||||||
dst_port: Optional[int]
|
dst_port: Optional[int]
|
||||||
|
app_protocol: Optional[str]
|
||||||
|
app_master_protocol: Optional[str]
|
||||||
|
app_category: Optional[str]
|
||||||
|
app_confidence: Optional[str]
|
||||||
|
app_hostname: Optional[str]
|
||||||
|
app_is_encrypted: Optional[bool]
|
||||||
|
app_risk_score: Optional[int]
|
||||||
|
dpi_metadata: Optional[Dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
# small bounded buffer for packets produced before shared_objects is ready
|
# small bounded buffer for packets produced before shared_objects is ready
|
||||||
@@ -163,6 +172,14 @@ def parse_packet(pkt, bridge_label: str) -> None:
|
|||||||
"dst_ip": None,
|
"dst_ip": None,
|
||||||
"src_port": None,
|
"src_port": None,
|
||||||
"dst_port": None,
|
"dst_port": None,
|
||||||
|
"app_protocol": None,
|
||||||
|
"app_master_protocol": None,
|
||||||
|
"app_category": None,
|
||||||
|
"app_confidence": None,
|
||||||
|
"app_hostname": None,
|
||||||
|
"app_is_encrypted": None,
|
||||||
|
"app_risk_score": None,
|
||||||
|
"dpi_metadata": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ethernet layer
|
# Ethernet layer
|
||||||
@@ -274,6 +291,14 @@ def parse_packet(pkt, bridge_label: str) -> None:
|
|||||||
if Raw in pkt and not pkt_info.get("protocol_name"):
|
if Raw in pkt and not pkt_info.get("protocol_name"):
|
||||||
pkt_info["protocol_name"] = "RAW"
|
pkt_info["protocol_name"] = "RAW"
|
||||||
|
|
||||||
|
# Best-effort DPI enrichment using nDPI (optional dependency).
|
||||||
|
try:
|
||||||
|
dpi_info = ndpi_classifier.classify_packet(pkt)
|
||||||
|
if dpi_info:
|
||||||
|
pkt_info.update(dpi_info)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("nDPI enrichment failed")
|
||||||
|
|
||||||
# Submit DB insert to shared web loop if available, otherwise buffer
|
# Submit DB insert to shared web loop if available, otherwise buffer
|
||||||
try:
|
try:
|
||||||
web_loop = getattr(shared_objects, "web_loop", None)
|
web_loop = getattr(shared_objects, "web_loop", None)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
@@ -83,8 +84,16 @@ class DatabasePool:
|
|||||||
src_port,
|
src_port,
|
||||||
dst_port,
|
dst_port,
|
||||||
length,
|
length,
|
||||||
raw
|
raw,
|
||||||
) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
app_protocol,
|
||||||
|
app_master_protocol,
|
||||||
|
app_category,
|
||||||
|
app_confidence,
|
||||||
|
app_hostname,
|
||||||
|
app_is_encrypted,
|
||||||
|
app_risk_score,
|
||||||
|
dpi_metadata
|
||||||
|
) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20::jsonb)
|
||||||
RETURNING id, timestamp
|
RETURNING id, timestamp
|
||||||
""",
|
""",
|
||||||
pkt_info["iface"],
|
pkt_info["iface"],
|
||||||
@@ -99,6 +108,14 @@ class DatabasePool:
|
|||||||
pkt_info.get("dst_port"),
|
pkt_info.get("dst_port"),
|
||||||
pkt_info["length"],
|
pkt_info["length"],
|
||||||
pkt_info["raw"],
|
pkt_info["raw"],
|
||||||
|
pkt_info.get("app_protocol"),
|
||||||
|
pkt_info.get("app_master_protocol"),
|
||||||
|
pkt_info.get("app_category"),
|
||||||
|
pkt_info.get("app_confidence"),
|
||||||
|
pkt_info.get("app_hostname"),
|
||||||
|
pkt_info.get("app_is_encrypted"),
|
||||||
|
pkt_info.get("app_risk_score"),
|
||||||
|
json.dumps(pkt_info.get("dpi_metadata")) if pkt_info.get("dpi_metadata") is not None else None,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("DB insert failed")
|
logger.exception("DB insert failed")
|
||||||
|
|||||||
262
backend/src/utilities/ndpi_classifier.py
Normal file
262
backend/src/utilities/ndpi_classifier.py
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
"""Best-effort nDPI flow classifier wrapper.
|
||||||
|
|
||||||
|
This module keeps nDPI integration optional:
|
||||||
|
- If the Python nDPI bindings are present, packets are classified per flow.
|
||||||
|
- If bindings are missing or fail, callers still receive stable fallback fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
from scapy.all import IP, IPv6, TCP, UDP # type: ignore
|
||||||
|
|
||||||
|
logger = logging.getLogger("ndpi_classifier")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import ndpi # type: ignore
|
||||||
|
except Exception: # pragma: no cover - optional dependency
|
||||||
|
ndpi = None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_jsonable_scalar(value: Any) -> bool:
|
||||||
|
return isinstance(value, (str, int, float, bool)) or value is None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_string(value: Any) -> Optional[str]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text if text else None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _FlowState:
|
||||||
|
flow_obj: Any
|
||||||
|
last_seen_ts: float
|
||||||
|
packet_count: int = 0
|
||||||
|
detected_app: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NDPIClassifier:
|
||||||
|
"""Stateful nDPI classifier for TCP/UDP IPv4/IPv6 traffic."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._engine = None
|
||||||
|
self._flows: Dict[Tuple[Any, ...], _FlowState] = {}
|
||||||
|
self._max_flows = 200_000
|
||||||
|
self._flow_ttl_seconds = 120.0
|
||||||
|
self._cleanup_interval_packets = 10_000
|
||||||
|
self._packet_counter = 0
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self._engine is not None
|
||||||
|
|
||||||
|
def _init_engine(self) -> None:
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
if ndpi is None:
|
||||||
|
logger.warning("nDPI Python bindings not installed; DPI enrichment disabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
engine_cls = getattr(ndpi, "NDPI", None)
|
||||||
|
if engine_cls is None:
|
||||||
|
logger.warning("nDPI bindings found but NDPI class missing; DPI enrichment disabled")
|
||||||
|
return
|
||||||
|
self._engine = engine_cls()
|
||||||
|
logger.info("nDPI classifier initialized")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to initialize nDPI classifier; enrichment disabled")
|
||||||
|
self._engine = None
|
||||||
|
|
||||||
|
def _maybe_cleanup(self, now_ts: float) -> None:
|
||||||
|
self._packet_counter += 1
|
||||||
|
if self._packet_counter % self._cleanup_interval_packets != 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
expiry = now_ts - self._flow_ttl_seconds
|
||||||
|
stale_keys = [k for k, state in self._flows.items() if state.last_seen_ts < expiry]
|
||||||
|
for key in stale_keys:
|
||||||
|
self._flows.pop(key, None)
|
||||||
|
|
||||||
|
if len(self._flows) > self._max_flows:
|
||||||
|
# Drop oldest half of excess flows.
|
||||||
|
sorted_items = sorted(self._flows.items(), key=lambda kv: kv[1].last_seen_ts)
|
||||||
|
trim_count = len(self._flows) - self._max_flows // 2
|
||||||
|
for key, _ in sorted_items[:trim_count]:
|
||||||
|
self._flows.pop(key, None)
|
||||||
|
|
||||||
|
def _flow_key(self, pkt: Any) -> Optional[Tuple[Any, ...]]:
|
||||||
|
if IP in pkt:
|
||||||
|
ip_layer = pkt[IP]
|
||||||
|
src_ip = getattr(ip_layer, "src", None)
|
||||||
|
dst_ip = getattr(ip_layer, "dst", None)
|
||||||
|
proto = int(getattr(ip_layer, "proto", 0))
|
||||||
|
ip_version = 4
|
||||||
|
elif IPv6 in pkt:
|
||||||
|
ip_layer = pkt[IPv6]
|
||||||
|
src_ip = getattr(ip_layer, "src", None)
|
||||||
|
dst_ip = getattr(ip_layer, "dst", None)
|
||||||
|
proto = int(getattr(ip_layer, "nh", 0))
|
||||||
|
ip_version = 6
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
src_port = None
|
||||||
|
dst_port = None
|
||||||
|
if TCP in pkt:
|
||||||
|
src_port = int(getattr(pkt[TCP], "sport", 0) or 0)
|
||||||
|
dst_port = int(getattr(pkt[TCP], "dport", 0) or 0)
|
||||||
|
elif UDP in pkt:
|
||||||
|
src_port = int(getattr(pkt[UDP], "sport", 0) or 0)
|
||||||
|
dst_port = int(getattr(pkt[UDP], "dport", 0) or 0)
|
||||||
|
|
||||||
|
left = (src_ip, src_port)
|
||||||
|
right = (dst_ip, dst_port)
|
||||||
|
ep1, ep2 = (left, right) if left <= right else (right, left)
|
||||||
|
return (ip_version, proto, ep1, ep2)
|
||||||
|
|
||||||
|
def _packet_payload_for_ndpi(self, pkt: Any) -> Optional[bytes]:
|
||||||
|
try:
|
||||||
|
if IP in pkt:
|
||||||
|
return bytes(pkt[IP])
|
||||||
|
if IPv6 in pkt:
|
||||||
|
return bytes(pkt[IPv6])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _extract_result(self, result: Any) -> Dict[str, Any]:
|
||||||
|
if result is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
raw: Dict[str, Any] = {}
|
||||||
|
if isinstance(result, dict):
|
||||||
|
raw.update(result)
|
||||||
|
else:
|
||||||
|
for attr in (
|
||||||
|
"app_protocol",
|
||||||
|
"application_protocol",
|
||||||
|
"master_protocol",
|
||||||
|
"protocol",
|
||||||
|
"category",
|
||||||
|
"confidence",
|
||||||
|
"hostname",
|
||||||
|
"server_name",
|
||||||
|
"sni",
|
||||||
|
"is_encrypted",
|
||||||
|
"risk_score",
|
||||||
|
"risks",
|
||||||
|
"ja3",
|
||||||
|
"ja4",
|
||||||
|
"alpn",
|
||||||
|
):
|
||||||
|
if hasattr(result, attr):
|
||||||
|
raw[attr] = getattr(result, attr)
|
||||||
|
|
||||||
|
app_protocol = (
|
||||||
|
_safe_string(raw.get("app_protocol"))
|
||||||
|
or _safe_string(raw.get("application_protocol"))
|
||||||
|
or _safe_string(raw.get("protocol"))
|
||||||
|
)
|
||||||
|
master_protocol = _safe_string(raw.get("master_protocol"))
|
||||||
|
category = _safe_string(raw.get("category"))
|
||||||
|
confidence = _safe_string(raw.get("confidence"))
|
||||||
|
hostname = _safe_string(raw.get("hostname")) or _safe_string(raw.get("server_name")) or _safe_string(raw.get("sni"))
|
||||||
|
|
||||||
|
risks_val = raw.get("risks")
|
||||||
|
if isinstance(risks_val, (list, tuple, set)):
|
||||||
|
risk_score = len(risks_val)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
risk_score = int(raw.get("risk_score")) if raw.get("risk_score") is not None else None
|
||||||
|
except Exception:
|
||||||
|
risk_score = None
|
||||||
|
|
||||||
|
encrypted = raw.get("is_encrypted")
|
||||||
|
is_encrypted = bool(encrypted) if isinstance(encrypted, bool) else None
|
||||||
|
if is_encrypted is None and app_protocol:
|
||||||
|
proto_upper = app_protocol.upper()
|
||||||
|
if any(token in proto_upper for token in ("TLS", "SSL", "HTTPS", "QUIC", "VPN")):
|
||||||
|
is_encrypted = True
|
||||||
|
|
||||||
|
metadata: Dict[str, Any] = {}
|
||||||
|
for key, value in raw.items():
|
||||||
|
if _is_jsonable_scalar(value):
|
||||||
|
metadata[key] = value
|
||||||
|
elif isinstance(value, (list, tuple)):
|
||||||
|
metadata[key] = [str(v) for v in value][:32]
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
cleaned = {str(k): str(v) for k, v in list(value.items())[:32]}
|
||||||
|
metadata[key] = cleaned
|
||||||
|
else:
|
||||||
|
metadata[key] = str(value)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"app_protocol": app_protocol,
|
||||||
|
"app_master_protocol": master_protocol,
|
||||||
|
"app_category": category,
|
||||||
|
"app_confidence": confidence,
|
||||||
|
"app_hostname": hostname,
|
||||||
|
"app_is_encrypted": is_encrypted,
|
||||||
|
"app_risk_score": risk_score,
|
||||||
|
"dpi_metadata": metadata if metadata else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def classify_packet(self, pkt: Any) -> Dict[str, Any]:
|
||||||
|
"""Classify packet and return enrichment fields for packet metadata."""
|
||||||
|
self._init_engine()
|
||||||
|
if not self.enabled:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
flow_key = self._flow_key(pkt)
|
||||||
|
if flow_key is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
payload = self._packet_payload_for_ndpi(pkt)
|
||||||
|
if not payload:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
flow_cls = getattr(ndpi, "NDPIFlow", None)
|
||||||
|
now_ts = time.time()
|
||||||
|
tick_ms = int(now_ts * 1000)
|
||||||
|
|
||||||
|
flow_state = self._flows.get(flow_key)
|
||||||
|
if flow_state is None:
|
||||||
|
flow_obj = flow_cls() if flow_cls is not None else None
|
||||||
|
flow_state = _FlowState(flow_obj=flow_obj, last_seen_ts=now_ts, packet_count=0)
|
||||||
|
self._flows[flow_key] = flow_state
|
||||||
|
|
||||||
|
flow_state.last_seen_ts = now_ts
|
||||||
|
flow_state.packet_count += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
if flow_state.flow_obj is not None:
|
||||||
|
result = self._engine.process_packet(flow_state.flow_obj, payload, tick_ms)
|
||||||
|
else:
|
||||||
|
# Fallback for bindings that don't expose per-flow object.
|
||||||
|
result = self._engine.process_packet(payload, tick_ms)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("nDPI process_packet failed for flow=%s", flow_key, exc_info=True)
|
||||||
|
self._maybe_cleanup(now_ts)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
enriched = self._extract_result(result)
|
||||||
|
if enriched.get("app_protocol"):
|
||||||
|
flow_state.detected_app = enriched["app_protocol"]
|
||||||
|
elif flow_state.detected_app:
|
||||||
|
enriched["app_protocol"] = flow_state.detected_app
|
||||||
|
|
||||||
|
self._maybe_cleanup(now_ts)
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
|
ndpi_classifier = NDPIClassifier()
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
ScriptWithStatus,
|
ScriptWithStatus,
|
||||||
StatusForNameResponse,
|
StatusForNameResponse,
|
||||||
} from '../types/scripting';
|
} from '../types/scripting';
|
||||||
|
import { FetchPacketsResponse } from '../types/packets';
|
||||||
import {
|
import {
|
||||||
SnifferStartRequest,
|
SnifferStartRequest,
|
||||||
SnifferStartResponse,
|
SnifferStartResponse,
|
||||||
@@ -114,8 +115,8 @@ export const fetchSnifferStatus = async (): Promise<SnifferStatusResponse> => {
|
|||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchPackets = async (limit = 100): Promise<any> => {
|
export const fetchPackets = async (limit = 100): Promise<FetchPacketsResponse> => {
|
||||||
const res = await api.get('/packets/packets', { params: { limit } });
|
const res = await api.get<FetchPacketsResponse>('/packets/packets', { params: { limit } });
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
|
// 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 { PlusOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +38,9 @@ const { Title, Text } = Typography;
|
|||||||
|
|
||||||
type FormValues = Record<string, any>;
|
type FormValues = Record<string, any>;
|
||||||
|
|
||||||
|
/* ----------------------
|
||||||
|
Token types (canonical per user)
|
||||||
|
---------------------- */
|
||||||
type TokenType =
|
type TokenType =
|
||||||
| 'meta'
|
| 'meta'
|
||||||
| 'ct'
|
| 'ct'
|
||||||
@@ -45,10 +59,642 @@ type TokenType =
|
|||||||
| 'nat'
|
| 'nat'
|
||||||
| 'queue';
|
| '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<
|
const TOKEN_FIELD_OPTIONS: Record<
|
||||||
TokenType,
|
TokenType,
|
||||||
Array<{ value: string; label: string; kind?: 'string' | 'number' | 'enum' }>
|
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">
|
<Row gutter={16} align="middle">
|
||||||
<Col xs={24} sm={12}>
|
<Col xs={24} sm={12}>
|
||||||
<Form.Item name="tableSelect" label="Table (family:name)" rules={[{ required: true }]}>
|
<Form.Item name="tableSelect" label="Table (family:name)" rules={[{ required: true }]}>
|
||||||
@@ -71,7 +717,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{/* Insert before */}
|
||||||
<Row gutter={16} align="middle">
|
<Row gutter={16} align="middle">
|
||||||
<Col xs={24} sm={12}>
|
<Col xs={24} sm={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@@ -104,7 +750,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
{/* Token builder header + add control */}
|
||||||
<Row align="middle" justify="space-between" style={{ marginBottom: 8 }}>
|
<Row align="middle" justify="space-between" style={{ marginBottom: 8 }}>
|
||||||
<Col>
|
<Col>
|
||||||
<Text strong>Token builder</Text>
|
<Text strong>Token builder</Text>
|
||||||
@@ -120,6 +766,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
<Select
|
<Select
|
||||||
placeholder="Add token..."
|
placeholder="Add token..."
|
||||||
onSelect={(val: TokenType) => {
|
onSelect={(val: TokenType) => {
|
||||||
|
// sensible defaults per token type
|
||||||
const defaultData =
|
const defaultData =
|
||||||
val === 'meta'
|
val === 'meta'
|
||||||
? { field: 'iifname', value: '' }
|
? { field: 'iifname', value: '' }
|
||||||
@@ -176,7 +823,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
{/* Tokens Form.List rendering */}
|
||||||
<Form.List name="tokens">
|
<Form.List name="tokens">
|
||||||
{(fields, { remove, move }) =>
|
{(fields, { remove, move }) =>
|
||||||
fields.length === 0 ? (
|
fields.length === 0 ? (
|
||||||
@@ -195,7 +842,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flex: 1, minWidth: 0 }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flex: 1, minWidth: 0 }}>
|
||||||
|
{/* Token type select */}
|
||||||
<Form.Item name={[field.name, 'type']} style={{ marginBottom: 0 }}>
|
<Form.Item name={[field.name, 'type']} style={{ marginBottom: 0 }}>
|
||||||
<Select style={typeSelectStyle}>
|
<Select style={typeSelectStyle}>
|
||||||
{Object.keys(TOKEN_FIELD_OPTIONS).map((k) => (
|
{Object.keys(TOKEN_FIELD_OPTIONS).map((k) => (
|
||||||
@@ -206,7 +853,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* Token field + value UI (depends on token type and subfield) */}
|
||||||
<Form.Item
|
<Form.Item
|
||||||
shouldUpdate={(prev, cur) =>
|
shouldUpdate={(prev, cur) =>
|
||||||
prev.tokens?.[field.name]?.type !== cur.tokens?.[field.name]?.type ||
|
prev.tokens?.[field.name]?.type !== cur.tokens?.[field.name]?.type ||
|
||||||
@@ -218,6 +865,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
const tokenType = form.getFieldValue(['tokens', field.name, 'type']) as TokenType | undefined;
|
const tokenType = form.getFieldValue(['tokens', field.name, 'type']) as TokenType | undefined;
|
||||||
const options = tokenType ? TOKEN_FIELD_OPTIONS[tokenType] || [] : [];
|
const options = tokenType ? TOKEN_FIELD_OPTIONS[tokenType] || [] : [];
|
||||||
|
|
||||||
|
// COUNTER special-case
|
||||||
if (tokenType === 'counter') {
|
if (tokenType === 'counter') {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
|
||||||
@@ -237,6 +885,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LIMIT special-case
|
||||||
if (tokenType === 'limit') {
|
if (tokenType === 'limit') {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
|
||||||
@@ -254,6 +903,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LOG special-case
|
||||||
if (tokenType === 'log') {
|
if (tokenType === 'log') {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
|
||||||
@@ -291,6 +941,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QUEUE special-case inside token list (separate from NFQUEUE action)
|
||||||
if (tokenType === 'queue') {
|
if (tokenType === 'queue') {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
|
||||||
@@ -310,6 +961,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NAT special-case
|
||||||
if (tokenType === 'nat') {
|
if (tokenType === 'nat') {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
|
||||||
@@ -331,6 +983,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generic tokens with subfield dropdown
|
||||||
if (options.length > 0) {
|
if (options.length > 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -364,7 +1017,9 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
const meta = opts.find((o) => o.value === selField);
|
const meta = opts.find((o) => o.value === selField);
|
||||||
const kind = meta?.kind ?? 'string';
|
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')) {
|
if (tType === 'meta' && (selField === 'iifname' || selField === 'oifname')) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -379,6 +1034,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// L4PROTO dropdown for meta.l4proto
|
||||||
if (tType === 'meta' && selField === 'l4proto') {
|
if (tType === 'meta' && selField === 'l4proto') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -396,6 +1052,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Day of week (meta.day)
|
||||||
if (tType === 'meta' && selField === 'day') {
|
if (tType === 'meta' && selField === 'day') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -415,6 +1072,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hour range (meta.hour) — free text but show placeholder/range hint
|
||||||
if (tType === 'meta' && selField === 'hour') {
|
if (tType === 'meta' && selField === 'hour') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -429,6 +1087,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Packet type (meta.pkttype)
|
||||||
if (tType === 'meta' && selField === 'pkttype') {
|
if (tType === 'meta' && selField === 'pkttype') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -448,6 +1107,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Packet/conn mark
|
||||||
if ((tType === 'meta' || tType === 'ct') && selField === 'mark') {
|
if ((tType === 'meta' || tType === 'ct') && selField === 'mark') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -461,6 +1121,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skuid / skgid
|
||||||
if (tType === 'meta' && (selField === 'skuid' || selField === 'skgid')) {
|
if (tType === 'meta' && (selField === 'skuid' || selField === 'skgid')) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -479,6 +1140,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CT state
|
||||||
if (tType === 'ct' && selField === 'state') {
|
if (tType === 'ct' && selField === 'state') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -503,6 +1165,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CT direction
|
||||||
if (tType === 'ct' && selField === 'direction') {
|
if (tType === 'ct' && selField === 'direction') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -522,7 +1185,8 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
/* --- IP / IP6 address helpers --- */
|
||||||
|
if (
|
||||||
(tType === 'ip' || tType === 'ip6') &&
|
(tType === 'ip' || tType === 'ip6') &&
|
||||||
(selField === 'saddr' || selField === 'daddr')
|
(selField === 'saddr' || selField === 'daddr')
|
||||||
) {
|
) {
|
||||||
@@ -550,6 +1214,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// protocol / nexthdr / ip.protocol (L4 protocol): show l4proto list
|
||||||
if (
|
if (
|
||||||
(tType === 'ip' && selField === 'protocol') ||
|
(tType === 'ip' && selField === 'protocol') ||
|
||||||
(tType === 'ip6' && selField === 'nexthdr')
|
(tType === 'ip6' && selField === 'nexthdr')
|
||||||
@@ -573,6 +1238,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DSCP
|
||||||
if ((tType === 'ip' || tType === 'ip6') && selField === 'dscp') {
|
if ((tType === 'ip' || tType === 'ip6') && selField === 'dscp') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -592,6 +1258,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TTL / hoplimit numeric
|
||||||
if (
|
if (
|
||||||
(tType === 'ip' && selField === 'ttl') ||
|
(tType === 'ip' && selField === 'ttl') ||
|
||||||
(tType === 'ip6' && selField === 'hoplimit')
|
(tType === 'ip6' && selField === 'hoplimit')
|
||||||
@@ -611,6 +1278,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IP fragment bits (frag-off) — single string placeholder
|
||||||
if (tType === 'ip' && selField === 'frag-off') {
|
if (tType === 'ip' && selField === 'frag-off') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -624,7 +1292,9 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Transport: TCP/UDP/ICMP --- */
|
||||||
|
|
||||||
|
// Ports: allow numeric or service name
|
||||||
if (
|
if (
|
||||||
(tType === 'tcp' || tType === 'udp') &&
|
(tType === 'tcp' || tType === 'udp') &&
|
||||||
(selField === 'dport' || selField === 'sport')
|
(selField === 'dport' || selField === 'sport')
|
||||||
@@ -641,6 +1311,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TCP flags multi-select
|
||||||
if (tType === 'tcp' && selField === 'flags') {
|
if (tType === 'tcp' && selField === 'flags') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -664,6 +1335,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ICMP type/code dropdowns
|
||||||
if (tType === 'icmp' && selField === 'type') {
|
if (tType === 'icmp' && selField === 'type') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -702,6 +1374,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Layer 2: Ethernet / VLAN --- */
|
||||||
|
|
||||||
if (tType === 'ether') {
|
if (tType === 'ether') {
|
||||||
if (selField === 'saddr' || selField === 'daddr') {
|
if (selField === 'saddr' || selField === 'daddr') {
|
||||||
@@ -735,6 +1408,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VLAN ID / PCP / CFI
|
||||||
if (tType === 'vlan') {
|
if (tType === 'vlan') {
|
||||||
if (selField === 'id') {
|
if (selField === 'id') {
|
||||||
return (
|
return (
|
||||||
@@ -785,7 +1459,8 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tType === 'payload' || (kind === 'string' && !selField)) {
|
/* --- Payload / default free text input --- */
|
||||||
|
if (tType === 'payload' || (kind === 'string' && !selField)) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
|
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
|
||||||
@@ -799,6 +1474,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default fallback free-text with helpful examples
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
|
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
|
||||||
@@ -839,7 +1515,8 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
|
|
||||||
<Divider />
|
<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">
|
<Row gutter={16} align="top">
|
||||||
<Col xs={24} sm={12}>
|
<Col xs={24} sm={12}>
|
||||||
<Form.Item name="action" label="Action / verdict" rules={[{ required: true }]}>
|
<Form.Item name="action" label="Action / verdict" rules={[{ required: true }]}>
|
||||||
@@ -851,7 +1528,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* Reject options (render under radios, same column) */}
|
||||||
<Form.Item shouldUpdate={(prev, cur) => prev.action !== cur.action} noStyle>
|
<Form.Item shouldUpdate={(prev, cur) => prev.action !== cur.action} noStyle>
|
||||||
{() =>
|
{() =>
|
||||||
form.getFieldValue('action') === 'reject' ? (
|
form.getFieldValue('action') === 'reject' ? (
|
||||||
@@ -870,7 +1547,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* IPv4 reject reasons */}
|
||||||
<Form.Item shouldUpdate={(prev, cur) => prev.rejectType !== cur.rejectType} noStyle>
|
<Form.Item shouldUpdate={(prev, cur) => prev.rejectType !== cur.rejectType} noStyle>
|
||||||
{() =>
|
{() =>
|
||||||
form.getFieldValue('rejectType') === 'icmp' ? (
|
form.getFieldValue('rejectType') === 'icmp' ? (
|
||||||
@@ -895,7 +1572,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
}
|
}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* IPv6 reject reasons */}
|
||||||
<Form.Item shouldUpdate={(prev, cur) => prev.rejectType !== cur.rejectType} noStyle>
|
<Form.Item shouldUpdate={(prev, cur) => prev.rejectType !== cur.rejectType} noStyle>
|
||||||
{() =>
|
{() =>
|
||||||
form.getFieldValue('rejectType') === 'icmpv6' ? (
|
form.getFieldValue('rejectType') === 'icmpv6' ? (
|
||||||
@@ -920,7 +1597,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
}
|
}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* icmpx (inet) */}
|
||||||
<Form.Item shouldUpdate={(prev, cur) => prev.rejectType !== cur.rejectType} noStyle>
|
<Form.Item shouldUpdate={(prev, cur) => prev.rejectType !== cur.rejectType} noStyle>
|
||||||
{() =>
|
{() =>
|
||||||
form.getFieldValue('rejectType') === 'icmpx' ? (
|
form.getFieldValue('rejectType') === 'icmpx' ? (
|
||||||
@@ -951,7 +1628,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
}
|
}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* NFQUEUE options (now rendered under radios in same column) */}
|
||||||
<Form.Item shouldUpdate={(prev, cur) => prev.action !== cur.action} noStyle>
|
<Form.Item shouldUpdate={(prev, cur) => prev.action !== cur.action} noStyle>
|
||||||
{() =>
|
{() =>
|
||||||
form.getFieldValue('action') === 'nfqueue' ? (
|
form.getFieldValue('action') === 'nfqueue' ? (
|
||||||
@@ -971,6 +1648,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
<Form.Item name="nfqueue_bypass" valuePropName="checked">
|
<Form.Item name="nfqueue_bypass" valuePropName="checked">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
|
// update preview immediately
|
||||||
schedulePreviewUpdate();
|
schedulePreviewUpdate();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -987,7 +1665,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
{/* right column is free for notes / quick helpers */}
|
||||||
<Col xs={24} sm={12}>
|
<Col xs={24} sm={12}>
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
Use NFQUEUE to hand packets to userspace. Full reject support requires kernel >= 3.18 — when using
|
Use NFQUEUE to hand packets to userspace. Full reject support requires kernel >= 3.18 — when using
|
||||||
@@ -996,7 +1674,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{/* advanced text */}
|
||||||
<Row>
|
<Row>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Form.Item name="advanced" label="Advanced (optional)">
|
<Form.Item name="advanced" label="Advanced (optional)">
|
||||||
@@ -1010,7 +1688,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
|||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
{/* preview + run */}
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', width: '100%' }}>
|
<div style={{ display: 'flex', gap: 12, alignItems: 'center', width: '100%' }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
|
|||||||
@@ -169,7 +169,8 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
|
|||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch
|
} catch {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -205,7 +206,8 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
|
|||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch
|
} catch {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -241,7 +243,8 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
|
|||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch
|
} catch {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,28 +18,11 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { clearPackets, fetchPackets } from '../api/apiClient';
|
import { clearPackets, fetchPackets } from '../api/apiClient';
|
||||||
|
import type { PacketRow } from '../types/packets';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
|
|
||||||
type PacketRow = {
|
|
||||||
id?: number | string;
|
|
||||||
timestamp?: string; // ISO or DB formatted
|
|
||||||
iface?: string | string[]; // may be single iface or list
|
|
||||||
src_mac?: string | null;
|
|
||||||
dst_mac?: string | null;
|
|
||||||
eth_type?: string | number | null;
|
|
||||||
ip_proto?: string | number | null;
|
|
||||||
src_ip?: string | null;
|
|
||||||
dst_ip?: string | null;
|
|
||||||
src_port?: number | null;
|
|
||||||
dst_port?: number | null;
|
|
||||||
vlan_id?: number | null;
|
|
||||||
length?: number | null;
|
|
||||||
raw_b64?: string | null;
|
|
||||||
[k: string]: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_LIMIT = 200;
|
const DEFAULT_LIMIT = 200;
|
||||||
const MAX_PACKETS = 2000; // in-memory cap
|
const MAX_PACKETS = 2000; // in-memory cap
|
||||||
|
|
||||||
@@ -164,7 +147,7 @@ export default function PacketViewer(): ReactElement {
|
|||||||
setStatusLoading(true);
|
setStatusLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetchPackets(limitVal);
|
const res = await fetchPackets(limitVal);
|
||||||
const list: PacketRow[] = (res.packets ?? []).map((p: any) => p);
|
const list: PacketRow[] = res.packets ?? [];
|
||||||
setPackets(list);
|
setPackets(list);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('fetchHistory error', err);
|
console.error('fetchHistory error', err);
|
||||||
@@ -337,7 +320,30 @@ export default function PacketViewer(): ReactElement {
|
|||||||
title: 'Protocol',
|
title: 'Protocol',
|
||||||
dataIndex: 'ip_proto',
|
dataIndex: 'ip_proto',
|
||||||
key: 'ip_proto',
|
key: 'ip_proto',
|
||||||
width: 100,
|
width: 110,
|
||||||
|
render: (v: any) => <Text>{v ?? '-'}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'App',
|
||||||
|
key: 'app',
|
||||||
|
width: 170,
|
||||||
|
render: (_: any, rec: PacketRow) => {
|
||||||
|
const app = rec.app_protocol ?? rec.app_master_protocol ?? '-';
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Text>{app}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{rec.app_confidence ?? rec.app_category ?? ''}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Host',
|
||||||
|
dataIndex: 'app_hostname',
|
||||||
|
key: 'app_hostname',
|
||||||
|
width: 180,
|
||||||
render: (v: any) => <Text>{v ?? '-'}</Text>,
|
render: (v: any) => <Text>{v ?? '-'}</Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -375,6 +381,13 @@ export default function PacketViewer(): ReactElement {
|
|||||||
width: 80,
|
width: 80,
|
||||||
render: (n: any) => (typeof n === 'number' ? n.toLocaleString('de-DE') : '-'),
|
render: (n: any) => (typeof n === 'number' ? n.toLocaleString('de-DE') : '-'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Risk',
|
||||||
|
dataIndex: 'app_risk_score',
|
||||||
|
key: 'app_risk_score',
|
||||||
|
width: 70,
|
||||||
|
render: (n: any) => (typeof n === 'number' ? n : '-'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Actions',
|
title: 'Actions',
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
|
|||||||
30
frontend/src/types/packets.ts
Normal file
30
frontend/src/types/packets.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export interface PacketRow {
|
||||||
|
id?: number | string;
|
||||||
|
timestamp?: string;
|
||||||
|
iface?: string | string[];
|
||||||
|
src_mac?: string | null;
|
||||||
|
dst_mac?: string | null;
|
||||||
|
eth_type?: string | number | null;
|
||||||
|
ip_proto?: string | number | null;
|
||||||
|
src_ip?: string | null;
|
||||||
|
dst_ip?: string | null;
|
||||||
|
src_port?: number | null;
|
||||||
|
dst_port?: number | null;
|
||||||
|
vlan_id?: number | null;
|
||||||
|
length?: number | null;
|
||||||
|
raw_b64?: string | null;
|
||||||
|
app_protocol?: string | null;
|
||||||
|
app_master_protocol?: string | null;
|
||||||
|
app_category?: string | null;
|
||||||
|
app_confidence?: string | null;
|
||||||
|
app_hostname?: string | null;
|
||||||
|
app_is_encrypted?: boolean | null;
|
||||||
|
app_risk_score?: number | null;
|
||||||
|
dpi_metadata?: Record<string, unknown> | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchPacketsResponse {
|
||||||
|
count: number;
|
||||||
|
packets: PacketRow[];
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@ PYTHON_VERSION="3" # aktuelle Python 3 Version
|
|||||||
# -----------------------------
|
# -----------------------------
|
||||||
echo "==> Update & Upgrade"
|
echo "==> Update & Upgrade"
|
||||||
apt update && apt upgrade -y
|
apt update && apt upgrade -y
|
||||||
apt install -y git curl build-essential nginx python3 python3-pip python3-venv unzip wget python3-dev libnetfilter-queue-dev libnfnetlink-dev libpcap-dev
|
apt install -y git curl build-essential nginx python3 python3-pip python3-venv unzip wget python3-dev \
|
||||||
|
libnetfilter-queue-dev libnfnetlink-dev libpcap-dev autoconf automake libtool pkg-config libjson-c-dev
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Node.js installieren (LTS)
|
# Node.js installieren (LTS)
|
||||||
@@ -61,6 +62,31 @@ cd "$BACKEND_DIR"
|
|||||||
python3 -m venv venv
|
python3 -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# nDPI installieren (system lib + python bindings)
|
||||||
|
# -----------------------------
|
||||||
|
if ! ldconfig -p | grep -q "libndpi"; then
|
||||||
|
echo "==> Build and install nDPI library"
|
||||||
|
NDPIDIR="/tmp/nDPI"
|
||||||
|
rm -rf "$NDPIDIR"
|
||||||
|
git clone --depth 1 https://github.com/ntop/nDPI.git "$NDPIDIR"
|
||||||
|
cd "$NDPIDIR"
|
||||||
|
./autogen.sh
|
||||||
|
./configure --prefix=/usr
|
||||||
|
make -j"$(nproc)"
|
||||||
|
make install
|
||||||
|
ldconfig
|
||||||
|
else
|
||||||
|
echo "==> nDPI library already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d "/tmp/nDPI/python" ]; then
|
||||||
|
echo "==> Install nDPI Python bindings"
|
||||||
|
pip install /tmp/nDPI/python
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$BACKEND_DIR"
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
deactivate
|
deactivate
|
||||||
|
|
||||||
@@ -199,6 +225,9 @@ npm run build
|
|||||||
# Backend Dependencies
|
# Backend Dependencies
|
||||||
cd $BACKEND_DIR
|
cd $BACKEND_DIR
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
|
if [ -d "/tmp/nDPI/python" ]; then
|
||||||
|
pip install /tmp/nDPI/python
|
||||||
|
fi
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
deactivate
|
deactivate
|
||||||
|
|
||||||
|
|||||||
@@ -75,9 +75,28 @@ CREATE TABLE IF NOT EXISTS packets (
|
|||||||
-- Packet metadata
|
-- Packet metadata
|
||||||
length INTEGER,
|
length INTEGER,
|
||||||
|
|
||||||
|
-- DPI / nDPI metadata
|
||||||
|
app_protocol VARCHAR(128),
|
||||||
|
app_master_protocol VARCHAR(128),
|
||||||
|
app_category VARCHAR(128),
|
||||||
|
app_confidence VARCHAR(64),
|
||||||
|
app_hostname VARCHAR(255),
|
||||||
|
app_is_encrypted BOOLEAN,
|
||||||
|
app_risk_score INTEGER,
|
||||||
|
dpi_metadata JSONB,
|
||||||
|
|
||||||
-- Full packet dump
|
-- Full packet dump
|
||||||
raw BYTEA
|
raw BYTEA
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE packets ADD COLUMN IF NOT EXISTS app_protocol VARCHAR(128);
|
||||||
|
ALTER TABLE packets ADD COLUMN IF NOT EXISTS app_master_protocol VARCHAR(128);
|
||||||
|
ALTER TABLE packets ADD COLUMN IF NOT EXISTS app_category VARCHAR(128);
|
||||||
|
ALTER TABLE packets ADD COLUMN IF NOT EXISTS app_confidence VARCHAR(64);
|
||||||
|
ALTER TABLE packets ADD COLUMN IF NOT EXISTS app_hostname VARCHAR(255);
|
||||||
|
ALTER TABLE packets ADD COLUMN IF NOT EXISTS app_is_encrypted BOOLEAN;
|
||||||
|
ALTER TABLE packets ADD COLUMN IF NOT EXISTS app_risk_score INTEGER;
|
||||||
|
ALTER TABLE packets ADD COLUMN IF NOT EXISTS dpi_metadata JSONB;
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|
||||||
@@ -100,6 +119,8 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT, UPDATE ON SEQUENC
|
|||||||
-- Create indexes for faster queries
|
-- Create indexes for faster queries
|
||||||
CREATE INDEX IF NOT EXISTS idx_packets_timestamp ON packets(timestamp DESC);
|
CREATE INDEX IF NOT EXISTS idx_packets_timestamp ON packets(timestamp DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_packets_src_ip ON packets(src_ip);
|
CREATE INDEX IF NOT EXISTS idx_packets_src_ip ON packets(src_ip);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packets_app_protocol ON packets(app_protocol);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packets_app_hostname ON packets(app_hostname);
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo "Done. PostgreSQL is ready for LAN + localhost connections."
|
echo "Done. PostgreSQL is ready for LAN + localhost connections."
|
||||||
|
|||||||
Reference in New Issue
Block a user