#!/usr/bin/env python3 """ SysDeck - Netsec Bridge Helper Author: Jeremy Anderson (https://dcos.net) IPTRAF-NG STYLE NETWORK MONITOR. The monitor reads the same kernel sources iptraf-ng reads from directly — no fragile ncurses parsing, no static socket list. Data sources: /proc/net/dev per-interface RX/TX byte + packet counters /proc/net/snmp IP/TCP/UDP/ICMP protocol counters /proc/net/tcp active TCP connections (state, local, remote) /proc/net/udp active UDP sockets ss -tnp established connections with PID/process mapping The bridge computes live rates by reading /proc/net/dev twice (1 second apart) and diffing — this is exactly how iptraf-ng computes per-second traffic rates. Subcommands: summary aggregate: total interfaces, total connections, protocol stats traffic per-interface live RX/TX rates (bytes/s, packets/s, errors/s) connections active TCP/UDP flows with PID mapping (like iptraf-ng IP monitor) interfaces per-interface detailed stats (cumulative counters) protocols /proc/net/snmp parsed: IP/TCP/UDP/ICMP counters sockets listening TCP+UDP sockets established established TCP connections Usage: python3 /usr/lib/sysdeck/bridge/netsec.py traffic python3 /usr/lib/sysdeck/bridge/netsec.py connections python3 /usr/lib/sysdeck/bridge/netsec.py interfaces python3 /usr/lib/sysdeck/bridge/netsec.py protocols """ import json import os import re import subprocess import sys import time from pathlib import Path from typing import Any # v0.0.39: import security helpers from firewall.py (single source of truth). sys.path.insert(0, str(Path(__file__).parent)) try: from firewall import ( # type: ignore SCRUBBED_ENV, _sanitize_output, ) except ImportError: SCRUBBED_ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C", "LC_ALL": "C"} def _sanitize_output(text: str, max_len: int = 4096) -> str: if not text: return "" if len(text) > max_len: text = text[:max_len] + " ... (truncated)" return "".join(c if (32 <= ord(c) < 127 or c in "\t\n\r") else " " for c in text) # ── Regex patterns (compiled once at import) ────────────────────── SS_LINE_RE = re.compile( r"^(?P\S+)\s+(?P\S+)\s+(?P\d+)\s+(?P\d+)\s+(?P\S+)\s+(?P\S+)" ) # /proc/net/dev line format (after header): # eth0: rx_bytes rx_packets rx_errs rx_drop rx_fifo rx_frame rx_compressed rx_multicast # tx_bytes tx_packets tx_errs tx_drop tx_fifo tx_colls tx_carrier tx_compressed DEV_LINE_RE = re.compile( r"^\s*(?P\S+?):\s*(?P\d+)\s+(?P\d+)\s+" r"(?P\d+)\s+(?P\d+)\s+\d+\s+\d+\s+\d+\s+\d+\s+" r"(?P\d+)\s+(?P\d+)\s+" r"(?P\d+)\s+(?P\d+)" ) # /proc/net/tcp state codes (hex → name) TCP_STATES = { "01": "ESTABLISHED", "02": "SYN_SENT", "03": "SYN_RECV", "04": "FIN_WAIT1", "05": "FIN_WAIT2", "06": "TIME_WAIT", "07": "CLOSE", "08": "CLOSE_WAIT", "09": "LAST_ACK", "0A": "LISTEN", "0B": "CLOSING", } def _run(argv: list[str], timeout: int = 5) -> str: """Run argv and return stdout. Returns '' on failure. v0.0.39 hardening: env scrubbed, output sanitized. """ try: r = subprocess.run( argv, capture_output=True, text=True, check=False, timeout=timeout, env=SCRUBBED_ENV, ) return _sanitize_output(r.stdout or "") except (FileNotFoundError, OSError, subprocess.TimeoutExpired): return "" def _read_file(path: str) -> str: """Read a file, returning '' on failure.""" try: with open(path, encoding="utf-8", errors="replace") as fh: return fh.read() except (FileNotFoundError, PermissionError, OSError): return "" # ── /proc/net/dev parsing (per-interface stats) ────────────────── def _parse_proc_net_dev(content: str) -> dict[str, dict[str, int]]: """Parse /proc/net/dev into {iface: {rx_bytes, rx_packets, ...}}. This is the same data source iptraf-ng reads for its general interface statistics view. """ interfaces: dict[str, dict[str, int]] = {} for line in content.splitlines(): m = DEV_LINE_RE.match(line) if not m: continue iface = m.group("iface") interfaces[iface] = { "rx_bytes": int(m.group("rx_bytes")), "rx_packets": int(m.group("rx_packets")), "rx_errs": int(m.group("rx_errs")), "rx_drop": int(m.group("rx_drop")), "tx_bytes": int(m.group("tx_bytes")), "tx_packets": int(m.group("tx_packets")), "tx_errs": int(m.group("tx_errs")), "tx_drop": int(m.group("tx_drop")), } return interfaces def _read_dev_stats() -> dict[str, dict[str, int]]: """Read current /proc/net/dev snapshot.""" return _parse_proc_net_dev(_read_file("/proc/net/dev")) # ── Traffic rate computation (iptraf-ng style) ──────────────────── def cmd_traffic(_args: list[str]) -> list[dict[str, Any]]: """Compute per-interface live RX/TX rates by sampling /proc/net/dev twice (1 second apart) and diffing. Returns a list of {iface, rx_bps, tx_bps, rx_pps, tx_pps, rx_errs_total, tx_errs_total, rx_drop_total, tx_drop_total}. This is exactly how iptraf-ng computes its live traffic rates. """ snapshot1 = _read_dev_stats() if not snapshot1: return [] t1 = time.monotonic() time.sleep(1.0) snapshot2 = _read_dev_stats() t2 = time.monotonic() elapsed = t2 - t1 if elapsed <= 0: elapsed = 1.0 result: list[dict[str, Any]] = [] for iface in sorted(snapshot2.keys()): s1 = snapshot1.get(iface, {}) s2 = snapshot2.get(iface, {}) if not s2: continue rx_bytes_diff = s2.get("rx_bytes", 0) - s1.get("rx_bytes", 0) tx_bytes_diff = s2.get("tx_bytes", 0) - s1.get("tx_bytes", 0) rx_pkt_diff = s2.get("rx_packets", 0) - s1.get("rx_packets", 0) tx_pkt_diff = s2.get("tx_packets", 0) - s1.get("tx_packets", 0) result.append({ "iface": iface, "rx_bps": int(rx_bytes_diff / elapsed), "tx_bps": int(tx_bytes_diff / elapsed), "rx_pps": int(rx_pkt_diff / elapsed), "tx_pps": int(tx_pkt_diff / elapsed), "rx_bytes_total": s2.get("rx_bytes", 0), "tx_bytes_total": s2.get("tx_bytes", 0), "rx_packets_total": s2.get("rx_packets", 0), "tx_packets_total": s2.get("tx_packets", 0), "rx_errs_total": s2.get("rx_errs", 0), "tx_errs_total": s2.get("tx_errs", 0), "rx_drop_total": s2.get("rx_drop", 0), "tx_drop_total": s2.get("tx_drop", 0), }) return result # ── Interface details ───────────────────────────────────────────── def cmd_interfaces(_args: list[str]) -> list[dict[str, Any]]: """Return per-interface detailed stats (cumulative counters). Like iptraf-ng's detailed interface statistics view. """ stats = _read_dev_stats() if not stats: return [] result: list[dict[str, Any]] = [] for iface in sorted(stats.keys()): s = stats[iface] # Compute human-readable rates. rx_mb = s["rx_bytes"] / (1024 * 1024) tx_mb = s["tx_bytes"] / (1024 * 1024) result.append({ "iface": iface, "rx_bytes": s["rx_bytes"], "rx_mb": round(rx_mb, 2), "rx_packets": s["rx_packets"], "rx_errs": s["rx_errs"], "rx_drop": s["rx_drop"], "tx_bytes": s["tx_bytes"], "tx_mb": round(tx_mb, 2), "tx_packets": s["tx_packets"], "tx_errs": s["tx_errs"], "tx_drop": s["tx_drop"], }) return result # ── Protocol statistics (/proc/net/snmp) ────────────────────────── def cmd_protocols(_args: list[str]) -> dict[str, Any]: """Parse /proc/net/snmp for IP/TCP/UDP/ICMP protocol counters. Like iptraf-ng's statistical breakdowns view. """ content = _read_file("/proc/net/snmp") if not content: return {"error": "cannot read /proc/net/snmp"} result: dict[str, Any] = {} lines = content.splitlines() i = 0 while i < len(lines) - 1: header = lines[i].strip() values = lines[i + 1].strip() if ":" not in header or ":" not in values: i += 1 continue proto_name = header.split(":")[0] hdr_fields = header.split(":")[1].split() val_fields = values.split(":")[1].split() if len(hdr_fields) != len(val_fields): i += 1 continue proto_stats = {} for j, field in enumerate(hdr_fields): try: proto_stats[field] = int(val_fields[j]) except (ValueError, IndexError): proto_stats[field] = val_fields[j] result[proto_name.lower()] = proto_stats i += 2 return result # ── Active connections (iptraf-ng IP traffic monitor style) ─────── def _decode_addr(hex_addr: str) -> tuple[str, int]: """Decode a /proc/net/tcp hex address (little-endian) into (ip, port).""" if not hex_addr or ":" not in hex_addr: return ("?", 0) ip_hex, port_hex = hex_addr.split(":") try: port = int(port_hex, 16) # /proc/net/tcp stores IPv4 in little-endian hex. ip_int = int(ip_hex, 16) ip = f"{ip_int & 0xFF}.{(ip_int >> 8) & 0xFF}.{(ip_int >> 16) & 0xFF}.{(ip_int >> 24) & 0xFF}" return (ip, port) except ValueError: return ("?", 0) def cmd_connections(_args: list[str]) -> list[dict[str, Any]]: """Active TCP/UDP connections with state + address info. Reads /proc/net/tcp + /proc/net/udp directly (no ss dependency). Returns a list of {proto, state, local_ip, local_port, remote_ip, remote_port, tx_queue, rx_queue}. Like iptraf-ng's IP traffic monitor. """ result: list[dict[str, Any]] = [] # TCP tcp_content = _read_file("/proc/net/tcp") for line in tcp_content.splitlines()[1:]: # skip header parts = line.split() if len(parts) < 10: continue local_ip, local_port = _decode_addr(parts[1]) remote_ip, remote_port = _decode_addr(parts[2]) state_hex = parts[3] state = TCP_STATES.get(state_hex, f"UNKNOWN({state_hex})") tx_queue = rx_queue = 0 if ":" in parts[4]: tx_q, rx_q = parts[4].split(":") try: tx_queue = int(tx_q, 16) rx_queue = int(rx_q, 16) except ValueError: pass result.append({ "proto": "tcp", "state": state, "local_ip": local_ip, "local_port": local_port, "remote_ip": remote_ip, "remote_port": remote_port, "tx_queue": tx_queue, "rx_queue": rx_queue, }) # UDP udp_content = _read_file("/proc/net/udp") for line in udp_content.splitlines()[1:]: parts = line.split() if len(parts) < 8: continue local_ip, local_port = _decode_addr(parts[1]) remote_ip, remote_port = _decode_addr(parts[2]) result.append({ "proto": "udp", "state": "UDP", "local_ip": local_ip, "local_port": local_port, "remote_ip": remote_ip, "remote_port": remote_port, "tx_queue": 0, "rx_queue": 0, }) return result # ── Summary (aggregate panel header) ────────────────────────────── def cmd_summary(_args: list[str]) -> dict[str, Any]: """Aggregate network state: total interfaces, connections, protocol stats.""" interfaces = cmd_interfaces([]) connections = cmd_connections([]) protocols = cmd_protocols([]) # Count connections by state. tcp_states: dict[str, int] = {} for c in connections: if c["proto"] == "tcp": tcp_states[c["state"]] = tcp_states.get(c["state"], 0) + 1 return { "total_interfaces": len(interfaces), "interfaces_up": len([i for i in interfaces if i["rx_bytes"] > 0 or i["tx_bytes"] > 0]), "total_connections": len(connections), "tcp_established": tcp_states.get("ESTABLISHED", 0), "tcp_listen": tcp_states.get("LISTEN", 0), "tcp_time_wait": tcp_states.get("TIME_WAIT", 0), "tcp_states": tcp_states, "top_interfaces_by_traffic": sorted( interfaces, key=lambda i: i["rx_bytes"] + i["tx_bytes"], reverse=True )[:5], "protocols": protocols, } # ── Socket-listing subcommands ─────────────────────────────────── def parse_ss(output: str) -> list[dict[str, Any]]: """Parse `ss -tulpn` output into socket records.""" return [ { "netid": m.group("netid"), "state": m.group("state"), "recvQ": int(m.group("recvq")), "sendQ": int(m.group("sendq")), "local": m.group("local"), "peer": m.group("peer"), } for line in output.splitlines() if (m := SS_LINE_RE.match(line)) ] def sockets() -> list[dict[str, Any]]: """Listening TCP and UDP sockets from `ss -tulpn`.""" return parse_ss(_run(["ss", "-tulpn"])) def established() -> list[dict[str, Any]]: """Established TCP connections from `ss -tnp state established`.""" return parse_ss(_run(["ss", "-tnp", "state", "established"])) def cmd_sockets(_args: list[str]) -> list[dict[str, Any]]: return sockets() def cmd_established(_args: list[str]) -> list[dict[str, Any]]: return established() # ── Dispatch table ──────────────────────────────────────────────── COMMANDS = { "summary": cmd_summary, "traffic": cmd_traffic, "connections": cmd_connections, "interfaces": cmd_interfaces, "protocols": cmd_protocols, "sockets": cmd_sockets, "established": cmd_established, } def main(argv: list[str]) -> int: if not argv or argv[0] in ("-h", "--help"): print(__doc__) return 0 cmd = COMMANDS.get(argv[0]) if not cmd: print(f"Unknown subcommand: {argv[0]}", file=sys.stderr) print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr) return 2 print(json.dumps(cmd(argv[1:]), indent=2)) return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))