443 lines
15 KiB
Python
Executable File
443 lines
15 KiB
Python
Executable File
#!/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<netid>\S+)\s+(?P<state>\S+)\s+(?P<recvq>\d+)\s+(?P<sendq>\d+)\s+(?P<local>\S+)\s+(?P<peer>\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<iface>\S+?):\s*(?P<rx_bytes>\d+)\s+(?P<rx_packets>\d+)\s+"
|
|
r"(?P<rx_errs>\d+)\s+(?P<rx_drop>\d+)\s+\d+\s+\d+\s+\d+\s+\d+\s+"
|
|
r"(?P<tx_bytes>\d+)\s+(?P<tx_packets>\d+)\s+"
|
|
r"(?P<tx_errs>\d+)\s+(?P<tx_drop>\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:]))
|