| Current Path : /proc/3/cwd/opt/dedrads/ |
| Current File : //proc/3/cwd/opt/dedrads/show-conns-adv.py |
#!/usr/lib/rads/venv/bin/python3
import argparse
import os
import re
import shutil
import subprocess
import sys
import time
from collections import defaultdict
from datetime import datetime
from ipaddress import ip_address, ip_network
from prettytable import PrettyTable
from rads import color
ss_path = shutil.which("ss")
SKIPPED_SERVER_IPS = {'*', '127.0.0.1', '[::]', '::', '::1'}
SKIPPED_CLIENT_IPS = {'*'}
HOT_IP_THRESHOLD = 200
HOT_SERVICE_THRESHOLD = 2000
BRUTE_FORCE_MIN_UNIQUE_IPS = 30
BRUTE_FORCE_MAX_AVG_PER_IP = 10
SUBNET_FLOOD_THRESHOLD = 100
SUSPICIOUS_TCP_STATES = {'SYN-RECV', 'SYNRECV'}
DEFAULT_WATCH_INTERVAL = 5
DEFAULT_MAX_TABLES_PER_ROW = 2
MAIL_SERVICES_MAX_PER_ROW = 2
ESTIMATED_TABLE_WIDTH = 44
WEB_EXTRA_PORTS = {8080, 8443, 8000, 8888}
INTERNAL_WEB_DOMINANCE_THRESHOLD = 0.5
PROCESS_SERVICE_HINTS = (
('sshd', 'ssh'),
('exim', 'smtp'),
('dovecot', 'imap'),
('pop3', 'pop3'),
('httpd', 'http'),
('apache2', 'http'),
('nginx', 'http'),
('litespeed', 'http'),
('openlitespeed', 'http'),
('cpsrvd', 'cpanel'),
('cwpsrv', 'cwp_admin'),
('pure-ftpd', 'ftp'),
('proftpd', 'ftp'),
('mysqld', 'mysql'),
('mariadbd', 'mysql'),
('named', 'dns'),
('pdns_server', 'dns'),
)
def is_cwp_server():
if os.path.isfile('/usr/local/cpanel/cpanel'):
return False
return os.path.isdir('/usr/local/cwpsrv') or os.path.isdir('/opt/cwprads')
def auth_attack_services():
services = {'smtp', 'imap', 'pop3', 'ssh', 'webmail', 'ftp', 'mysql'}
if is_cwp_server():
return services | {'cwp_user', 'cwp_admin'}
return services | {'cpanel', 'whm'}
class ServiceRegistry:
"""Maps services to ports."""
def __init__(self):
if is_cwp_server():
panel_user, panel_admin = 'cwp_user', 'cwp_admin'
else:
panel_user, panel_admin = 'cpanel', 'whm'
self.panel_user = panel_user
self.panel_admin = panel_admin
self.services = {
'tcp': {
'http': [80, 443],
'imap': [143, 993],
'pop3': [110, 995],
'smtp': [25, 465, 587],
'ftp': [20, 21],
'ssh': [22, 2222],
panel_user: [2082, 2083],
panel_admin: [2086, 2087],
'webmail': [2095, 2096],
'mysql': [3306],
},
'udp': {'dns': [53], 'ntp': [123]},
}
self.port_to_service_name_map = {'tcp': {}, 'udp': {}}
for protocol, service_to_ports_map in self.services.items():
for service_name, port_list in service_to_ports_map.items():
for port_number in port_list:
self.port_to_service_name_map[protocol][
port_number
] = service_name
# Gets a service name.
def get_service_name(self, port_number: int, protocol: str) -> str:
return self.port_to_service_name_map.get(protocol, {}).get(
port_number, 'other'
)
# Checks if port is tracked.
def is_tracked_port(self, port_number: int, protocol: str) -> bool:
return port_number in self.port_to_service_name_map.get(protocol, {})
# Gets all tracked ports.
def get_all_ports(self, protocol: str) -> set:
return set(self.port_to_service_name_map.get(protocol, {}).keys())
class ConnectionReporter:
"""Manages connection reporting."""
def __init__(self):
self.service_registry = ServiceRegistry()
self.command_line_args = self.parse_command_line_arguments()
self.selected_ports_by_protocol = {'tcp': set(), 'udp': set()}
self.requested_tcp_ports = set()
self.requested_udp_ports = set()
self.use_explicit_port_filter = False
self.tcp_connections_by_port = defaultdict(lambda: defaultdict(int))
self.udp_connections_by_port = defaultdict(lambda: defaultdict(int))
self.tcp_states_by_port = defaultdict(lambda: defaultdict(int))
self.udp_states_by_port = defaultdict(lambda: defaultdict(int))
self.processed_connection_data = {}
self.collected_client_ips_for_subnetting = []
self.discovered_service_ports = {'tcp': set(), 'udp': set()}
self.port_service_overrides = {}
self.server_ips = self._detect_server_ips()
self._validate_command_line_combinations()
self.process_port_selection_from_args()
@staticmethod
def _detect_server_ips():
"""Collect addresses assigned to this host (loopback + interfaces)."""
server_ips = {'127.0.0.1', '::1'}
ip_path = shutil.which('ip')
if ip_path:
try:
result = subprocess.run(
[ip_path, '-o', 'addr'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
if result.returncode == 0:
for line in result.stdout.splitlines():
match = re.search(r'\s(inet6?)\s([^\s/]+)', line)
if not match:
continue
address = match.group(2).split('%')[0]
try:
server_ips.add(str(ip_address(address)))
except ValueError:
continue
except OSError:
pass
else:
hostname_path = shutil.which('hostname')
if hostname_path:
try:
result = subprocess.run(
[hostname_path, '-I'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
if result.returncode == 0:
for address in result.stdout.split():
try:
server_ips.add(str(ip_address(address)))
except ValueError:
continue
except OSError:
pass
return server_ips
def _is_web_port(self, port_number, protocol):
if protocol != 'tcp':
return False
if port_number in WEB_EXTRA_PORTS:
return True
return self._get_service_name(port_number, protocol) == 'http'
def _is_internal_client(self, ip_string):
try:
ip_object = ip_address(ip_string)
except ValueError:
return False
if (
ip_object.is_loopback
or ip_object.is_private
or ip_object.is_link_local
or ip_object.is_unspecified
):
return True
return ip_string in self.server_ips
def _internal_client_reason(self, ip_string):
try:
ip_object = ip_address(ip_string)
except ValueError:
return 'internal'
if ip_object.is_loopback:
return 'loopback'
if ip_string in self.server_ips:
return 'self'
if ip_object.is_private or ip_object.is_link_local:
return 'private'
return 'internal'
def _format_client_ip(self, ip_string, port_number, protocol):
if (
self._is_web_port(port_number, protocol)
and self._is_internal_client(ip_string)
):
return f"{ip_string} ({self._internal_client_reason(ip_string)})"
return ip_string
def _external_ip_counts(self, ip_counts, port_number, protocol):
if not self._is_web_port(port_number, protocol):
return ip_counts
return {
ip_string: count
for ip_string, count in ip_counts.items()
if not self._is_internal_client(ip_string)
}
def _web_internal_traffic_totals(self):
internal_total = 0
external_total = 0
for data_key, connection_data in self.processed_connection_data.items():
protocol, port_string = data_key.split(':')
port_number = int(port_string)
if not self._is_web_port(port_number, protocol):
continue
for ip_string, count in connection_data['ips'].items():
if self._is_internal_client(ip_string):
internal_total += count
else:
external_total += count
return internal_total, external_total
def _print_web_internal_note(self):
internal_total, external_total = self._web_internal_traffic_totals()
web_total = internal_total + external_total
if web_total == 0 or internal_total == 0:
return
internal_share = internal_total / web_total
if internal_share < INTERNAL_WEB_DOMINANCE_THRESHOLD:
return
print(self._warn_color()('WEB STACK NOTE'.center(100)))
print(
self._warn_color()(
f" {internal_total:,} of {web_total:,} HTTP connections "
f"({internal_share:.0f}%) are from loopback, private, or "
"this server's own IPs — typical Apache/Nginx/cPanel "
"proxy/health-check traffic, not external clients."
)
)
print()
def _validate_command_line_combinations(self):
if self.command_line_args.watch and self.command_line_args.subnet:
print(
"Error: --subnet cannot be used with --watch",
file=sys.stderr,
)
sys.exit(1)
if self.command_line_args.watch is not None:
if self.command_line_args.watch < 1:
print("Error: watch interval must be >= 1 second", file=sys.stderr)
sys.exit(1)
def _reset_collection_state(self):
self.tcp_connections_by_port = defaultdict(lambda: defaultdict(int))
self.udp_connections_by_port = defaultdict(lambda: defaultdict(int))
self.tcp_states_by_port = defaultdict(lambda: defaultdict(int))
self.udp_states_by_port = defaultdict(lambda: defaultdict(int))
self.processed_connection_data = {}
self.collected_client_ips_for_subnetting = []
self.discovered_service_ports = {'tcp': set(), 'udp': set()}
self.port_service_overrides = {}
# Parses command-line arguments.
def parse_command_line_arguments(self):
parser = argparse.ArgumentParser(
description='Summarize TCP/UDP connection info.'
)
for service_name in self.service_registry.services['tcp']:
parser.add_argument(
f'--{service_name}',
action='store_true',
help=f'Show only {service_name.upper()} connections',
)
parser.add_argument(
'--tcp', action='store_true', help='Show all TCP connections'
)
parser.add_argument(
'--udp', action='store_true', help='Show all UDP connections'
)
parser.add_argument(
'--top',
action='store_true',
help='Only show services ≥2000 connections or IPs >200',
)
parser.add_argument(
'-s',
'--subnet',
action='store_true',
help='Group connections by /24 and /16 subnets',
)
parser.add_argument(
'-p',
'--port',
nargs='+',
help='Specify up to 10 ports (space-separated)',
)
parser.add_argument(
'ports', nargs='*', help='Deprecated: use -p or --port instead'
)
parser.add_argument(
'-w',
'--watch',
nargs='?',
const=DEFAULT_WATCH_INTERVAL,
type=int,
metavar='SECS',
help=(
f'Refresh every SECS seconds (default: {DEFAULT_WATCH_INTERVAL}); '
'Ctrl+C to exit'
),
)
parser.add_argument(
'--all-ports',
action='store_true',
help='Include every local TCP/UDP port with active connections',
)
return parser.parse_args()
# Processes selected port arguments.
def process_port_selection_from_args(self):
any_port_was_selected = False
if self.command_line_args.tcp:
tcp_ports = self.service_registry.get_all_ports('tcp')
self.selected_ports_by_protocol['tcp'].update(tcp_ports)
self.requested_tcp_ports.update(tcp_ports)
any_port_was_selected = True
else:
for service_name in self.service_registry.services['tcp']:
if getattr(self.command_line_args, service_name, False):
ports = self.service_registry.services['tcp'][service_name]
self.selected_ports_by_protocol['tcp'].update(ports)
self.requested_tcp_ports.update(ports)
any_port_was_selected = True
if self.command_line_args.udp:
udp_ports = self.service_registry.get_all_ports('udp')
self.selected_ports_by_protocol['udp'].update(udp_ports)
self.requested_udp_ports.update(udp_ports)
any_port_was_selected = True
explicit_ports = list(self.command_line_args.port or [])
explicit_ports.extend(self.command_line_args.ports)
if explicit_ports:
if len(explicit_ports) > 10:
print("Error: max 10 ports", file=sys.stderr)
sys.exit(1)
for port_string in explicit_ports:
if port_string.isdigit():
port_number = int(port_string)
self.selected_ports_by_protocol['tcp'].add(port_number)
self.requested_tcp_ports.add(port_number)
if (
self.command_line_args.udp
or self.command_line_args.top
or self.service_registry.is_tracked_port(
port_number, 'udp'
)
):
self.selected_ports_by_protocol['udp'].add(
port_number
)
self.requested_udp_ports.add(port_number)
any_port_was_selected = True
if not any_port_was_selected:
tcp_ports = self.service_registry.get_all_ports('tcp')
self.selected_ports_by_protocol['tcp'].update(tcp_ports)
self.requested_tcp_ports.update(tcp_ports)
if self.command_line_args.top:
udp_ports = self.service_registry.get_all_ports('udp')
self.selected_ports_by_protocol['udp'].update(udp_ports)
self.requested_udp_ports.update(udp_ports)
if (
self.command_line_args.udp
and not self._has_explicit_tcp_selection()
):
tcp_ports = self.service_registry.get_all_ports('tcp')
self.selected_ports_by_protocol['tcp'].update(tcp_ports)
self.requested_tcp_ports.update(tcp_ports)
self.use_explicit_port_filter = any_port_was_selected
def _has_explicit_tcp_selection(self):
if self.command_line_args.tcp:
return True
for service_name in self.service_registry.services['tcp']:
if getattr(self.command_line_args, service_name, False):
return True
if self.command_line_args.port or self.command_line_args.ports:
return True
return False
def _wants_tcp(self):
return bool(self.requested_tcp_ports)
def _wants_udp(self):
return bool(self.requested_udp_ports)
def _explicitly_selected_service_names(self):
selected = set()
for service_name in self.service_registry.services['tcp']:
if getattr(self.command_line_args, service_name, False):
selected.add(service_name)
if self.command_line_args.tcp:
selected.update(self.service_registry.services['tcp'])
if self.command_line_args.udp:
selected.update(self.service_registry.services['udp'])
return selected
def _merge_discovered_ports_for_selected_services(self):
if self.command_line_args.all_ports:
return
selected_services = self._explicitly_selected_service_names()
if not selected_services:
return
for protocol, ports in self.discovered_service_ports.items():
for port_number in ports:
if self._get_service_name(port_number, protocol) in selected_services:
self.selected_ports_by_protocol[protocol].add(port_number)
def _get_service_name(self, port_number, protocol):
override = self.port_service_overrides.get((protocol, port_number))
if override:
return override
return self.service_registry.get_service_name(port_number, protocol)
def _is_connection_allowed(self, port_number, protocol):
if self.command_line_args.all_ports:
return True
if self.use_explicit_port_filter:
if port_number in self.selected_ports_by_protocol.get(protocol, set()):
return True
if port_number in self.discovered_service_ports.get(protocol, set()):
return (
self._get_service_name(port_number, protocol)
in self._explicitly_selected_service_names()
)
return False
if self.service_registry.is_tracked_port(port_number, protocol):
return True
if port_number in self.discovered_service_ports.get(protocol, set()):
return True
return False
@staticmethod
def _service_from_process(process_field):
process_lower = process_field.lower()
for needle, service_name in PROCESS_SERVICE_HINTS:
if needle in process_lower:
return service_name
return None
@staticmethod
def _admin_ssh_sessions():
sessions = []
seen = set()
for variable in ('SSH_CLIENT', 'SSH_CONNECTION'):
value = os.environ.get(variable, '')
if not value:
continue
parts = value.split()
if not parts:
continue
client_ip = ConnectionReporter._normalize_ip(parts[0])
server_port = ''
if variable == 'SSH_CLIENT' and len(parts) >= 3:
server_port = parts[2]
elif variable == 'SSH_CONNECTION' and len(parts) >= 4:
server_port = parts[3]
session_key = (client_ip, server_port)
if session_key in seen:
continue
seen.add(session_key)
sessions.append({
'ip': client_ip,
'server_port': server_port,
})
return sessions
def _register_discovered_port(self, port_number, protocol, service_name=None):
self.discovered_service_ports[protocol].add(port_number)
if service_name:
self.port_service_overrides[(protocol, port_number)] = service_name
def _apply_admin_session_ports(self):
for session in self._admin_ssh_sessions():
if not session['server_port'].isdigit():
continue
port_number = int(session['server_port'])
self._register_discovered_port(port_number, 'tcp', 'ssh')
def _parse_listen_line(self, line, protocol):
line_parts = re.split(r'\s+', line.strip())
if line_parts[0] not in {'LISTEN', 'UNCONN'}:
return
try:
local_address = re.sub(r'^::ffff:', '', line_parts[3])
_, port_string = local_address.rsplit(':', 1)
port_number = int(port_string)
except (ValueError, IndexError):
return
service_name = None
if self.service_registry.is_tracked_port(port_number, protocol):
tracked = self.service_registry.get_service_name(
port_number, protocol
)
if tracked != 'other':
service_name = tracked
if not service_name:
process_field = ''
if 'users:(' in line:
process_field = line[line.index('users:('):]
service_name = self._service_from_process(process_field)
self._register_discovered_port(port_number, protocol, service_name)
def collect_listening_ports(self):
self.discovered_service_ports = {'tcp': set(), 'udp': set()}
self.port_service_overrides = {}
self._apply_admin_session_ports()
for flags, protocol in (('-tlnp', 'tcp'), ('-ulnp', 'udp')):
try:
subprocess_output = subprocess.run(
[ss_path, flags],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=True,
)
except subprocess.CalledProcessError:
fallback_flags = flags.replace('p', '')
try:
subprocess_output = subprocess.run(
[ss_path, fallback_flags],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=True,
)
except subprocess.CalledProcessError:
continue
for line in subprocess_output.stdout.splitlines():
self._parse_listen_line(line, protocol)
self._merge_discovered_ports_for_selected_services()
@staticmethod
def _normalize_ip(ip_string):
if ip_string.startswith('[') and ip_string.endswith(']'):
return ip_string[1:-1]
return ip_string
@staticmethod
def _subnet_label(ip_string, prefix):
try:
ip_object = ip_address(ip_string)
if (
ip_object.is_loopback
or ip_object.is_private
or ip_object.is_unspecified
):
return 'local'
return str(ip_network(f"{ip_object}/{prefix}", strict=False))
except ValueError:
return '?'
@staticmethod
def _warn_color():
return getattr(color, 'yellow', color.magenta)
def _aggregate_ip_counts(self):
ip_counts = defaultdict(int)
for data_key, connection_data in self.processed_connection_data.items():
protocol, port_string = data_key.split(':')
port_number = int(port_string)
filtered_ips = self._external_ip_counts(
connection_data['ips'], port_number, protocol
)
for ip_string, count in filtered_ips.items():
ip_counts[ip_string] += count
return ip_counts
def _subnet_counts(self, ip_counts, prefix=24):
subnet_counts = defaultdict(int)
for ip_string, count in ip_counts.items():
subnet = self._subnet_label(ip_string, prefix)
if subnet not in {'local', '?'}:
subnet_counts[subnet] += count
return subnet_counts
def _top_subnets(self, ip_counts, prefix=24, limit=10):
return sorted(
self._subnet_counts(ip_counts, prefix).items(),
key=lambda item: -item[1],
)[:limit]
def _analyze_threats(self):
alerts = []
for data_key, connection_data in self.processed_connection_data.items():
protocol, port_string = data_key.split(':')
port_number = int(port_string)
service_name = self._get_service_name(
port_number, protocol
)
ip_counts = self._external_ip_counts(
connection_data['ips'], port_number, protocol
)
if not ip_counts:
continue
total = sum(ip_counts.values())
unique_ips = len(ip_counts)
top_ip, top_count = max(ip_counts.items(), key=lambda item: item[1])
label = f"{service_name.upper()}:{port_number} ({protocol})"
top_share = (top_count / total) * 100 if total else 0
if top_count >= HOT_IP_THRESHOLD:
alerts.append({
'severity': 'high',
'message': (
f"{label}: hot source {top_ip} has {top_count} "
f"connections ({top_share:.0f}% of service traffic)"
),
})
if total >= HOT_SERVICE_THRESHOLD:
alerts.append({
'severity': 'high',
'message': (
f"{label}: volume flood — {total} connections from "
f"{unique_ips} unique IPs"
),
})
if service_name in auth_attack_services():
avg_per_ip = total / unique_ips if unique_ips else 0
if (
unique_ips >= BRUTE_FORCE_MIN_UNIQUE_IPS
and avg_per_ip <= BRUTE_FORCE_MAX_AVG_PER_IP
):
alerts.append({
'severity': 'medium',
'message': (
f"{label}: possible auth brute-force — "
f"{unique_ips} unique IPs averaging "
f"{avg_per_ip:.1f} conns/IP"
),
})
top_subnet, subnet_count = max(
self._subnet_counts(ip_counts, 24).items(),
key=lambda item: item[1],
default=('', 0),
)
if (
subnet_count >= SUBNET_FLOOD_THRESHOLD
and top_subnet not in {'local', '?', ''}
):
alerts.append({
'severity': 'medium',
'message': (
f"{label}: /24 cluster {top_subnet} accounts for "
f"{subnet_count} connections"
),
})
if unique_ips >= 100 and top_share < 20 and total >= 500:
alerts.append({
'severity': 'medium',
'message': (
f"{label}: distributed pattern — {unique_ips} sources, "
f"no single IP dominates (top {top_share:.0f}%)"
),
})
return alerts
def _aggregate_states(self, protocol):
state_counts = defaultdict(int)
states_by_port = (
self.tcp_states_by_port
if protocol == 'tcp'
else self.udp_states_by_port
)
for port_states in states_by_port.values():
for state_name, count in port_states.items():
state_counts[state_name] += count
return state_counts
def _format_state_summary(self, state_counts, limit=4):
if not state_counts:
return ''
ordered_states = sorted(
state_counts.items(), key=lambda item: (-item[1], item[0])
)
return ', '.join(
f"{state_name}:{count:,}"
for state_name, count in ordered_states[:limit]
)
def print_connection_state_breakdown(self):
sections = []
if self._wants_tcp():
tcp_states = self._aggregate_states('tcp')
if tcp_states:
sections.append(('TCP connection states', tcp_states))
if self._wants_udp():
udp_states = self._aggregate_states('udp')
if udp_states:
sections.append(('UDP socket states', udp_states))
if not sections:
return
print(color.magenta('CONNECTION STATE BREAKDOWN'.center(100)))
for title, state_counts in sections:
total = sum(state_counts.values())
table = PrettyTable(['State', 'Count', '%'])
for state_name, count in sorted(
state_counts.items(), key=lambda item: -item[1]
):
share = (count / total) * 100 if total else 0
table.add_row([state_name, count, f'{share:.1f}%'])
table.align['State'] = 'l'
table.align['Count'] = 'r'
table.align['%'] = 'r'
print(color.cyan(title.center(100)))
for line in table.get_string().splitlines():
if 'SYN-RECV' in line or 'SYNRECV' in line:
count_match = re.search(r'\|\s+(\d+)\s+\|', line)
if count_match and int(count_match.group(1)) >= 50:
print(color.red(line))
else:
print(self._warn_color()(line))
else:
print(color.cyan(line))
suspicious = sum(
count
for state_name, count in state_counts.items()
if state_name in SUSPICIOUS_TCP_STATES
)
if suspicious >= 50:
print(
color.red(
f" ! High SYN-RECV count ({suspicious:,}) — "
"possible SYN flood"
)
)
print()
def print_executive_summary(self):
tcp_total = sum(
data['total']
for key, data in self.processed_connection_data.items()
if key.startswith('tcp:')
)
udp_total = sum(
data['total']
for key, data in self.processed_connection_data.items()
if key.startswith('udp:')
)
ip_counts = self._aggregate_ip_counts()
unique_ips = len(ip_counts)
total_connections = tcp_total + udp_total
print(color.cyan('=' * 100))
print(
color.cyan(
f"CONNECTION SNAPSHOT {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
.center(100)
)
)
print(
color.cyan(
(
f"Total: {total_connections:,} connections | "
f"Unique IPs: {unique_ips:,} | "
f"TCP: {tcp_total:,} | UDP: {udp_total:,}"
).center(100)
)
)
print(color.cyan('=' * 100))
print()
admin_sessions = self._admin_ssh_sessions()
if admin_sessions:
print(color.cyan('YOUR SSH SESSION(S)'.center(100)))
for session in admin_sessions:
port_label = (
f"port {session['server_port']}"
if session['server_port']
else 'unknown port'
)
line = f" {session['ip']} connected to {port_label}"
if session['ip'] in ip_counts:
print(color.cyan(f"{line} (visible below)"))
else:
print(
color.cyan(f"{line} (")
+ self._warn_color()("checking discovered ports")
+ color.cyan(")")
)
print()
extra_ports = []
for protocol in ('tcp', 'udp'):
for port_number in sorted(self.discovered_service_ports[protocol]):
if self.service_registry.is_tracked_port(port_number, protocol):
continue
service_name = self._get_service_name(port_number, protocol)
extra_ports.append(f"{service_name}:{port_number}/{protocol}")
if extra_ports:
print(self._warn_color()('DISCOVERED LISTEN PORTS'.center(100)))
print(self._warn_color()(f" {', '.join(extra_ports[:12])}"))
if len(extra_ports) > 12:
print(self._warn_color()(f" ... +{len(extra_ports) - 12} more"))
print()
self._print_web_internal_note()
alerts = self._analyze_threats()
if alerts:
print(self._warn_color()('ALERTS'.center(100)))
for alert in sorted(
alerts, key=lambda item: item['severity'], reverse=True
):
line = f" ! {alert['message']}"
if alert['severity'] == 'high':
print(color.red(line))
else:
print(self._warn_color()(line))
print()
self.print_connection_state_breakdown()
top_subnets = self._top_subnets(ip_counts, prefix=24, limit=5)
if top_subnets:
print(color.magenta('TOP /24 SUBNETS (all services)'.center(100)))
subnet_table = PrettyTable(['Subnet', 'Connections', '% of total'])
for subnet, count in top_subnets:
share = (count / total_connections) * 100 if total_connections else 0
subnet_table.add_row([subnet, count, f'{share:.1f}%'])
subnet_table.align['Subnet'] = 'l'
subnet_table.align['Connections'] = 'r'
subnet_table.align['% of total'] = 'r'
for line in subnet_table.get_string().splitlines():
print(color.cyan(line))
print()
def _require_ss(self):
if not ss_path:
print("Error: ss command not found", file=sys.stderr)
sys.exit(1)
# Collects TCP connection data.
def collect_tcp_connections(self):
self._require_ss()
try:
subprocess_output = subprocess.run(
[ss_path, "-tan"],
stdout=subprocess.PIPE,
check=True,
text=True,
)
except subprocess.CalledProcessError:
print("Error running: ss -tan", file=sys.stderr)
sys.exit(1)
for line in subprocess_output.stdout.splitlines():
self._parse_ss_output_line(
line, 'tcp', self.tcp_connections_by_port
)
# Collects UDP connection data.
def collect_udp_connections(self):
self._require_ss()
try:
subprocess_output = subprocess.run(
[ss_path, "-uan"],
stdout=subprocess.PIPE,
check=True,
text=True,
)
except subprocess.CalledProcessError:
print("Error running: ss -uan", file=sys.stderr)
sys.exit(1)
for line in subprocess_output.stdout.splitlines():
self._parse_ss_output_line(
line, 'udp', self.udp_connections_by_port
)
# Parses a single connection line.
def _parse_ss_output_line(self, line, protocol, connection_storage_dict):
line_parts = re.split(r'\s+', line.strip())
if len(line_parts) < 5 or line_parts[0] in {'State', 'LISTEN'}:
return
connection_state = line_parts[0]
try:
server_address_string = re.sub(r'^::ffff:', '', line_parts[3])
client_address_string = re.sub(r'^::ffff:', '', line_parts[4])
server_ip, server_port_string = server_address_string.rsplit(':', 1)
client_ip, client_port_string = client_address_string.rsplit(':', 1)
except ValueError:
return
server_ip = self._normalize_ip(server_ip)
client_ip = self._normalize_ip(client_ip)
if server_port_string == '*' or client_port_string == '*':
return
if server_ip in SKIPPED_SERVER_IPS or client_ip in SKIPPED_CLIENT_IPS:
return
try:
port_number = int(server_port_string)
except ValueError:
return
if not self._is_connection_allowed(port_number, protocol):
return
if self.command_line_args.subnet:
self.collected_client_ips_for_subnetting.append(client_ip)
return
state_storage = (
self.tcp_states_by_port
if protocol == 'tcp'
else self.udp_states_by_port
)
state_storage[port_number][connection_state] += 1
connection_storage_dict[port_number][client_ip] += 1
# Summarizes all connection data.
def summarize_collected_connections(self):
if self._wants_tcp():
for (
port_number,
ip_to_count_map,
) in self.tcp_connections_by_port.items():
self.processed_connection_data[f"tcp:{port_number}"] = {
'total': sum(ip_to_count_map.values()),
'ips': ip_to_count_map,
'states': dict(self.tcp_states_by_port[port_number]),
}
if self._wants_udp():
for (
port_number,
ip_to_count_map,
) in self.udp_connections_by_port.items():
self.processed_connection_data[f"udp:{port_number}"] = {
'total': sum(ip_to_count_map.values()),
'ips': ip_to_count_map,
'states': dict(self.udp_states_by_port[port_number]),
}
# Ensures requested services appear.
def ensure_all_requested_services_in_summary(self):
if self._wants_tcp():
for port_number in self.requested_tcp_ports:
data_key = f"tcp:{port_number}"
if data_key not in self.processed_connection_data:
self.processed_connection_data[data_key] = {
'total': 0,
'ips': {},
'states': {},
}
if self._wants_udp():
for port_number in self.requested_udp_ports:
data_key = f"udp:{port_number}"
if data_key not in self.processed_connection_data:
self.processed_connection_data[data_key] = {
'total': 0,
'ips': {},
'states': {},
}
# Prints tables side-by-side.
def _terminal_width(self, default=100):
try:
return shutil.get_terminal_size().columns
except OSError:
return default
def _tables_per_row(self, table_count, preferred=DEFAULT_MAX_TABLES_PER_ROW):
width_based = max(1, self._terminal_width() // ESTIMATED_TABLE_WIDTH)
return max(1, min(table_count, preferred, width_based))
@staticmethod
def _chunk_tables(tables_with_row_data, max_per_row):
for start in range(0, len(tables_with_row_data), max_per_row):
yield tables_with_row_data[start:start + max_per_row]
def _print_service_title(self, service_title, service_totals):
total_connections = service_totals['connections']
service_is_hot = total_connections >= HOT_SERVICE_THRESHOLD
title_parts = [
f"{service_title.upper()}",
f"{total_connections:,} conns",
f"{service_totals['unique_ips']:,} unique IPs",
]
internal_web = service_totals.get('internal_web_connections', 0)
if internal_web > 0:
title_parts.append(f"{internal_web:,} internal/web-stack")
top_subnet = service_totals.get('top_subnet', '')
state_summary = service_totals.get('state_summary', '')
if top_subnet:
title_parts.append(f"top /24: {top_subnet}")
if state_summary:
title_parts.append(f"states: {state_summary}")
title_string = ' | '.join(title_parts)
if service_is_hot:
print(color.red(title_string.center(100)))
else:
print(color.magenta(title_string.center(100)))
def _print_table_row_chunk(self, tables_with_row_data):
list_of_table_line_lists = []
list_of_high_traffic_ip_sets = []
for table, rows, port_number, protocol in tables_with_row_data:
list_of_table_line_lists.append(table.get_string().splitlines())
list_of_high_traffic_ip_sets.append({
ip
for ip, count in rows
if count >= HOT_IP_THRESHOLD
and not (
self._is_web_port(port_number, protocol)
and self._is_internal_client(ip)
)
})
max_height = max(
len(lines) for lines in list_of_table_line_lists if lines
)
for lines in list_of_table_line_lists:
if not lines:
continue
table_width = len(lines[0])
while len(lines) < max_height:
lines.append(' ' * table_width)
for i in range(max_height):
line_parts = []
for current_table_index, lines in enumerate(
list_of_table_line_lists
):
line = lines[i]
is_header = i == 1
is_border = line.strip().startswith('+') or not line.strip()
if is_border:
line_parts.append(color.cyan(line))
elif is_header:
line_parts.append(color.magenta(line))
else:
is_hot_line = any(
self._line_contains_ip(line, hot_ip)
for hot_ip in list_of_high_traffic_ip_sets[
current_table_index
]
)
if is_hot_line:
line_parts.append(color.red(line))
else:
line_parts.append(line)
print(" ".join(line_parts))
def print_service_tables_side_by_side(
self,
service_title,
tables_with_row_data,
service_totals=None,
max_per_row=None,
):
if not tables_with_row_data:
return
if service_totals is None:
displayed_connections = sum(
sum(count for _, count in rows)
for _, rows, _, _ in tables_with_row_data
)
service_totals = {
'connections': displayed_connections,
'unique_ips': len(
{
ip
for _, rows, _, _ in tables_with_row_data
for ip, _ in rows
}
),
}
if max_per_row is None:
max_per_row = self._tables_per_row(len(tables_with_row_data))
self._print_service_title(service_title, service_totals)
table_chunks = list(
self._chunk_tables(tables_with_row_data, max_per_row)
)
for chunk_index, chunk in enumerate(table_chunks):
if chunk_index > 0:
print()
self._print_table_row_chunk(chunk)
print()
@staticmethod
def _line_contains_ip(line, ip):
return f'| {ip} ' in line or f'| {ip}|' in line
def _service_totals(self, ports_data, protocol='tcp'):
ip_counts = defaultdict(int)
state_counts = defaultdict(int)
total_connections = 0
internal_web_connections = 0
for port_number, info in ports_data.items():
total_connections += info['total']
for ip_string, count in info['ips'].items():
if (
self._is_web_port(port_number, protocol)
and self._is_internal_client(ip_string)
):
internal_web_connections += count
else:
ip_counts[ip_string] += count
for state_name, count in info.get('states', {}).items():
state_counts[state_name] += count
top_subnet = ''
subnet_totals = self._subnet_counts(ip_counts, 24)
if subnet_totals:
top_subnet = max(subnet_totals.items(), key=lambda item: item[1])[0]
return {
'connections': total_connections,
'external_connections': total_connections - internal_web_connections,
'internal_web_connections': internal_web_connections,
'unique_ips': len(ip_counts),
'top_subnet': top_subnet if top_subnet not in {'local', '?'} else '',
'state_summary': self._format_state_summary(state_counts),
}
def _build_service_tables(self, service_name, ports_data, protocol='tcp'):
tables_to_print = []
for port_number, info in ports_data.items():
if info['total'] <= 0:
continue
service_total = info['total']
rows = sorted(info['ips'].items(), key=lambda x: -x[1])[:10]
table = PrettyTable(
[
f"{service_name.upper()}:{port_number}",
"Count",
"%",
]
)
for ip_string, count in rows:
share = (count / service_total) * 100
table.add_row([
self._format_client_ip(ip_string, port_number, protocol),
count,
f'{share:.1f}%',
])
table.align = "l"
table.align["Count"] = "r"
table.align["%"] = "r"
tables_to_print.append((table, rows, port_number, protocol))
return tables_to_print
def _should_print_service(self, ports_data, protocol='tcp'):
total_connections = sum(d['total'] for d in ports_data.values())
if total_connections <= 0:
return False
any_ip_over_200 = any(
count > 200
for port_number, data in ports_data.items()
for ip_string, count in self._external_ip_counts(
data['ips'], port_number, protocol
).items()
)
return not self.command_line_args.top or (
total_connections >= 2000 or any_ip_over_200
)
def _print_service_if_needed(
self, service_title, ports_data, tables_to_print, max_per_row=None,
protocol='tcp',
):
if tables_to_print and self._should_print_service(
ports_data, protocol=protocol
):
self.print_service_tables_side_by_side(
service_title,
tables_to_print,
service_totals=self._service_totals(
ports_data, protocol=protocol
),
max_per_row=max_per_row,
)
# Reports on service connections.
def generate_connection_report_tables(self):
service_map_by_protocol = defaultdict(
lambda: defaultdict(lambda: {'total': 0, 'ips': {}})
)
for data_key, connection_data in self.processed_connection_data.items():
protocol, port_string = data_key.split(':')
port_number = int(port_string)
service_name = self._get_service_name(
port_number, protocol
)
service_map_by_protocol[(protocol, service_name)][port_number] = {
'total': connection_data['total'],
'ips': connection_data['ips'],
'states': connection_data.get('states', {}),
}
service_print_order_by_protocol = {
'tcp': [
'http',
self.service_registry.panel_user,
self.service_registry.panel_admin,
'smtp',
'imap',
'pop3',
'webmail',
'ssh',
'ftp',
'mysql',
],
'udp': ['dns', 'ntp'],
}
for protocol in ['tcp', 'udp']:
if protocol == 'udp' and not self._wants_udp():
continue
service_print_order = service_print_order_by_protocol[protocol]
i = 0
while i < len(service_print_order):
service_name = service_print_order[i]
# Group mail services (TCP only)
if protocol == 'tcp' and service_name == 'imap':
combined_mail_services = ['imap', 'pop3', 'webmail']
tables_to_print = []
ports_data = {}
for sub_service_name in combined_mail_services:
sub_service_ports_data = service_map_by_protocol.get(
(protocol, sub_service_name), {}
)
ports_data.update(sub_service_ports_data)
tables_to_print.extend(
self._build_service_tables(
sub_service_name,
sub_service_ports_data,
protocol=protocol,
)
)
self._print_service_if_needed(
'MAIL SERVICES',
ports_data,
tables_to_print,
max_per_row=MAIL_SERVICES_MAX_PER_ROW,
protocol=protocol,
)
i += len(combined_mail_services)
continue
ports_data = service_map_by_protocol.get(
(protocol, service_name), {}
)
if ports_data:
tables_to_print = self._build_service_tables(
service_name, ports_data, protocol=protocol
)
self._print_service_if_needed(
service_name,
ports_data,
tables_to_print,
protocol=protocol,
)
i += 1
other_ports_data = service_map_by_protocol.get(
(protocol, 'other'), {}
)
if other_ports_data:
tables_to_print = self._build_service_tables(
'other', other_ports_data, protocol=protocol
)
self._print_service_if_needed(
'other',
other_ports_data,
tables_to_print,
protocol=protocol,
)
# Summarizes subnet connections.
def generate_subnet_summary_tables(self):
slash_24_subnet_counts = defaultdict(int)
slash_16_subnet_counts = defaultdict(int)
for ip_string in self.collected_client_ips_for_subnetting:
try:
ip_address_object = ip_address(ip_string)
if (
ip_address_object.is_loopback
or ip_address_object.is_private
or ip_address_object.is_unspecified
):
continue
slash_24_subnet_counts[
str(ip_network(f"{ip_address_object}/24", strict=False))
] += 1
slash_16_subnet_counts[
str(ip_network(f"{ip_address_object}/16", strict=False))
] += 1
except ValueError:
continue
for table_title_label, subnet_counts_data in (
("Top /24 Subnets", slash_24_subnet_counts),
("Top /16 Subnets", slash_16_subnet_counts),
):
if not subnet_counts_data:
continue
# Filter rows based on the --top flag
all_rows_sorted = sorted(
subnet_counts_data.items(), key=lambda kv: -kv[1]
)
if self.command_line_args.top:
rows_to_display = [
row for row in all_rows_sorted if row[1] > 200
]
else:
rows_to_display = all_rows_sorted[:20]
if not rows_to_display:
continue
table = PrettyTable(["Subnet", "Count"])
table.title = table_title_label
high_traffic_row_indices = {
i for i, row in enumerate(rows_to_display) if row[1] > 200
}
for row in rows_to_display:
table.add_row(row)
table.align["Subnet"] = "l"
table.align["Count"] = "r"
lines = table.get_string().splitlines()
for i, line in enumerate(lines):
# Line 1 is title, Line 3 is header text
if i in {1, 3}:
print(color.magenta(line))
# Data rows start at index 5
elif i >= 5 and (i - 5) in high_traffic_row_indices:
print(color.red(line))
else:
print(color.cyan(line))
print()
def _run_report_once(self):
self._require_ss()
self.collect_listening_ports()
if self.command_line_args.subnet:
if self._wants_tcp():
self.collect_tcp_connections()
if self._wants_udp():
self.collect_udp_connections()
self.generate_subnet_summary_tables()
return
if self._wants_udp():
self.collect_udp_connections()
if self._wants_tcp():
self.collect_tcp_connections()
self.summarize_collected_connections()
self.ensure_all_requested_services_in_summary()
self.print_executive_summary()
self.generate_connection_report_tables()
@staticmethod
def _clear_screen():
if sys.stdout.isatty():
print('\033[2J\033[H', end='')
def run_watch_mode(self):
interval = self.command_line_args.watch
print(
color.cyan(
f"Watch mode: refreshing every {interval}s (Ctrl+C to exit)"
)
)
try:
while True:
self._clear_screen()
self._reset_collection_state()
self._run_report_once()
time.sleep(interval)
except KeyboardInterrupt:
print(color.cyan('\nWatch mode stopped.'))
# Main execution function.
def run(self):
if self.command_line_args.watch is not None:
self.run_watch_mode()
return
self._run_report_once()
if __name__ == "__main__":
ConnectionReporter().run()