feat: add AF_PACKET flow collector to agent for real VM traffic visibility with IPv4 TCP/UDP/ICMP flow extraction

Add packet flow collector in agent v0.2.0 using Linux AF_PACKET sockets to capture and aggregate IPv4 TCP/UDP/ICMP flows from VM interfaces (tap/fwln) with configurable window/limit, implement parse_packet_flow to extract 5-tuple from raw Ethernet frames with VLAN tag handling, add selected_flow_interfaces to choose best interface per VM NIC for packet capture, include packet collector
This commit is contained in:
2026-07-09 15:18:35 +02:00
parent 3bfd77a74a
commit 4ceb4489c5
3 changed files with 189 additions and 11 deletions
+17 -6
View File
@@ -156,16 +156,15 @@ Proxmox inventory and guest agent data are enough for:
- Static LXC IP discovery. - Static LXC IP discovery.
- Policy matching and firewall previews. - Policy matching and firewall previews.
Actual traffic flow visibility, top talkers, byte counters, and per-workload traffic history require an additional telemetry source. Proxmox VE does not provide full flow telemetry for every VM through the basic inventory API. Actual traffic flow visibility, top talkers, byte counters, and per-workload traffic history require the NexaFabric node agent or another telemetry source. Proxmox VE does not provide full flow telemetry for every VM through the basic inventory API.
Supported or planned options: Supported options:
- NexaFabric node agent on Proxmox nodes to read host interface counters, VM/LXC interface hints, conntrack flows, nftables ruleset state, and pve-firewall status. - NexaFabric node agent on Proxmox nodes to read VM/LXC interface hints, host interface counters, real IPv4 TCP/UDP/ICMP flows from Linux VM interfaces, conntrack flows when available, nftables ruleset state, and pve-firewall status.
- Open vSwitch with sFlow/NetFlow/IPFIX exported to a collector. - Open vSwitch with sFlow/NetFlow/IPFIX exported to a collector.
- Router/firewall flow exports from pfSense, OPNsense, FRR/BGP edge devices, or physical switches. - Router/firewall flow exports from pfSense, OPNsense, FRR/BGP edge devices, or physical switches.
- eBPF or host-level telemetry in future agent builds.
Until such a source is configured, NexaFabric will show `No flow telemetry collected yet` instead of fake traffic. Until such a source is configured, NexaFabric will show `No flow telemetry collected yet` instead of fake traffic. If the node agent can see VM interface counters but no packet flows, NexaFabric displays the counters as an explicitly marked fallback.
### 8. Install The Node Agent ### 8. Install The Node Agent
@@ -192,7 +191,19 @@ journalctl -u nexafabric-agent -f
systemctl restart nexafabric-agent systemctl restart nexafabric-agent
``` ```
The agent reports host/interface counters, VMID hints from Proxmox interface names, conntrack flow records, pve-firewall status, and an nftables ruleset hash. NexaFabric maps flow source/destination IPs back to workloads through IPAM, so VM/LXC details can show observed traffic once guest IPs have been discovered. The agent does not enforce policies itself; Proxmox firewall rule apply remains API-driven through NexaFabric. Agent version `0.2.0` reports host/interface counters, VMID hints from Proxmox interface names, real packet-derived IPv4 TCP/UDP/ICMP flows from VM interfaces, conntrack flow records when available, pve-firewall status, and an nftables ruleset hash. NexaFabric maps flow source/destination IPs back to workloads through IPAM, so VM/LXC details can show observed traffic once guest IPs have been discovered. The agent does not enforce policies itself; Proxmox firewall rule apply remains API-driven through NexaFabric.
The default agent config enables the packet flow collector:
```json
{
"packet_flow_collector": true,
"packet_flow_window_seconds": 10,
"flow_limit": 500
}
```
The collector runs as root through the Linux `AF_PACKET` interface and attaches to Proxmox VM interfaces such as `tap100i0` and `fwln100i0`. It aggregates locally before sending data to NexaFabric; packet payloads are not stored or uploaded.
### 9. Troubleshooting Proxmox Integration ### 9. Troubleshooting Proxmox Integration
+170 -5
View File
@@ -5,8 +5,10 @@ import json
import os import os
import platform import platform
import re import re
import select
import socket import socket
import ssl import ssl
import struct
import subprocess import subprocess
import time import time
import urllib.error import urllib.error
@@ -16,9 +18,14 @@ from pathlib import Path
from typing import Any from typing import Any
VERSION = "0.1.0" VERSION = "0.2.0"
VM_INTERFACE_RE = re.compile(r"(?:tap|fwbr|fwln|fwpr)(\d+)") VM_INTERFACE_RE = re.compile(r"(?:tap|fwbr|fwln|fwpr)(\d+)")
VM_INTERFACE_DETAIL_RE = re.compile(r"(?:tap|fwbr|fwln|fwpr)(\d+)i(\d+)") VM_INTERFACE_DETAIL_RE = re.compile(r"(?:tap|fwbr|fwln|fwpr)(\d+)i(\d+)")
ETH_P_IP = 0x0800
ETH_P_ALL = 0x0003
ETH_P_8021Q = 0x8100
ETH_P_8021AD = 0x88A8
IP_PROTOCOLS = {1: "icmp", 6: "tcp", 17: "udp"}
def read_text(path: str) -> str | None: def read_text(path: str) -> str | None:
@@ -110,6 +117,152 @@ def collect_interface_traffic(interfaces: list[dict[str, Any]]) -> list[dict[str
return traffic return traffic
def selected_flow_interfaces(interfaces: list[dict[str, Any]]) -> list[dict[str, Any]]:
selected: dict[tuple[str, str], dict[str, Any]] = {}
for interface in interfaces:
name = str(interface.get("name") or "")
vmid = interface.get("vmid")
if not vmid or not name.startswith(("tap", "fwln")):
continue
detail_match = VM_INTERFACE_DETAIL_RE.search(name)
nic = detail_match.group(2) if detail_match else "0"
key = (str(vmid), nic)
current = selected.get(key)
if current and interface_rank(str(current.get("name") or "")) <= interface_rank(name):
continue
selected[key] = interface
return list(selected.values())
def ipv4_address(raw: bytes) -> str:
return socket.inet_ntoa(raw)
def parse_packet_flow(packet: bytes) -> dict[str, Any] | None:
if len(packet) < 34:
return None
offset = 12
eth_type = struct.unpack("!H", packet[offset:offset + 2])[0]
offset = 14
while eth_type in {ETH_P_8021Q, ETH_P_8021AD}:
if len(packet) < offset + 4:
return None
eth_type = struct.unpack("!H", packet[offset + 2:offset + 4])[0]
offset += 4
if eth_type != ETH_P_IP or len(packet) < offset + 20:
return None
version_ihl = packet[offset]
version = version_ihl >> 4
ihl = (version_ihl & 0x0F) * 4
if version != 4 or ihl < 20 or len(packet) < offset + ihl:
return None
total_length = struct.unpack("!H", packet[offset + 2:offset + 4])[0]
protocol_number = packet[offset + 9]
protocol = IP_PROTOCOLS.get(protocol_number)
if not protocol:
return None
source_ip = ipv4_address(packet[offset + 12:offset + 16])
destination_ip = ipv4_address(packet[offset + 16:offset + 20])
transport_offset = offset + ihl
source_port = None
destination_port = None
if protocol in {"tcp", "udp"}:
if len(packet) < transport_offset + 4:
return None
source_port, destination_port = struct.unpack("!HH", packet[transport_offset:transport_offset + 4])
elif protocol == "icmp" and len(packet) >= transport_offset + 2:
source_port = packet[transport_offset]
destination_port = packet[transport_offset + 1]
return {
"source_ip": source_ip,
"destination_ip": destination_ip,
"protocol": protocol,
"source_port": source_port,
"destination_port": destination_port,
"packets": 1,
"bytes": total_length if total_length else max(len(packet) - offset, 0),
"state": "observed",
}
def collect_packet_flows(interfaces: list[dict[str, Any]], duration: int, limit: int = 500) -> tuple[list[dict[str, Any]], dict[str, Any]]:
flow_interfaces = selected_flow_interfaces(interfaces)
sockets: dict[socket.socket, dict[str, Any]] = {}
diagnostics: dict[str, Any] = {
"collector": "linux-af-packet",
"duration_seconds": duration,
"interfaces_requested": [interface.get("name") for interface in flow_interfaces],
"interfaces_opened": [],
"errors": [],
}
for interface in flow_interfaces:
name = str(interface.get("name") or "")
try:
packet_socket = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(ETH_P_ALL))
packet_socket.bind((name, 0))
packet_socket.setblocking(False)
sockets[packet_socket] = interface
diagnostics["interfaces_opened"].append(name)
except OSError as exc:
diagnostics["errors"].append({"interface": name, "error": str(exc)})
if not sockets:
return [], diagnostics
flows: dict[tuple[object, ...], dict[str, Any]] = {}
deadline = time.monotonic() + max(duration, 1)
try:
while time.monotonic() < deadline:
timeout = min(1.0, max(deadline - time.monotonic(), 0.0))
readable, _, _ = select.select(list(sockets), [], [], timeout)
for packet_socket in readable:
interface = sockets[packet_socket]
try:
packet = packet_socket.recv(65535)
except OSError:
continue
flow = parse_packet_flow(packet)
if not flow:
continue
name = str(interface.get("name") or "")
vmid = str(interface.get("vmid") or "")
detail_match = VM_INTERFACE_DETAIL_RE.search(name)
nic = detail_match.group(2) if detail_match else "0"
key = (
vmid,
nic,
flow["source_ip"],
flow["destination_ip"],
flow["protocol"],
flow.get("source_port"),
flow.get("destination_port"),
)
current = flows.get(key)
if current:
current["packets"] += 1
current["bytes"] += int(flow["bytes"])
continue
flow.update(
{
"vmid": vmid,
"nic": nic,
"interface": name,
"collector": "linux-af-packet",
}
)
flows[key] = flow
if len(flows) >= limit:
diagnostics["truncated"] = True
return sorted(flows.values(), key=lambda item: int(item.get("bytes") or 0), reverse=True), diagnostics
finally:
for packet_socket in sockets:
packet_socket.close()
return sorted(flows.values(), key=lambda item: int(item.get("bytes") or 0), reverse=True), diagnostics
def parse_conntrack_line(line: str) -> dict[str, Any] | None: def parse_conntrack_line(line: str) -> dict[str, Any] | None:
parts = line.split() parts = line.split()
if len(parts) < 5 or parts[0] not in {"tcp", "udp", "icmp"}: if len(parts) < 5 or parts[0] not in {"tcp", "udp", "icmp"}:
@@ -186,10 +339,11 @@ def collect_conntrack() -> dict[str, Any]:
return {"count": None, "source": "unavailable"} return {"count": None, "source": "unavailable"}
def collect_flow_diagnostics(conntrack: dict[str, Any], flow_count: int) -> dict[str, Any]: def collect_flow_diagnostics(conntrack: dict[str, Any], flow_count: int, packet_diagnostics: dict[str, Any] | None = None) -> dict[str, Any]:
code, conntrack_path = run_command(["sh", "-c", "command -v conntrack"]) code, conntrack_path = run_command(["sh", "-c", "command -v conntrack"])
return { return {
"flow_count": flow_count, "flow_count": flow_count,
"packet_collector": packet_diagnostics,
"conntrack_binary": conntrack_path if code == 0 else None, "conntrack_binary": conntrack_path if code == 0 else None,
"conntrack_count": conntrack.get("count"), "conntrack_count": conntrack.get("count"),
"conntrack_source": conntrack.get("source"), "conntrack_source": conntrack.get("source"),
@@ -215,7 +369,16 @@ def collect_firewall() -> dict[str, Any]:
def collect_payload(config: dict[str, Any]) -> dict[str, Any]: def collect_payload(config: dict[str, Any]) -> dict[str, Any]:
uptime = read_text("/proc/uptime") uptime = read_text("/proc/uptime")
interfaces = collect_interfaces() interfaces = collect_interfaces()
flows = collect_flows(int(config.get("flow_limit", 500))) flow_limit = int(config.get("flow_limit", 500))
packet_flows: list[dict[str, Any]] = []
packet_diagnostics: dict[str, Any] | None = None
if bool(config.get("packet_flow_collector", True)):
packet_flows, packet_diagnostics = collect_packet_flows(
interfaces,
int(config.get("packet_flow_window_seconds", 10)),
flow_limit,
)
flows = packet_flows or collect_flows(flow_limit)
conntrack = collect_conntrack() conntrack = collect_conntrack()
return { return {
"version": VERSION, "version": VERSION,
@@ -230,7 +393,7 @@ def collect_payload(config: dict[str, Any]) -> dict[str, Any]:
"flows": flows, "flows": flows,
"conntrack": conntrack, "conntrack": conntrack,
"firewall": collect_firewall(), "firewall": collect_firewall(),
"extra": {"platform": platform.platform(), "flow_diagnostics": collect_flow_diagnostics(conntrack, len(flows))}, "extra": {"platform": platform.platform(), "flow_diagnostics": collect_flow_diagnostics(conntrack, len(flows), packet_diagnostics)},
} }
@@ -277,6 +440,7 @@ def main() -> int:
interval = int(config.get("interval_seconds", 30)) interval = int(config.get("interval_seconds", 30))
while True: while True:
started_at = time.monotonic()
payload = collect_payload(config) payload = collect_payload(config)
try: try:
post_heartbeat(config, payload) post_heartbeat(config, payload)
@@ -288,7 +452,8 @@ def main() -> int:
print(f"heartbeat failed: {exc} api_url={normalized_api_url(config)}", flush=True) print(f"heartbeat failed: {exc} api_url={normalized_api_url(config)}", flush=True)
if args.once: if args.once:
return 0 return 0
time.sleep(interval) elapsed = time.monotonic() - started_at
time.sleep(max(interval - elapsed, 1))
if __name__ == "__main__": if __name__ == "__main__":
+2
View File
@@ -706,6 +706,8 @@ cat > "$CONFIG_DIR/config.json" <<'JSON'
"node_name": "{node.name}", "node_name": "{node.name}",
"interval_seconds": 30, "interval_seconds": 30,
"flow_limit": 500, "flow_limit": 500,
"packet_flow_collector": true,
"packet_flow_window_seconds": 10,
"verify_tls": true "verify_tls": true
}} }}
JSON JSON