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
|
||||
length: Optional[int] = None
|
||||
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
|
||||
packets: Optional[int] = None
|
||||
|
||||
@@ -43,5 +51,13 @@ class PacketDBModel(BaseModel):
|
||||
"vlan_id": None,
|
||||
"length": 128,
|
||||
"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,
|
||||
get_bridge_ports_once,
|
||||
)
|
||||
from src.utilities.ndpi_classifier import ndpi_classifier
|
||||
from src.Models.etherType import EtherTypeEnum, ethertype_from_int
|
||||
from src.Models.ip_protocol import IPProtocolEnum, protocol_from_number
|
||||
|
||||
@@ -73,6 +74,14 @@ class PacketInfo(TypedDict, total=False):
|
||||
dst_ip: Optional[str]
|
||||
src_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
|
||||
@@ -163,6 +172,14 @@ def parse_packet(pkt, bridge_label: str) -> None:
|
||||
"dst_ip": None,
|
||||
"src_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
|
||||
@@ -274,6 +291,14 @@ def parse_packet(pkt, bridge_label: str) -> None:
|
||||
if Raw in pkt and not pkt_info.get("protocol_name"):
|
||||
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
|
||||
try:
|
||||
web_loop = getattr(shared_objects, "web_loop", None)
|
||||
@@ -618,4 +643,4 @@ def get_internal_debug_state() -> dict:
|
||||
for sid, s in sessions.items()
|
||||
},
|
||||
"buffer_len": len(_PACKET_BUFFER),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -83,8 +84,16 @@ class DatabasePool:
|
||||
src_port,
|
||||
dst_port,
|
||||
length,
|
||||
raw
|
||||
) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
raw,
|
||||
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
|
||||
""",
|
||||
pkt_info["iface"],
|
||||
@@ -99,6 +108,14 @@ class DatabasePool:
|
||||
pkt_info.get("dst_port"),
|
||||
pkt_info["length"],
|
||||
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:
|
||||
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,
|
||||
StatusForNameResponse,
|
||||
} from '../types/scripting';
|
||||
import { FetchPacketsResponse } from '../types/packets';
|
||||
import {
|
||||
SnifferStartRequest,
|
||||
SnifferStartResponse,
|
||||
@@ -114,8 +115,8 @@ export const fetchSnifferStatus = async (): Promise<SnifferStatusResponse> => {
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const fetchPackets = async (limit = 100): Promise<any> => {
|
||||
const res = await api.get('/packets/packets', { params: { limit } });
|
||||
export const fetchPackets = async (limit = 100): Promise<FetchPacketsResponse> => {
|
||||
const res = await api.get<FetchPacketsResponse>('/packets/packets', { params: { limit } });
|
||||
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 {
|
||||
@@ -27,6 +38,9 @@ const { Title, Text } = Typography;
|
||||
|
||||
type FormValues = Record<string, any>;
|
||||
|
||||
/* ----------------------
|
||||
Token types (canonical per user)
|
||||
---------------------- */
|
||||
type TokenType =
|
||||
| 'meta'
|
||||
| 'ct'
|
||||
@@ -45,10 +59,642 @@ type TokenType =
|
||||
| 'nat'
|
||||
| 'queue';
|
||||
|
||||
/* ----------------------
|
||||
TOKEN_FIELD_OPTIONS
|
||||
Each token lists allowed subfields (exactly the fields from the user's canonical list).
|
||||
The `kind` tells the UI which input widget to show (number, enum, string).
|
||||
---------------------- */
|
||||
const TOKEN_FIELD_OPTIONS: Record<
|
||||
TokenType,
|
||||
Array<{ value: string; label: string; kind?: 'string' | 'number' | 'enum' }>
|
||||
> =
|
||||
> = {
|
||||
/* 1) Metadata & Connection Tracking */
|
||||
meta: [
|
||||
{ value: 'iifname', label: 'iifname (input interface)', kind: 'string' },
|
||||
{ value: 'oifname', label: 'oifname (output interface)', kind: 'string' },
|
||||
{ value: 'l4proto', label: 'l4proto (protocol L4)', kind: 'enum' }, // tcp/udp/icmp/...
|
||||
{ value: 'day', label: 'day (day of week)', kind: 'enum' },
|
||||
{ value: 'hour', label: 'hour (hour of day/range)', kind: 'string' },
|
||||
{ value: 'pkttype', label: 'pkttype (packet type)', kind: 'enum' },
|
||||
{ value: 'mark', label: 'mark (packet mark)', kind: 'string' },
|
||||
{ value: 'skuid', label: 'skuid (socket UID)', kind: 'number' },
|
||||
{ value: 'skgid', label: 'skgid (socket GID)', kind: 'number' },
|
||||
],
|
||||
ct: [
|
||||
{ value: 'state', label: 'state (ct state)', kind: 'enum' },
|
||||
{ value: 'direction', label: 'direction (original/reply)', kind: 'enum' },
|
||||
{ value: 'status', label: 'status', kind: 'string' },
|
||||
{ value: 'mark', label: 'mark (conntrack mark)', kind: 'string' },
|
||||
{ value: 'expiration', label: 'expiration', kind: 'string' },
|
||||
{ value: 'helper', label: 'helper', kind: 'string' },
|
||||
],
|
||||
|
||||
/* 2) Layer 3: Network Headers */
|
||||
ip: [
|
||||
{ value: 'saddr', label: 'saddr (source IPv4)', kind: 'string' },
|
||||
{ value: 'daddr', label: 'daddr (destination IPv4)', kind: 'string' },
|
||||
{ value: 'protocol', label: 'protocol (L4) — alias to l4proto', kind: 'enum' },
|
||||
{ value: 'dscp', label: 'dscp (DSCP)', kind: 'enum' },
|
||||
{ value: 'ttl', label: 'ttl (time to live)', kind: 'number' },
|
||||
{ value: 'frag-off', label: 'frag-off (fragment bits)', kind: 'string' },
|
||||
],
|
||||
ip6: [
|
||||
{ value: 'saddr', label: 'saddr (source IPv6)', kind: 'string' },
|
||||
{ value: 'daddr', label: 'daddr (destination IPv6)', kind: 'string' },
|
||||
{ value: 'nexthdr', label: 'nexthdr (protocol / next header)', kind: 'enum' },
|
||||
{ value: 'dscp', label: 'dscp (DSCP)', kind: 'enum' },
|
||||
{ value: 'hoplimit', label: 'hoplimit (IPv6 hop limit)', kind: 'number' },
|
||||
{ value: 'flowlabel', label: 'flowlabel', kind: 'number' },
|
||||
],
|
||||
|
||||
/* 3) Layer 4: Transport Headers (appear only when token type tcp/udp/icmp is chosen) */
|
||||
tcp: [
|
||||
{ value: 'sport', label: 'sport (source port)', kind: 'number' },
|
||||
{ value: 'dport', label: 'dport (destination port)', kind: 'number' },
|
||||
{ value: 'flags', label: 'flags (tcp flags bitmask)', kind: 'enum' },
|
||||
],
|
||||
udp: [
|
||||
{ value: 'sport', label: 'sport (source port)', kind: 'number' },
|
||||
{ value: 'dport', label: 'dport (destination port)', kind: 'number' },
|
||||
],
|
||||
icmp: [
|
||||
{ value: 'type', label: 'type (icmp type)', kind: 'enum' },
|
||||
{ value: 'code', label: 'code (icmp code)', kind: 'enum' },
|
||||
],
|
||||
|
||||
/* 4) Layer 2: Ethernet & VLAN */
|
||||
ether: [
|
||||
{ value: 'saddr', label: 'saddr (src MAC)', kind: 'string' },
|
||||
{ value: 'daddr', label: 'daddr (dst MAC)', kind: 'string' },
|
||||
{ value: 'type', label: 'type (ethertype)', kind: 'enum' },
|
||||
],
|
||||
vlan: [
|
||||
{ value: 'id', label: 'id (VLAN ID)', kind: 'number' },
|
||||
// CFI/DEI and PCP exist but user's list specified only VLAN ID; add PCP & DEI as optional helpers:
|
||||
{ value: 'pcp', label: 'pcp (priority code point)', kind: 'number' },
|
||||
{ value: 'cfi', label: 'cfi / DEI (drop eligible)', kind: 'number' },
|
||||
],
|
||||
|
||||
/* leftovers and statements */
|
||||
payload: [{ value: 'payload', label: 'payload(protocol.field)', kind: 'string' }],
|
||||
raw: [{ value: 'raw', label: 'raw text', kind: 'string' }],
|
||||
counter: [{ value: 'counter', label: 'counter', kind: 'string' }],
|
||||
limit: [{ value: 'limit', label: 'limit (rate)', kind: 'string' }],
|
||||
log: [{ value: 'log', label: 'log', kind: 'string' }],
|
||||
nat: [
|
||||
{ value: 'dnat', label: 'dnat to', kind: 'string' },
|
||||
{ value: 'snat', label: 'snat to', kind: 'string' },
|
||||
{ value: 'masquerade', label: 'masquerade', kind: 'string' },
|
||||
],
|
||||
queue: [{ value: 'queue', label: 'queue num', kind: 'string' }],
|
||||
};
|
||||
|
||||
/* ----------------------
|
||||
ENUM_VALUES (dropdown contents)
|
||||
Keep these aligned with the user's canonical lists.
|
||||
---------------------- */
|
||||
const ENUM_VALUES: Record<string, string[]> = {
|
||||
l4proto: ['tcp', 'udp', 'icmp', 'icmpv6', 'igmp', 'esp', 'ah'],
|
||||
days: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
|
||||
pkttype: ['unicast', 'multicast', 'broadcast', 'other'],
|
||||
ct_state: ['new', 'established', 'related', 'invalid', 'untracked'],
|
||||
ct_direction: ['original', 'reply'],
|
||||
// ICMP message *types* (used for e.g. echo-request/echo-reply)
|
||||
icmp_types: ['echo-request', 'echo-reply', 'destination-unreachable'],
|
||||
// IPv4 reject *reasons* (ICMPv4 codes / textual reasons used with `reject with icmp type <reason>`)
|
||||
icmpv4_reasons: [
|
||||
'net-unreachable',
|
||||
'host-unreachable',
|
||||
'prot-unreachable',
|
||||
'port-unreachable', // default
|
||||
'net-prohibited',
|
||||
'host-prohibited',
|
||||
'admin-prohibited',
|
||||
],
|
||||
// IPv6 reject reasons (ICMPv6 textual reasons)
|
||||
icmpv6_reasons: ['no-route', 'admin-prohibited', 'addr-unreachable', 'port-unreachable'],
|
||||
dscp_values: [
|
||||
'cs0',
|
||||
'cs1',
|
||||
'cs2',
|
||||
'cs3',
|
||||
'cs4',
|
||||
'cs5',
|
||||
'cs6',
|
||||
'cs7',
|
||||
'af11',
|
||||
'af12',
|
||||
'af13',
|
||||
'af21',
|
||||
'af22',
|
||||
'af23',
|
||||
'af31',
|
||||
'af32',
|
||||
'af33',
|
||||
'af41',
|
||||
'af42',
|
||||
'af43',
|
||||
'ef',
|
||||
],
|
||||
tcp_flags: ['fin', 'syn', 'rst', 'psh', 'ack', 'urg', 'ece', 'cwr'],
|
||||
ethertypes: ['ip', 'ip6', 'arp', 'vlan', 'loopback'],
|
||||
// top-level reject types used in select control. Note `icmpv6` spelled out.
|
||||
reject_types: ['icmp', 'icmpv6', 'icmpx', 'tcp-reset'],
|
||||
};
|
||||
|
||||
/* ----------------------
|
||||
tokenToText: produce nft textual representation from token value
|
||||
(keeps command generation consistent with the UI)
|
||||
---------------------- */
|
||||
function tokenToText(token: any): string {
|
||||
if (!token || !token.type) return '';
|
||||
const t = token.type as TokenType;
|
||||
const d = token.data || {};
|
||||
|
||||
// META
|
||||
if (t === 'meta') {
|
||||
const f = d.field;
|
||||
if (!f) return '';
|
||||
// special formatting: meta l4proto <proto>
|
||||
if (f === 'l4proto') {
|
||||
return `meta l4proto ${String(d.value ?? '')}`.trim();
|
||||
}
|
||||
if (f === 'iifname' || f === 'oifname') {
|
||||
return `meta ${f} ${String(d.value ?? '')}`.trim();
|
||||
}
|
||||
if (f === 'day') {
|
||||
return `meta day ${String(d.value ?? '')}`.trim();
|
||||
}
|
||||
if (f === 'hour') {
|
||||
return `meta hour ${String(d.value ?? '')}`.trim();
|
||||
}
|
||||
if (f === 'pkttype') {
|
||||
return `meta pkttype ${String(d.value ?? '')}`.trim();
|
||||
}
|
||||
if (f === 'mark') {
|
||||
return `meta mark ${String(d.value ?? '')}`.trim();
|
||||
}
|
||||
if (f === 'skuid' || f === 'skgid') {
|
||||
return `meta ${f} ${String(d.value ?? '')}`.trim();
|
||||
}
|
||||
return `meta ${f} ${String(d.value ?? '')}`.trim();
|
||||
}
|
||||
|
||||
// CT
|
||||
if (t === 'ct') {
|
||||
const f = d.field;
|
||||
if (!f) return '';
|
||||
return `ct ${f} ${String(d.value ?? '')}`.trim();
|
||||
}
|
||||
|
||||
// IP/IPv6
|
||||
if (t === 'ip' || t === 'ip6') {
|
||||
const f = d.field;
|
||||
if (!f) return '';
|
||||
// saddr/daddr: allow CIDR/list/range raw text
|
||||
return `${t} ${f} ${String(d.value ?? '')}`.trim();
|
||||
}
|
||||
|
||||
// Transport protocols
|
||||
if (t === 'tcp' || t === 'udp') {
|
||||
const f = d.field;
|
||||
if (!f) return t;
|
||||
if (f === 'dport' || f === 'sport') {
|
||||
return `${t} ${f} ${String(d.value ?? '')}`.trim();
|
||||
}
|
||||
if (f === 'flags') {
|
||||
// flags could be array or comma-separated
|
||||
const vals = Array.isArray(d.value)
|
||||
? d.value
|
||||
: String(d.value ?? '')
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (vals.length === 0) return t;
|
||||
// render as: tcp flags { syn, ack }
|
||||
return `${t} flags { ${vals.join(', ')} }`;
|
||||
}
|
||||
return `${t} ${f} ${String(d.value ?? '')}`.trim();
|
||||
}
|
||||
|
||||
if (t === 'icmp') {
|
||||
const f = d.field;
|
||||
if (!f) return 'icmp';
|
||||
return `icmp ${f} ${String(d.value ?? '')}`.trim();
|
||||
}
|
||||
|
||||
// ETHER
|
||||
if (t === 'ether') {
|
||||
const f = d.field;
|
||||
if (!f) return '';
|
||||
return `ether ${f} ${String(d.value ?? '')}`.trim();
|
||||
}
|
||||
|
||||
// VLAN
|
||||
if (t === 'vlan') {
|
||||
const f = d.field;
|
||||
if (!f) return 'vlan';
|
||||
return `vlan ${f} ${String(d.value ?? '')}`.trim();
|
||||
}
|
||||
|
||||
// Statements
|
||||
if (t === 'counter') {
|
||||
if (d.packets || d.bytes) {
|
||||
return `counter${d.packets ? ` packets ${d.packets}` : ''}${d.bytes ? ` bytes ${d.bytes}` : ''}`.trim();
|
||||
}
|
||||
return 'counter';
|
||||
}
|
||||
if (t === 'limit') {
|
||||
const r = d.rate ?? d.value;
|
||||
return r ? `limit rate ${r}` : 'limit';
|
||||
}
|
||||
if (t === 'log') {
|
||||
const parts: string[] = [];
|
||||
if (d.level) parts.push(`level ${d.level}`);
|
||||
if (d.group) parts.push(`group ${d.group}`);
|
||||
if (d.snaplen) parts.push(`snaplen ${d.snaplen}`);
|
||||
if (d.prefix) parts.push(`prefix "${d.prefix}"`);
|
||||
return parts.length ? `log ${parts.join(' ')}` : 'log';
|
||||
}
|
||||
if (t === 'nat') {
|
||||
if (d.kind === 'dnat' && d.to) return `dnat to ${d.to}`;
|
||||
if (d.kind === 'snat' && d.to) return `snat to ${d.to}`;
|
||||
if (d.kind === 'masquerade') return d.to ? `masquerade to ${d.to}` : 'masquerade';
|
||||
return 'nat';
|
||||
}
|
||||
if (t === 'queue') {
|
||||
if (d.num) {
|
||||
// allow optional extra token words following queue num, e.g. "queue num 1 bypass"
|
||||
const extra = d.extra ? ` ${String(d.extra)}` : '';
|
||||
return `queue num ${d.num}${extra}`.trim();
|
||||
}
|
||||
return 'queue';
|
||||
}
|
||||
if (t === 'raw') {
|
||||
return String(d.text ?? '').trim();
|
||||
}
|
||||
if (t === 'payload') {
|
||||
if (d.value) return `payload(${d.value})`;
|
||||
return 'payload';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/* ----------------------
|
||||
generateCommandFromValues (build textual + final nft add/insert)
|
||||
---------------------- */
|
||||
function generateCommandFromValues(values: FormValues) {
|
||||
const tokens = Array.isArray(values.tokens) ? values.tokens : [];
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const t of tokens) {
|
||||
const txt = tokenToText(t);
|
||||
if (txt) parts.push(txt);
|
||||
}
|
||||
|
||||
if (values.advanced && typeof values.advanced === 'string' && values.advanced.trim() !== '') {
|
||||
parts.push(values.advanced.trim());
|
||||
}
|
||||
|
||||
// Build queue text for NFQUEUE action or queue token
|
||||
if (values.action === 'nfqueue' || values.action === 'queue') {
|
||||
const qnum = values.nfqueue ?? values.queue ?? 1;
|
||||
const bypass = values.nfqueue_bypass ? ' bypass' : '';
|
||||
const queueText = `queue num ${Number(qnum)}${bypass}`;
|
||||
const combined = parts.join(' ');
|
||||
if (!/\bqueue(?:\s+num)?\b/i.test(combined)) {
|
||||
parts.push(queueText);
|
||||
} else {
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (/\bqueue(?:\s+num)?\b/i.test(parts[i])) {
|
||||
parts[i] = queueText;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build action/reject/nfqueue textual suffix
|
||||
let actionText: string | null = null;
|
||||
if (values.action === 'accept' || values.action === 'drop') {
|
||||
actionText = values.action;
|
||||
} else if (values.action === 'reject') {
|
||||
// reject requires a rejectType (form enforces it)
|
||||
const rtype = values.rejectType;
|
||||
if (!rtype) {
|
||||
actionText = 'reject'; // fallback, though form validation should prevent this
|
||||
} else if (rtype === 'tcp-reset') {
|
||||
// nft "reject with tcp reset"
|
||||
actionText = 'reject with tcp reset';
|
||||
} else if (rtype === 'icmp') {
|
||||
// IPv4: "reject with icmp type <reason>"
|
||||
const reason = values.rejectIcmpReason || '';
|
||||
actionText = reason ? `reject with icmp type ${reason}` : 'reject';
|
||||
} else if (rtype === 'icmpv6') {
|
||||
// IPv6: "reject with icmpv6 type <reason>"
|
||||
const reason = values.rejectIcmp6Reason || '';
|
||||
actionText = reason ? `reject with icmpv6 type ${reason}` : 'reject';
|
||||
} else if (rtype === 'icmpx') {
|
||||
// inet family abstraction (icmpx)
|
||||
const reason = values.rejectIcmpxReason || '';
|
||||
actionText = reason ? `reject with icmpx type ${reason}` : 'reject';
|
||||
} else {
|
||||
actionText = 'reject';
|
||||
}
|
||||
} else if (values.action === 'nfqueue') {
|
||||
// NFQUEUE action is represented by queue token above; no extra action verb
|
||||
actionText = null;
|
||||
}
|
||||
|
||||
const textual = (parts.join(' ') + (actionText ? ` ${actionText}` : '')).trim();
|
||||
|
||||
const tableSelect = values.tableSelect;
|
||||
const chain = values.chainSelect || 'input';
|
||||
const [family = 'inet', table = 'filter'] = tableSelect ? String(tableSelect).split(':') : ['inet', 'filter'];
|
||||
|
||||
const before = values.insertBeforeHandle;
|
||||
const hasBefore = before != null && String(before) !== '';
|
||||
const verb = hasBefore ? 'insert' : 'add';
|
||||
const positionPart = hasBefore ? ` position ${before}` : '';
|
||||
|
||||
const cmd = `${verb} rule ${family} ${table} ${chain}${positionPart} ${textual}`.replace(/\s+/g, ' ').trim();
|
||||
|
||||
return { cmd, textual, position: hasBefore ? Number(before) : undefined };
|
||||
}
|
||||
|
||||
/* -------------------------
|
||||
Component
|
||||
------------------------- */
|
||||
|
||||
interface RuleBuilderProps {
|
||||
onCreated?: () => Promise<void> | void;
|
||||
tables?: TableOut[] | null;
|
||||
rulesLoading?: boolean;
|
||||
rulesError?: string | null;
|
||||
refreshRules?: () => Promise<void>;
|
||||
onRulesChange?: (tables: TableOut[]) => void;
|
||||
}
|
||||
|
||||
export const RuleBuilder: React.FC<RuleBuilderProps> = (props: RuleBuilderProps) => {
|
||||
const [form] = Form.useForm();
|
||||
const [cmdPreview, setCmdPreview] = useState('');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const tableOptions = useMemo(
|
||||
() => (props.tables || []).map((t) => ({ value: `${t.family}:${t.name}`, label: `${t.family}:${t.name}` })),
|
||||
[props.tables],
|
||||
);
|
||||
const noTables = !(props.tables && props.tables.length > 0);
|
||||
|
||||
const [insertBeforeOptions, setInsertBeforeOptions] = useState<Array<{ value: any; label: string }>>([]);
|
||||
const updateInsertOptions = useCallback(() => {
|
||||
const ts = form.getFieldValue('tableSelect');
|
||||
const cs = form.getFieldValue('chainSelect');
|
||||
if (!ts || !cs) {
|
||||
setInsertBeforeOptions([]);
|
||||
return;
|
||||
}
|
||||
const [family, table] = String(ts).split(':');
|
||||
const tbl = props.tables?.find((t) => t.family === family && t.name === table);
|
||||
if (!tbl) {
|
||||
setInsertBeforeOptions([]);
|
||||
return;
|
||||
}
|
||||
const ch = (tbl.chains || []).find((c: ChainOut) => c.name === cs);
|
||||
if (!ch || !Array.isArray(ch.rules)) {
|
||||
setInsertBeforeOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const opts = ch.rules
|
||||
.filter((r: RuleOut) => r && r.handle != null)
|
||||
.map((r: RuleOut) => ({
|
||||
value: r.handle,
|
||||
label: `#${r.handle} — ${r.text ?? (typeof r.expr === 'string' ? r.expr : JSON.stringify(r.expr || {}).slice(0, 120))}`,
|
||||
}));
|
||||
setInsertBeforeOptions(opts);
|
||||
}, [form, props.tables]);
|
||||
|
||||
const previewTimerRef = useRef<number | null>(null);
|
||||
const schedulePreviewUpdate = useCallback(() => {
|
||||
if (previewTimerRef.current) window.clearTimeout(previewTimerRef.current);
|
||||
previewTimerRef.current = window.setTimeout(() => {
|
||||
const v = form.getFieldsValue();
|
||||
const { cmd } = generateCommandFromValues(v);
|
||||
setCmdPreview(cmd);
|
||||
previewTimerRef.current = null;
|
||||
}, 40);
|
||||
}, [form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tableOptions.length > 0) {
|
||||
const first = tableOptions[0].value;
|
||||
form.setFieldsValue({
|
||||
tableSelect: first,
|
||||
action: 'drop',
|
||||
nfqueue: 1,
|
||||
nfqueue_bypass: false,
|
||||
tokens: [],
|
||||
});
|
||||
|
||||
const [f, n] = String(first).split(':');
|
||||
const tbl = props.tables?.find((t) => t.family === f && t.name === n);
|
||||
if (tbl && tbl.chains && tbl.chains.length > 0) {
|
||||
form.setFieldsValue({ chainSelect: tbl.chains[0].name });
|
||||
} else {
|
||||
form.setFieldsValue({ chainSelect: undefined });
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
updateInsertOptions();
|
||||
schedulePreviewUpdate();
|
||||
}, 0);
|
||||
} else {
|
||||
form.setFieldsValue({
|
||||
action: 'drop',
|
||||
nfqueue: 1,
|
||||
nfqueue_bypass: false,
|
||||
tableSelect: undefined,
|
||||
chainSelect: undefined,
|
||||
tokens: [],
|
||||
});
|
||||
setInsertBeforeOptions([]);
|
||||
setTimeout(() => schedulePreviewUpdate(), 0);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.tables, tableOptions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const cur = form.getFieldsValue();
|
||||
if (cur.nfqueue == null) form.setFieldsValue({ nfqueue: 1 });
|
||||
schedulePreviewUpdate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onValuesChange = useCallback(
|
||||
(_: any, allValues: FormValues) => {
|
||||
if (allValues.action === 'nfqueue' && (allValues.nfqueue == null || allValues.nfqueue === '')) {
|
||||
form.setFieldsValue({ nfqueue: 1 });
|
||||
allValues.nfqueue = 1;
|
||||
}
|
||||
|
||||
const ts = allValues.tableSelect;
|
||||
if (ts) {
|
||||
const [f, n] = String(ts).split(':');
|
||||
const tbl = props.tables?.find((t) => t.family === f && t.name === n);
|
||||
if (tbl) {
|
||||
if (tbl.chains && tbl.chains.length > 0) {
|
||||
if (!allValues.chainSelect) form.setFieldsValue({ chainSelect: tbl.chains[0].name });
|
||||
} else {
|
||||
form.setFieldsValue({ chainSelect: undefined });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateInsertOptions();
|
||||
schedulePreviewUpdate();
|
||||
},
|
||||
[form, props.tables, updateInsertOptions, schedulePreviewUpdate],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (values: FormValues) => {
|
||||
try {
|
||||
const validated = await form.validateFields();
|
||||
const { cmd } = generateCommandFromValues(validated);
|
||||
Modal.confirm({
|
||||
title: 'Run raw nft command',
|
||||
content: (
|
||||
<div>
|
||||
<Text>
|
||||
About to run nft command in <b>{String(validated.tableSelect ?? 'inet:filter')}</b> (see preview).
|
||||
</Text>
|
||||
<Divider />
|
||||
<Text strong>Command:</Text>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', marginTop: 8 }}>{cmd}</pre>
|
||||
</div>
|
||||
),
|
||||
okText: 'Run',
|
||||
onOk: async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const out: ExecResult = await execFirewallRaw(cmd);
|
||||
const stderrText = out?.stderr ? String(out.stderr).trim() : '';
|
||||
if (stderrText) {
|
||||
notification.error({
|
||||
message: 'Command produced Error',
|
||||
description: stderrText,
|
||||
});
|
||||
} else if (out && (out.rc === 0 || out.rc === -1)) {
|
||||
notification.success({
|
||||
message: 'Command executed successfully',
|
||||
});
|
||||
if (props.refreshRules) await props.refreshRules();
|
||||
if (props.onCreated) await props.onCreated();
|
||||
} else {
|
||||
const info = out
|
||||
? `rc:${out.rc}` +
|
||||
(out.stdout ? ` stdout:${out.stdout}` : '') +
|
||||
(out.stderr ? ` stderr:${out.stderr}` : '')
|
||||
: 'unknown result';
|
||||
notification.error({
|
||||
message: 'Command failed',
|
||||
description: info,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
notification.error({
|
||||
message: 'Execution failed',
|
||||
description: err?.message ?? String(err),
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
schedulePreviewUpdate();
|
||||
}
|
||||
},
|
||||
[form, props.refreshRules, props.onCreated, schedulePreviewUpdate],
|
||||
);
|
||||
|
||||
const chainOptions = useMemo(() => {
|
||||
const ts = form.getFieldValue('tableSelect');
|
||||
if (!ts) return [];
|
||||
const [f, n] = String(ts).split(':');
|
||||
const tbl = props.tables?.find((t) => t.family === f && t.name === n);
|
||||
if (!tbl) return [];
|
||||
return tbl.chains.map((c) => (
|
||||
<Option key={c.name} value={c.name}>
|
||||
{c.name}
|
||||
</Option>
|
||||
));
|
||||
}, [form, props.tables]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
if (props.refreshRules) {
|
||||
await props.refreshRules();
|
||||
message.success('Rules refresh requested');
|
||||
} else {
|
||||
message.info('No refresh function provided by parent.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('refresh failed', err);
|
||||
message.error('Refresh failed');
|
||||
} finally {
|
||||
updateInsertOptions();
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [props.refreshRules, updateInsertOptions]);
|
||||
|
||||
/* helper styles */
|
||||
const tokenRowStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'nowrap',
|
||||
width: '100%',
|
||||
};
|
||||
|
||||
const leftControlsStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
minWidth: 72,
|
||||
flex: '0 0 72px',
|
||||
};
|
||||
|
||||
const typeSelectStyle: React.CSSProperties = { minWidth: 180, maxWidth: 260, flex: '0 0 220px' };
|
||||
const fieldSelectStyle: React.CSSProperties = { minWidth: 160, maxWidth: 260, flex: '0 0 220px' };
|
||||
const valueInputStyle: React.CSSProperties = { minWidth: 120, flex: '1 1 240px', maxWidth: '60%' };
|
||||
const actionControlsStyle: React.CSSProperties = {
|
||||
minWidth: 96,
|
||||
flex: '0 0 96px',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="Firewall Rule Builder" extra>
|
||||
<Form
|
||||
layout="vertical"
|
||||
form={form}
|
||||
initialValues={{
|
||||
action: 'drop',
|
||||
nfqueue: 1,
|
||||
nfqueue_bypass: false,
|
||||
tableSelect: tableOptions.length > 0 ? tableOptions[0].value : undefined,
|
||||
tokens: [],
|
||||
}}
|
||||
onFinish={handleCreate}
|
||||
onValuesChange={onValuesChange}
|
||||
>
|
||||
{/* Table / chain */}
|
||||
<Row gutter={16} align="middle">
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item name="tableSelect" label="Table (family:name)" rules={[{ required: true }]}>
|
||||
@@ -71,7 +717,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
{/* Insert before */}
|
||||
<Row gutter={16} align="middle">
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item
|
||||
@@ -104,7 +750,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
|
||||
<Divider />
|
||||
|
||||
|
||||
{/* Token builder header + add control */}
|
||||
<Row align="middle" justify="space-between" style={{ marginBottom: 8 }}>
|
||||
<Col>
|
||||
<Text strong>Token builder</Text>
|
||||
@@ -120,6 +766,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
<Select
|
||||
placeholder="Add token..."
|
||||
onSelect={(val: TokenType) => {
|
||||
// sensible defaults per token type
|
||||
const defaultData =
|
||||
val === 'meta'
|
||||
? { field: 'iifname', value: '' }
|
||||
@@ -176,7 +823,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
|
||||
<Divider />
|
||||
|
||||
|
||||
{/* Tokens Form.List rendering */}
|
||||
<Form.List name="tokens">
|
||||
{(fields, { remove, move }) =>
|
||||
fields.length === 0 ? (
|
||||
@@ -195,7 +842,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flex: 1, minWidth: 0 }}>
|
||||
|
||||
{/* Token type select */}
|
||||
<Form.Item name={[field.name, 'type']} style={{ marginBottom: 0 }}>
|
||||
<Select style={typeSelectStyle}>
|
||||
{Object.keys(TOKEN_FIELD_OPTIONS).map((k) => (
|
||||
@@ -206,7 +853,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
|
||||
{/* Token field + value UI (depends on token type and subfield) */}
|
||||
<Form.Item
|
||||
shouldUpdate={(prev, cur) =>
|
||||
prev.tokens?.[field.name]?.type !== cur.tokens?.[field.name]?.type ||
|
||||
@@ -218,6 +865,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
const tokenType = form.getFieldValue(['tokens', field.name, 'type']) as TokenType | undefined;
|
||||
const options = tokenType ? TOKEN_FIELD_OPTIONS[tokenType] || [] : [];
|
||||
|
||||
// COUNTER special-case
|
||||
if (tokenType === 'counter') {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', width: '100%' }}>
|
||||
@@ -237,6 +885,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
);
|
||||
}
|
||||
|
||||
// LIMIT special-case
|
||||
if (tokenType === 'limit') {
|
||||
return (
|
||||
<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') {
|
||||
return (
|
||||
<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') {
|
||||
return (
|
||||
<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') {
|
||||
return (
|
||||
<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) {
|
||||
return (
|
||||
<div
|
||||
@@ -364,7 +1017,9 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
const meta = opts.find((o) => o.value === selField);
|
||||
const kind = meta?.kind ?? 'string';
|
||||
|
||||
|
||||
/* --- Field-specific UIs & helpers (placeholders + explanatory text) --- */
|
||||
|
||||
// STRING typed helpers for interface names
|
||||
if (tType === 'meta' && (selField === 'iifname' || selField === 'oifname')) {
|
||||
return (
|
||||
<div>
|
||||
@@ -379,6 +1034,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
);
|
||||
}
|
||||
|
||||
// L4PROTO dropdown for meta.l4proto
|
||||
if (tType === 'meta' && selField === 'l4proto') {
|
||||
return (
|
||||
<div>
|
||||
@@ -396,6 +1052,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
);
|
||||
}
|
||||
|
||||
// Day of week (meta.day)
|
||||
if (tType === 'meta' && selField === 'day') {
|
||||
return (
|
||||
<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') {
|
||||
return (
|
||||
<div>
|
||||
@@ -429,6 +1087,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
);
|
||||
}
|
||||
|
||||
// Packet type (meta.pkttype)
|
||||
if (tType === 'meta' && selField === 'pkttype') {
|
||||
return (
|
||||
<div>
|
||||
@@ -448,6 +1107,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
);
|
||||
}
|
||||
|
||||
// Packet/conn mark
|
||||
if ((tType === 'meta' || tType === 'ct') && selField === 'mark') {
|
||||
return (
|
||||
<div>
|
||||
@@ -461,6 +1121,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
);
|
||||
}
|
||||
|
||||
// skuid / skgid
|
||||
if (tType === 'meta' && (selField === 'skuid' || selField === 'skgid')) {
|
||||
return (
|
||||
<div>
|
||||
@@ -479,6 +1140,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
);
|
||||
}
|
||||
|
||||
// CT state
|
||||
if (tType === 'ct' && selField === 'state') {
|
||||
return (
|
||||
<div>
|
||||
@@ -503,6 +1165,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
);
|
||||
}
|
||||
|
||||
// CT direction
|
||||
if (tType === 'ct' && selField === 'direction') {
|
||||
return (
|
||||
<div>
|
||||
@@ -522,7 +1185,8 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
/* --- IP / IP6 address helpers --- */
|
||||
if (
|
||||
(tType === 'ip' || tType === 'ip6') &&
|
||||
(selField === 'saddr' || selField === 'daddr')
|
||||
) {
|
||||
@@ -550,6 +1214,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
);
|
||||
}
|
||||
|
||||
// protocol / nexthdr / ip.protocol (L4 protocol): show l4proto list
|
||||
if (
|
||||
(tType === 'ip' && selField === 'protocol') ||
|
||||
(tType === 'ip6' && selField === 'nexthdr')
|
||||
@@ -573,6 +1238,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
);
|
||||
}
|
||||
|
||||
// DSCP
|
||||
if ((tType === 'ip' || tType === 'ip6') && selField === 'dscp') {
|
||||
return (
|
||||
<div>
|
||||
@@ -592,6 +1258,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
);
|
||||
}
|
||||
|
||||
// TTL / hoplimit numeric
|
||||
if (
|
||||
(tType === 'ip' && selField === 'ttl') ||
|
||||
(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') {
|
||||
return (
|
||||
<div>
|
||||
@@ -624,7 +1292,9 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* --- Transport: TCP/UDP/ICMP --- */
|
||||
|
||||
// Ports: allow numeric or service name
|
||||
if (
|
||||
(tType === 'tcp' || tType === 'udp') &&
|
||||
(selField === 'dport' || selField === 'sport')
|
||||
@@ -641,6 +1311,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
);
|
||||
}
|
||||
|
||||
// TCP flags multi-select
|
||||
if (tType === 'tcp' && selField === 'flags') {
|
||||
return (
|
||||
<div>
|
||||
@@ -664,6 +1335,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
);
|
||||
}
|
||||
|
||||
// ICMP type/code dropdowns
|
||||
if (tType === 'icmp' && selField === 'type') {
|
||||
return (
|
||||
<div>
|
||||
@@ -702,7 +1374,8 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* --- Layer 2: Ethernet / VLAN --- */
|
||||
|
||||
if (tType === 'ether') {
|
||||
if (selField === 'saddr' || selField === 'daddr') {
|
||||
return (
|
||||
@@ -735,6 +1408,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
}
|
||||
}
|
||||
|
||||
// VLAN ID / PCP / CFI
|
||||
if (tType === 'vlan') {
|
||||
if (selField === 'id') {
|
||||
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 (
|
||||
<div>
|
||||
<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 (
|
||||
<div>
|
||||
<Form.Item name={[field.name, 'data', 'value']} style={{ margin: 0 }}>
|
||||
@@ -839,7 +1515,8 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
|
||||
<Divider />
|
||||
|
||||
|
||||
{/* Action + NFQUEUE + Reject options: render action radios, then render
|
||||
reject subform and nfqueue subform directly under it (same column) */}
|
||||
<Row gutter={16} align="top">
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item name="action" label="Action / verdict" rules={[{ required: true }]}>
|
||||
@@ -851,7 +1528,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
|
||||
{/* Reject options (render under radios, same column) */}
|
||||
<Form.Item shouldUpdate={(prev, cur) => prev.action !== cur.action} noStyle>
|
||||
{() =>
|
||||
form.getFieldValue('action') === 'reject' ? (
|
||||
@@ -870,7 +1547,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
|
||||
{/* IPv4 reject reasons */}
|
||||
<Form.Item shouldUpdate={(prev, cur) => prev.rejectType !== cur.rejectType} noStyle>
|
||||
{() =>
|
||||
form.getFieldValue('rejectType') === 'icmp' ? (
|
||||
@@ -895,7 +1572,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
}
|
||||
</Form.Item>
|
||||
|
||||
|
||||
{/* IPv6 reject reasons */}
|
||||
<Form.Item shouldUpdate={(prev, cur) => prev.rejectType !== cur.rejectType} noStyle>
|
||||
{() =>
|
||||
form.getFieldValue('rejectType') === 'icmpv6' ? (
|
||||
@@ -920,7 +1597,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
}
|
||||
</Form.Item>
|
||||
|
||||
|
||||
{/* icmpx (inet) */}
|
||||
<Form.Item shouldUpdate={(prev, cur) => prev.rejectType !== cur.rejectType} noStyle>
|
||||
{() =>
|
||||
form.getFieldValue('rejectType') === 'icmpx' ? (
|
||||
@@ -951,7 +1628,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
}
|
||||
</Form.Item>
|
||||
|
||||
|
||||
{/* NFQUEUE options (now rendered under radios in same column) */}
|
||||
<Form.Item shouldUpdate={(prev, cur) => prev.action !== cur.action} noStyle>
|
||||
{() =>
|
||||
form.getFieldValue('action') === 'nfqueue' ? (
|
||||
@@ -971,6 +1648,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
<Form.Item name="nfqueue_bypass" valuePropName="checked">
|
||||
<Checkbox
|
||||
onChange={() => {
|
||||
// update preview immediately
|
||||
schedulePreviewUpdate();
|
||||
}}
|
||||
>
|
||||
@@ -987,7 +1665,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
|
||||
{/* right column is free for notes / quick helpers */}
|
||||
<Col xs={24} sm={12}>
|
||||
<Text type="secondary">
|
||||
Use NFQUEUE to hand packets to userspace. Full reject support requires kernel >= 3.18 — when using
|
||||
@@ -996,7 +1674,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
{/* advanced text */}
|
||||
<Row>
|
||||
<Col span={24}>
|
||||
<Form.Item name="advanced" label="Advanced (optional)">
|
||||
@@ -1010,7 +1688,7 @@ const TOKEN_FIELD_OPTIONS: Record<
|
||||
|
||||
<Divider />
|
||||
|
||||
|
||||
{/* preview + run */}
|
||||
<Form.Item>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', width: '100%' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
|
||||
@@ -169,7 +169,8 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
|
||||
} finally {
|
||||
try {
|
||||
await refresh();
|
||||
} catch
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -205,7 +206,8 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
|
||||
} finally {
|
||||
try {
|
||||
await refresh();
|
||||
} catch
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -241,7 +243,8 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
|
||||
} finally {
|
||||
try {
|
||||
await refresh();
|
||||
} catch
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,28 +18,11 @@ import {
|
||||
} from 'antd';
|
||||
import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { clearPackets, fetchPackets } from '../api/apiClient';
|
||||
import type { PacketRow } from '../types/packets';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
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 MAX_PACKETS = 2000; // in-memory cap
|
||||
|
||||
@@ -164,7 +147,7 @@ export default function PacketViewer(): ReactElement {
|
||||
setStatusLoading(true);
|
||||
try {
|
||||
const res = await fetchPackets(limitVal);
|
||||
const list: PacketRow[] = (res.packets ?? []).map((p: any) => p);
|
||||
const list: PacketRow[] = res.packets ?? [];
|
||||
setPackets(list);
|
||||
} catch (err: any) {
|
||||
console.error('fetchHistory error', err);
|
||||
@@ -337,7 +320,30 @@ export default function PacketViewer(): ReactElement {
|
||||
title: 'Protocol',
|
||||
dataIndex: '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>,
|
||||
},
|
||||
{
|
||||
@@ -375,6 +381,13 @@ export default function PacketViewer(): ReactElement {
|
||||
width: 80,
|
||||
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',
|
||||
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"
|
||||
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)
|
||||
@@ -61,6 +62,31 @@ cd "$BACKEND_DIR"
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
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
|
||||
deactivate
|
||||
|
||||
@@ -199,6 +225,9 @@ npm run build
|
||||
# Backend Dependencies
|
||||
cd $BACKEND_DIR
|
||||
source venv/bin/activate
|
||||
if [ -d "/tmp/nDPI/python" ]; then
|
||||
pip install /tmp/nDPI/python
|
||||
fi
|
||||
pip install -r requirements.txt
|
||||
deactivate
|
||||
|
||||
|
||||
@@ -75,9 +75,28 @@ CREATE TABLE IF NOT EXISTS packets (
|
||||
-- Packet metadata
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -100,6 +119,8 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT, UPDATE ON SEQUENC
|
||||
-- Create indexes for faster queries
|
||||
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_app_protocol ON packets(app_protocol);
|
||||
CREATE INDEX IF NOT EXISTS idx_packets_app_hostname ON packets(app_hostname);
|
||||
EOF
|
||||
|
||||
echo "Done. PostgreSQL is ready for LAN + localhost connections."
|
||||
|
||||
Reference in New Issue
Block a user