diff --git a/backend/src/Models/packets.py b/backend/src/Models/packets.py index a19fa37..09731d4 100644 --- a/backend/src/Models/packets.py +++ b/backend/src/Models/packets.py @@ -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"}, } } diff --git a/backend/src/network_sniffer.py b/backend/src/network_sniffer.py index 6c4089a..25ed73e 100644 --- a/backend/src/network_sniffer.py +++ b/backend/src/network_sniffer.py @@ -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), - } \ No newline at end of file + } diff --git a/backend/src/utilities/database.py b/backend/src/utilities/database.py index 5e1c298..82f3868 100644 --- a/backend/src/utilities/database.py +++ b/backend/src/utilities/database.py @@ -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") diff --git a/backend/src/utilities/ndpi_classifier.py b/backend/src/utilities/ndpi_classifier.py new file mode 100644 index 0000000..f161058 --- /dev/null +++ b/backend/src/utilities/ndpi_classifier.py @@ -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() diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 8af0f9d..375d824 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -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 => { return res.data; }; -export const fetchPackets = async (limit = 100): Promise => { - const res = await api.get('/packets/packets', { params: { limit } }); +export const fetchPackets = async (limit = 100): Promise => { + const res = await api.get('/packets/packets', { params: { limit } }); return res.data; }; diff --git a/frontend/src/components/FirewallRuleBuilder.tsx b/frontend/src/components/FirewallRuleBuilder.tsx index c8d504d..901987b 100644 --- a/frontend/src/components/FirewallRuleBuilder.tsx +++ b/frontend/src/components/FirewallRuleBuilder.tsx @@ -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; +/* ---------------------- + 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 = { + 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 `) + 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 + 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 " + const reason = values.rejectIcmpReason || ''; + actionText = reason ? `reject with icmp type ${reason}` : 'reject'; + } else if (rtype === 'icmpv6') { + // IPv6: "reject with icmpv6 type " + 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; + tables?: TableOut[] | null; + rulesLoading?: boolean; + rulesError?: string | null; + refreshRules?: () => Promise; + onRulesChange?: (tables: TableOut[]) => void; +} + +export const RuleBuilder: React.FC = (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>([]); + 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(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: ( +
+ + About to run nft command in {String(validated.tableSelect ?? 'inet:filter')} (see preview). + + + Command: +
{cmd}
+
+ ), + 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) => ( + + )); + }, [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 ( + +
0 ? tableOptions[0].value : undefined, + tokens: [], + }} + onFinish={handleCreate} + onValuesChange={onValuesChange} + > + {/* Table / chain */} @@ -71,7 +717,7 @@ const TOKEN_FIELD_OPTIONS: Record< - + {/* Insert before */} - + {/* Token builder header + add control */} Token builder @@ -120,6 +766,7 @@ const TOKEN_FIELD_OPTIONS: Record< {Object.keys(TOKEN_FIELD_OPTIONS).map((k) => ( @@ -206,7 +853,7 @@ const TOKEN_FIELD_OPTIONS: Record< - + {/* Token field + value UI (depends on token type and subfield) */} 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 (
@@ -237,6 +885,7 @@ const TOKEN_FIELD_OPTIONS: Record< ); } + // LIMIT special-case if (tokenType === 'limit') { return (
@@ -254,6 +903,7 @@ const TOKEN_FIELD_OPTIONS: Record< ); } + // LOG special-case if (tokenType === 'log') { return (
@@ -291,6 +941,7 @@ const TOKEN_FIELD_OPTIONS: Record< ); } + // QUEUE special-case inside token list (separate from NFQUEUE action) if (tokenType === 'queue') { return (
@@ -310,6 +961,7 @@ const TOKEN_FIELD_OPTIONS: Record< ); } + // NAT special-case if (tokenType === 'nat') { return (
@@ -331,6 +983,7 @@ const TOKEN_FIELD_OPTIONS: Record< ); } + // Generic tokens with subfield dropdown if (options.length > 0) { return (
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 (
@@ -379,6 +1034,7 @@ const TOKEN_FIELD_OPTIONS: Record< ); } + // L4PROTO dropdown for meta.l4proto if (tType === 'meta' && selField === 'l4proto') { return (
@@ -396,6 +1052,7 @@ const TOKEN_FIELD_OPTIONS: Record< ); } + // Day of week (meta.day) if (tType === 'meta' && selField === 'day') { return (
@@ -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 (
@@ -429,6 +1087,7 @@ const TOKEN_FIELD_OPTIONS: Record< ); } + // Packet type (meta.pkttype) if (tType === 'meta' && selField === 'pkttype') { return (
@@ -448,6 +1107,7 @@ const TOKEN_FIELD_OPTIONS: Record< ); } + // Packet/conn mark if ((tType === 'meta' || tType === 'ct') && selField === 'mark') { return (
@@ -461,6 +1121,7 @@ const TOKEN_FIELD_OPTIONS: Record< ); } + // skuid / skgid if (tType === 'meta' && (selField === 'skuid' || selField === 'skgid')) { return (
@@ -479,6 +1140,7 @@ const TOKEN_FIELD_OPTIONS: Record< ); } + // CT state if (tType === 'ct' && selField === 'state') { return (
@@ -503,6 +1165,7 @@ const TOKEN_FIELD_OPTIONS: Record< ); } + // CT direction if (tType === 'ct' && selField === 'direction') { return (
@@ -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 (
@@ -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 (
@@ -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 (
@@ -664,6 +1335,7 @@ const TOKEN_FIELD_OPTIONS: Record< ); } + // ICMP type/code dropdowns if (tType === 'icmp' && selField === 'type') { return (
@@ -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 (
@@ -799,6 +1474,7 @@ const TOKEN_FIELD_OPTIONS: Record< ); } + // Default fallback free-text with helpful examples return (
@@ -839,7 +1515,8 @@ const TOKEN_FIELD_OPTIONS: Record< - + {/* Action + NFQUEUE + Reject options: render action radios, then render + reject subform and nfqueue subform directly under it (same column) */} @@ -851,7 +1528,7 @@ const TOKEN_FIELD_OPTIONS: Record< - + {/* Reject options (render under radios, same column) */} prev.action !== cur.action} noStyle> {() => form.getFieldValue('action') === 'reject' ? ( @@ -870,7 +1547,7 @@ const TOKEN_FIELD_OPTIONS: Record< - + {/* IPv4 reject reasons */} prev.rejectType !== cur.rejectType} noStyle> {() => form.getFieldValue('rejectType') === 'icmp' ? ( @@ -895,7 +1572,7 @@ const TOKEN_FIELD_OPTIONS: Record< } - + {/* IPv6 reject reasons */} prev.rejectType !== cur.rejectType} noStyle> {() => form.getFieldValue('rejectType') === 'icmpv6' ? ( @@ -920,7 +1597,7 @@ const TOKEN_FIELD_OPTIONS: Record< } - + {/* icmpx (inet) */} prev.rejectType !== cur.rejectType} noStyle> {() => form.getFieldValue('rejectType') === 'icmpx' ? ( @@ -951,7 +1628,7 @@ const TOKEN_FIELD_OPTIONS: Record< } - + {/* NFQUEUE options (now rendered under radios in same column) */} prev.action !== cur.action} noStyle> {() => form.getFieldValue('action') === 'nfqueue' ? ( @@ -971,6 +1648,7 @@ const TOKEN_FIELD_OPTIONS: Record< { + // update preview immediately schedulePreviewUpdate(); }} > @@ -987,7 +1665,7 @@ const TOKEN_FIELD_OPTIONS: Record< - + {/* right column is free for notes / quick helpers */} 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< - + {/* advanced text */} @@ -1010,7 +1688,7 @@ const TOKEN_FIELD_OPTIONS: Record< - + {/* preview + run */}
diff --git a/frontend/src/components/FirewallRulesetViewer.tsx b/frontend/src/components/FirewallRulesetViewer.tsx index c307b6a..a0238ad 100644 --- a/frontend/src/components/FirewallRulesetViewer.tsx +++ b/frontend/src/components/FirewallRulesetViewer.tsx @@ -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 { + } } }, }); diff --git a/frontend/src/components/PacketViewer.tsx b/frontend/src/components/PacketViewer.tsx index 3d9d87c..b671793 100644 --- a/frontend/src/components/PacketViewer.tsx +++ b/frontend/src/components/PacketViewer.tsx @@ -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) => {v ?? '-'}, + }, + { + title: 'App', + key: 'app', + width: 170, + render: (_: any, rec: PacketRow) => { + const app = rec.app_protocol ?? rec.app_master_protocol ?? '-'; + return ( + + {app} + + {rec.app_confidence ?? rec.app_category ?? ''} + + + ); + }, + }, + { + title: 'Host', + dataIndex: 'app_hostname', + key: 'app_hostname', + width: 180, render: (v: any) => {v ?? '-'}, }, { @@ -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', diff --git a/frontend/src/types/packets.ts b/frontend/src/types/packets.ts new file mode 100644 index 0000000..8ac5b78 --- /dev/null +++ b/frontend/src/types/packets.ts @@ -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 | null; + [key: string]: unknown; +} + +export interface FetchPacketsResponse { + count: number; + packets: PacketRow[]; +} diff --git a/setup_build_server.sh b/setup_build_server.sh index af5c298..65e1d7d 100755 --- a/setup_build_server.sh +++ b/setup_build_server.sh @@ -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 diff --git a/setup_database.sh b/setup_database.sh index 548c276..7f3b889 100755 --- a/setup_database.sh +++ b/setup_database.sh @@ -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."