test backend codec ndpi
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m40s

This commit is contained in:
2026-03-06 22:05:45 +01:00
parent 991f26e5f2
commit 25bb7be29d
11 changed files with 1144 additions and 49 deletions

View File

@@ -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"},
} }
} }

View File

@@ -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)

View File

@@ -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")

View 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()

View File

@@ -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;
}; };

View File

@@ -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 &gt;= 3.18 — when using Use NFQUEUE to hand packets to userspace. Full reject support requires kernel &gt;= 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 }}>

View File

@@ -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 {
}
} }
}, },
}); });

View File

@@ -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',

View 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[];
}

View File

@@ -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

View File

@@ -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."