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:
@@ -5,8 +5,10 @@ import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import select
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -16,9 +18,14 @@ from pathlib import Path
|
||||
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_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:
|
||||
@@ -110,6 +117,152 @@ def collect_interface_traffic(interfaces: list[dict[str, Any]]) -> list[dict[str
|
||||
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:
|
||||
parts = line.split()
|
||||
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"}
|
||||
|
||||
|
||||
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"])
|
||||
return {
|
||||
"flow_count": flow_count,
|
||||
"packet_collector": packet_diagnostics,
|
||||
"conntrack_binary": conntrack_path if code == 0 else None,
|
||||
"conntrack_count": conntrack.get("count"),
|
||||
"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]:
|
||||
uptime = read_text("/proc/uptime")
|
||||
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()
|
||||
return {
|
||||
"version": VERSION,
|
||||
@@ -230,7 +393,7 @@ def collect_payload(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"flows": flows,
|
||||
"conntrack": conntrack,
|
||||
"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))
|
||||
|
||||
while True:
|
||||
started_at = time.monotonic()
|
||||
payload = collect_payload(config)
|
||||
try:
|
||||
post_heartbeat(config, payload)
|
||||
@@ -288,7 +452,8 @@ def main() -> int:
|
||||
print(f"heartbeat failed: {exc} api_url={normalized_api_url(config)}", flush=True)
|
||||
if args.once:
|
||||
return 0
|
||||
time.sleep(interval)
|
||||
elapsed = time.monotonic() - started_at
|
||||
time.sleep(max(interval - elapsed, 1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -706,6 +706,8 @@ cat > "$CONFIG_DIR/config.json" <<'JSON'
|
||||
"node_name": "{node.name}",
|
||||
"interval_seconds": 30,
|
||||
"flow_limit": 500,
|
||||
"packet_flow_collector": true,
|
||||
"packet_flow_window_seconds": 10,
|
||||
"verify_tls": true
|
||||
}}
|
||||
JSON
|
||||
|
||||
Reference in New Issue
Block a user