Your IP : 216.73.216.215


Current Path : /proc/3/cwd/opt/dedrads/
Upload File :
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()