From 1495f3b69921baae4feca22c40e58271a75222a7 Mon Sep 17 00:00:00 2001 From: Aleksei Fedorov Date: Fri, 3 Apr 2026 15:03:48 +0000 Subject: [PATCH] python refactoring stage 1 --- app.py | 5196 +---------------- gunicorn.conf.py | 14 + kernel_ai/__init__.py | 5 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 289 bytes kernel_ai/__pycache__/config.cpython-310.pyc | Bin 0 -> 794 bytes kernel_ai/__pycache__/hooks.cpython-310.pyc | Bin 0 -> 1172 bytes .../prometheus_setup.cpython-310.pyc | Bin 0 -> 2616 bytes kernel_ai/__pycache__/state.cpython-310.pyc | Bin 0 -> 1070 bytes kernel_ai/__pycache__/webapp.cpython-310.pyc | Bin 0 -> 133393 bytes kernel_ai/api/__init__.py | 5 + .../api/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 251 bytes .../api/__pycache__/rest.cpython-310.pyc | Bin 0 -> 4021 bytes kernel_ai/api/rest.py | 132 + kernel_ai/collectors/__init__.py | 15 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 409 bytes .../__pycache__/proc_fs.cpython-310.pyc | Bin 0 -> 2496 bytes kernel_ai/collectors/proc_fs.py | 82 + kernel_ai/config.py | 18 + kernel_ai/hooks.py | 34 + kernel_ai/http/__init__.py | 5 + .../http/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 271 bytes .../http/__pycache__/register.cpython-310.pyc | Bin 0 -> 501 bytes kernel_ai/http/register.py | 9 + kernel_ai/prometheus_setup.py | 76 + kernel_ai/state.py | 50 + kernel_ai/views/__init__.py | 5 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 270 bytes .../views/__pycache__/pages.cpython-310.pyc | Bin 0 -> 2562 bytes kernel_ai/views/pages.py | 82 + kernel_ai/webapp.py | 4941 ++++++++++++++++ 30 files changed, 5490 insertions(+), 5179 deletions(-) create mode 100644 gunicorn.conf.py create mode 100644 kernel_ai/__init__.py create mode 100644 kernel_ai/__pycache__/__init__.cpython-310.pyc create mode 100644 kernel_ai/__pycache__/config.cpython-310.pyc create mode 100644 kernel_ai/__pycache__/hooks.cpython-310.pyc create mode 100644 kernel_ai/__pycache__/prometheus_setup.cpython-310.pyc create mode 100644 kernel_ai/__pycache__/state.cpython-310.pyc create mode 100644 kernel_ai/__pycache__/webapp.cpython-310.pyc create mode 100644 kernel_ai/api/__init__.py create mode 100644 kernel_ai/api/__pycache__/__init__.cpython-310.pyc create mode 100644 kernel_ai/api/__pycache__/rest.cpython-310.pyc create mode 100644 kernel_ai/api/rest.py create mode 100644 kernel_ai/collectors/__init__.py create mode 100644 kernel_ai/collectors/__pycache__/__init__.cpython-310.pyc create mode 100644 kernel_ai/collectors/__pycache__/proc_fs.cpython-310.pyc create mode 100644 kernel_ai/collectors/proc_fs.py create mode 100644 kernel_ai/config.py create mode 100644 kernel_ai/hooks.py create mode 100644 kernel_ai/http/__init__.py create mode 100644 kernel_ai/http/__pycache__/__init__.cpython-310.pyc create mode 100644 kernel_ai/http/__pycache__/register.cpython-310.pyc create mode 100644 kernel_ai/http/register.py create mode 100644 kernel_ai/prometheus_setup.py create mode 100644 kernel_ai/state.py create mode 100644 kernel_ai/views/__init__.py create mode 100644 kernel_ai/views/__pycache__/__init__.cpython-310.pyc create mode 100644 kernel_ai/views/__pycache__/pages.cpython-310.pyc create mode 100644 kernel_ai/views/pages.py create mode 100644 kernel_ai/webapp.py diff --git a/app.py b/app.py index 115af68..5b006a4 100644 --- a/app.py +++ b/app.py @@ -1,5204 +1,42 @@ -#!/usr/bin/env python3 -""" -Linux Kernel Visualization Backend -Organized version with proper project structure -""" - -import os -import sys -import json -import time -import random -import platform -import subprocess -import re -import ipaddress -import shutil -from datetime import datetime -from threading import Lock -from flask import Flask, jsonify, render_template, send_from_directory, request, redirect, g, Response -import psutil - -# Gunicorn -w N: set PROMETHEUS_MULTIPROC_DIR before workers import this module (e.g. in gunicorn.conf.py). -if os.environ.get("PROMETHEUS_MULTIPROC_DIR", "").strip(): - _prom_mpdir = os.environ["PROMETHEUS_MULTIPROC_DIR"].strip() - os.makedirs(_prom_mpdir, exist_ok=True) - -try: - from prometheus_client import ( - CONTENT_TYPE_LATEST, - CollectorRegistry, - Counter, - Histogram, - generate_latest, - multiprocess, - ) - - _PROMETHEUS_AVAILABLE = True -except ImportError: - _PROMETHEUS_AVAILABLE = False - CONTENT_TYPE_LATEST = "text/plain; version=0.0.4; charset=utf-8" - -# Try to import OpenAI (optional) -try: - import openai - OPENAI_AVAILABLE = True -except ImportError: - OPENAI_AVAILABLE = False - -app = Flask(__name__) - -if _PROMETHEUS_AVAILABLE: - REQUEST_COUNT = Counter( - "http_requests_total", - "Total HTTP requests", - ["method", "endpoint", "status"], - ) - REQUEST_LATENCY = Histogram( - "http_request_duration_seconds", - "HTTP request latency in seconds", - buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, float("inf")), - ) - - @app.before_request - def _prometheus_before_request(): - g._prom_start = time.perf_counter() - - @app.after_request - def _prometheus_after_request(response): - start = getattr(g, "_prom_start", None) - if start is not None: - REQUEST_LATENCY.observe(time.perf_counter() - start) - ep = request.endpoint - rule = request.url_rule.rule if request.url_rule else None - endpoint_label = ep or rule or "unmatched" - try: - REQUEST_COUNT.labels( - method=request.method, - endpoint=endpoint_label, - status=str(response.status_code), - ).inc() - except Exception: - pass - return response - - @app.route("/metrics") - def prometheus_metrics(): - if os.environ.get("PROMETHEUS_MULTIPROC_DIR", "").strip(): - registry = CollectorRegistry() - multiprocess.MultiProcessCollector(registry) - data = generate_latest(registry) - else: - data = generate_latest() - return Response(data, mimetype=CONTENT_TYPE_LATEST) - -else: - - @app.route("/metrics") - def prometheus_metrics_disabled(): - return jsonify( - {"error": "prometheus_client not installed; pip install prometheus-client"} - ), 503 - - -TRACEROUTE_CACHE = {} -TRACEROUTE_CACHE_TTL_SECONDS = 60 -NETWORK_STACK_PREV = { - "timestamp": None, - "tcpext_retrans": None, - "ip_in": None, - "ip_out": None, - "ip_discards": None, - "iface_rx": None, - "iface_tx": None, - "iface_drops": None -} -DEVICES_PREV = { - "timestamp": None, - "disk_sectors": {}, - "net_bytes": {}, - "tty_irq_total": None, - "irq_by_key": {} -} -FILESYSTEM_PREV = { - "timestamp": None, - "write_bytes": None -} -CRYPTO_PREV = { - "timestamp": None, - "active_flows": 0 -} -ENTROPY_PREV = { - "timestamp": None, - "disk_read_bytes": None, - "disk_write_bytes": None, - "net_sent_bytes": None, - "net_recv_bytes": None, - "interrupt_total": None -} -EXEC_CONTEXT_PREV = { - "timestamp": None, - "irq_totals": {}, - "softirq_totals": {} -} -SECURITY_PREV = { - "timestamp": None, - "events": 0 -} -FRONTEND_LOG_WRITE_LOCK = Lock() -FRONTEND_LOG_FILE = os.getenv("FRONTEND_LOG_FILE", "/opt/ring0/kernel-ai/logs/frontend-events.jsonl") - -def safe_trim(value, limit=2048): - """Trim large strings to keep log payload size bounded.""" - if value is None: - return "" - text = str(value) - if len(text) <= limit: - return text - return text[:limit] + "...[truncated]" - -def write_frontend_event(event_payload): - """Write one frontend event as JSON line for Elastic Agent tail input.""" - event = { - "@timestamp": datetime.utcnow().isoformat() + "Z", - "service.name": "kernel-ai-frontend", - "event.dataset": "kernel_ai.frontend", - "event.kind": "event", - "log.level": safe_trim(event_payload.get("level", "info"), 16).lower(), - "message": safe_trim(event_payload.get("message", "")), - "url.path": safe_trim(event_payload.get("path", ""), 512), - "url.full": safe_trim(event_payload.get("url", ""), 2048), - "user_agent.original": safe_trim(event_payload.get("userAgent", ""), 1024), - "session.id": safe_trim(event_payload.get("sessionId", ""), 128), - "error.stack_trace": safe_trim(event_payload.get("stack", ""), 12000), - "event.module": safe_trim(event_payload.get("module", "frontend"), 128), - "tags": event_payload.get("tags", []), - "meta": event_payload.get("meta", {}) - } - os.makedirs(os.path.dirname(FRONTEND_LOG_FILE), exist_ok=True) - line = json.dumps(event, ensure_ascii=False) - with FRONTEND_LOG_WRITE_LOCK: - with open(FRONTEND_LOG_FILE, "a", encoding="utf-8") as f: - f.write(line + "\n") - -def resolve_binary(cmd_name): - """Resolve executable path even when service PATH misses sbin directories.""" - found = shutil.which(cmd_name) - if found: - return found - for base in ("/usr/sbin", "/usr/bin", "/sbin", "/bin"): - candidate = os.path.join(base, cmd_name) - if os.path.isfile(candidate) and os.access(candidate, os.X_OK): - return candidate - return None - -# Configuration -class Config: - # Detect environment: production if DEBUG env var is not set or is False - DEBUG = os.getenv('FLASK_DEBUG', 'False').lower() == 'true' - ENV = os.getenv('FLASK_ENV', 'production' if not DEBUG else 'development') - STATIC_FOLDER = 'static' - TEMPLATES_FOLDER = 'templates' - API_PREFIX = '/api' - # Cache settings - SEND_FILE_MAX_AGE_DEFAULT = 0 if DEBUG else 31536000 # 1 year in production - -app.config.from_object(Config) - -# CORS and cache control headers -@app.after_request -def add_headers(response): - """Add CORS headers and cache control based on environment""" - # Add CORS headers for all API requests - response.headers['Access-Control-Allow-Origin'] = '*' - response.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS' - response.headers['Access-Control-Allow-Headers'] = 'Content-Type' - - # Always disable HTML caching so browsers pick up fresh script version URLs. - if response.content_type and 'text/html' in response.content_type: - response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' - response.headers['Pragma'] = 'no-cache' - response.headers['Expires'] = '0' - return response - - # Only apply cache control to static files (JS, CSS, images) - if response.content_type and ( - 'text/javascript' in response.content_type or - 'application/javascript' in response.content_type or - 'text/css' in response.content_type or - 'image/' in response.content_type - ): - if app.config['DEBUG']: - # Development: no cache - response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' - response.headers['Pragma'] = 'no-cache' - response.headers['Expires'] = '0' - else: - # Production: long cache with revalidation - # Files with ?v= parameter will be cached, but browser will check for updates - response.headers['Cache-Control'] = 'public, max-age=31536000, immutable' - # Remove Pragma header in production (not needed with Cache-Control) - if 'Pragma' in response.headers: - del response.headers['Pragma'] - return response - -def get_system_info(): - """Get system information""" - return { - 'platform': platform.system(), - 'kernel': platform.release(), - 'python_version': platform.python_version(), - 'cpu_count': psutil.cpu_count(), - 'memory_total': psutil.virtual_memory().total - } - -# System call number to name mapping (common Linux syscalls) -SYSCALL_NAMES = { - 0: 'read', 1: 'write', 2: 'open', 3: 'close', 4: 'stat', 5: 'fstat', - 6: 'lstat', 7: 'poll', 8: 'lseek', 9: 'mmap', 10: 'mprotect', - 11: 'munmap', 12: 'brk', 13: 'rt_sigaction', 14: 'rt_sigprocmask', - 15: 'rt_sigreturn', 16: 'ioctl', 17: 'pread64', 18: 'pwrite64', - 19: 'readv', 20: 'writev', 21: 'access', 22: 'pipe', 23: 'select', - 24: 'sched_yield', 25: 'mremap', 26: 'msync', 27: 'mincore', - 28: 'madvise', 29: 'shmget', 30: 'shmat', 31: 'shmctl', 32: 'dup', - 33: 'dup2', 34: 'pause', 35: 'nanosleep', 36: 'getitimer', - 37: 'alarm', 38: 'setitimer', 39: 'getpid', 40: 'sendfile', - 41: 'socket', 42: 'connect', 43: 'accept', 44: 'sendto', 45: 'recvfrom', - 46: 'sendmsg', 47: 'recvmsg', 48: 'shutdown', 49: 'bind', 50: 'listen', - 51: 'getsockname', 52: 'getpeername', 53: 'socketpair', 54: 'setsockopt', - 55: 'getsockopt', 56: 'clone', 57: 'fork', 58: 'vfork', 59: 'execve', - 60: 'exit', 61: 'wait4', 62: 'kill', 63: 'uname', 64: 'semget', - 65: 'semop', 66: 'semctl', 67: 'shmdt', 68: 'msgget', 69: 'msgsnd', - 70: 'msgrcv', 71: 'msgctl', 72: 'fcntl', 73: 'flock', 74: 'fsync', - 75: 'fdatasync', 76: 'truncate', 77: 'ftruncate', 78: 'getdents', - 79: 'getcwd', 80: 'chdir', 81: 'fchdir', 82: 'rename', 83: 'mkdir', - 84: 'rmdir', 85: 'creat', 86: 'link', 87: 'unlink', 88: 'symlink', - 89: 'readlink', 90: 'chmod', 91: 'fchmod', 92: 'chown', 93: 'fchown', - 94: 'lchown', 95: 'umask', 96: 'gettimeofday', 97: 'getrlimit', - 98: 'getrusage', 99: 'sysinfo', 100: 'times', 101: 'ptrace', - 102: 'getuid', 103: 'syslog', 104: 'getgid', 105: 'setuid', 106: 'setgid', - 107: 'geteuid', 108: 'getegid', 109: 'setpgid', 110: 'getppid', - 111: 'getpgrp', 112: 'setsid', 113: 'setreuid', 114: 'setregid', - 115: 'getgroups', 116: 'setgroups', 117: 'setresuid', 118: 'getresuid', - 119: 'setresgid', 120: 'getresgid', 121: 'getpgid', 122: 'setfsuid', - 123: 'setfsgid', 124: 'getsid', 125: 'capget', 126: 'capset', - 127: 'rt_sigpending', 128: 'rt_sigtimedwait', 129: 'rt_sigqueueinfo', - 130: 'rt_sigsuspend', 131: 'sigaltstack', 132: 'utime', 133: 'mknod', - 134: 'uselib', 135: 'personality', 136: 'ustat', 137: 'statfs', - 138: 'fstatfs', 139: 'sysfs', 140: 'getpriority', 141: 'setpriority', - 142: 'sched_setparam', 143: 'sched_getparam', 144: 'sched_setscheduler', - 145: 'sched_getscheduler', 146: 'sched_get_priority_max', - 147: 'sched_get_priority_min', 148: 'sched_rr_get_interval', - 149: 'mlock', 150: 'munlock', 151: 'mlockall', 152: 'munlockall', - 153: 'vhangup', 154: 'modify_ldt', 155: 'pivot_root', 156: 'prctl', - 157: 'arch_prctl', 158: 'adjtimex', 159: 'setrlimit', 160: 'chroot', - 161: 'sync', 162: 'acct', 163: 'settimeofday', 164: 'mount', - 165: 'umount2', 166: 'swapon', 167: 'swapoff', 168: 'reboot', - 169: 'sethostname', 170: 'setdomainname', 171: 'iopl', 172: 'ioperm', - 173: 'create_module', 174: 'init_module', 175: 'delete_module', - 176: 'get_kernel_syms', 177: 'query_module', 178: 'quotactl', - 179: 'nfsservctl', 180: 'getpmsg', 181: 'putpmsg', 182: 'afs_syscall', - 183: 'tuxcall', 184: 'security', 185: 'gettid', 186: 'readahead', - 187: 'setxattr', 188: 'lsetxattr', 189: 'fsetxattr', 190: 'getxattr', - 191: 'lgetxattr', 192: 'fgetxattr', 193: 'listxattr', 194: 'llistxattr', - 195: 'flistxattr', 196: 'removexattr', 197: 'lremovexattr', - 198: 'fremovexattr', 199: 'tkill', 200: 'time', 201: 'futex', - 202: 'sched_setaffinity', 203: 'sched_getaffinity', 204: 'set_thread_area', - 205: 'io_setup', 206: 'io_destroy', 207: 'io_getevents', 208: 'io_submit', - 209: 'io_cancel', 210: 'get_thread_area', 211: 'lookup_dcookie', - 212: 'epoll_create', 213: 'epoll_ctl_old', 214: 'epoll_wait_old', - 215: 'remap_file_pages', 216: 'getdents64', 217: 'set_tid_address', - 218: 'restart_syscall', 219: 'semtimedop', 220: 'fadvise64', - 221: 'timer_create', 222: 'timer_settime', 223: 'timer_gettime', - 224: 'timer_getoverrun', 225: 'timer_delete', 226: 'clock_settime', - 227: 'clock_gettime', 228: 'clock_getres', 229: 'clock_nanosleep', - 230: 'exit_group', 231: 'epoll_wait', 232: 'epoll_ctl', 233: 'tgkill', - 234: 'utimes', 235: 'vserver', 236: 'mbind', 237: 'set_mempolicy', - 238: 'get_mempolicy', 239: 'mq_open', 240: 'mq_unlink', 241: 'mq_timedsend', - 242: 'mq_timedreceive', 243: 'mq_notify', 244: 'mq_getsetattr', - 245: 'kexec_load', 246: 'waitid', 247: 'add_key', 248: 'request_key', - 249: 'keyctl', 250: 'ioprio_set', 251: 'ioprio_get', 252: 'inotify_init', - 253: 'inotify_add_watch', 254: 'inotify_rm_watch', 255: 'migrate_pages', - 256: 'openat', 257: 'mkdirat', 258: 'mknodat', 259: 'fchownat', - 260: 'futimesat', 261: 'newfstatat', 262: 'unlinkat', 263: 'renameat', - 264: 'linkat', 265: 'symlinkat', 266: 'readlinkat', 267: 'fchmodat', - 268: 'faccessat', 269: 'pselect6', 270: 'ppoll', 271: 'unshare', - 272: 'set_robust_list', 273: 'get_robust_list', 274: 'splice', - 275: 'tee', 276: 'sync_file_range', 277: 'vmsplice', 278: 'move_pages', - 279: 'utimensat', 280: 'epoll_pwait', 281: 'signalfd', 282: 'timerfd_create', - 283: 'eventfd', 284: 'fallocate', 285: 'timerfd_settime', - 286: 'timerfd_gettime', 287: 'accept4', 288: 'signalfd4', 289: 'eventfd2', - 290: 'epoll_create1', 291: 'dup3', 292: 'pipe2', 293: 'inotify_init1', - 294: 'preadv', 295: 'pwritev', 296: 'rt_tgsigqueueinfo', 297: 'perf_event_open', - 298: 'recvmmsg', 299: 'fanotify_init', 300: 'fanotify_mark', - 301: 'prlimit64', 302: 'name_to_handle_at', 303: 'open_by_handle_at', - 304: 'clock_adjtime', 305: 'syncfs', 306: 'sendmmsg', 307: 'setns', - 308: 'getcpu', 309: 'process_vm_readv', 310: 'process_vm_writev', - 311: 'kcmp', 312: 'finit_module', 313: 'sched_setattr', 314: 'sched_getattr', - 315: 'renameat2', 316: 'seccomp', 317: 'getrandom', 318: 'memfd_create', - 319: 'kexec_file_load', 320: 'bpf', 321: 'execveat', 322: 'userfaultfd', - 323: 'membarrier', 324: 'mlock2', 325: 'copy_file_range', 326: 'preadv2', - 327: 'pwritev2', 328: 'pkey_mprotect', 329: 'pkey_alloc', 330: 'pkey_free', - 331: 'statx', 332: 'io_pgetevents', 333: 'rseq', 334: 'pidfd_send_signal', - 335: 'io_uring_setup', 336: 'io_uring_enter', 337: 'io_uring_register', - 338: 'open_tree', 339: 'move_mount', 340: 'fsopen', 341: 'fsconfig', - 342: 'fsmount', 343: 'fspick', 344: 'pidfd_open', 345: 'clone3', - 346: 'close_range', 347: 'openat2', 348: 'pidfd_getfd', 349: 'faccessat2', - 350: 'process_madvise', 351: 'epoll_pwait2', 352: 'mount_setattr', - 353: 'quotactl_fd', 354: 'landlock_create_ruleset', 355: 'landlock_add_rule', - 356: 'landlock_restrict_self', 357: 'memfd_secret', 358: 'process_mrelease', - 359: 'futex_waitv', 360: 'set_mempolicy_home_node', 361: 'cachestat', - 362: 'fchmodat2', 363: 'map_shadow_stack', 364: 'futex_wake', 365: 'futex_wait', - 366: 'futex_requeue', 367: 'futex_wake_op', 368: 'futex_lock_pi', - 369: 'futex_unlock_pi', 370: 'futex_trylock_pi', 371: 'futex_wait_requeue_pi', - 372: 'futex_cmp_requeue_pi', 373: 'futex_wake_requeue_pi', 374: 'futex_waitv', - 375: 'futex_wake', 376: 'futex_wait', 377: 'futex_requeue', 378: 'futex_wake_op', - 379: 'futex_lock_pi', 380: 'futex_unlock_pi', 381: 'futex_trylock_pi', - 382: 'futex_wait_requeue_pi', 383: 'futex_cmp_requeue_pi', 384: 'futex_wake_requeue_pi', - 385: 'futex_waitv', 386: 'futex_wake', 387: 'futex_wait', 388: 'futex_requeue', - 389: 'futex_wake_op', 390: 'futex_lock_pi', 391: 'futex_unlock_pi', - 392: 'futex_trylock_pi', 393: 'futex_wait_requeue_pi', 394: 'futex_cmp_requeue_pi', - 395: 'futex_wake_requeue_pi' -} - -# Max PIDs to scan for /proc/[pid]/syscall (tasks currently blocked in a syscall). -KERNEL_DNA_MAX_PROCS = int(os.environ.get('KERNEL_DNA_MAX_PROCS', '1200')) - - -def _kernel_dna_read_proc_vmstat(): - """Parse /proc/vmstat into a dict of int counters.""" - vm = {} - try: - with open('/proc/vmstat', 'r', encoding='utf-8', errors='replace') as f: - for line in f: - parts = line.split() - if len(parts) >= 2: - vm[parts[0]] = int(parts[1]) - except (OSError, ValueError): - pass - return vm - - -def _kernel_dna_vmstat_activity_nucleotides(): - """Real VM counters when no per-task syscall sample is available.""" - result = [] - vm = _kernel_dna_read_proc_vmstat() - mapping = [ - ('pgfault', 'mm'), - ('pgmajfault', 'mm'), - ('pswpin', 'mm'), - ('pswpout', 'mm'), - ('oom_kill', 'mm'), - ('nr_dirty', 'mm'), - ('nr_written', 'mm'), - ('pgscan_kswapd', 'mm'), - ('pgscan_direct', 'mm'), - ('workingset_refault', 'mm'), - ] - for key, sub in mapping: - if key in vm and vm[key] > 0: - result.append({'name': f'vm:{key}', 'count': vm[key], 'subsystem': sub}) - return result - - -def _kernel_dna_block_device_activity_nucleotides(): - """Cumulative I/O from /sys/block//stat.""" - result = [] - tr = tw = tsr = tsw = 0 - try: - for name in os.listdir('/sys/block'): - if name.startswith(('loop', 'ram')): - continue - stat_path = os.path.join('/sys/block', name, 'stat') - if not os.path.isfile(stat_path): - continue - with open(stat_path, 'r', encoding='utf-8', errors='replace') as f: - st = f.read().split() - if len(st) < 7: - continue - tr += int(st[0]) - tsr += int(st[2]) - tw += int(st[4]) - tsw += int(st[6]) - except (OSError, ValueError, IndexError): - pass - if tr > 0: - result.append({'name': 'disk:read_ios', 'count': tr, 'subsystem': 'fs'}) - if tw > 0: - result.append({'name': 'disk:write_ios', 'count': tw, 'subsystem': 'fs'}) - if tsr > 0: - result.append({'name': 'disk:sectors_read', 'count': tsr, 'subsystem': 'fs'}) - if tsw > 0: - result.append({'name': 'disk:sectors_written', 'count': tsw, 'subsystem': 'fs'}) - return result - - -def _kernel_dna_sockstat_activity_nucleotides(): - """Socket counts from /proc/net/sockstat.""" - result = [] - try: - with open('/proc/net/sockstat', 'r', encoding='utf-8', errors='replace') as f: - for line in f: - parts = line.split() - if line.startswith('TCP:') and len(parts) >= 3: - result.append({'name': 'net:tcp_inuse', 'count': int(parts[2]), 'subsystem': 'net'}) - elif line.startswith('UDP:') and len(parts) >= 3: - result.append({'name': 'net:udp_inuse', 'count': int(parts[2]), 'subsystem': 'net'}) - except (OSError, ValueError, IndexError): - pass - return result - - -def _kernel_dna_softirq_nucleotides(limit=8): - """Per-vector softirq totals from /proc/softirqs.""" - out = [] - try: - with open('/proc/softirqs', 'r', encoding='utf-8', errors='replace') as f: - lines = f.readlines() - if len(lines) < 2: - return out - for line in lines[1 : 1 + limit]: - parts = line.split() - if len(parts) < 2: - continue - vec = parts[0].rstrip(':') - total = sum(int(x) for x in parts[1:] if x.isdigit()) - if total > 0: - out.append({ - 'type': 'interrupt', - 'code': 'T', - 'name': f'softirq:{vec}', - 'count': total, - 'subsystem': map_interrupt_to_subsystem(vec), - 'timestamp': datetime.now().isoformat(), - }) - except (OSError, ValueError): - pass - return out - - -def get_real_system_calls(): - """Blocked-in-syscall sample from /proc/[pid]/syscall; else real vmstat + block + sockstat (no random on Linux).""" - try: - if platform.system() != 'Linux': - return get_mock_system_calls() - - try: - proc_dirs = [d for d in os.listdir('/proc') if d.isdigit()] - except PermissionError: - proc_dirs = [] - - sampled = sorted(proc_dirs, key=int)[: min(KERNEL_DNA_MAX_PROCS, len(proc_dirs))] - - syscall_counts = {} - for pid in sampled: - try: - syscall_path = f'/proc/{pid}/syscall' - if not os.path.exists(syscall_path): - continue - with open(syscall_path, 'r', encoding='utf-8', errors='replace') as f: - line = f.read().strip() - if not line or line in ('-1', 'running'): - continue - parts = line.split() - if not parts: - continue - try: - syscall_num = int(parts[0]) - except ValueError: - continue - syscall_name = SYSCALL_NAMES.get(syscall_num, f'syscall_{syscall_num}') - syscall_counts[syscall_name] = syscall_counts.get(syscall_name, 0) + 1 - except (PermissionError, FileNotFoundError, IOError, ValueError): - continue - - if syscall_counts: - syscalls = [] - for name, count in sorted(syscall_counts.items(), key=lambda x: x[1], reverse=True)[:20]: - syscalls.append({ - 'name': name, - 'count': count, - 'subsystem': map_syscall_to_subsystem(name), - }) - return syscalls - - merged = [] - merged.extend(_kernel_dna_vmstat_activity_nucleotides()) - merged.extend(_kernel_dna_block_device_activity_nucleotides()) - merged.extend(_kernel_dna_sockstat_activity_nucleotides()) - if merged: - merged.sort(key=lambda x: x['count'], reverse=True) - return merged[:20] - return [] - - except Exception as e: - print(f"Error getting system calls: {e}") - import traceback - traceback.print_exc() - return [] if platform.system() == 'Linux' else get_mock_system_calls() - -def get_mock_system_calls(): - """Mock data for system calls""" - return [ - {'name': 'read', 'count': '166 643218'}, - {'name': 'write', 'count': '964 016161'}, - {'name': 'open', 'count': '972 983879'}, - {'name': 'close', 'count': '989 612075'}, - {'name': 'mmap', 'count': '819 540732'}, - {'name': 'fork', 'count': '512 826219'}, - {'name': 'execve', 'count': '025 461491'}, - {'name': 'socket', 'count': '838 475394'}, - {'name': 'connect', 'count': '632 094939'}, - {'name': 'accept', 'count': '417 205788'} - ] - -def get_kernel_subsystem_status(): - """Get real kernel subsystem status from /proc filesystem""" - try: - if platform.system() != 'Linux': - return get_mock_kernel_subsystems() - - subsystems = {} - - # 1. Memory Management - from /proc/meminfo - try: - with open('/proc/meminfo', 'r') as f: - meminfo = {} - for line in f: - if ':' in line: - key, value = line.split(':', 1) - meminfo[key.strip()] = value.strip() - - # Calculate memory usage percentage - mem_total_kb = int(meminfo.get('MemTotal', '0').replace(' kB', '')) - mem_available_kb = int(meminfo.get('MemAvailable', '0').replace(' kB', '')) - mem_free_kb = int(meminfo.get('MemFree', '0').replace(' kB', '')) - - if mem_total_kb > 0: - mem_used_kb = mem_total_kb - mem_available_kb - memory_usage = int((mem_used_kb / mem_total_kb) * 100) - else: - memory_usage = 0 - - # Count processes using memory (rough estimate from active pages) - active_kb = int(meminfo.get('Active', '0').replace(' kB', '')) - processes_estimate = max(10, min(100, active_kb // 50000)) # Rough estimate - - subsystems['memory_management'] = { - 'status': 'active', - 'usage': memory_usage, - 'processes': processes_estimate - } - except (IOError, ValueError, KeyError) as e: - print(f"Error reading meminfo: {e}") - subsystems['memory_management'] = { - 'status': 'active', - 'usage': 75, - 'processes': 25 - } - - # 2. Process Scheduler - from /proc/stat - try: - with open('/proc/stat', 'r') as f: - stat_data = {} - for line in f: - if line.startswith('cpu '): - parts = line.split() - # CPU stats: user, nice, system, idle, iowait, irq, softirq, steal, guest, guest_nice - if len(parts) >= 5: - user_time = int(parts[1]) - system_time = int(parts[3]) - idle_time = int(parts[4]) - total_time = user_time + system_time + idle_time - - if total_time > 0: - cpu_usage = int(((user_time + system_time) / total_time) * 100) - else: - cpu_usage = 0 - elif line.startswith('processes '): - total_processes = int(line.split()[1]) - elif line.startswith('ctxt '): - context_switches = int(line.split()[1]) - - # Estimate scheduler activity from context switches - # More context switches = more scheduler activity - scheduler_usage = min(100, max(50, cpu_usage)) - - # Get current running processes - try: - with open('/proc/loadavg', 'r') as f: - loadavg = f.read().strip().split() - running_processes = int(float(loadavg[3].split('/')[0])) - except: - running_processes = len(psutil.pids()) if 'psutil' in sys.modules else 50 - - subsystems['process_scheduler'] = { - 'status': 'active', - 'usage': scheduler_usage, - 'processes': running_processes - } - except (IOError, ValueError, KeyError) as e: - print(f"Error reading /proc/stat: {e}") - subsystems['process_scheduler'] = { - 'status': 'active', - 'usage': 85, - 'processes': 45 - } - - # 3. File System - from /proc/mounts and /proc/filesystems - try: - # Count mounted filesystems - with open('/proc/mounts', 'r') as f: - mount_count = len([line for line in f if line.strip() and not line.startswith('#')]) - - # Count filesystem types - with open('/proc/filesystems', 'r') as f: - fs_types = len([line for line in f if line.strip() and not line.startswith('#')]) - - # Estimate filesystem activity from I/O wait - try: - with open('/proc/stat', 'r') as f: - for line in f: - if line.startswith('cpu '): - parts = line.split() - if len(parts) >= 6: - iowait = int(parts[5]) - # Use iowait as indicator of filesystem activity - fs_usage = min(100, max(20, iowait // 100)) - else: - fs_usage = 60 - break - except: - fs_usage = 60 - - # Estimate processes using filesystem - fs_processes = max(5, min(50, mount_count * 2)) - - subsystems['file_system'] = { - 'status': 'active', - 'usage': fs_usage, - 'processes': fs_processes - } - except (IOError, ValueError) as e: - print(f"Error reading filesystem info: {e}") - subsystems['file_system'] = { - 'status': 'active', - 'usage': 60, - 'processes': 15 - } - - # 4. Network Stack - from /proc/net/sockstat and /proc/net/tcp - try: - network_usage = 30 - network_processes = 8 - - # Try to read socket statistics - try: - with open('/proc/net/sockstat', 'r') as f: - for line in f: - if line.startswith('TCP:'): - # Format: TCP: inuse 26 orphan 0 tw 44 alloc 28 mem 3 - parts = line.split() - # Find indices of key values - try: - inuse_idx = parts.index('inuse') + 1 if 'inuse' in parts else -1 - alloc_idx = parts.index('alloc') + 1 if 'alloc' in parts else -1 - - if inuse_idx > 0 and inuse_idx < len(parts): - tcp_inuse = int(parts[inuse_idx]) - else: - tcp_inuse = 0 - - if alloc_idx > 0 and alloc_idx < len(parts): - tcp_alloc = int(parts[alloc_idx]) - else: - tcp_alloc = tcp_inuse + 10 # Fallback - - if tcp_alloc > 0: - network_usage = min(100, max(20, int((tcp_inuse / tcp_alloc) * 100))) - else: - network_usage = 30 - - network_processes = max(8, min(50, tcp_inuse // 2)) - except (ValueError, IndexError): - # Fallback parsing - network_usage = 30 - network_processes = 12 - break - except FileNotFoundError: - # Fallback: count TCP connections from /proc/net/tcp - try: - with open('/proc/net/tcp', 'r') as f: - tcp_connections = len([line for line in f if line.strip() and not line.startswith('sl')]) - network_usage = min(100, max(20, tcp_connections // 10)) - network_processes = max(8, min(50, tcp_connections // 5)) - except: - pass - - subsystems['network_stack'] = { - 'status': 'active', - 'usage': network_usage, - 'processes': network_processes - } - except (IOError, ValueError) as e: - print(f"Error reading network info: {e}") - subsystems['network_stack'] = { - 'status': 'active', - 'usage': 50, - 'processes': 12 - } - - return subsystems - - except Exception as e: - print(f"Error getting subsystem status: {e}") - import traceback - traceback.print_exc() - return get_mock_kernel_subsystems() - -def get_mock_kernel_subsystems(): - """Mock data for kernel subsystems""" - return { - 'memory_management': {'status': 'active', 'usage': 75, 'processes': 25}, - 'process_scheduler': {'status': 'active', 'usage': 85, 'processes': 45}, - 'file_system': {'status': 'active', 'usage': 60, 'processes': 15}, - 'network_stack': {'status': 'active', 'usage': 50, 'processes': 12} - } - -def get_process_kernel_map(): - """Get process to kernel subsystem mapping""" - try: - if not OPENAI_AVAILABLE: - return get_mock_process_kernel_map() - - # Try to use OpenAI API - if not hasattr(openai, 'api_key') or not openai.api_key: - return get_mock_process_kernel_map() - - # Here would be OpenAI API logic - # For now return mock data - return get_mock_process_kernel_map() - - except Exception as e: - print(f"Error getting process map: {e}") - return get_mock_process_kernel_map() - -def get_mock_process_kernel_map(): - """Mock data for process mapping""" - return { - "systemd": ["kernel/sched/core.c", "kernel/time/timekeeping.c"], - "sshd": ["kernel/security/security.c", "kernel/audit/audit.c"], - "nginx": ["kernel/net/socket.c", "kernel/net/core/sock.c"], - "python3": ["kernel/fs/read_write.c", "kernel/mm/memory.c"], - "bash": ["kernel/exec.c", "kernel/fork.c"], - "cron": ["kernel/time/timer.c", "kernel/sched/clock.c"] - } - -def get_proc_matrix_data(): - """Build Matrix view data - processes and their resource usage""" - matrix = [] - - # Collect processes with required fields - processes = [] - for proc in psutil.process_iter( - ['pid', 'name', 'cpu_percent', 'memory_info', 'io_counters', 'num_fds'] - ): - try: - info = proc.info - pid = info['pid'] - - # CPU usage (may be 0 on first call) - cpu_percent = info.get('cpu_percent') or 0.0 - - # Memory: resident set size in MB - mem_mb = 0.0 - mem_info = info.get('memory_info') - if mem_info: - mem_mb = mem_info.rss / 1024 / 1024 - - # IO: sum of read/write bytes in MB - io_total_mb = 0.0 - io_counters = info.get('io_counters') - if io_counters: - io_total_mb = ( - io_counters.read_bytes + io_counters.write_bytes - ) / 1024 / 1024 - - # NET: count TCP entries from /proc/[pid]/net/tcp - net_connections = 0 - tcp_path = f'/proc/{pid}/net/tcp' - try: - if os.path.exists(tcp_path): - with open(tcp_path, 'r') as f: - lines = f.readlines() - # subtract header - net_connections = max(0, len(lines) - 1) - except (IOError, PermissionError): - pass - - # FD: number of file descriptors - num_fds = info.get('num_fds') or 0 - - processes.append({ - 'pid': pid, - 'name': info.get('name') or 'unknown', - 'cpu': float(cpu_percent), - 'mem': float(mem_mb), - 'io': float(io_total_mb), - 'net': int(net_connections), - 'fd': int(num_fds), - }) - except (psutil.NoSuchProcess, psutil.AccessDenied): - continue - - # Sort by CPU usage and take top 20 for clarity - processes.sort(key=lambda p: p['cpu'], reverse=True) - matrix = processes[:20] - - return matrix - -# API Endpoints - -@app.route('/') -def index(): - """Main page""" - # Serve index.html from root directory - return send_from_directory('.', 'index.html') - -@app.route('/linux-crypto-subsystem') -def linux_crypto_subsystem_page(): - """SEO-friendly Linux crypto subsystem page.""" - return render_template('linux-crypto-subsystem.html') - -@app.route('/crypto') -def crypto_page_legacy(): - """Legacy path redirect to Linux crypto subsystem page.""" - return redirect('/linux-crypto-subsystem', code=301) - -@app.route('/linux-security-subsystem') -def linux_security_subsystem_page(): - """SEO-friendly Linux security subsystem page.""" - return render_template('linux-security-subsystem.html') - -@app.route('/security') -def security_page_legacy(): - """Legacy path redirect to Linux security subsystem page.""" - return redirect('/linux-security-subsystem', code=301) - -@app.route('/linux-processes-subsystem') -def linux_processes_subsystem_page(): - """SEO-friendly Linux processes subsystem page.""" - return render_template('linux-processes-subsystem.html') - -@app.route('/processes') -def processes_page_legacy(): - """Legacy path redirect to Linux processes subsystem page.""" - return redirect('/linux-processes-subsystem', code=301) - - -@app.route('/linux-crypto-subsystem.html') -def linux_crypto_subsystem_html(): - return redirect('/linux-crypto-subsystem', code=301) - - -@app.route('/linux-security-subsystem.html') -def linux_security_subsystem_html(): - return redirect('/linux-security-subsystem', code=301) - - -@app.route('/linux-processes-subsystem.html') -def linux_processes_subsystem_html(): - return redirect('/linux-processes-subsystem', code=301) - - -@app.route('/linux-memory-subsystem') -def linux_memory_subsystem_page(): - """SEO-friendly Linux memory subsystem page.""" - return render_template('linux-memory-subsystem.html') - - -@app.route('/linux-memory-subsystem.html') -def linux_memory_subsystem_html(): - return redirect('/linux-memory-subsystem', code=301) - -@app.route('/api/syscalls-realtime') -def syscalls_realtime(): - """API for real-time system calls""" - try: - data = { - 'timestamp': datetime.now().isoformat(), - 'syscalls': get_real_system_calls(), - 'cpu_usage': psutil.cpu_percent(interval=1), - 'memory_usage': psutil.virtual_memory().percent, - 'system_info': get_system_info() - } - return jsonify(data) - except Exception as e: - return jsonify({'error': str(e)}), 500 - -@app.route('/api/kernel-data') -def kernel_data(): - """API for kernel data""" - try: - data = { - 'timestamp': datetime.now().isoformat(), - 'syscalls': get_real_system_calls(), - 'subsystems': get_kernel_subsystem_status(), - 'processes': len(psutil.pids()), - 'system_stats': { - 'cpu_count': psutil.cpu_count(), - 'memory_total': psutil.virtual_memory().total, - 'disk_usage': psutil.disk_usage('/').percent - } - } - return jsonify(data) - except Exception as e: - return jsonify({'error': str(e)}), 500 - -@app.route('/api/process-kernel-map') -def process_kernel_map(): - """API for process to kernel subsystem mapping""" - try: - data = get_process_kernel_map() - return jsonify(data) - except Exception as e: - return jsonify({'error': str(e)}), 500 - -@app.route('/api/processes') -def get_processes(): - """API for getting all Linux processes""" - try: - processes = [] - for proc in psutil.process_iter(['pid', 'name', 'status', 'memory_info']): - try: - memory_info = proc.info['memory_info'] - memory_mb = memory_info.rss / 1024 / 1024 # Convert to MB - processes.append({ - 'pid': proc.info['pid'], - 'name': proc.info['name'], - 'status': proc.info['status'], - 'memory_mb': round(memory_mb, 1) - }) - except (psutil.NoSuchProcess, psutil.AccessDenied): - continue - - return jsonify({'processes': processes}) - except Exception as e: - return jsonify({'error': str(e)}), 500 - -@app.route('/health') -def health_check(): - """Application health check""" - return jsonify({ - 'status': 'healthy', - 'timestamp': datetime.now().isoformat(), - 'system_info': get_system_info() - }) - -# Static files handling -@app.route('/static/') -def static_files(filename): - """Serve static files""" - return send_from_directory(app.config['STATIC_FOLDER'], filename) - -# Error handling -# Active connections functions -# Nginx files functions -def get_nginx_open_files(): - """Get open files for Nginx process""" - try: - import psutil - nginx_processes = [] - for proc in psutil.process_iter(["pid", "name", "open_files"]): - try: - if proc.info["name"] and "nginx" in proc.info["name"].lower(): - nginx_processes.append(proc.info["pid"]) - except (psutil.NoSuchProcess, psutil.AccessDenied): - continue - - if nginx_processes: - # Get open files for first nginx process - proc = psutil.Process(nginx_processes[0]) - open_files = proc.open_files() - - # Filter and format file paths - files = [] - for file in open_files: - if file.path: - # Extract relative path from full path - if "/etc/nginx/" in file.path: - rel_path = file.path.split("/etc/nginx/")[-1] - files.append({"path": f"nginx/{rel_path}", "type": "config"}) - elif "/var/log/nginx/" in file.path: - rel_path = file.path.split("/var/log/nginx/")[-1] - files.append({"path": f"nginx/logs/{rel_path}", "type": "log"}) - else: - files.append({"path": file.path, "type": "other"}) - - return files[:10] # Limit to 10 files - else: - return get_mock_nginx_files() - - except Exception as e: - print(f"Error getting nginx files: {e}") - return get_mock_nginx_files() - -def get_mock_nginx_files(): - """Mock data for nginx files""" - return [ - {"path": "nginx/nginx.conf", "type": "config"}, - {"path": "nginx/sites-enabled/default", "type": "config"}, - {"path": "nginx/conf.d/default.conf", "type": "config"}, - {"path": "nginx/logs/access.log", "type": "log"}, - {"path": "nginx/logs/error.log", "type": "log"} - ] - -@app.route("/api/nginx-files") -def nginx_files(): - """API for nginx open files""" - try: - files = get_nginx_open_files() - return jsonify({"files": files}) - except Exception as e: - return jsonify({"error": str(e)}), 500 -def get_active_connections(): - """Get active network connections""" - try: - connections = [] - # Get TCP connections - with open("/proc/net/tcp", "r") as f: - lines = f.readlines()[1:] # Skip header - for line in lines: - parts = line.strip().split() - if len(parts) >= 4: - local_addr = parts[1] - remote_addr = parts[2] - state = parts[3] - - # Convert hex addresses to readable format - # IP addresses in /proc/net/tcp are stored in little-endian format - def hex_to_ip(hex_str): - # Reverse the hex string to convert from little-endian - hex_bytes = [hex_str[i:i+2] for i in range(0, 8, 2)] - hex_bytes.reverse() - return ".".join([str(int(b, 16)) for b in hex_bytes]) - - local_ip = hex_to_ip(local_addr.split(":")[0]) - local_port = int(local_addr.split(":")[1], 16) - - if remote_addr != "00000000:0000": # Not listening - remote_ip = hex_to_ip(remote_addr.split(":")[0]) - remote_port = int(remote_addr.split(":")[1], 16) - - connections.append({ - "local": f"{local_ip}:{local_port}", - "remote": f"{remote_ip}:{remote_port}", - "state": state, - "type": "TCP" - }) - - # Limit to first 20 connections for display - return connections[:20] - - except Exception as e: - print(f"Error getting active connections: {e}") - return get_mock_active_connections() - -def get_mock_active_connections(): - """Mock data for active connections""" - return [ - {"local": "127.0.0.1:22", "remote": "192.168.1.100:54321", "state": "01", "type": "TCP"}, - {"local": "0.0.0.0:80", "remote": "10.0.0.50:12345", "state": "01", "type": "TCP"}, - {"local": "127.0.0.1:3306", "remote": "172.16.0.10:65432", "state": "01", "type": "TCP"}, - {"local": "0.0.0.0:443", "remote": "203.0.113.0:54321", "state": "01", "type": "TCP"}, - {"local": "127.0.0.1:5001", "remote": "192.168.1.101:12345", "state": "01", "type": "TCP"} - ] - -def _tcp_state_name(code): - states = { - "01": "ESTABLISHED", - "02": "SYN_SENT", - "03": "SYN_RECV", - "04": "FIN_WAIT1", - "05": "FIN_WAIT2", - "06": "TIME_WAIT", - "07": "CLOSE", - "08": "CLOSE_WAIT", - "09": "LAST_ACK", - "0A": "LISTEN", - "0B": "CLOSING" - } - return states.get(str(code).upper(), str(code).upper()) - -def _get_default_iface(): - try: - with open("/proc/net/route", "r") as f: - lines = f.readlines()[1:] - for line in lines: - parts = line.strip().split() - if len(parts) < 11: - continue - iface = parts[0] - destination = parts[1] - flags = int(parts[3], 16) - if destination == "00000000" and (flags & 0x2): - return iface - except (OSError, ValueError): - pass - # Fallback: first non-loopback interface. - try: - pernic = psutil.net_io_counters(pernic=True) - for iface in pernic.keys(): - if iface != "lo": - return iface - except Exception: - pass - return "lo" - -def _parse_netstat_tcpext(): - try: - with open("/proc/net/netstat", "r") as f: - lines = [line.strip() for line in f if line.strip()] - for i in range(0, len(lines) - 1, 2): - header = lines[i].split() - values = lines[i + 1].split() - if not header or header[0] != "TcpExt:": - continue - if not values or values[0] != "TcpExt:": - continue - fields = header[1:] - nums = values[1:] - if len(fields) != len(nums): - continue - mapping = {} - for name, val in zip(fields, nums): - try: - mapping[name] = int(val) - except ValueError: - mapping[name] = 0 - return mapping - except OSError: - return {} - return {} - -def _parse_snmp_section(section_name): - try: - with open("/proc/net/snmp", "r") as f: - lines = [line.strip() for line in f if line.strip()] - for i in range(0, len(lines) - 1, 2): - header = lines[i].split() - values = lines[i + 1].split() - expected_prefix = f"{section_name}:" - if not header or header[0] != expected_prefix: - continue - if not values or values[0] != expected_prefix: - continue - fields = header[1:] - nums = values[1:] - if len(fields) != len(nums): - continue - out = {} - for name, val in zip(fields, nums): - try: - out[name] = int(val) - except ValueError: - out[name] = 0 - return out - except OSError: - return {} - return {} - -def _get_ss_tcp_metrics(): - """Extract cwnd/rtt/retrans and tx queue from ss -tin (best effort).""" - ss_cmd = resolve_binary("ss") - if not ss_cmd: - return {} - try: - result = subprocess.run( - [ss_cmd, "-tin"], - capture_output=True, - text=True, - timeout=2, - check=False - ) - lines = (result.stdout or "").splitlines() - except (subprocess.TimeoutExpired, OSError): - return {} - - for idx, line in enumerate(lines): - if not line.strip().startswith("ESTAB"): - continue - metrics = {} - parts = line.split() - # ESTAB Recv-Q Send-Q Local:Port Peer:Port - if len(parts) >= 4: - try: - metrics["tx_queue"] = int(parts[2]) - metrics["rx_queue"] = int(parts[1]) - except ValueError: - pass - - details = lines[idx + 1] if (idx + 1) < len(lines) else "" - rtt_match = re.search(r'rtt:(\d+(?:\.\d+)?)/', details) - cwnd_match = re.search(r'cwnd:(\d+)', details) - retrans_match = re.search(r'retrans:(\d+)(?:/\d+)?', details) - if rtt_match: - metrics["rtt_ms"] = float(rtt_match.group(1)) - if cwnd_match: - metrics["cwnd"] = int(cwnd_match.group(1)) - if retrans_match: - metrics["retrans_now"] = int(retrans_match.group(1)) - if metrics: - return metrics - return {} - -def _read_diskstats(): - stats = {} - try: - with open("/proc/diskstats", "r") as f: - for line in f: - parts = line.split() - if len(parts) < 14: - continue - name = parts[2] - # Skip loop/ram for cleaner belt. - if name.startswith("loop") or name.startswith("ram"): - continue - try: - sectors_read = int(parts[5]) - sectors_written = int(parts[9]) - stats[name] = sectors_read + sectors_written - except ValueError: - continue - except OSError: - pass - return stats - -def _read_tty_irq_total(): - total = 0 - try: - with open("/proc/interrupts", "r") as f: - for line in f: - lower = line.lower() - if "tty" not in lower and "serial" not in lower: - continue - parts = line.split() - # Sum first CPU counters columns. - for token in parts[1:9]: - if token.isdigit(): - total += int(token) - except OSError: - pass - return total - -def _safe_read_text(path): - try: - with open(path, "r") as f: - return f.read().strip() - except OSError: - return None - -def _read_major_minor_from_devfile(devfile_path): - value = _safe_read_text(devfile_path) - if not value or ":" not in value: - return (None, None) - major_s, minor_s = value.split(":", 1) - try: - return (int(major_s), int(minor_s)) - except ValueError: - return (None, None) - -def _driver_from_symlink(base_path): - link_path = os.path.join(base_path, "device", "driver") - try: - if os.path.islink(link_path): - return os.path.basename(os.path.realpath(link_path)) - except OSError: - pass - return None - -def _detect_bus(sys_path, category): - if category == "net": - return "net" - real = "" - try: - real = os.path.realpath(sys_path).lower() - except OSError: - real = str(sys_path).lower() - if "/usb" in real: - return "usb" - if "/pci" in real: - return "pcie" - if "/virtual" in real: - return "virtual" - return "pcie" - -def _irq_total_for_tokens(interrupt_lines, tokens): - if not tokens: - return 0 - token_set = [t.lower() for t in tokens if t] - total = 0 - for line_lower, irq_total in interrupt_lines: - if any(tok in line_lower for tok in token_set): - total += irq_total - return total - -def _read_interrupt_lines(): - out = [] - try: - with open("/proc/interrupts", "r") as f: - for line in f: - if ":" not in line: - continue - raw = line.strip() - parts = raw.split() - if len(parts) < 2: - continue - irq_sum = 0 - for token in parts[1:]: - if token.isdigit(): - irq_sum += int(token) - else: - break - out.append((raw.lower(), irq_sum)) - except OSError: - pass - return out - -def _subsystem_for_category(category): - mapping = { - "block": "block -> VFS", - "net": "network -> net stack", - "char": "char -> tty/mem", - "misc": "misc -> kernel core", - "usb": "usb core -> usbfs", - "input": "input -> evdev", - "gpu": "drm -> graphics" - } - return mapping.get(category, "kernel core") - -def _user_interaction_for_category(category): - mapping = { - "block": "open/read/write/ioctl", - "net": "socket/send/recv", - "char": "read/write/ioctl", - "misc": "ioctl/control", - "usb": "udev/hotplug/ioctl", - "input": "events -> userspace", - "gpu": "drm ioctl/mmap" - } - return mapping.get(category, "syscall/ioctl") - -def _collect_block_devices(disk_now, dt): - devices = [] - for name, sectors_total in disk_now.items(): - prev = DEVICES_PREV["disk_sectors"].get(name) - delta_sectors = max(0, sectors_total - prev) if prev is not None else 0 - bps = (delta_sectors * 512) / dt - sys_path = os.path.join("/sys/block", name) - major, minor = _read_major_minor_from_devfile(os.path.join(sys_path, "dev")) - devices.append({ - "name": name, - "category": "block", - "bus": _detect_bus(sys_path, "block"), - "sys_path": sys_path, - "driver": _driver_from_symlink(sys_path), - "major": major, - "minor": minor, - "throughput_bps": bps, - "irq_tokens": [name], - "subsystem": _subsystem_for_category("block"), - "user_interaction": _user_interaction_for_category("block") - }) - return devices - -def _collect_net_devices(dt): - devices = [] - net_now = {} - try: - pernic = psutil.net_io_counters(pernic=True) - for iface, counters in pernic.items(): - total_bytes = counters.bytes_recv + counters.bytes_sent - net_now[iface] = total_bytes - prev = DEVICES_PREV["net_bytes"].get(iface) - delta = max(0, total_bytes - prev) if prev is not None else 0 - bps = delta / dt - sys_path = os.path.join("/sys/class/net", iface) - major, minor = _read_major_minor_from_devfile(os.path.join(sys_path, "dev")) - devices.append({ - "name": iface, - "category": "net", - "bus": "net", - "sys_path": sys_path, - "driver": _driver_from_symlink(sys_path), - "major": major, - "minor": minor, - "throughput_bps": bps, - "irq_tokens": [iface], - "errors": int(counters.errin + counters.errout), - "drops": int(counters.dropin + counters.dropout), - "subsystem": _subsystem_for_category("net"), - "user_interaction": _user_interaction_for_category("net") - }) - except Exception: - return [], {} - return devices, net_now - -def _collect_char_devices(): - devices = [] - seeds = [("tty0", "/sys/class/tty/tty0"), ("null", "/sys/devices/virtual/mem/null"), ("random", "/sys/devices/virtual/mem/random")] - for name, sys_path in seeds: - major, minor = _read_major_minor_from_devfile(os.path.join(sys_path, "dev")) - devices.append({ - "name": name, - "category": "char", - "bus": _detect_bus(sys_path, "char"), - "sys_path": sys_path, - "driver": _driver_from_symlink(sys_path), - "major": major, - "minor": minor, - "throughput_bps": 0.0, - "irq_tokens": [name, "tty"] if "tty" in name else [name], - "subsystem": _subsystem_for_category("char"), - "user_interaction": _user_interaction_for_category("char") - }) - return devices - -def _collect_misc_input_gpu_usb(): - out = [] - - misc_path = "/sys/class/misc" - if os.path.isdir(misc_path): - for name in sorted(os.listdir(misc_path))[:4]: - sys_path = os.path.join(misc_path, name) - major, minor = _read_major_minor_from_devfile(os.path.join(sys_path, "dev")) - out.append({ - "name": name, - "category": "misc", - "bus": _detect_bus(sys_path, "misc"), - "sys_path": sys_path, - "driver": _driver_from_symlink(sys_path), - "major": major, - "minor": minor, - "throughput_bps": 0.0, - "irq_tokens": [name], - "subsystem": _subsystem_for_category("misc"), - "user_interaction": _user_interaction_for_category("misc") - }) - - input_path = "/sys/class/input" - if os.path.isdir(input_path): - for name in sorted(os.listdir(input_path)): - if not name.startswith("event"): - continue - sys_path = os.path.join(input_path, name) - major, minor = _read_major_minor_from_devfile(os.path.join(sys_path, "dev")) - out.append({ - "name": name, - "category": "input", - "bus": _detect_bus(sys_path, "input"), - "sys_path": sys_path, - "driver": _driver_from_symlink(sys_path), - "major": major, - "minor": minor, - "throughput_bps": 0.0, - "irq_tokens": [name, "input"], - "subsystem": _subsystem_for_category("input"), - "user_interaction": _user_interaction_for_category("input") - }) - if len([d for d in out if d["category"] == "input"]) >= 4: - break - - drm_path = "/sys/class/drm" - if os.path.isdir(drm_path): - for name in sorted(os.listdir(drm_path)): - if not re.match(r"^card\d+$", name): - continue - sys_path = os.path.join(drm_path, name) - major, minor = _read_major_minor_from_devfile(os.path.join(sys_path, "dev")) - out.append({ - "name": name, - "category": "gpu", - "bus": _detect_bus(sys_path, "gpu"), - "sys_path": sys_path, - "driver": _driver_from_symlink(sys_path), - "major": major, - "minor": minor, - "throughput_bps": 0.0, - "irq_tokens": [name, "drm", "gpu"], - "subsystem": _subsystem_for_category("gpu"), - "user_interaction": _user_interaction_for_category("gpu") - }) - if len([d for d in out if d["category"] == "gpu"]) >= 2: - break - - usb_path = "/sys/bus/usb/devices" - if os.path.isdir(usb_path): - for name in sorted(os.listdir(usb_path)): - if ":" in name or name in ("usb1", "usb2", "usb3", "usb4"): - continue - sys_path = os.path.join(usb_path, name) - if not os.path.isdir(sys_path): - continue - out.append({ - "name": name, - "category": "usb", - "bus": "usb", - "sys_path": sys_path, - "driver": _driver_from_symlink(sys_path), - "major": None, - "minor": None, - "throughput_bps": 0.0, - "irq_tokens": [name, "usb"], - "subsystem": _subsystem_for_category("usb"), - "user_interaction": _user_interaction_for_category("usb") - }) - if len([d for d in out if d["category"] == "usb"]) >= 4: - break - - return out - -def get_devices_realtime(): - now = time.time() - prev_ts = DEVICES_PREV["timestamp"] - dt = max(0.001, now - prev_ts) if prev_ts else 1.0 - disk_now = _read_diskstats() - block_devices = _collect_block_devices(disk_now, dt) - net_devices, net_now = _collect_net_devices(dt) - char_devices = _collect_char_devices() - extra_devices = _collect_misc_input_gpu_usb() - - devices = block_devices + net_devices + char_devices + extra_devices - interrupt_lines = _read_interrupt_lines() - - max_bps = max([d.get("throughput_bps", 0.0) for d in devices] + [1.0]) - for d in devices: - key = f"{d.get('category','unknown')}::{d.get('name','unknown')}" - irq_total = _irq_total_for_tokens(interrupt_lines, d.get("irq_tokens", [])) - prev_irq = DEVICES_PREV["irq_by_key"].get(key) - irq_per_sec = 0.0 if prev_irq is None else max(0.0, (irq_total - prev_irq) / dt) - - throughput = float(d.get("throughput_bps", 0.0)) - synthetic = irq_per_sec * 4096.0 - weighted = max(throughput, synthetic) - d["throughput_bps"] = round(throughput, 2) - d["throughput_mb_s"] = round(throughput / (1024 * 1024), 4) - d["irq_total"] = int(irq_total) - d["irq_per_sec"] = round(irq_per_sec, 2) - d["load_norm"] = round(min(1.0, weighted / max_bps), 4) - d["layer_path"] = [ - "Physical layer", - "Driver layer", - "Kernel subsystem", - "User interaction" - ] - d["driver"] = d.get("driver") or "n/a" - - devices.sort(key=lambda d: (d.get("load_norm", 0.0), d.get("throughput_bps", 0.0), d.get("irq_per_sec", 0.0)), reverse=True) - top_devices = devices[:20] - - DEVICES_PREV["timestamp"] = now - DEVICES_PREV["disk_sectors"] = disk_now - DEVICES_PREV["net_bytes"] = net_now - DEVICES_PREV["tty_irq_total"] = _read_tty_irq_total() - DEVICES_PREV["irq_by_key"] = { - f"{d.get('category','unknown')}::{d.get('name','unknown')}": d.get("irq_total", 0) - for d in top_devices - } - - bus_counts = {"pcie": 0, "usb": 0, "virtual": 0, "net": 0} - category_counts = {} - for d in top_devices: - bus_counts[d.get("bus", "pcie")] = bus_counts.get(d.get("bus", "pcie"), 0) + 1 - c = d.get("category", "unknown") - category_counts[c] = category_counts.get(c, 0) + 1 - - return { - "timestamp": datetime.now().isoformat(), - "layout": { - "name": "Hardware Bus Map", - "layers": ["Physical layer", "Driver layer", "Kernel subsystem", "User interaction"], - "buses": ["pcie", "usb", "virtual", "net"] - }, - "devices": top_devices, - "meta": { - "count": len(top_devices), - "max_throughput_bps": round(max_bps, 2), - "bus_counts": bus_counts, - "category_counts": category_counts - } - } - -def get_filesystem_blocks(): - now = time.time() - try: - usage = psutil.disk_usage("/") - except Exception: - usage = None - - used_percent = float(usage.percent) if usage else 0.0 - total_gb = round((usage.total / (1024 ** 3)), 2) if usage else 0.0 - used_gb = round((usage.used / (1024 ** 3)), 2) if usage else 0.0 - free_gb = round((usage.free / (1024 ** 3)), 2) if usage else 0.0 - - io = psutil.disk_io_counters() - write_bytes = int(io.write_bytes) if io else 0 - prev_ts = FILESYSTEM_PREV["timestamp"] - prev_write = FILESYSTEM_PREV["write_bytes"] - dt = max(0.001, now - prev_ts) if prev_ts else 1.0 - write_bps = 0.0 if prev_write is None else max(0.0, (write_bytes - prev_write) / dt) - - rows = 20 - cols = 34 - total_blocks = rows * cols - used_ratio_global = max(0.0, min(1.0, used_percent / 100.0)) - - # Logical filesystem zones for a visible map layout. - zone_defs = [ - {"id": "root", "name": "/", "path": "/", "base": 1.5, "bias": 0.00}, - {"id": "var", "name": "/var", "path": "/var", "base": 1.6, "bias": 0.10}, - {"id": "home", "name": "/home", "path": "/home", "base": 1.35, "bias": 0.06}, - {"id": "usr", "name": "/usr", "path": "/usr", "base": 1.45, "bias": 0.08}, - {"id": "etc", "name": "/etc", "path": "/etc", "base": 1.0, "bias": -0.03}, - {"id": "tmp", "name": "/tmp", "path": "/tmp", "base": 1.0, "bias": -0.02}, - {"id": "dev", "name": "/dev", "path": "/dev", "base": 0.85, "bias": -0.06}, - ] - - activity_counts = {z["id"]: 0 for z in zone_defs} - - # Best-effort activity sampling from open file descriptors by path prefix. - try: - processes = list(psutil.process_iter(["pid"]))[:90] - zone_paths = sorted([(z["path"], z["id"]) for z in zone_defs], key=lambda x: len(x[0]), reverse=True) - for proc in processes: - try: - open_files = proc.open_files()[:28] - except (psutil.AccessDenied, psutil.NoSuchProcess, psutil.ZombieProcess, OSError): - continue - for of in open_files: - fpath = str(getattr(of, "path", "") or "") - if not fpath.startswith("/"): - continue - for prefix, zone_id in zone_paths: - if prefix == "/": - continue - if fpath == prefix or fpath.startswith(prefix + "/"): - activity_counts[zone_id] += 1 - break - else: - activity_counts["root"] += 1 - except Exception: - pass - - weighted = [] - for z in zone_defs: - act = float(activity_counts.get(z["id"], 0)) - z["activity"] = act - weighted.append(max(0.2, z["base"] + act * 0.08)) - - total_weight = sum(weighted) or 1.0 - row_counts = [max(1, int(round(rows * w / total_weight))) for w in weighted] - # Normalize row counts to exact total rows. - while sum(row_counts) > rows: - idx = max(range(len(row_counts)), key=lambda i: row_counts[i]) - if row_counts[idx] > 1: - row_counts[idx] -= 1 - else: - break - while sum(row_counts) < rows: - idx = max(range(len(weighted)), key=lambda i: weighted[i]) - row_counts[idx] += 1 - - writing_ratio = min(0.20, write_bps / (300 * 1024 * 1024)) - writing_blocks_total = int(round(total_blocks * writing_ratio)) - writing_blocks_total = max(0, min(total_blocks, writing_blocks_total)) - - blocks = [] - zones = [] - cursor_row = 0 - zone_used_total = 0 - zone_writing_total = 0 - zone_scores = [] - for z in zone_defs: - zone_scores.append(z["activity"] + 1.0) - score_sum = sum(zone_scores) or 1.0 - - seed = int(now * 3) - for idx, z in enumerate(zone_defs): - row_span = row_counts[idx] - row_start = cursor_row - row_end = min(rows - 1, cursor_row + row_span - 1) - cursor_row += row_span - - zone_cells = max(1, (row_end - row_start + 1) * cols) - local_used_ratio = max(0.05, min(0.98, used_ratio_global + z["bias"] + min(0.18, z["activity"] / 120.0))) - zone_used = int(round(zone_cells * local_used_ratio)) - zone_used = max(0, min(zone_cells, zone_used)) - zone_used_total += zone_used - - zone_write_share = zone_scores[idx] / score_sum - zone_writing = int(round(writing_blocks_total * zone_write_share)) - zone_writing = max(0, min(zone_used, zone_writing)) - zone_writing_total += zone_writing - inode_pressure = int(max(0, min( - 100, - round((z["activity"] * 2.6) + (zone_writing * 0.9) + (local_used_ratio * 38.0)) - ))) - - cell_index = 0 - for r in range(row_start, row_end + 1): - for c in range(cols): - state = "used" if cell_index < zone_used else "free" - blocks.append({ - "r": r, - "c": c, - "i": r * cols + c, - "zone_id": z["id"], - "state": state - }) - cell_index += 1 - - if zone_writing > 0 and zone_used > 0: - # Convert some used cells into writing cells within this zone segment. - zone_block_indices = [ - i for i, b in enumerate(blocks) - if b["zone_id"] == z["id"] and b["state"] == "used" - ] - used_len = len(zone_block_indices) - for n in range(min(zone_writing, used_len)): - pick = (seed * 31 + idx * 67 + n * 43) % used_len - blocks[zone_block_indices[pick]]["state"] = "writing" - - zones.append({ - "id": z["id"], - "name": z["name"], - "path": z["path"], - "row_start": row_start, - "row_end": row_end, - "activity": int(z["activity"]), - "used_percent": round(local_used_ratio * 100.0, 1), - "writing_blocks": zone_writing, - "inode_pressure": inode_pressure - }) - - writing_blocks = sum(1 for b in blocks if b["state"] == "writing") - - FILESYSTEM_PREV["timestamp"] = now - FILESYSTEM_PREV["write_bytes"] = write_bytes - - inode_pressure_global = 0 - if zones: - inode_pressure_global = int(round(sum(int(z.get("inode_pressure", 0)) for z in zones) / len(zones))) - - return { - "timestamp": datetime.now().isoformat(), - "rows": rows, - "cols": cols, - "zones": zones, - "blocks": blocks, - "meta": { - "total_gb": total_gb, - "used_gb": used_gb, - "free_gb": free_gb, - "used_percent": round(used_percent, 2), - "write_bps": round(write_bps, 2), - "writing_blocks": writing_blocks, - "inode_pressure": inode_pressure_global - } - } - -def get_network_stack_realtime(): - now = time.time() - iface = _get_default_iface() - pernic = psutil.net_io_counters(pernic=True) - iface_stats = pernic.get(iface) - all_connections = get_active_connections() - interesting = [ - c for c in all_connections - if not c["remote"].startswith("127.0.0.1") and not c["remote"].startswith("0.0.0.0") - ] - flow = interesting[0] if interesting else (all_connections[0] if all_connections else None) - if flow: - flow = { - "local": flow.get("local"), - "remote": flow.get("remote"), - "type": str(flow.get("type", "TCP")).upper(), - "state_code": flow.get("state", "00"), - "state_name": _tcp_state_name(flow.get("state", "00")) - } - - tcpext = _parse_netstat_tcpext() - ip_stats = _parse_snmp_section("Ip") - tcp_stats = _parse_snmp_section("Tcp") - ss_metrics = _get_ss_tcp_metrics() - - retrans_total = tcpext.get("RetransSegs", 0) - ip_in_total = ip_stats.get("InReceives", 0) - ip_out_total = ip_stats.get("OutRequests", 0) - ip_discards_total = ip_stats.get("InDiscards", 0) + ip_stats.get("OutDiscards", 0) - - established = 0 - try: - with open("/proc/net/tcp", "r") as f: - for line in f.readlines()[1:]: - parts = line.strip().split() - if len(parts) >= 4 and parts[3] == "01": - established += 1 - except OSError: - established = 0 - - prev_ts = NETWORK_STACK_PREV["timestamp"] - dt = max(0.001, now - prev_ts) if prev_ts else 1.0 - - def rate(curr, prev): - if prev is None: - return 0.0 - return max(0.0, (curr - prev) / dt) - - retrans_per_sec = rate(retrans_total, NETWORK_STACK_PREV["tcpext_retrans"]) - ip_in_per_sec = rate(ip_in_total, NETWORK_STACK_PREV["ip_in"]) - ip_out_per_sec = rate(ip_out_total, NETWORK_STACK_PREV["ip_out"]) - ip_drop_per_sec = rate(ip_discards_total, NETWORK_STACK_PREV["ip_discards"]) - - rx_per_sec = 0.0 - tx_per_sec = 0.0 - iface_drop_per_sec = 0.0 - rx_bytes = iface_stats.bytes_recv if iface_stats else 0 - tx_bytes = iface_stats.bytes_sent if iface_stats else 0 - iface_drops = (iface_stats.dropin + iface_stats.dropout) if iface_stats else 0 - if NETWORK_STACK_PREV["iface_rx"] is not None: - rx_per_sec = max(0.0, (rx_bytes - NETWORK_STACK_PREV["iface_rx"]) / dt) - if NETWORK_STACK_PREV["iface_tx"] is not None: - tx_per_sec = max(0.0, (tx_bytes - NETWORK_STACK_PREV["iface_tx"]) / dt) - if NETWORK_STACK_PREV["iface_drops"] is not None: - iface_drop_per_sec = max(0.0, (iface_drops - NETWORK_STACK_PREV["iface_drops"]) / dt) - - NETWORK_STACK_PREV["timestamp"] = now - NETWORK_STACK_PREV["tcpext_retrans"] = retrans_total - NETWORK_STACK_PREV["ip_in"] = ip_in_total - NETWORK_STACK_PREV["ip_out"] = ip_out_total - NETWORK_STACK_PREV["ip_discards"] = ip_discards_total - NETWORK_STACK_PREV["iface_rx"] = rx_bytes - NETWORK_STACK_PREV["iface_tx"] = tx_bytes - NETWORK_STACK_PREV["iface_drops"] = iface_drops - - packets_per_sec = ip_in_per_sec + ip_out_per_sec - drop_ratio = (ip_drop_per_sec / packets_per_sec) if packets_per_sec > 0 else 0.0 - throughput_mb_s = (rx_per_sec + tx_per_sec) / (1024 * 1024) - - retrans_prob = min(0.75, retrans_per_sec / 600.0) - drop_prob = min(0.75, (ip_drop_per_sec / 500.0) + (drop_ratio * 8.0)) - packet_speed = max(1.4, min(4.8, 1.8 + throughput_mb_s / 8.0)) - - socket_activity = min(1.0, (len(all_connections) / 80.0) + (retrans_per_sec / 250.0)) - tcp_activity = min(1.0, (ss_metrics.get("cwnd", 0) / 80.0) + (retrans_per_sec / 300.0)) - ip_activity = min(1.0, packets_per_sec / 15000.0) - netfilter_activity = min(1.0, (ip_drop_per_sec / 120.0) + (drop_ratio * 6.0)) - driver_activity = min(1.0, ((rx_per_sec + tx_per_sec) / (60 * 1024 * 1024)) + (iface_drop_per_sec / 40.0)) - nic_activity = min(1.0, ((rx_per_sec + tx_per_sec) / (80 * 1024 * 1024))) - - return { - "timestamp": datetime.now().isoformat(), - "flow": flow, - "layer_metrics": { - "userspace": { - "active_processes": len(psutil.pids()) - }, - "socket_api": { - "active_sockets": len(all_connections), - "established": established, - "retransmits_per_sec": round(retrans_per_sec, 2) - }, - "tcp_udp": { - "established": established, - "retrans_per_sec": round(retrans_per_sec, 2), - "cwnd": int(ss_metrics.get("cwnd", 0)), - "rtt_ms": round(float(ss_metrics.get("rtt_ms", 0.0)), 2), - "tx_queue": int(ss_metrics.get("tx_queue", 0)) - }, - "ip": { - "in_packets_per_sec": round(ip_in_per_sec, 2), - "out_packets_per_sec": round(ip_out_per_sec, 2), - "drop_per_sec": round(ip_drop_per_sec, 3), - "drop_ratio": round(drop_ratio, 5) - }, - "netfilter": { - "drop_per_sec": round(ip_drop_per_sec, 3), - "drop_ratio": round(drop_ratio, 5) - }, - "driver": { - "iface": iface, - "rx_mb_s": round(rx_per_sec / (1024 * 1024), 3), - "tx_mb_s": round(tx_per_sec / (1024 * 1024), 3), - "tx_queue": int(ss_metrics.get("tx_queue", 0)), - "drops_per_sec": round(iface_drop_per_sec, 3) - }, - "nic": { - "iface": iface, - "rx_errors": int(getattr(iface_stats, "errin", 0)) if iface_stats else 0, - "tx_errors": int(getattr(iface_stats, "errout", 0)) if iface_stats else 0, - "drops_total": int(iface_drops) - } - }, - "layer_activity": { - "userspace": min(1.0, len(psutil.pids()) / 400.0), - "socket": round(socket_activity, 4), - "tcp": round(tcp_activity, 4), - "ip": round(ip_activity, 4), - "netfilter": round(netfilter_activity, 4), - "driver": round(driver_activity, 4), - "nic": round(nic_activity, 4) - }, - "signals": { - "drop_probability": round(drop_prob, 4), - "retransmit_probability": round(retrans_prob, 4), - "packet_speed": round(packet_speed, 3) - }, - "throughput_mb_s": round(throughput_mb_s, 3), - "tcp_counters": { - "in_segs": int(tcp_stats.get("InSegs", 0)), - "out_segs": int(tcp_stats.get("OutSegs", 0)), - "retrans_segs_total": int(retrans_total) - } - } - -def _safe_read_text(path): - try: - with open(path, "r") as f: - return f.read().strip() - except (OSError, PermissionError): - return None - -def _parse_cgroup_path(pid): - cgroup_text = _safe_read_text(f"/proc/{pid}/cgroup") - if not cgroup_text: - return "/" - chosen = "/" - for line in cgroup_text.splitlines(): - parts = line.split(":") - if len(parts) != 3: - continue - _, controllers, path = parts - path = path.strip() or "/" - # Prefer cgroup v2 unified hierarchy entry "0::/path" - if controllers == "": - return path - if path and path != "/": - chosen = path - return chosen - -def _read_namespace_inode(pid, ns_name): - ns_link = f"/proc/{pid}/ns/{ns_name}" - try: - target = os.readlink(ns_link) - except (OSError, PermissionError): - return None - match = re.search(r'\[(\d+)\]', target) - return match.group(1) if match else target - -def _read_cgroup_v2_stats(cgroup_path): - root = "/sys/fs/cgroup" - rel = cgroup_path.lstrip("/") - base = os.path.join(root, rel) if rel else root - - cpu_max_text = _safe_read_text(os.path.join(base, "cpu.max")) - cpu_quota_cores = None - if cpu_max_text: - parts = cpu_max_text.split() - if len(parts) >= 2 and parts[0] != "max": - try: - quota = float(parts[0]) - period = float(parts[1]) - if period > 0: - cpu_quota_cores = round(quota / period, 2) - except ValueError: - cpu_quota_cores = None - - mem_current = _safe_read_text(os.path.join(base, "memory.current")) - mem_max = _safe_read_text(os.path.join(base, "memory.max")) - pids_current = _safe_read_text(os.path.join(base, "pids.current")) - pids_max = _safe_read_text(os.path.join(base, "pids.max")) - io_stat_text = _safe_read_text(os.path.join(base, "io.stat")) - - memory_current_mb = None - memory_max_mb = None - try: - if mem_current is not None: - memory_current_mb = round(int(mem_current) / (1024 * 1024), 1) - except ValueError: - memory_current_mb = None - - try: - if mem_max and mem_max != "max": - memory_max_mb = round(int(mem_max) / (1024 * 1024), 1) - except ValueError: - memory_max_mb = None - - io_bytes = None - if io_stat_text: - total = 0 - for line in io_stat_text.splitlines(): - rbytes_match = re.search(r'rbytes=(\d+)', line) - wbytes_match = re.search(r'wbytes=(\d+)', line) - if rbytes_match: - total += int(rbytes_match.group(1)) - if wbytes_match: - total += int(wbytes_match.group(1)) - io_bytes = total - - return { - "cpu_quota_cores": cpu_quota_cores, - "memory_current_mb": memory_current_mb, - "memory_max_mb": memory_max_mb, - "pids_current": int(pids_current) if pids_current and pids_current.isdigit() else None, - "pids_max": None if pids_max in (None, "max") else (int(pids_max) if pids_max.isdigit() else None), - "io_total_mb": round(io_bytes / (1024 * 1024), 1) if io_bytes is not None else None - } - -def get_isolation_context(): - """Aggregate namespace and cgroup context for UI layer.""" - namespace_keys = ["mnt", "pid", "net", "ipc", "uts", "user"] - namespace_labels = { - "mnt": "MNT", - "pid": "PID", - "net": "NET", - "ipc": "IPC", - "uts": "UTS", - "user": "USER" - } - namespace_counts = {k: {} for k in namespace_keys} - cgroup_aggregates = {} - total_scanned = 0 - - for proc in psutil.process_iter(["pid", "name", "memory_info"]): - try: - pid = proc.info["pid"] - total_scanned += 1 - - cgroup_path = _parse_cgroup_path(pid) - agg = cgroup_aggregates.setdefault(cgroup_path, { - "path": cgroup_path, - "process_count": 0, - "memory_mb_sum": 0.0, - "sample_processes": [] - }) - agg["process_count"] += 1 - - mem_info = proc.info.get("memory_info") - if mem_info: - agg["memory_mb_sum"] += (mem_info.rss / (1024 * 1024)) - - if len(agg["sample_processes"]) < 4: - process_name = proc.info.get("name") or "unknown" - agg["sample_processes"].append(process_name) - - for ns_name in namespace_keys: - inode = _read_namespace_inode(pid, ns_name) - if inode: - ns_map = namespace_counts[ns_name] - ns_map[inode] = ns_map.get(inode, 0) + 1 - except (psutil.NoSuchProcess, psutil.AccessDenied, KeyError): - continue - - namespaces = [] - for ns_name in namespace_keys: - entries = namespace_counts[ns_name] - unique_count = len(entries) - dominant_inode = None - dominant_count = 0 - if entries: - dominant_inode, dominant_count = max(entries.items(), key=lambda kv: kv[1]) - activity = round((dominant_count / total_scanned), 3) if total_scanned > 0 else 0 - namespaces.append({ - "id": ns_name, - "label": namespace_labels[ns_name], - "unique_count": unique_count, - "dominant_inode": dominant_inode, - "dominant_count": dominant_count, - "activity": activity - }) - - top_cgroups = sorted( - cgroup_aggregates.values(), - key=lambda x: (x["process_count"], x["memory_mb_sum"]), - reverse=True - )[:4] - - for item in top_cgroups: - stats = _read_cgroup_v2_stats(item["path"]) - item["memory_mb_sum"] = round(item["memory_mb_sum"], 1) - item.update(stats) - - return { - "timestamp": datetime.now().isoformat(), - "processes_scanned": total_scanned, - "namespaces": namespaces, - "top_cgroups": top_cgroups - } - -def get_route_hint(remote_ip): - """Fallback path hint using Linux routing table when traceroute tools are absent.""" - ip_cmd = resolve_binary("ip") - if not ip_cmd: - return { - "remote_ip": remote_ip, - "tool": None, - "reached": False, - "hop_count": 0, - "hops": [], - "note": "Path tools unavailable on host" - } - - try: - result = subprocess.run( - [ip_cmd, "-o", "route", "get", remote_ip], - capture_output=True, - text=True, - timeout=2, - check=False - ) - line = (result.stdout or "").strip() - if not line: - return { - "remote_ip": remote_ip, - "tool": "ip-route", - "reached": False, - "hop_count": 0, - "hops": [], - "note": "No route information available" - } - - via_match = re.search(r'\svia\s(\d{1,3}(?:\.\d{1,3}){3})', line) - dev_match = re.search(r'\sdev\s([A-Za-z0-9_.:-]+)', line) - src_match = re.search(r'\ssrc\s(\d{1,3}(?:\.\d{1,3}){3})', line) - - hops = [] - if via_match: - hops.append({ - "hop": 1, - "target": via_match.group(1), - "rtt_ms": None - }) - hops.append({ - "hop": 2, - "target": remote_ip, - "rtt_ms": None - }) - else: - hops.append({ - "hop": 1, - "target": remote_ip, - "rtt_ms": None - }) - - note_parts = ["Traceroute not installed, showing kernel route hint"] - if dev_match: - note_parts.append(f"dev={dev_match.group(1)}") - if src_match: - note_parts.append(f"src={src_match.group(1)}") - - return { - "remote_ip": remote_ip, - "tool": "ip-route", - "reached": False, - "hop_count": len(hops), - "hops": hops, - "note": ", ".join(note_parts) - } - except (subprocess.TimeoutExpired, OSError): - return { - "remote_ip": remote_ip, - "tool": "ip-route", - "reached": False, - "hop_count": 0, - "hops": [], - "note": "Route hint lookup timed out" - } - -def get_traceroute_info(remote_ip, max_hops=8): - """Get traceroute/tracepath information for a remote IP with short timeout.""" - try: - target_ip = ipaddress.ip_address(remote_ip) - # Skip loopback/local addresses - traceroute is not meaningful here. - if target_ip.is_loopback or target_ip.is_unspecified: - return { - "remote_ip": remote_ip, - "tool": None, - "reached": False, - "hop_count": 0, - "hops": [], - "note": "Local address, traceroute skipped" - } - except ValueError: - raise ValueError("Invalid IP address") - - now = time.time() - cached = TRACEROUTE_CACHE.get(remote_ip) - if cached and (now - cached["timestamp"]) < TRACEROUTE_CACHE_TTL_SECONDS: - return cached["data"] - traceroute_cmd = resolve_binary("traceroute") - tracepath_cmd = resolve_binary("tracepath") - cmd = None - tool = None - if traceroute_cmd: - cmd = [traceroute_cmd, "-n", "-m", str(max_hops), "-q", "1", "-w", "1", remote_ip] - tool = "traceroute" - elif tracepath_cmd: - cmd = [tracepath_cmd, "-n", "-m", str(max_hops), remote_ip] - tool = "tracepath" - else: - data = get_route_hint(remote_ip) - TRACEROUTE_CACHE[remote_ip] = {"timestamp": now, "data": data} - return data - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=7, - check=False - ) - output = (result.stdout or "").strip() - if not output and result.stderr: - output = result.stderr.strip() - except subprocess.TimeoutExpired: - return { - "remote_ip": remote_ip, - "tool": tool, - "reached": False, - "hop_count": 0, - "hops": [], - "note": "Traceroute timed out" - } - hops = [] - for raw_line in output.splitlines(): - line = raw_line.strip() - hop_match = re.match(r'^(\d+)\s+', line) - if not hop_match: - tracepath_match = re.match(r'^(\d+):\s+', line) - if not tracepath_match: - continue - hop_idx = int(tracepath_match.group(1)) - else: - hop_idx = int(hop_match.group(1)) - if "*" in line and re.search(r'\*\s*\*\s*\*', line): - hops.append({ - "hop": hop_idx, - "target": "*", - "rtt_ms": None - }) - continue - ip_match = re.search(r'(\d{1,3}(?:\.\d{1,3}){3})', line) - rtt_match = re.search(r'(\d+(?:\.\d+)?)\s*ms', line) - hops.append({ - "hop": hop_idx, - "target": ip_match.group(1) if ip_match else "?", - "rtt_ms": float(rtt_match.group(1)) if rtt_match else None - }) - reached = any(h.get("target") == remote_ip for h in hops) - data = { - "remote_ip": remote_ip, - "tool": tool, - "reached": reached, - "hop_count": len(hops), - "hops": hops[:max_hops], - "note": None - } - TRACEROUTE_CACHE[remote_ip] = {"timestamp": now, "data": data} - return data - -@app.route("/api/active-connections") -def active_connections(): - """API for active network connections""" - try: - connections = get_active_connections() - return jsonify({"connections": connections}) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route("/api/traceroute") -def traceroute_info(): - """API endpoint for traceroute path to remote IP.""" - try: - remote_ip = request.args.get("ip", "").strip() - if not remote_ip: - return jsonify({"error": "Missing 'ip' query parameter"}), 400 - - data = get_traceroute_info(remote_ip) - return jsonify(data) - except ValueError as e: - return jsonify({"error": str(e)}), 400 - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route("/api/network-stack-realtime") -def network_stack_realtime(): - """Live telemetry for Network Stack visualization.""" - try: - return jsonify(get_network_stack_realtime()) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route("/api/devices-realtime") -def devices_realtime(): - """Live telemetry for Devices belt visualization.""" - try: - return jsonify(get_devices_realtime()) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route("/api/filesystem-blocks") -def filesystem_blocks(): - """Live block-map style filesystem telemetry.""" - try: - return jsonify(get_filesystem_blocks()) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route("/api/isolation-context") -def isolation_context(): - """API endpoint for cgroups + namespaces design layer.""" - try: - return jsonify(get_isolation_context()) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route("/api/process//threads") -def get_process_threads(pid): - """API for getting thread information for a specific process""" - try: - thread_info = get_process_threads_info(pid) - return jsonify(thread_info) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route("/api/process//cpu") -def get_process_cpu(pid): - """API for getting CPU statistics for a specific process""" - try: - cpu_info = get_process_cpu_info(pid) - return jsonify(cpu_info) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route("/api/process//fds") -def get_process_fds(pid): - """API for getting file descriptors for a specific process""" - try: - fds_info = get_process_fds_info(pid) - return jsonify(fds_info) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route("/api/processes-detailed") -def get_processes_detailed(): - """API for getting all processes with detailed information (threads, CPU, FDs)""" - try: - processes = [] - for proc in psutil.process_iter(['pid', 'name', 'status', 'memory_info', 'cpu_percent', 'num_threads', 'num_fds']): - try: - memory_info = proc.info.get('memory_info') - if memory_info is None: - # Some transient/zombie processes may have incomplete info. - continue - memory_mb = float(memory_info.rss) / 1024 / 1024 - - # Get num_fds with fallback - num_fds = proc.info.get('num_fds') - if num_fds is None or num_fds == 0: - # Try to count from /proc/[pid]/fd directly - try: - pid = proc.info['pid'] - fd_dir = f'/proc/{pid}/fd' - if os.path.exists(fd_dir): - num_fds = len([f for f in os.listdir(fd_dir) if f.isdigit()]) - else: - num_fds = 0 - except (OSError, PermissionError): - num_fds = 0 - if num_fds is None: - num_fds = 0 - - # Get process name - use cmdline for nginx to get full name like "nginx: master process" - process_name = proc.info.get('name') or f'pid-{proc.info.get("pid", "unknown")}' - try: - cmdline = proc.cmdline() - if cmdline and len(cmdline) > 0: - # For nginx, cmdline[0] is "nginx:" and we want the full description - if cmdline[0] == 'nginx:' and len(cmdline) > 1: - process_name = f"nginx: {cmdline[1]}" - except (psutil.AccessDenied, psutil.NoSuchProcess): - pass - - # Get cmdline for better process identification - cmdline_str = '' - try: - cmdline = proc.cmdline() - if cmdline: - cmdline_str = ' '.join(cmdline) - except (psutil.AccessDenied, psutil.NoSuchProcess): - pass - - processes.append({ - 'pid': proc.info['pid'], - 'name': process_name, - 'cmdline': cmdline_str, # Add cmdline for better identification - 'status': proc.info.get('status', 'unknown'), - 'memory_mb': round(memory_mb, 1), - 'cpu_percent': round(float(proc.info.get('cpu_percent', 0) or 0), 1), - 'num_threads': int(proc.info.get('num_threads', 0) or 0), - 'num_fds': int(num_fds or 0) - }) - except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): - continue - except Exception: - # Never fail the whole endpoint because of one malformed/transient process. - continue - - return jsonify({'processes': processes}) - except Exception as e: - return jsonify({'error': str(e)}), 500 - - -def get_ipc_links_summary(max_pairs=120, max_nodes=24): - """Collect IPC relationships by shared sockets, pipes and shared memory mappings.""" - socket_inode_re = re.compile(r"^socket:\[(\d+)\]$") - pipe_inode_re = re.compile(r"^pipe:\[(\d+)\]$") - # /proc//maps sample: - # address perms offset dev inode pathname - # We use mappings with shared perms (e.g. rw-s) and real inode/path. - socket_owners = {} - pipe_owners = {} - shm_owners = {} - namespace_keys = ["mnt", "pid", "net", "ipc", "uts", "user"] - namespace_owners = {} - - for proc_dir in os.listdir("/proc"): - if not proc_dir.isdigit(): - continue - pid = int(proc_dir) - try: - with open(f"/proc/{pid}/comm", "r", encoding="utf-8", errors="replace") as f: - proc_name = f.read().strip() - except (OSError, PermissionError): - continue - if not proc_name: - continue - - fd_dir = f"/proc/{pid}/fd" - try: - fd_entries = os.listdir(fd_dir) - except (OSError, PermissionError): - continue - - for fd_entry in fd_entries: - fd_path = f"{fd_dir}/{fd_entry}" - try: - target = os.readlink(fd_path) - except (OSError, PermissionError): - continue - - sm = socket_inode_re.match(target) - if sm: - inode = int(sm.group(1)) - socket_owners.setdefault(inode, set()).add((pid, proc_name)) - continue - - pm = pipe_inode_re.match(target) - if pm: - inode = int(pm.group(1)) - pipe_owners.setdefault(inode, set()).add((pid, proc_name)) - - maps_path = f"/proc/{pid}/maps" - try: - with open(maps_path, "r", encoding="utf-8", errors="replace") as maps_file: - for map_line in maps_file: - parts = map_line.strip().split(None, 5) - if len(parts) < 5: - continue - perms = parts[1] - dev = parts[3] - inode_text = parts[4] - map_path = parts[5] if len(parts) > 5 else "" - - if len(perms) < 4 or perms[3] != "s": - continue - if not inode_text.isdigit(): - continue - inode = int(inode_text) - if inode <= 0: - continue - if not map_path: - continue - # Skip anonymous pseudo-regions like [heap], [stack], [anon] - if map_path.startswith("["): - continue - - shm_key = f"{dev}:{inode}:{map_path}" - shm_owners.setdefault(shm_key, set()).add((pid, proc_name)) - except (OSError, PermissionError): - continue - - for ns_name in namespace_keys: - ns_inode = _read_namespace_inode(pid, ns_name) - if not ns_inode: - continue - ns_key = f"{ns_name}:{ns_inode}" - namespace_owners.setdefault(ns_key, set()).add((pid, proc_name)) - - pair_totals = {} - pair_socket = {} - pair_pipe = {} - pair_shm = {} - pair_namespace = {} - degree_total = {} - degree_socket = {} - degree_pipe = {} - degree_shm = {} - degree_namespace = {} - - def add_pair_counts(name_a, name_b, kind): - if name_a == name_b: - key = (name_a, name_b) - else: - key = tuple(sorted((name_a, name_b))) - pair_totals[key] = pair_totals.get(key, 0) + 1 - if kind == "socket": - pair_socket[key] = pair_socket.get(key, 0) + 1 - elif kind == "pipe": - pair_pipe[key] = pair_pipe.get(key, 0) + 1 - elif kind == "shm": - pair_shm[key] = pair_shm.get(key, 0) + 1 - elif kind == "namespace": - pair_namespace[key] = pair_namespace.get(key, 0) + 1 - - for nm in (name_a, name_b): - degree_total[nm] = degree_total.get(nm, 0) + 1 - if kind == "socket": - degree_socket[nm] = degree_socket.get(nm, 0) + 1 - elif kind == "pipe": - degree_pipe[nm] = degree_pipe.get(nm, 0) + 1 - elif kind == "shm": - degree_shm[nm] = degree_shm.get(nm, 0) + 1 - elif kind == "namespace": - degree_namespace[nm] = degree_namespace.get(nm, 0) + 1 - - def consume_inode_owners(owner_map, kind): - for _inode, owners in owner_map.items(): - unique = sorted({(pid, name) for pid, name in owners}) - if len(unique) < 2: - continue - for i in range(len(unique)): - for j in range(i + 1, len(unique)): - add_pair_counts(unique[i][1], unique[j][1], kind) - - consume_inode_owners(socket_owners, "socket") - consume_inode_owners(pipe_owners, "pipe") - consume_inode_owners(shm_owners, "shm") - consume_inode_owners(namespace_owners, "namespace") - - sorted_pairs = sorted(pair_totals.items(), key=lambda kv: kv[1], reverse=True)[:max_pairs] - pair_links = [] - for (left, right), weight in sorted_pairs: - pair_links.append({ - "left": left, - "right": right, - "weight": int(weight), - "socket_weight": int(pair_socket.get((left, right), pair_socket.get((right, left), 0))), - "pipe_weight": int(pair_pipe.get((left, right), pair_pipe.get((right, left), 0))), - "shm_weight": int(pair_shm.get((left, right), pair_shm.get((right, left), 0))), - "ns_weight": int(pair_namespace.get((left, right), pair_namespace.get((right, left), 0))), - }) - - sorted_nodes = sorted(degree_total.items(), key=lambda kv: kv[1], reverse=True)[:max_nodes] - process_nodes = [] - for name, degree in sorted_nodes: - process_nodes.append({ - "name": name, - "degree": int(degree), - "socket_degree": int(degree_socket.get(name, 0)), - "pipe_degree": int(degree_pipe.get(name, 0)), - "shm_degree": int(degree_shm.get(name, 0)), - "ns_degree": int(degree_namespace.get(name, 0)), - }) - - return { - "process_nodes": process_nodes, - "pair_links": pair_links, - "stats": { - "shared_socket_inodes": int(sum(1 for owners in socket_owners.values() if len({pid for pid, _ in owners}) > 1)), - "shared_pipe_inodes": int(sum(1 for owners in pipe_owners.values() if len({pid for pid, _ in owners}) > 1)), - "shared_memory_regions": int(sum(1 for owners in shm_owners.values() if len({pid for pid, _ in owners}) > 1)), - "shared_namespace_groups": int(sum(1 for owners in namespace_owners.values() if len({pid for pid, _ in owners}) > 1)), - "pair_count": len(pair_links), - "node_count": len(process_nodes), - } - } - - -@app.route("/api/ipc-links") -def get_ipc_links(): - """API: shared IPC/socket links across processes.""" - try: - max_pairs = request.args.get("max_pairs", default=120, type=int) - max_nodes = request.args.get("max_nodes", default=24, type=int) - max_pairs = max(20, min(300, max_pairs)) - max_nodes = max(8, min(64, max_nodes)) - return jsonify(get_ipc_links_summary(max_pairs=max_pairs, max_nodes=max_nodes)) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -def get_process_threads_info(pid): - """Get thread information for a specific process""" - try: - proc = psutil.Process(pid) - threads = proc.threads() - - # Also read from /proc/[pid]/status for additional info - thread_count = proc.num_threads() - - try: - with open(f'/proc/{pid}/status', 'r') as f: - status_data = {} - for line in f: - if ':' in line: - key, value = line.split(':', 1) - status_data[key.strip()] = value.strip() - - voluntary_switches = int(status_data.get('voluntary_ctxt_switches', 0)) - nonvoluntary_switches = int(status_data.get('nonvoluntary_ctxt_switches', 0)) - except: - voluntary_switches = 0 - nonvoluntary_switches = 0 - - return { - 'pid': pid, - 'thread_count': thread_count, - 'threads': [ - { - 'id': t.id, - 'user_time': t.user_time, - 'system_time': t.system_time - } for t in threads - ], - 'voluntary_ctxt_switches': voluntary_switches, - 'nonvoluntary_ctxt_switches': nonvoluntary_switches - } - except (psutil.NoSuchProcess, psutil.AccessDenied) as e: - return {'error': str(e)} - except Exception as e: - return {'error': str(e)} - -def get_process_cpu_info(pid): - """Get CPU statistics for a specific process""" - try: - proc = psutil.Process(pid) - - # Get CPU times - cpu_times = proc.cpu_times() - cpu_percent = proc.cpu_percent(interval=0.1) - - # Get CPU affinity if available - try: - cpu_affinity = proc.cpu_affinity() - except: - cpu_affinity = [] - - # Get nice value - try: - nice = proc.nice() - except: - nice = None - - return { - 'pid': pid, - 'cpu_percent': round(cpu_percent, 1), - 'cpu_times': { - 'user': round(cpu_times.user, 2), - 'system': round(cpu_times.system, 2), - 'children_user': round(cpu_times.children_user, 2) if hasattr(cpu_times, 'children_user') else 0, - 'children_system': round(cpu_times.children_system, 2) if hasattr(cpu_times, 'children_system') else 0 - }, - 'cpu_affinity': cpu_affinity, - 'nice': nice - } - except (psutil.NoSuchProcess, psutil.AccessDenied) as e: - return {'error': str(e)} - except Exception as e: - return {'error': str(e)} - -def get_process_fds_info(pid): - """Get file descriptors information for a specific process""" - try: - proc = psutil.Process(pid) - - # Get number of file descriptors - try: - num_fds = proc.num_fds() - except (psutil.AccessDenied, AttributeError): - # Try to count from /proc/[pid]/fd - try: - fd_dir = f'/proc/{pid}/fd' - if os.path.exists(fd_dir): - num_fds = len([f for f in os.listdir(fd_dir) if f.isdigit()]) - else: - num_fds = 0 - except: - num_fds = 0 - - # Get open files - open_files = [] - try: - for fd in proc.open_files(): - open_files.append({ - 'path': fd.path, - 'fd': fd.fd if hasattr(fd, 'fd') else None - }) - except (psutil.AccessDenied, psutil.NoSuchProcess, AttributeError): - # Fallback: try to read from /proc/[pid]/fd directly - try: - fd_dir = f'/proc/{pid}/fd' - if os.path.exists(fd_dir): - for fd_num in os.listdir(fd_dir): - if fd_num.isdigit(): - try: - fd_path = os.readlink(f'{fd_dir}/{fd_num}') - # Filter out special files (sockets, pipes, etc.) - # Also filter out IP addresses (which might appear as socket paths) - # Check if it looks like an IP address (e.g., "0.0.0.0", "127.0.0.1") - ip_pattern = re.compile(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}') - if (fd_path.startswith('/') and - not fd_path.startswith('socket:') and - not fd_path.startswith('pipe:') and - not fd_path.startswith('anon_inode:') and - not ip_pattern.match(fd_path)): # Filter IP addresses - open_files.append({ - 'path': fd_path, - 'fd': int(fd_num) - }) - except (OSError, ValueError): - pass - except (OSError, PermissionError): - pass - - # Get connections (sockets) - connections = [] - try: - for conn in proc.connections(): - connections.append({ - 'fd': conn.fd if hasattr(conn, 'fd') else None, - 'family': str(conn.family), - 'type': str(conn.type), - 'local_address': f"{conn.laddr.ip}:{conn.laddr.port}" if conn.laddr else None, - 'remote_address': f"{conn.raddr.ip}:{conn.raddr.port}" if conn.raddr else None, - 'status': conn.status - }) - except (psutil.AccessDenied, psutil.NoSuchProcess, AttributeError): - pass - - return { - 'pid': pid, - 'num_fds': num_fds, - 'open_files': open_files[:20], # Limit to 20 - 'connections': connections[:20] # Limit to 20 - } - except (psutil.NoSuchProcess, psutil.AccessDenied) as e: - return {'error': f'Access denied or process not found: {str(e)}'} - except Exception as e: - return {'error': f'Error getting FDs: {str(e)}'} - - -@app.route('/api/proc-matrix') -def get_proc_matrix(): - """API: Matrix view data (processes vs CPU / MEM / IO / NET / FD)""" - try: - matrix = get_proc_matrix_data() - return jsonify({ - 'matrix': matrix, - 'timestamp': datetime.now().isoformat(), - }) - except Exception as e: - return jsonify({'error': str(e)}), 500 - -@app.route('/api/proc-timeline') -def get_proc_timeline(): - """API: Timeline view data - events for a specific process""" - try: - from flask import request - pid = request.args.get('pid', type=int) - if not pid: - return jsonify({'error': 'PID parameter required'}), 400 - - timeline = [] - - # Check if process exists - try: - proc = psutil.Process(pid) - except psutil.NoSuchProcess: - return jsonify({'error': f'Process {pid} not found'}), 404 - - # Get process info - proc_info = proc.as_dict(['pid', 'name', 'create_time', 'status']) - base_ts = float(proc_info['create_time']) - # Ordered real-derived events; timestamps are monotonic from process start (precise times not in /proc). - ordered_events = [] - ordered_events.append({'type': 'exec', 'pid': pid}) - - # Event: mmap (from /proc/[pid]/maps) - try: - maps_path = f'/proc/{pid}/maps' - if os.path.exists(maps_path): - with open(maps_path, 'r') as f: - map_count = len(f.readlines()) - if map_count > 0: - ordered_events.append({ - 'type': 'mmap', - 'pid': pid, - 'count': map_count - }) - except (IOError, PermissionError): - pass - - # Event: read/write (from /proc/[pid]/io) - try: - io_path = f'/proc/{pid}/io' - if os.path.exists(io_path): - with open(io_path, 'r') as f: - io_data = {} - for line in f: - if ':' in line: - key, value = line.split(':', 1) - io_data[key.strip()] = int(value.strip()) - - if io_data.get('read_bytes', 0) > 0: - ordered_events.append({ - 'type': 'read', - 'pid': pid, - 'bytes': io_data.get('read_bytes', 0) - }) - - if io_data.get('write_bytes', 0) > 0: - ordered_events.append({ - 'type': 'write', - 'pid': pid, - 'bytes': io_data.get('write_bytes', 0) - }) - except (IOError, PermissionError): - pass - - # Event: connect/accept (from /proc/[pid]/net/tcp) - try: - tcp_path = f'/proc/{pid}/net/tcp' - if os.path.exists(tcp_path): - with open(tcp_path, 'r') as f: - lines = f.readlines() - if len(lines) > 1: - for line in lines[1:]: - parts = line.split() - if len(parts) >= 4: - state = parts[3] - if state == '01': - ordered_events.append({'type': 'connect', 'pid': pid}) - elif state == '0A': - ordered_events.append({'type': 'accept', 'pid': pid}) - except (IOError, PermissionError): - pass - - step = 0.35 - timeline = [] - for i, ev in enumerate(ordered_events): - ev = dict(ev) - ev['timestamp'] = datetime.fromtimestamp(base_ts + i * step).isoformat() - timeline.append(ev) - - return jsonify({ - 'timeline': timeline, - 'pid': pid, - 'name': proc_info.get('name', 'unknown'), - 'timestamp': datetime.now().isoformat(), - 'timeline_time_basis': 'Events are ordered from process start; 0.35s steps separate rows for the helix (kernel does not expose per-event wall times for these signals).', - }) - - except Exception as e: - return jsonify({'error': str(e)}), 500 - -@app.route('/api/execution-context') -def get_execution_context(): - """Get execution context data for Ring-1 visualization""" - try: - if platform.system() != 'Linux': - return jsonify({ - 'mode': 'kernel', - 'cpu_state': 'running', - 'syscall_active': False, - 'syscall_name': None, - 'interrupts': [], - 'preempted': False, - 'preempted_pid': None - }) - - # Determine mode (user/kernel) by checking active processes - mode = 'user' # Default - syscall_active = False - syscall_name = None - active_pid = None - active_syscalls = [] # List of processes with active syscalls: [{pid, syscall_name}] - - # Check for active syscalls - try: - proc_dirs = [d for d in os.listdir('/proc') if d.isdigit()] - sampled_procs = proc_dirs[:100] # Sample more processes - - syscall_count = 0 - for pid in sampled_procs: - try: - syscall_path = f'/proc/{pid}/syscall' - if os.path.exists(syscall_path): - with open(syscall_path, 'r') as f: - line = f.read().strip() - if line and line != '-1': - parts = line.split() - if parts: - syscall_num = int(parts[0]) - if syscall_num > 0: - syscall_count += 1 - current_syscall_name = SYSCALL_NAMES.get(syscall_num, f'syscall_{syscall_num}') - - # Add to list of active syscalls - active_syscalls.append({ - 'pid': int(pid), - 'syscall_name': current_syscall_name - }) - - if not syscall_active: # Get first active syscall for main display - syscall_active = True - syscall_name = current_syscall_name - active_pid = int(pid) - mode = 'kernel' # Syscall means kernel mode - except (ValueError, IOError, PermissionError): - continue - - # If we found syscalls, we're in kernel mode - if syscall_count > 0: - mode = 'kernel' - except PermissionError: - pass - - # Get CPU state from /proc/stat - cpu_state = 'running' - try: - with open('/proc/stat', 'r') as f: - cpu_line = f.readline() - if cpu_line.startswith('cpu '): - parts = cpu_line.split() - if len(parts) >= 5: - idle_time = int(parts[4]) - total_time = sum(int(p) for p in parts[1:11] if p.isdigit()) - if total_time > 0: - idle_percent = (idle_time / total_time) * 100 - if idle_percent > 90: - cpu_state = 'idle' - elif idle_percent > 50: - cpu_state = 'sleeping' - except (IOError, ValueError, IndexError): - pass - - # Get recent interrupts and associate with processes - interrupts = [] - # Get list of ALL processes (not just active syscalls) for better distribution - all_process_pids = [] - try: - proc_dirs = [d for d in os.listdir('/proc') if d.isdigit()] - # Get all process PIDs (limit to reasonable number) - all_process_pids = [int(pid) for pid in proc_dirs[:200] if pid.isdigit()] - except PermissionError: - pass - - # Track previous interrupt counts to detect new interrupts - previous_interrupt_counts = {} - try: - # Try to read previous counts from a simple cache (in-memory) - # For now, we'll just report all interrupts and let frontend handle distribution - with open('/proc/interrupts', 'r') as f: - lines = f.readlines() - # Parse interrupt counts - for line in lines[1:]: # Skip header - if line.strip(): - parts = line.split() - if len(parts) > 1: - # Check if any CPU has non-zero interrupts - for i in range(1, min(len(parts), 5)): # Check first 4 CPUs - try: - count = int(parts[i]) - # Report interrupts more frequently (every 10 instead of 100) - if count > 0: - # Extract IRQ number - irq_num = parts[0].rstrip(':') - - # Associate with a process - use CPU and IRQ to select process - # This ensures consistent mapping: same CPU+IRQ = same process - associated_pid = None - if all_process_pids: - # Use CPU and IRQ to create a consistent hash for process selection - hash_value = (i - 1) * 100 + int(irq_num) if irq_num.isdigit() else (i - 1) * 100 - process_index = hash_value % len(all_process_pids) - associated_pid = all_process_pids[process_index] - - interrupts.append({ - 'cpu': i - 1, - 'irq': irq_num, - 'count': count, - 'pid': associated_pid, # Always associate with a process - 'timestamp': datetime.now().isoformat() - }) - break # Only one per IRQ line - except (ValueError, IndexError): - continue - except (IOError, PermissionError): - pass - - # Build IRQ/SoftIRQ stack data with rates for a compact "IRQ stack" UI panel. - now_ts = time.time() - prev_ts = EXEC_CONTEXT_PREV.get("timestamp") - dt = (now_ts - prev_ts) if prev_ts else None - if dt is not None and dt <= 0: - dt = None - - irq_totals_now = {} - irq_rows = [] - try: - with open('/proc/interrupts', 'r') as f: - lines = f.readlines() - for raw in lines[1:]: - if ":" not in raw: - continue - left, right = raw.split(":", 1) - irq_name = left.strip() - tokens = right.split() - if not tokens: - continue - - counts = [] - idx = 0 - while idx < len(tokens) and tokens[idx].isdigit(): - counts.append(int(tokens[idx])) - idx += 1 - if not counts: - continue - total = sum(counts) - desc = " ".join(tokens[idx:]).strip() or irq_name - key = f"{irq_name}:{desc}" - irq_totals_now[key] = total - - prev_total = EXEC_CONTEXT_PREV["irq_totals"].get(key) - per_sec = 0.0 - if dt and prev_total is not None: - per_sec = max(0.0, (total - prev_total) / dt) - - top_cpu = None - if counts: - top_cpu = int(max(range(len(counts)), key=lambda i: counts[i])) - - irq_rows.append({ - "irq": irq_name, - "label": desc, - "total": int(total), - "per_sec": round(per_sec, 2), - "top_cpu": top_cpu, - "subsystem": map_interrupt_to_subsystem(desc) - }) - except (IOError, PermissionError): - pass - - softirq_totals_now = {} - softirq_rows = [] - try: - with open('/proc/softirqs', 'r') as f: - lines = f.readlines() - for raw in lines[1:]: - if ":" not in raw: - continue - left, right = raw.split(":", 1) - name = left.strip() - counts = [] - for tok in right.split(): - if tok.isdigit(): - counts.append(int(tok)) - if not counts: - continue - total = sum(counts) - softirq_totals_now[name] = total - prev_total = EXEC_CONTEXT_PREV["softirq_totals"].get(name) - per_sec = 0.0 - if dt and prev_total is not None: - per_sec = max(0.0, (total - prev_total) / dt) - softirq_rows.append({ - "name": name, - "total": int(total), - "per_sec": round(per_sec, 2) - }) - except (IOError, PermissionError): - pass - - irq_rows.sort(key=lambda row: (row["per_sec"], row["total"]), reverse=True) - softirq_rows.sort(key=lambda row: (row["per_sec"], row["total"]), reverse=True) - hard_top = irq_rows[:5] - soft_top = softirq_rows[:4] - - hard_total_rate = sum(row["per_sec"] for row in irq_rows) - soft_total_rate = sum(row["per_sec"] for row in softirq_rows) - net_softirq_rate = 0.0 - block_softirq_rate = 0.0 - timer_softirq_rate = 0.0 - for row in softirq_rows: - nm = row["name"].upper() - if nm in ("NET_RX", "NET_TX"): - net_softirq_rate += row["per_sec"] - elif nm == "BLOCK": - block_softirq_rate += row["per_sec"] - elif nm == "TIMER": - timer_softirq_rate += row["per_sec"] - - EXEC_CONTEXT_PREV["timestamp"] = now_ts - EXEC_CONTEXT_PREV["irq_totals"] = irq_totals_now - EXEC_CONTEXT_PREV["softirq_totals"] = softirq_totals_now - - # Check for preempted processes (simplified - check if process is in 'R' state but not on CPU) - preempted = False - preempted_pid = None - try: - # This is a simplified check - in reality, preemption detection is more complex - # We check if there are processes in 'R' state (runnable but not running) - proc_dirs = [d for d in os.listdir('/proc') if d.isdigit()] - for pid in proc_dirs[:20]: # Check first 20 - try: - stat_path = f'/proc/{pid}/stat' - if os.path.exists(stat_path): - with open(stat_path, 'r') as f: - stat_data = f.read().split() - if len(stat_data) > 2: - state = stat_data[2] - # 'R' = running/runnable, but if it's not the active one, it might be preempted - if state == 'R' and active_pid and int(pid) != active_pid: - preempted = True - preempted_pid = int(pid) - break - except (ValueError, IOError, PermissionError, IndexError): - continue - except PermissionError: - pass - - return jsonify({ - 'mode': mode, - 'cpu_state': cpu_state, - 'syscall_active': syscall_active, - 'syscall_name': syscall_name, - 'active_pid': active_pid, - 'active_syscalls': active_syscalls, # List of processes with active syscalls - 'interrupts': interrupts[:10], # Limit to 10 most recent - 'irq_stack': { - 'hard': hard_top, - 'soft': soft_top, - 'summary': { - 'hard_total_per_sec': round(hard_total_rate, 2), - 'soft_total_per_sec': round(soft_total_rate, 2), - 'net_softirq_per_sec': round(net_softirq_rate, 2), - 'block_softirq_per_sec': round(block_softirq_rate, 2), - 'timer_softirq_per_sec': round(timer_softirq_rate, 2) - } - }, - 'preempted': preempted, - 'preempted_pid': preempted_pid, - 'cpu_count': psutil.cpu_count(), - 'timestamp': datetime.now().isoformat() - }) - except Exception as e: - return jsonify({'error': str(e)}), 500 - -def get_kernel_dna_data(): - """ - Collect Kernel DNA data: syscalls, interrupts, context switches, locks - Returns data structured for DNA visualization - """ - dna_data = { - 'nucleotides': [], # List of events: syscall, interrupt, context_switch, lock - 'genes': [], # Kernel subsystems segments - 'mutations': [], # Anomalies detected - 'timestamp': datetime.now().isoformat() - } - - # 1. Collect syscalls (A nucleotides) - try: - syscalls = get_real_system_calls() - for syscall in syscalls[:20]: # Limit to 20 most frequent - dna_data['nucleotides'].append({ - 'type': 'syscall', - 'code': 'A', - 'name': syscall['name'], - 'count': syscall.get('count', 0), - 'subsystem': syscall.get('subsystem') or map_syscall_to_subsystem(syscall['name']), - 'timestamp': datetime.now().isoformat() - }) - except Exception as e: - print(f"Error collecting syscalls: {e}") - - # 2. Collect interrupts (T nucleotides) - try: - with open('/proc/interrupts', 'r') as f: - interrupt_lines = f.readlines() - # Skip header line - for line in interrupt_lines[1:11]: # First 10 interrupt lines - parts = line.strip().split() - if len(parts) > 1: - interrupt_name = parts[0].rstrip(':') - total_count = sum(int(x) for x in parts[1:] if x.isdigit()) - if total_count > 0: - dna_data['nucleotides'].append({ - 'type': 'interrupt', - 'code': 'T', - 'name': interrupt_name, - 'count': total_count, - 'subsystem': map_interrupt_to_subsystem(interrupt_name), - 'timestamp': datetime.now().isoformat() - }) - except (IOError, ValueError, PermissionError) as e: - print(f"Error collecting interrupts: {e}") - dna_data['nucleotides'].extend(_kernel_dna_softirq_nucleotides()) - - # 3. Collect context switches (C nucleotides) - try: - with open('/proc/stat', 'r') as f: - for line in f: - if line.startswith('ctxt '): - ctxt_count = int(line.split()[1]) - # Calculate context switches per second (simplified) - dna_data['nucleotides'].append({ - 'type': 'context_switch', - 'code': 'C', - 'name': 'context_switch', - 'count': ctxt_count, - 'subsystem': 'sched', - 'timestamp': datetime.now().isoformat() - }) - break - except (IOError, ValueError, PermissionError) as e: - print(f"Error collecting context switches: {e}") - - # 4. Collect locks (G nucleotides) - from /proc/locks - try: - with open('/proc/locks', 'r') as f: - lock_lines = f.readlines() - lock_count = len(lock_lines) - if lock_count > 0: - dna_data['nucleotides'].append({ - 'type': 'lock', - 'code': 'G', - 'name': 'mutex/lock', - 'count': lock_count, - 'subsystem': 'kernel', - 'timestamp': datetime.now().isoformat() - }) - except (IOError, PermissionError) as e: - # Fallback: estimate locks based on process count - try: - process_count = len(psutil.pids()) - estimated_locks = process_count // 10 - dna_data['nucleotides'].append({ - 'type': 'lock', - 'code': 'G', - 'name': 'mutex/lock', - 'count': estimated_locks, - 'subsystem': 'kernel', - 'timestamp': datetime.now().isoformat() - }) - except: - pass - - # 5. Define gene segments (kernel subsystems) - dna_data['genes'] = [ - {'name': 'sched', 'start': 0, 'end': 0.2, 'color': '#58b6d8'}, - {'name': 'net', 'start': 0.2, 'end': 0.4, 'color': '#4a9eff'}, - {'name': 'fs', 'start': 0.4, 'end': 0.6, 'color': '#6bcf7f'}, - {'name': 'mm', 'start': 0.6, 'end': 0.8, 'color': '#ffa94d'}, - {'name': 'drivers', 'start': 0.8, 'end': 1.0, 'color': '#ff6b9d'} - ] - - # 6. Detect mutations (anomalies) - mutations = [] - - # Check for syscall flood - syscall_count = sum(1 for n in dna_data['nucleotides'] if n['type'] == 'syscall') - if syscall_count > 15: - mutations.append({ - 'type': 'syscall_flood', - 'severity': 'high', - 'message': f'Syscall flood detected: {syscall_count} active syscalls', - 'position': 0.3 - }) - - # Check for abnormal context switching - ctxt_switches = [n for n in dna_data['nucleotides'] if n['type'] == 'context_switch'] - if ctxt_switches and ctxt_switches[0]['count'] > 1000000: - mutations.append({ - 'type': 'abnormal_context_switch', - 'severity': 'medium', - 'message': 'Abnormal context switching rate detected', - 'position': 0.5 - }) - - # Check for lock contention - locks = [n for n in dna_data['nucleotides'] if n['type'] == 'lock'] - if locks and locks[0]['count'] > 100: - mutations.append({ - 'type': 'lock_contention', - 'severity': 'medium', - 'message': f'High lock contention: {locks[0]["count"]} active locks', - 'position': 0.7 - }) - - dna_data['mutations'] = mutations - - return dna_data - -def map_syscall_to_subsystem(syscall_name): - """Map syscall name to kernel subsystem""" - if not syscall_name: - return 'kernel' - if syscall_name.startswith('vm:'): - return 'mm' - if syscall_name.startswith('disk:'): - return 'fs' - if syscall_name.startswith('net:'): - return 'net' - syscall_lower = syscall_name.lower() - if any(x in syscall_lower for x in ['read', 'write', 'open', 'close', 'stat', 'fsync']): - return 'fs' - elif any(x in syscall_lower for x in ['socket', 'connect', 'send', 'recv', 'bind']): - return 'net' - elif any(x in syscall_lower for x in ['mmap', 'munmap', 'brk', 'mprotect']): - return 'mm' - elif any(x in syscall_lower for x in ['clone', 'fork', 'exec', 'wait', 'exit']): - return 'sched' - else: - return 'kernel' - -def map_interrupt_to_subsystem(interrupt_name): - """Map interrupt name to kernel subsystem""" - irq_lower = interrupt_name.lower() - if 'timer' in irq_lower: - return 'sched' - elif any(x in irq_lower for x in ['eth', 'network', 'wifi']): - return 'net' - elif any(x in irq_lower for x in ['keyboard', 'mouse', 'usb']): - return 'drivers' - else: - return 'kernel' - -def infer_crypto_protocol(local_port, remote_port, process_name): - """Infer protocol likely using kernel crypto from socket and process context.""" - tls_ports = {443, 465, 636, 853, 993, 995, 2376, 6443, 8443, 9443} - ssh_ports = {22} - wg_ports = {51820} - p_name = (process_name or "").lower() - ports = {int(local_port or 0), int(remote_port or 0)} - - if ports & ssh_ports or "sshd" in p_name or "ssh" in p_name: - return "SSH", "ChaCha20-Poly1305" - if ports & wg_ports or "wg" in p_name or "wireguard" in p_name: - return "WireGuard", "ChaCha20" - if ports & tls_ports or any(x in p_name for x in ["nginx", "haproxy", "curl", "wget", "openssl", "stunnel", "traefik"]): - return "TLS", "AES-GCM/SHA256" - return "Crypto API", "AES/SHA" - -def is_likely_crypto_actor(process_name, local_port, remote_port, protocol): - """Heuristic gate to avoid flooding with unrelated sockets.""" - p_name = (process_name or "").lower() - interesting_ports = {22, 443, 465, 636, 853, 993, 995, 2376, 6443, 8443, 9443, 51820} - process_tokens = [ - "nginx", "haproxy", "envoy", "caddy", "apache", "httpd", "traefik", - "sshd", "ssh", "wg", "wireguard", "openssl", "stunnel", "curl", "wget", - "python", "gunicorn", "uvicorn" - ] - if protocol in ("TLS", "SSH", "WireGuard"): - return True - if int(local_port or 0) in interesting_ports or int(remote_port or 0) in interesting_ports: - return True - return any(token in p_name for token in process_tokens) - -def infer_tls_terminator(process_name, local_port, protocol, tls_listener_names): - """Guess where TLS termination happens.""" - if protocol != "TLS": - return "n/a" - p_name = (process_name or "").lower() - if p_name and p_name != "unknown": - return p_name - if int(local_port or 0) in {443, 8443, 9443, 6443} and tls_listener_names: - top = next(iter(tls_listener_names)) - return f"listener:{top}" - if int(local_port or 0) in {443, 8443, 9443, 6443}: - return "unknown" - return "upstream-or-external-lb" - -def parse_proc_crypto_entries(): - """Parse /proc/crypto into a list of dict entries.""" - entries = [] - try: - with open("/proc/crypto", "r", encoding="utf-8", errors="ignore") as f: - raw = f.read() - except Exception: - return entries - - blocks = [block.strip() for block in raw.split("\n\n") if block.strip()] - for block in blocks: - item = {} - for line in block.splitlines(): - if ":" not in line: - continue - key, value = line.split(":", 1) - item[key.strip().lower()] = value.strip() - if item: - entries.append(item) - return entries - -def collect_algorithm_competition(requested_algorithm="aes"): - """ - Build algorithm implementation competition using kernel crypto registry. - The winner is the implementation with highest priority. - """ - entries = parse_proc_crypto_entries() - requested = (requested_algorithm or "aes").lower() - req_type_allow = { - "aes": {"skcipher", "aead", "cipher"}, - "sha": {"shash", "ahash", "hash"}, - "chacha20": {"skcipher", "aead", "cipher"} - } - req_tokens = { - "aes": ["aes"], - "sha": ["sha"], - "chacha20": ["chacha20", "xchacha20", "chacha"] - } - allowed_types = req_type_allow.get(requested, {"skcipher", "aead", "cipher", "shash", "ahash", "hash"}) - tokens = req_tokens.get(requested, [requested]) - candidates = [] - - for entry in entries: - name = str(entry.get("name", "")).lower() - driver = str(entry.get("driver", "")).lower() - alg_type = str(entry.get("type", "")).lower() - - if not any(token in name or token in driver for token in tokens): - continue - if alg_type and alg_type not in allowed_types: - continue - - try: - priority = int(entry.get("priority", "0") or 0) - except ValueError: - priority = 0 - - impl_name = driver or name or "unknown-impl" - candidates.append({ - "name": impl_name, - "priority": priority, - "type": alg_type or "unknown", - "source": "kernel" - }) - - # Deduplicate by implementation name, keep the highest priority variant. - dedup = {} - for item in candidates: - existing = dedup.get(item["name"]) - if existing is None or item["priority"] > existing["priority"]: - dedup[item["name"]] = item - candidates = list(dedup.values()) - candidates.sort(key=lambda x: x["priority"], reverse=True) - - if not candidates: - # Fallback keeps the UX informative on hosts without readable /proc/crypto. - fallback_map = { - "aes": [ - {"name": "aesni-intel", "priority": 300, "type": "skcipher", "source": "mock"}, - {"name": "aes-avx", "priority": 200, "type": "skcipher", "source": "mock"}, - {"name": "aes-generic", "priority": 100, "type": "skcipher", "source": "mock"} - ], - "sha": [ - {"name": "sha256-avx2", "priority": 240, "type": "shash", "source": "mock"}, - {"name": "sha256-ssse3", "priority": 180, "type": "shash", "source": "mock"}, - {"name": "sha256-generic", "priority": 100, "type": "shash", "source": "mock"} - ], - "chacha20": [ - {"name": "chacha20-neon", "priority": 260, "type": "skcipher", "source": "mock"}, - {"name": "chacha20-simd", "priority": 220, "type": "skcipher", "source": "mock"}, - {"name": "chacha20-generic", "priority": 100, "type": "skcipher", "source": "mock"} - ] - } - candidates = fallback_map.get(requested, fallback_map["aes"]) - - selected = candidates[0] if candidates else None - return { - "request": requested.upper(), - "implementations": candidates[:8], - "selected": selected, - "selection_policy": "max-priority" - } - -def collect_kernel_crypto_clients(items): - """Infer major kernel crypto clients from active process/protocol context.""" - client_rules = [ - ("kTLS", ["nginx", "haproxy", "envoy", "caddy", "apache", "httpd", "traefik"], "TLS"), - ("WireGuard", ["wg", "wireguard"], "WireGuard"), - ("IPsec/XFRM", ["charon", "strongswan", "ipsec", "racoon"], "TLS"), - ("dm-crypt", ["cryptsetup", "dmcrypt", "luks"], "CRYPTO API"), - ("fscrypt", ["fscrypt"], "CRYPTO API"), - ("AF_ALG", ["openssl", "python", "curl", "wget"], "CRYPTO API") - ] - results = [] - lowered_items = [] - for item in items: - lowered_items.append({ - "process": str(item.get("process", "")).lower(), - "protocol": str(item.get("protocol", "")), - "source_kind": str(item.get("source_kind", "")) - }) - - for name, tokens, proto_hint in client_rules: - flows = 0 - for item in lowered_items: - proc = item["process"] - proto = item["protocol"] - if any(token in proc for token in tokens): - flows += 1 - elif proto_hint and proto == proto_hint: - flows += 1 - status = "active" if flows > 0 else "idle" - results.append({ - "name": name, - "status": status, - "active_flows": int(flows) - }) - return results - -def collect_sync_async_queue(items): - """Estimate sync/async crypto execution pressure from active flows.""" - active_items = [i for i in items if str(i.get("status", "")).upper() != "LISTEN"] - async_items = [ - i for i in active_items - if str(i.get("source_kind", "")) == "connection" - or str(i.get("protocol", "")).upper() in {"TLS", "WIREGUARD", "SSH"} - ] - sync_items = max(len(active_items) - len(async_items), 0) + sum( - 1 for i in items if str(i.get("source_kind", "")) == "process" - ) - queue_depth = max(len(async_items) - 1, 0) - queue_latency_ms = round(0.35 + min(5.5, queue_depth * 0.42 + len(active_items) * 0.08), 2) - return { - "sync_ops_est": int(sync_items), - "async_ops_est": int(len(async_items)), - "queue_depth_est": int(queue_depth), - "queue_latency_ms_est": queue_latency_ms, - "mode": "heuristic" - } - -def collect_hw_offload_status(entries, algorithm_competitions): - """Estimate hardware acceleration availability from /proc/crypto drivers.""" - names = [] - for entry in entries: - n = str(entry.get("name", "")).lower() - d = str(entry.get("driver", "")).lower() - if n: - names.append(n) - if d: - names.append(d) - - def has_token(tokens): - return any(any(token in item for token in tokens) for item in names) - - selected_impls = { - key: str(value.get("selected", {}).get("name", "")).lower() - for key, value in (algorithm_competitions or {}).items() - } - selected_joined = " ".join(selected_impls.values()) - - engines = [ - { - "engine": "AES-NI / CPU INSTR", - "available": has_token(["aesni", "vaes"]), - "active": ("aesni" in selected_joined or "vaes" in selected_joined) - }, - { - "engine": "SIMD (AVX/NEON)", - "available": has_token(["avx", "sse", "simd", "neon"]), - "active": any(token in selected_joined for token in ["avx", "simd", "neon", "sse"]) - }, - { - "engine": "ARM CRYPTO EXT", - "available": has_token(["arm64", "ce", "neon"]), - "active": "arm64" in selected_joined - }, - { - "engine": "QAT OFFLOAD", - "available": has_token(["qat"]), - "active": "qat" in selected_joined - }, - { - "engine": "VIRTIO-CRYPTO", - "available": has_token(["virtio"]), - "active": "virtio" in selected_joined - } - ] - result = [] - for item in engines: - if item["active"]: - status = "active" - elif item["available"]: - status = "available" - else: - status = "unavailable" - result.append({ - "engine": item["engine"], - "status": status - }) - return result - -def read_sysctl_int(path, default=0): - """Read integer sysctl/proc file value safely.""" - try: - with open(path, "r", encoding="utf-8", errors="ignore") as f: - raw = f.read().strip() - return int(raw or default) - except Exception: - return int(default) - -def read_proc_interrupt_total(): - """Read total interrupts count from /proc/stat.""" - try: - with open("/proc/stat", "r", encoding="utf-8", errors="ignore") as f: - for line in f: - if line.startswith("intr "): - parts = line.strip().split() - if len(parts) >= 2: - return int(parts[1]) - except Exception: - return 0 - return 0 - -def collect_entropy_cloud_status(): - """ - Collect Linux random subsystem entropy status and source activity. - This is a best-effort realtime heuristic for UI visualization. - """ - now = time.time() - entropy_bits = read_sysctl_int("/proc/sys/kernel/random/entropy_avail", 0) - pool_size_bits = read_sysctl_int("/proc/sys/kernel/random/poolsize", 256) - read_threshold = read_sysctl_int("/proc/sys/kernel/random/read_wakeup_threshold", 128) - write_threshold = read_sysctl_int("/proc/sys/kernel/random/write_wakeup_threshold", 64) - - try: - disk = psutil.disk_io_counters() - except Exception: - disk = None - try: - net = psutil.net_io_counters() - except Exception: - net = None - intr_total = read_proc_interrupt_total() - - prev_ts = ENTROPY_PREV.get("timestamp") - dt = max(now - prev_ts, 0.001) if prev_ts else None - - disk_read_now = int(getattr(disk, "read_bytes", 0) or 0) - disk_write_now = int(getattr(disk, "write_bytes", 0) or 0) - net_sent_now = int(getattr(net, "bytes_sent", 0) or 0) - net_recv_now = int(getattr(net, "bytes_recv", 0) or 0) - - if dt: - disk_delta = max( - (disk_read_now - int(ENTROPY_PREV.get("disk_read_bytes") or disk_read_now)) - + (disk_write_now - int(ENTROPY_PREV.get("disk_write_bytes") or disk_write_now)), - 0 - ) - net_delta = max( - (net_sent_now - int(ENTROPY_PREV.get("net_sent_bytes") or net_sent_now)) - + (net_recv_now - int(ENTROPY_PREV.get("net_recv_bytes") or net_recv_now)), - 0 - ) - intr_delta = max(intr_total - int(ENTROPY_PREV.get("interrupt_total") or intr_total), 0) - else: - disk_delta = 0 - net_delta = 0 - intr_delta = 0 - - ENTROPY_PREV["timestamp"] = now - ENTROPY_PREV["disk_read_bytes"] = disk_read_now - ENTROPY_PREV["disk_write_bytes"] = disk_write_now - ENTROPY_PREV["net_sent_bytes"] = net_sent_now - ENTROPY_PREV["net_recv_bytes"] = net_recv_now - ENTROPY_PREV["interrupt_total"] = intr_total - - def scale_intensity(rate_value, scale): - return int(max(0, min(100, (float(rate_value) / float(scale)) * 100.0))) - - disk_rate = (disk_delta / dt) if dt else 0 - net_rate = (net_delta / dt) if dt else 0 - intr_rate = (intr_delta / dt) if dt else 0 - - irq_intensity = scale_intensity(intr_rate, 25000) - disk_intensity = scale_intensity(disk_rate, 80 * 1024 * 1024) - net_intensity = scale_intensity(net_rate, 120 * 1024 * 1024) - hwrng_intensity = 68 if entropy_bits > max(read_threshold, 128) else 34 - - sources = [ - { - "source": "interrupt timing", - "intensity": irq_intensity, - "status": "active" if irq_intensity >= 25 else "low" - }, - { - "source": "disk IO", - "intensity": disk_intensity, - "status": "active" if disk_intensity >= 18 else "low" - }, - { - "source": "network timing", - "intensity": net_intensity, - "status": "active" if net_intensity >= 18 else "low" - }, - { - "source": "hardware RNG", - "intensity": hwrng_intensity, - "status": "active" if hwrng_intensity >= 50 else "limited" - } - ] - - source_avg = int(sum(s["intensity"] for s in sources) / max(len(sources), 1)) - entropy_pct = max(0.0, min(1.0, float(entropy_bits) / max(float(pool_size_bits), 1.0))) - particle_density = max(16, min(84, int(18 + entropy_pct * 42 + source_avg * 0.35))) - key_birth_rate = round(0.6 + entropy_pct * 9.4 + source_avg * 0.06, 2) - - crng_state = "ready" if entropy_bits >= max(read_threshold, 128) else "warming" - random_state = "stable" if entropy_bits >= max(write_threshold, 64) else "refilling" - - return { - "entropy_pool_bits": int(entropy_bits), - "entropy_pool_size_bits": int(pool_size_bits), - "crng_state": crng_state, - "random_subsystem_state": random_state, - "particle_density": int(particle_density), - "key_birth_rate_est": float(key_birth_rate), - "sources": sources, - "read_wakeup_threshold": int(read_threshold), - "write_wakeup_threshold": int(write_threshold), - "mode": "live-heuristic" - } - -def collect_algorithm_requesters(items, kernel_clients): - """Infer likely requestor objects that trigger algorithm competition.""" - algo_map = {"aes": {}, "sha": {}, "chacha20": {}} - client_boost_rules = { - "aes": {"kTLS", "dm-crypt", "AF_ALG", "IPsec/XFRM"}, - "sha": {"kTLS", "AF_ALG", "IPsec/XFRM"}, - "chacha20": {"WireGuard", "AF_ALG"} - } - - for item in items or []: - process_name = str(item.get("process", "unknown")).lower() or "unknown" - protocol = str(item.get("protocol", "")).upper() - algorithm = str(item.get("algorithm", "")).upper() - status = str(item.get("status", "")).upper() - if status == "LISTEN": - continue - - matched_algorithms = set() - if "AES" in algorithm or protocol == "TLS": - matched_algorithms.add("aes") - if "SHA" in algorithm or protocol == "TLS": - matched_algorithms.add("sha") - if "CHACHA" in algorithm or protocol in {"WIREGUARD", "SSH"}: - matched_algorithms.add("chacha20") - if not matched_algorithms and protocol == "CRYPTO API": - matched_algorithms.update(["aes", "sha"]) - - for algo_key in matched_algorithms: - key = f"process:{process_name}" - bucket = algo_map[algo_key].setdefault(key, { - "name": process_name, - "kind": "process", - "score": 0 - }) - bucket["score"] += 1 - - for client in kernel_clients or []: - name = str(client.get("name", "")).strip() - flows = int(client.get("active_flows", 0) or 0) - if not name or flows <= 0: - continue - for algo_key, allowed_clients in client_boost_rules.items(): - if name not in allowed_clients: - continue - key = f"client:{name}" - bucket = algo_map[algo_key].setdefault(key, { - "name": name, - "kind": "kernel-client", - "score": 0 - }) - # Kernel clients are presented as primary requestor objects. - bucket["score"] += max(2, flows) - - result = {} - for algo_key, raw in algo_map.items(): - ranked = sorted(raw.values(), key=lambda x: x.get("score", 0), reverse=True) - if not ranked: - ranked = [{ - "name": "user/kernel request", - "kind": "generic", - "score": 1 - }] - result[algo_key] = ranked[:4] - return result - -def build_crypto_decision_pipelines(algorithm_competitions, kernel_clients, hw_offload, algorithm_requesters): - """Build visual decision pipeline metadata for each algorithm family.""" - hw_active = [h.get("engine") for h in (hw_offload or []) if h.get("status") == "active"] - hw_available = [h.get("engine") for h in (hw_offload or []) if h.get("status") == "available"] - capability_hint = ", ".join(hw_active[:2] or hw_available[:2]) if (hw_active or hw_available) else "generic-cpu-only" - - tfm_lookup_map = { - "AES": "crypto_alloc_skcipher(aes)", - "SHA": "crypto_alloc_shash(sha*)", - "CHACHA20": "crypto_alloc_skcipher(chacha20)" - } - - pipelines = {} - for key, comp in (algorithm_competitions or {}).items(): - request = str(comp.get("request", key)).upper() - impls = comp.get("implementations", []) or [] - shortlist = [str(x.get("name", "unknown")) for x in impls[:3]] - requesters = list((algorithm_requesters or {}).get(key, [])) - top_requester = requesters[0] if requesters else {"name": "user/kernel request", "kind": "generic"} - request_origin = f"{top_requester.get('kind', 'generic')}: {top_requester.get('name', 'unknown')}" - selected_driver = str((comp.get("selected") or {}).get("name", "unknown")) - fallback_driver = next((name for name in shortlist if "generic" in name.lower()), shortlist[-1] if shortlist else "none") - selected_is_generic = "generic" in selected_driver.lower() - selected_source = str((comp.get("selected") or {}).get("source", "kernel")).lower() - fallback_active = selected_is_generic and len(shortlist) > 1 - - pipelines[key] = { - "request": request, - "request_origin": request_origin, - "requesters": requesters, - "tfm_lookup": tfm_lookup_map.get(request, f"crypto_lookup({request.lower()})"), - "impl_shortlist": shortlist, - "priority_check": "max priority wins", - "capability_check": capability_hint, - "selected_driver": selected_driver, - "fallback_driver": fallback_driver, - "fallback_active": bool(fallback_active), - "fallback_reason": "higher-priority impl unavailable or unsupported" if fallback_active else "not-triggered", - "source": selected_source - } - return pipelines - -def collect_crypto_realtime(): - """ - Build a near-realtime list of processes likely interacting with kernel crypto. - This is heuristic-based and derived from active network/process context. - """ - items = [] - tls_listener_by_port = {} - tls_listener_names = set() - unknown_pid_flows = 0 - - try: - connections = psutil.net_connections(kind="inet") - except Exception: - connections = [] - - # Build TLS listener map first. This helps attribute ESTABLISHED sockets that - # may not expose pid under restricted privileges. - tls_ports = {443, 8443, 9443, 6443} - for conn in connections: - status = str(getattr(conn, "status", "") or "") - if status != "LISTEN": - continue - laddr = getattr(conn, "laddr", None) - local_port = getattr(laddr, "port", 0) if laddr else 0 - if int(local_port or 0) not in tls_ports: - continue - pid = getattr(conn, "pid", None) - pid_i = int(pid or 0) - process_name = "unknown" - if pid_i: - try: - process_name = psutil.Process(pid_i).name().lower() - except Exception: - process_name = f"pid-{pid_i}" - tls_listener_by_port[int(local_port)] = {"pid": pid_i, "process": process_name} - tls_listener_names.add(process_name) - items.append({ - "process": process_name, - "pid": pid_i, - "protocol": "TLS", - "algorithm": "AES-GCM/SHA256", - "endpoint": f"0.0.0.0:{int(local_port)}", - "local_port": int(local_port), - "remote_port": 0, - "status": "LISTEN", - "tls_terminator": process_name, - "source_kind": "listener" - }) - - for conn in connections: - pid = getattr(conn, "pid", None) - status = str(getattr(conn, "status", "") or "") - if status not in ("ESTABLISHED", "SYN_SENT", "SYN_RECV"): - continue - - laddr = getattr(conn, "laddr", None) - raddr = getattr(conn, "raddr", None) - local_ip = getattr(laddr, "ip", "") if laddr else "" - local_port = getattr(laddr, "port", 0) if laddr else 0 - remote_ip = getattr(raddr, "ip", "") if raddr else "" - remote_port = getattr(raddr, "port", 0) if raddr else 0 - - pid_i = int(pid or 0) - process_name = "unknown" - if pid_i: - try: - proc = psutil.Process(pid_i) - process_name = proc.name() - except Exception: - process_name = f"pid-{pid_i}" - else: - unknown_pid_flows += 1 - listener_meta = tls_listener_by_port.get(int(local_port or 0)) - if listener_meta: - process_name = listener_meta.get("process") or "unknown" - - protocol, algorithm = infer_crypto_protocol(local_port, remote_port, process_name) - if not is_likely_crypto_actor(process_name, local_port, remote_port, protocol): - continue - - tls_terminator = infer_tls_terminator(process_name, local_port, protocol, tls_listener_names) - endpoint = f"{remote_ip}:{remote_port}" if remote_ip else f"{local_ip}:{local_port}" - - items.append({ - "process": process_name.lower(), - "pid": pid_i, - "protocol": protocol, - "algorithm": algorithm, - "endpoint": endpoint, - "local_port": int(local_port or 0), - "remote_port": int(remote_port or 0), - "status": status, - "tls_terminator": tls_terminator, - "source_kind": "connection" - }) - - # If no sockets are available, still expose likely crypto actors. - if not items: - for proc in psutil.process_iter(attrs=["pid", "name"]): - try: - name = str(proc.info.get("name", "")).lower() - except Exception: - continue - if any(token in name for token in ["nginx", "sshd", "curl", "openssl", "kube", "vpn", "python"]): - protocol, algorithm = infer_crypto_protocol(0, 0, name) - items.append({ - "process": name, - "pid": int(proc.info.get("pid") or 0), - "protocol": protocol, - "algorithm": algorithm, - "endpoint": "-", - "local_port": 0, - "remote_port": 0, - "status": "RUNNING", - "tls_terminator": "n/a", - "source_kind": "process" - }) - if len(items) >= 12: - break - - # Deduplicate near-identical rows. - deduped = {} - for item in items: - key = ( - item.get("process"), - int(item.get("pid") or 0), - item.get("protocol"), - item.get("algorithm"), - item.get("endpoint"), - item.get("status"), - item.get("source_kind") - ) - if key not in deduped: - deduped[key] = item - items = list(deduped.values()) - - now = time.time() - prev_ts = CRYPTO_PREV["timestamp"] - prev_flows = CRYPTO_PREV["active_flows"] - active_flows = len(items) - CRYPTO_PREV["timestamp"] = now - CRYPTO_PREV["active_flows"] = active_flows - - if prev_ts: - dt = max(now - prev_ts, 0.001) - flow_delta = abs(active_flows - prev_flows) - ops_per_sec = round((active_flows * 90) + (flow_delta / dt) * 60, 2) - else: - ops_per_sec = round(active_flows * 90, 2) - - unique_processes = [] - for item in items: - p = item["process"] - if p not in unique_processes: - unique_processes.append(p) - - algorithm_competitions = { - "aes": collect_algorithm_competition("aes"), - "sha": collect_algorithm_competition("sha"), - "chacha20": collect_algorithm_competition("chacha20") - } - proc_crypto_entries = parse_proc_crypto_entries() - kernel_clients = collect_kernel_crypto_clients(items) - hw_offload = collect_hw_offload_status(proc_crypto_entries, algorithm_competitions) - crypto_stage1 = { - "kernel_clients": kernel_clients, - "sync_async": collect_sync_async_queue(items), - "hw_offload": hw_offload - } - algorithm_requesters = collect_algorithm_requesters(items, kernel_clients) - crypto_decision_pipelines = build_crypto_decision_pipelines( - algorithm_competitions=algorithm_competitions, - kernel_clients=kernel_clients, - hw_offload=hw_offload, - algorithm_requesters=algorithm_requesters - ) - entropy_cloud = collect_entropy_cloud_status() - - return { - "items": items[:24], - "processes": unique_processes[:16], - "meta": { - "ops_per_sec": ops_per_sec, - "tls_sessions": sum(1 for i in items if i.get("protocol") == "TLS"), - "active_flows": active_flows, - "unknown_pid_flows": int(unknown_pid_flows), - "tls_terminators": sorted(list(tls_listener_names))[:8], - "algorithm_competition": algorithm_competitions["aes"], - "algorithm_competitions": algorithm_competitions, - "algorithm_requesters": algorithm_requesters, - "crypto_stage1": crypto_stage1, - "entropy_cloud": entropy_cloud, - "crypto_decision_pipeline": crypto_decision_pipelines.get("aes", {}), - "crypto_decision_pipelines": crypto_decision_pipelines, - "source": "live-heuristic-v2", - "timestamp": datetime.utcnow().isoformat() + "Z" - } - } - -def collect_security_realtime(): - """ - Stage-1 security subsystem telemetry: - - Threat decision pipeline - - Process trust graph - - Attack surface map - """ - now = time.time() - process_rows = [] - suspicious_tokens = { - "nmap", "masscan", "hydra", "sqlmap", "metasploit", "msfconsole", - "netcat", "nc", "ncat", "socat", "john", "hashcat", "strace", "gdb" - } - trusted_tokens = { - "systemd", "sshd", "nginx", "python", "containerd", "dockerd", - "kubelet", "cron", "rsyslogd", "dbus-daemon" - } - ptrace_like = {"strace", "gdb", "ltrace"} - - def classify_trust(score): - if score >= 70: - return "blocked" - if score >= 48: - return "suspicious" - if score >= 28: - return "observe" - return "trusted" - - # Process sample and heuristic score. - for proc in psutil.process_iter(["pid", "name", "username", "memory_percent", "status", "num_threads"]): - try: - pid = int(proc.info.get("pid") or 0) - name = str(proc.info.get("name") or "unknown").lower() - mem = float(proc.info.get("memory_percent") or 0.0) - threads = int(proc.info.get("num_threads") or 0) - status = str(proc.info.get("status") or "unknown") - user = str(proc.info.get("username") or "") - - score = 12 - if any(tok in name for tok in suspicious_tokens): - score += 38 - if any(tok in name for tok in trusted_tokens): - score -= 10 - if user == "root": - score += 14 - if threads > 120: - score += 8 - if mem > 8.0: - score += 8 - if status in {"zombie", "stopped"}: - score += 10 - score = max(0, min(100, score)) - trust = classify_trust(score) - - process_rows.append({ - "pid": pid, - "name": name, - "trust": trust, - "risk_score": score, - "threads": threads, - "mem_percent": round(mem, 2), - "status": status, - "user": user - }) - except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): - continue - except Exception: - continue - - # Focus on the most security-relevant rows. - process_rows.sort(key=lambda p: (p["risk_score"], p["mem_percent"], p["threads"]), reverse=True) - trust_graph = process_rows[:12] - - # Build threat pipeline lanes from top rows. - request_candidates = [ - "open /etc/shadow", - "connect tcp:443", - "exec /usr/bin/sudo", - "ptrace attach", - "bpf program load", - "write /usr/lib/systemd/*" - ] - hook_candidates = [ - "security_file_open", - "security_socket_connect", - "security_bprm_check", - "seccomp-bpf", - "cgroup device policy", - "audit hook" - ] - lanes = [] - for idx, row in enumerate(trust_graph[:10]): - req = request_candidates[idx % len(request_candidates)] - hook = hook_candidates[idx % len(hook_candidates)] - score = int(row.get("risk_score") or 0) - if score >= 70: - verdict = "deny" - elif score >= 45: - verdict = "audit" - else: - verdict = "allow" - lanes.append({ - "process": row.get("name", "unknown"), - "pid": int(row.get("pid", 0)), - "request": req, - "hook": hook, - "verdict": verdict, - "reason": "risk-score-policy", - "risk_score": score - }) - - # Attack surface metrics. - try: - listen_ports = len([ - c for c in psutil.net_connections(kind="inet") - if str(getattr(c, "status", "") or "") == "LISTEN" - ]) - except Exception: - listen_ports = 0 - - try: - with open("/proc/modules", "r", encoding="utf-8") as f: - loaded_modules = sum(1 for _ in f) - except Exception: - loaded_modules = 0 - - ptrace_processes = sum(1 for p in process_rows if any(tok in p.get("name", "") for tok in ptrace_like)) - root_processes = sum(1 for p in process_rows if p.get("user") == "root") - suspicious_processes = sum(1 for p in process_rows if p.get("trust") in {"suspicious", "blocked"}) - - setuid_bins = 0 - try: - out = subprocess.check_output( - "find /usr/bin /usr/sbin -xdev -perm -4000 -type f 2>/dev/null | wc -l", - shell=True, - text=True, - timeout=1.8 - ).strip() - setuid_bins = int(out or 0) - except Exception: - setuid_bins = 0 - - attack_surface = [ - {"name": "open-listen-ports", "value": int(listen_ports), "severity": "high" if listen_ports > 40 else "medium"}, - {"name": "setuid-binaries", "value": int(setuid_bins), "severity": "high" if setuid_bins > 70 else "medium"}, - {"name": "loaded-kernel-modules", "value": int(loaded_modules), "severity": "medium" if loaded_modules > 180 else "low"}, - {"name": "ptrace-capable-processes", "value": int(ptrace_processes), "severity": "high" if ptrace_processes > 0 else "low"}, - {"name": "root-processes", "value": int(root_processes), "severity": "medium" if root_processes > 120 else "low"}, - {"name": "suspicious-processes", "value": int(suspicious_processes), "severity": "high" if suspicious_processes > 6 else "medium"} - ] - - # Stage 3: kernel security tools insights. - def _read_text(path): - try: - with open(path, "r", encoding="utf-8", errors="ignore") as f: - return str(f.read().strip()) - except Exception: - return "" - - # LSM status matrix (best effort, distro dependent). - apparmor_raw = _read_text("/sys/module/apparmor/parameters/enabled") - selinux_enforce = _read_text("/sys/fs/selinux/enforce") - selinux_mode = _read_text("/sys/fs/selinux/enforce") - selinux_policy = _read_text("/sys/fs/selinux/policyvers") - yama_scope = _read_text("/proc/sys/kernel/yama/ptrace_scope") - bpf_unpriv = _read_text("/proc/sys/kernel/unprivileged_bpf_disabled") - landlock_present = os.path.exists("/sys/kernel/security/landlock") - ima_present = os.path.exists("/sys/kernel/security/ima") - - # Check for BPF LSM (modern trend). - bpf_lsm_present = os.path.exists("/sys/kernel/security/bpf") - try: - lsm_list_raw = _read_text("/sys/kernel/security/lsm") - active_lsms = [x.strip() for x in lsm_list_raw.split(",")] if lsm_list_raw else [] - stacking_enabled = len([x for x in active_lsms if x in {"selinux", "apparmor", "bpf"}]) > 1 - except Exception: - active_lsms = [] - stacking_enabled = False - - lsm_status = [ - { - "name": "AppArmor", - "status": "enforcing" if apparmor_raw.lower().startswith("y") else ("disabled" if apparmor_raw else "unknown"), - "detail": apparmor_raw or "n/a", - "type": "policy_engine" - }, - { - "name": "SELinux", - "status": "enforcing" if selinux_enforce == "1" else ("disabled" if selinux_enforce == "0" else "unknown"), - "detail": selinux_enforce or "n/a", - "type": "policy_engine", - "policy_version": selinux_policy or "n/a" - }, - { - "name": "BPF LSM", - "status": "present" if bpf_lsm_present else "absent", - "detail": "eBPF-based LSM" if bpf_lsm_present else "n/a", - "type": "policy_engine" - }, - { - "name": "LSM Stacking", - "status": "enabled" if stacking_enabled else "disabled", - "detail": ",".join(active_lsms[:3]) if active_lsms else "n/a", - "type": "stacking" - }, - { - "name": "Yama ptrace", - "status": "hardened" if yama_scope in {"2", "3"} else ("relaxed" if yama_scope in {"0", "1"} else "unknown"), - "detail": yama_scope or "n/a", - "type": "restriction" - }, - { - "name": "unprivileged bpf", - "status": "blocked" if bpf_unpriv == "1" else ("allowed" if bpf_unpriv == "0" else "unknown"), - "detail": bpf_unpriv or "n/a", - "type": "restriction" - }, - { - "name": "Landlock", - "status": "present" if landlock_present else "absent", - "detail": "sysfs" if landlock_present else "n/a", - "type": "restriction" - }, - { - "name": "IMA/EVM", - "status": "present" if ima_present else "absent", - "detail": "sysfs" if ima_present else "n/a", - "type": "integrity" - } - ] - - # LSM engines detail for security core visualization. - lsm_engines = [] - if apparmor_raw.lower().startswith("y"): - lsm_engines.append({ - "name": "AppArmor", - "type": "policy_engine", - "status": "enforcing", - "hooks": ["file_open", "bprm_check", "socket_connect"], - "decisions_per_sec": random.randint(8, 45) - }) - if selinux_enforce == "1": - lsm_engines.append({ - "name": "SELinux", - "type": "policy_engine", - "status": "enforcing", - "hooks": ["file_open", "bprm_check", "socket_connect", "inode_create"], - "decisions_per_sec": random.randint(12, 52) - }) - if bpf_lsm_present: - lsm_engines.append({ - "name": "BPF LSM", - "type": "policy_engine", - "status": "enforcing", - "hooks": ["file_open", "bprm_check", "socket_connect"], - "decisions_per_sec": random.randint(5, 28) - }) - - # Capabilities drift (CapEff/CapPrm from /proc//status). - # Full capabilities map (all 40+ capabilities). - all_capabilities_map = { - 0: "CAP_CHOWN", 1: "CAP_DAC_OVERRIDE", 2: "CAP_DAC_READ_SEARCH", 3: "CAP_FOWNER", - 4: "CAP_FSETID", 5: "CAP_KILL", 6: "CAP_SETGID", 7: "CAP_SETUID", - 8: "CAP_SETPCAP", 9: "CAP_LINUX_IMMUTABLE", 10: "CAP_NET_BIND_SERVICE", - 11: "CAP_NET_BROADCAST", 12: "CAP_NET_ADMIN", 13: "CAP_NET_RAW", 14: "CAP_IPC_LOCK", - 15: "CAP_IPC_OWNER", 16: "CAP_SYS_MODULE", 17: "CAP_SYS_RAWIO", 18: "CAP_SYS_CHROOT", - 19: "CAP_SYS_PTRACE", 20: "CAP_SYS_PACCT", 21: "CAP_SYS_ADMIN", 22: "CAP_SYS_BOOT", - 23: "CAP_SYS_NICE", 24: "CAP_SYS_RESOURCE", 25: "CAP_SYS_TIME", 26: "CAP_SYS_TTY_CONFIG", - 27: "CAP_MKNOD", 28: "CAP_LEASE", 29: "CAP_AUDIT_WRITE", 30: "CAP_AUDIT_CONTROL", - 31: "CAP_SETFCAP", 32: "CAP_MAC_OVERRIDE", 33: "CAP_MAC_ADMIN", 34: "CAP_SYSLOG", - 35: "CAP_WAKE_ALARM", 36: "CAP_BLOCK_SUSPEND", 37: "CAP_AUDIT_READ", 38: "CAP_PERFMON", - 39: "CAP_BPF", 40: "CAP_CHECKPOINT_RESTORE" - } - dangerous_caps = { - 12: "CAP_NET_ADMIN", - 16: "CAP_SYS_MODULE", - 17: "CAP_SYS_RAWIO", - 19: "CAP_SYS_PTRACE", - 21: "CAP_SYS_ADMIN", - 39: "CAP_BPF" - } - capabilities_rows = [] - seccomp_counts = {"none": 0, "strict": 0, "filter": 0, "unknown": 0} - seccomp_processes = [] # For security core visualization. - capabilities_processes = [] # For security core visualization. - - # Common syscalls for seccomp visualization. - common_syscalls = [ - "read", "write", "open", "close", "stat", "fstat", "lstat", "poll", "lseek", - "mmap", "mprotect", "munmap", "brk", "rt_sigaction", "rt_sigprocmask", - "rt_sigreturn", "ioctl", "pread64", "pwrite64", "readv", "writev", - "access", "pipe", "select", "sched_yield", "mremap", "msync", "mincore", - "madvise", "shmget", "shmat", "shmctl", "dup", "dup2", "pause", "nanosleep", - "getitimer", "alarm", "setitimer", "getpid", "sendfile", "socket", "connect", - "accept", "sendto", "recvfrom", "sendmsg", "recvmsg", "shutdown", "bind", - "listen", "getsockname", "getpeername", "socketpair", "setsockopt", "getsockopt", - "clone", "fork", "vfork", "execve", "exit", "wait4", "kill", "uname", - "semget", "semop", "semctl", "shmdt", "msgget", "msgsnd", "msgrcv", "msgctl", - "fcntl", "flock", "fsync", "fdatasync", "truncate", "ftruncate", "getdents", - "getcwd", "chdir", "fchdir", "rename", "mkdir", "rmdir", "creat", "link", - "unlink", "symlink", "readlink", "chmod", "fchmod", "chown", "fchown", - "lchown", "umask", "gettimeofday", "getrlimit", "getrusage", "sysinfo", - "times", "ptrace", "getuid", "syslog", "getgid", "setuid", "setgid", - "geteuid", "getegid", "setpgid", "getppid", "getpgrp", "setsid", "setreuid", - "setregid", "getgroups", "setgroups", "setresuid", "getresuid", "setresgid", - "getresgid", "getpgid", "setfsuid", "setfsgid", "getsid", "capget", "capset", - "rt_sigpending", "rt_sigtimedwait", "rt_sigqueueinfo", "rt_sigsuspend", - "sigaltstack", "utime", "mknod", "uselib", "personality", "ustat", "statfs", - "fstatfs", "sysfs", "getpriority", "setpriority", "sched_setparam", - "sched_getparam", "sched_setscheduler", "sched_getscheduler", - "sched_get_priority_max", "sched_get_priority_min", "sched_rr_get_interval", - "mlock", "munlock", "mlockall", "munlockall", "vhangup", "modify_ldt", - "pivot_root", "prctl", "arch_prctl", "adjtimex", "setrlimit", "chroot", - "sync", "acct", "settimeofday", "mount", "umount2", "swapon", "swapoff", - "reboot", "sethostname", "setdomainname", "iopl", "ioperm", "create_module", - "init_module", "delete_module", "get_kernel_syms", "query_module", "quotactl", - "nfsservctl", "getpmsg", "putpmsg", "afs_syscall", "tuxcall", "security", - "gettid", "readahead", "setxattr", "lsetxattr", "fsetxattr", "getxattr", - "lgetxattr", "fgetxattr", "listxattr", "llistxattr", "flistxattr", - "removexattr", "lremovexattr", "fremovexattr", "tkill", "time", "futex", - "sched_setaffinity", "sched_getaffinity", "set_thread_area", "io_setup", - "io_destroy", "io_getevents", "io_submit", "io_cancel", "get_thread_area", - "lookup_dcookie", "epoll_create", "epoll_ctl_old", "epoll_wait_old", - "remap_file_pages", "getdents64", "set_tid_address", "restart_syscall", - "semtimedop", "fadvise64", "timer_create", "timer_settime", "timer_gettime", - "timer_getoverrun", "timer_delete", "clock_settime", "clock_gettime", - "clock_getres", "clock_nanosleep", "exit_group", "epoll_wait", "epoll_ctl", - "tgkill", "utimes", "vserver", "mbind", "set_mempolicy", "get_mempolicy", - "mq_open", "mq_unlink", "mq_timedsend", "mq_timedreceive", "mq_notify", - "mq_getsetattr", "kexec_load", "waitid", "add_key", "request_key", "keyctl", - "ioprio_set", "ioprio_get", "inotify_init", "inotify_add_watch", - "inotify_rm_watch", "migrate_pages", "openat", "mkdirat", "mknodat", - "fchownat", "futimesat", "newfstatat", "unlinkat", "renameat", "linkat", - "symlinkat", "readlinkat", "fchmodat", "faccessat", "pselect6", "ppoll", - "unshare", "set_robust_list", "get_robust_list", "splice", "tee", - "sync_file_range", "vmsplice", "move_pages", "utimensat", "epoll_pwait", - "signalfd", "timerfd_create", "eventfd", "fallocate", "timerfd_settime", - "timerfd_gettime", "accept4", "signalfd4", "eventfd2", "epoll_create1", - "dup3", "pipe2", "inotify_init1", "preadv", "pwritev", "rt_tgsigqueueinfo", - "perf_event_open", "recvmmsg", "fanotify_init", "fanotify_mark", - "prlimit64", "name_to_handle_at", "open_by_handle_at", "clock_adjtime", - "syncfs", "sendmmsg", "setns", "getcpu", "process_vm_readv", - "process_vm_writev", "kcmp", "finit_module", "sched_setattr", - "sched_getattr", "renameat2", "seccomp", "getrandom", "memfd_create", - "kexec_file_load", "bpf", "execveat", "userfaultfd", "membarrier", - "mlock2", "copy_file_range", "preadv2", "pwritev2", "pkey_mprotect", - "pkey_alloc", "pkey_free", "statx", "io_pgetevents", "rseq" - ] - - for row in process_rows[:180]: - pid = int(row.get("pid") or 0) - if pid <= 0: - continue - cap_eff_hex = "" - cap_prm_hex = "" - seccomp_mode = "unknown" - try: - with open(f"/proc/{pid}/status", "r", encoding="utf-8", errors="ignore") as f: - for ln in f: - if ln.startswith("CapEff:"): - cap_eff_hex = ln.split(":", 1)[1].strip() - elif ln.startswith("CapPrm:"): - cap_prm_hex = ln.split(":", 1)[1].strip() - elif ln.startswith("Seccomp:"): - seccomp_raw = ln.split(":", 1)[1].strip() - if seccomp_raw == "0": - seccomp_mode = "none" - elif seccomp_raw == "1": - seccomp_mode = "strict" - elif seccomp_raw == "2": - seccomp_mode = "filter" - else: - seccomp_mode = "unknown" - except Exception: - pass - - seccomp_counts[seccomp_mode] = seccomp_counts.get(seccomp_mode, 0) + 1 - - # Collect seccomp details for security core visualization. - if seccomp_mode in {"filter", "strict"}: - # Heuristic: generate allowed/blocked syscalls based on process type. - allowed_syscalls = [] - blocked_syscalls = [] - proc_name_lower = str(row.get("name", "")).lower() - if "nginx" in proc_name_lower or "apache" in proc_name_lower: - allowed_syscalls = ["read", "write", "open", "close", "socket", "accept", "send", "recv", "epoll_wait", "fstat"] - blocked_syscalls = ["ptrace", "mount", "umount", "sys_module", "bpf", "keyctl"] - elif "sshd" in proc_name_lower: - allowed_syscalls = ["read", "write", "open", "close", "socket", "accept", "send", "recv", "fork", "execve"] - blocked_syscalls = ["mount", "umount", "sys_module", "bpf"] - elif "docker" in proc_name_lower or "containerd" in proc_name_lower: - allowed_syscalls = ["read", "write", "open", "close", "socket", "clone", "unshare", "mount", "umount"] - blocked_syscalls = ["sys_module", "bpf"] - else: - # Generic: allow common syscalls, block dangerous ones. - allowed_syscalls = common_syscalls[:40] # First 40 common syscalls - blocked_syscalls = ["ptrace", "mount", "umount", "sys_module", "bpf", "keyctl", "kexec_load"] - - seccomp_processes.append({ - "pid": pid, - "name": row.get("name", "unknown"), - "mode": seccomp_mode, - "allowed_syscalls": allowed_syscalls[:20], # Limit for visualization - "blocked_syscalls": blocked_syscalls, - "sandbox_level": "strict" if seccomp_mode == "strict" else "filter" - }) - - if not cap_eff_hex: - continue - try: - cap_eff_val = int(cap_eff_hex, 16) - cap_prm_val = int(cap_prm_hex or "0", 16) - except Exception: - continue - - # Collect all capabilities (not just dangerous ones) for security core visualization. - all_caps = [all_capabilities_map.get(bit, f"CAP_{bit}") for bit in range(41) if (cap_eff_val & (1 << bit))] - matched = [name for bit, name in dangerous_caps.items() if (cap_eff_val & (1 << bit))] - - # Store capabilities as "keys" for visualization. - capabilities_processes.append({ - "pid": pid, - "name": row.get("name", "unknown"), - "user": row.get("user", ""), - "capabilities": all_caps[:15], # Limit for visualization - "dangerous_caps": matched, - "cap_eff_hex": cap_eff_hex, - "has_keys": len(all_caps) > 0 - }) - - if not matched: - continue - risk = min(100, 20 + len(matched) * 16 + (10 if row.get("user") == "root" else 0)) - capabilities_rows.append({ - "pid": pid, - "name": row.get("name", "unknown"), - "user": row.get("user", ""), - "seccomp": seccomp_mode, - "cap_eff": cap_eff_hex, - "cap_prm": cap_prm_hex or "0", - "dangerous": matched[:4], - "risk_score": int(risk) - }) - - capabilities_rows.sort(key=lambda x: (x.get("risk_score", 0), len(x.get("dangerous", []))), reverse=True) - capabilities_drift = capabilities_rows[:8] - - # Seccomp coverage summary + top unsandboxed risky processes. - total_seccomp_sample = max(1, sum(seccomp_counts.values())) - unsandboxed = [r for r in capabilities_rows if r.get("seccomp") == "none"] - unsandboxed.sort(key=lambda x: x.get("risk_score", 0), reverse=True) - seccomp_coverage = { - "none": int(seccomp_counts.get("none", 0)), - "strict": int(seccomp_counts.get("strict", 0)), - "filter": int(seccomp_counts.get("filter", 0)), - "unknown": int(seccomp_counts.get("unknown", 0)), - "coverage_percent": round((seccomp_counts.get("filter", 0) + seccomp_counts.get("strict", 0)) * 100.0 / total_seccomp_sample, 2), - "high_risk_unsandboxed": [ - { - "pid": int(r.get("pid", 0)), - "name": str(r.get("name", "unknown")), - "risk_score": int(r.get("risk_score", 0)) - } - for r in unsandboxed[:6] - ] - } - - prev_ts = SECURITY_PREV["timestamp"] - prev_events = int(SECURITY_PREV["events"] or 0) - current_events = len(lanes) - SECURITY_PREV["timestamp"] = now - SECURITY_PREV["events"] = current_events - if prev_ts: - dt = max(0.001, now - prev_ts) - decisions_per_sec = round((current_events / dt) + abs(current_events - prev_events) * 0.6, 2) - else: - decisions_per_sec = float(current_events) - - return { - "timestamp": datetime.utcnow().isoformat() + "Z", - "pipeline": { - "stages": [ - "request event", - "LSM/seccomp hook", - "policy verdict" - ], - "lanes": lanes - }, - "trust_graph": trust_graph, - "attack_surface": attack_surface, - "security_tools": { - "lsm_status": lsm_status, - "capabilities_drift": capabilities_drift, - "seccomp_coverage": seccomp_coverage - }, - "security_core": { - "lsm_engines": lsm_engines, - "seccomp_processes": seccomp_processes[:12], # Top 12 for visualization - "capabilities_processes": capabilities_processes[:12], # Top 12 for visualization - "stacking_enabled": stacking_enabled, - "active_lsms": active_lsms - }, - "meta": { - "decisions_per_sec": decisions_per_sec, - "events": current_events, - "trusted": sum(1 for p in trust_graph if p.get("trust") == "trusted"), - "observe": sum(1 for p in trust_graph if p.get("trust") == "observe"), - "suspicious": sum(1 for p in trust_graph if p.get("trust") == "suspicious"), - "blocked": sum(1 for p in trust_graph if p.get("trust") == "blocked"), - "seccomp_coverage_percent": seccomp_coverage.get("coverage_percent", 0.0), - "mode": "live-heuristic-v2" - } - } - -def _parse_meminfo_kb(): - """Linux /proc/meminfo values in kB (same units as psutil docs).""" - out = {} - try: - with open("/proc/meminfo", "r", encoding="utf-8", errors="ignore") as f: - for line in f: - parts = line.split() - if len(parts) >= 2 and parts[1].isdigit(): - out[parts[0].rstrip(":")] = int(parts[1]) - except Exception: - pass - return out - - -def _memory_strip_blocks(kind, kb_k, mem_total_kb, n_blocks, seed0): - """Variable-width blocks in one horizontal strip; heat ~ share of RAM in this category.""" - mem_total_kb = max(1, int(mem_total_kb)) - kb_k = max(0, int(kb_k)) - weights = [] - for i in range(n_blocks): - v = (((seed0 + i * 104729) % 1000) + 40) / 1040.0 - weights.append(v) - sw = sum(weights) - share = kb_k / float(mem_total_kb) - blocks = [] - for i in range(n_blocks): - w = weights[i] / sw - heat = min( - 1.0, - 0.05 + min(0.92, share * 2.0) + (((seed0 + i * 31) % 15) / 120.0), - ) - blocks.append({"w": round(w, 6), "heat": round(heat, 4), "kind": kind}) - return blocks - - -def _build_memory_visual_rows(meminfo_kb, syscall_nodes, vm, swap): - """ - Rows of horizontal strips: each row ≈ one kernel/accounting bucket from /proc/meminfo. - Block widths are stylistic subdivisions; row mass is proportional to kb / MemTotal. - """ - mi = meminfo_kb or {} - mt = max(1, int(mi.get("MemTotal") or 0)) - if mt <= 1: - try: - mt = max(1, int(getattr(vm, "total", 0) / 1024)) - except Exception: - mt = 1 - - seed_base = (mt % 100000) + int(mi.get("Active", 0) or 0) % 50000 - - row_specs = [ - ("buffers", "buffers", mi.get("Buffers", 0)), - ("cached", "page cache", mi.get("Cached", 0)), - ("anon", "anonymous (heap/stack)", mi.get("AnonPages", 0)), - ] - # Slab: split reclaimable vs unreclaimable when both exist (Linux 2.6.19+). - if mi.get("SReclaimable") is not None and mi.get("SUnreclaim") is not None: - row_specs.append(("sreclaim", "slab reclaimable", mi.get("SReclaimable", 0))) - row_specs.append(("sunreclaim", "slab unreclaimable", mi.get("SUnreclaim", 0))) - else: - row_specs.append(("slab", "slab / kmalloc", mi.get("Slab", 0))) - row_specs.extend( - [ - ("shmem", "shmem / tmpfs", mi.get("Shmem", 0)), - ("mapped", "file mappings", mi.get("Mapped", 0)), - ] - ) - dirty_wb = int(mi.get("Dirty", 0) or 0) + int(mi.get("Writeback", 0) or 0) + int( - mi.get("WritebackTmp", 0) or 0 - ) - if dirty_wb > 0: - row_specs.append(("dirty_wb", "dirty + writeback", dirty_wb)) - ah = int(mi.get("AnonHugePages", 0) or 0) - if ah > 0: - row_specs.append(("anon_huge", "transparent huge pages (anon)", ah)) - shm_h = int(mi.get("ShmemHugePages", 0) or 0) - if shm_h > 0: - row_specs.append(("shmem_huge", "huge pages (shmem)", shm_h)) - vmu = int(mi.get("VmallocUsed", 0) or 0) - if vmu > 0: - row_specs.append(("vmalloc", "vmalloc used", vmu)) - ac = int(mi.get("Active", 0) or 0) - iac = int(mi.get("Inactive", 0) or 0) - if ac > 0: - row_specs.append(("active", "active (LRU)", ac)) - if iac > 0: - row_specs.append(("inactive", "inactive (LRU)", iac)) - swap_tot = int(mi.get("SwapTotal", 0) or 0) - swap_free = int(mi.get("SwapFree", 0) or 0) - swap_used = max(0, swap_tot - swap_free) - if swap_tot > 0: - row_specs.append(("swap", "swap occupied", swap_used)) - - pt = int(mi.get("PageTables", 0) or 0) - ks = int(mi.get("KernelStack", 0) or 0) - if pt + ks > 0: - row_specs.append(("kmeta", "pagetables + kernel stacks", pt + ks)) - - rows = [] - for sk, label, kb in row_specs: - kb = int(kb or 0) - if kb <= 0 and sk != "swap": - continue - if sk == "swap" and kb <= 0: - continue - sk_seed = sum(ord(c) for c in sk) * 31 + len(sk) - n_blocks = 22 + (seed_base % 11) + (sk_seed % 9) - seed0 = seed_base + (sk_seed % 100000) - blocks = _memory_strip_blocks(sk, kb, mt, n_blocks, seed0) - rows.append( - { - "id": sk, - "label": label, - "kb": kb, - "pct_of_ram": round(100.0 * kb / float(mt), 2) if mt else 0.0, - "blocks": blocks, - } - ) - - top_tasks = sorted( - syscall_nodes, - key=lambda x: int(x.get("rss_bytes") or 0), - reverse=True, - )[:6] - if top_tasks: - tr_bytes = sum(int(x.get("rss_bytes") or 0) for x in top_tasks) or 1 - tr_kb = max(1, int(tr_bytes / 1024)) - task_blocks = [] - for p in top_tasks: - rss = int(p.get("rss_bytes") or 0) - if rss <= 0: - continue - w = rss / float(tr_bytes) - mp = float(p.get("memory_percent") or 0.0) - heat = min(1.0, 0.2 + (mp / 100.0) * 0.75 + (rss / float(tr_bytes)) * 0.15) - task_blocks.append( - { - "w": round(w, 6), - "heat": round(heat, 4), - "kind": "task", - "pid": int(p.get("pid") or 0), - "name": str(p.get("name") or "")[:14], - } - ) - if task_blocks: - sw = sum(b["w"] for b in task_blocks) - if sw > 0: - for b in task_blocks: - b["w"] = round(b["w"] / sw, 6) - rows.append( - { - "id": "tasks", - "label": "sampled tasks RSS (top)", - "kb": tr_kb, - "pct_of_ram": round(100.0 * tr_kb / float(mt), 2) if mt else 0.0, - "blocks": task_blocks, - } - ) - - dirty_kb = int(mi.get("Dirty", 0) or 0) - wb_kb = int(mi.get("Writeback", 0) or 0) - sr_kb = int(mi.get("SReclaimable", 0) or 0) - su_kb = int(mi.get("SUnreclaim", 0) or 0) - slab_total_kb = int(mi.get("Slab", 0) or 0) or (sr_kb + su_kb) - summary = { - "total_mb": round(mt / 1024.0, 1), - "used_percent": round(vm.percent, 1) if vm else 0.0, - "available_mb": round((mi.get("MemAvailable", 0) or 0) / 1024.0, 1), - "swap_percent": round(swap.percent, 1) if swap else 0.0, - "buffers_mb": round((mi.get("Buffers", 0) or 0) / 1024.0, 1), - "cached_mb": round((mi.get("Cached", 0) or 0) / 1024.0, 1), - "anon_mb": round((mi.get("AnonPages", 0) or 0) / 1024.0, 1), - "slab_mb": round(slab_total_kb / 1024.0, 1), - "sreclaimable_mb": round(sr_kb / 1024.0, 1), - "sunreclaim_mb": round(su_kb / 1024.0, 1), - "dirty_mb": round(dirty_kb / 1024.0, 2), - "writeback_mb": round(wb_kb / 1024.0, 2), - "dirty_writeback_mb": round(dirty_wb / 1024.0, 2), - "anon_huge_mb": round(ah / 1024.0, 2), - "shmem_huge_mb": round(shm_h / 1024.0, 2), - "vmalloc_mb": round(vmu / 1024.0, 2), - "active_mb": round(ac / 1024.0, 1), - "inactive_mb": round(iac / 1024.0, 1), - "swap_used_mb": round(swap_used / 1024.0, 1) if swap_tot else 0.0, - "source": "proc_meminfo+psutil+v2", - } - return rows, summary - - -def collect_processes_realtime(): - """ - Processes subsystem telemetry focused on: - - syscall interception signals - - network tracing - - security hooks - """ - lsm_raw = "" - try: - with open("/sys/kernel/security/lsm", "r", encoding="utf-8", errors="ignore") as f: - lsm_raw = str(f.read().strip()) - except Exception: - lsm_raw = "" - active_lsms = [x.strip() for x in lsm_raw.split(",") if x.strip()] - - yama_scope = "" - try: - with open("/proc/sys/kernel/yama/ptrace_scope", "r", encoding="utf-8", errors="ignore") as f: - yama_scope = str(f.read().strip()) - except Exception: - yama_scope = "" - - syscall_nodes = [] - seccomp_modes = {"none": 0, "strict": 0, "filter": 0, "unknown": 0} - for proc in psutil.process_iter(["pid", "ppid", "name", "username", "cpu_percent", "memory_percent", "num_threads"]): - try: - pid = int(proc.info.get("pid") or 0) - if pid <= 0: - continue - ppid = int(proc.info.get("ppid") or 0) - name = str(proc.info.get("name") or "unknown") - user = str(proc.info.get("username") or "") - cpu = float(proc.info.get("cpu_percent") or 0.0) - mem = float(proc.info.get("memory_percent") or 0.0) - threads = int(proc.info.get("num_threads") or 0) - rss = 0 - try: - rss = int(getattr(proc.memory_info(), "rss", 0) or 0) - except Exception: - rss = 0 - fd_count = 0 - try: - fd_count = int(proc.num_fds() or 0) - except Exception: - fd_count = 0 - seccomp_mode = "unknown" - with open(f"/proc/{pid}/status", "r", encoding="utf-8", errors="ignore") as f: - for ln in f: - if ln.startswith("Seccomp:"): - raw = ln.split(":", 1)[1].strip() - if raw == "0": - seccomp_mode = "none" - elif raw == "1": - seccomp_mode = "strict" - elif raw == "2": - seccomp_mode = "filter" - else: - seccomp_mode = "unknown" - break - seccomp_modes[seccomp_mode] = seccomp_modes.get(seccomp_mode, 0) + 1 - syscall_pressure = min(100, int(cpu * 1.5 + threads * 0.35 + mem * 0.8)) - syscall_nodes.append({ - "pid": pid, - "ppid": ppid, - "name": name, - "user": user, - "fd_count": fd_count, - "syscall_pressure": syscall_pressure, - "seccomp_mode": seccomp_mode, - "memory_percent": round(mem, 2), - "rss_bytes": rss, - }) - except Exception: - continue - syscall_nodes.sort(key=lambda x: x.get("syscall_pressure", 0), reverse=True) - syscall_nodes = syscall_nodes[:14] - - network_nodes = {} - try: - for conn in psutil.net_connections(kind="inet"): - pid = int(getattr(conn, "pid", 0) or 0) - if pid <= 0: - continue - remote_ip = "" - try: - raddr = getattr(conn, "raddr", None) - if raddr and len(raddr) >= 1: - remote_ip = str(raddr[0]) - except Exception: - remote_ip = "" - status = str(getattr(conn, "status", "") or "").upper() - bucket = network_nodes.get(pid) - if not bucket: - proc_name = "unknown" - try: - proc_name = psutil.Process(pid).name() - except Exception: - proc_name = "unknown" - bucket = { - "pid": pid, - "name": proc_name, - "connections": 0, - "remote_ips": set(), - "states": {} - } - network_nodes[pid] = bucket - bucket["connections"] += 1 - if remote_ip: - bucket["remote_ips"].add(remote_ip) - if status: - bucket["states"][status] = bucket["states"].get(status, 0) + 1 - except Exception: - pass - - network_tracing = [] - for _, row in network_nodes.items(): - states_sorted = sorted(row["states"].items(), key=lambda kv: kv[1], reverse=True) - top_state = states_sorted[0][0] if states_sorted else "UNKNOWN" - network_tracing.append({ - "pid": int(row["pid"]), - "name": str(row["name"]), - "connections": int(row["connections"]), - "unique_peers": int(len(row["remote_ips"])), - "peer_sample": sorted(list(row["remote_ips"]))[:4], - "top_state": top_state - }) - network_tracing.sort(key=lambda x: (x.get("connections", 0), x.get("unique_peers", 0)), reverse=True) - network_tracing = network_tracing[:14] - - security_hooks = [ - { - "name": "LSM stack", - "status": "active" if active_lsms else "unknown", - "detail": ",".join(active_lsms[:4]) if active_lsms else "n/a" - }, - { - "name": "SELinux/AppArmor engines", - "status": "active" if any(x in {"selinux", "apparmor"} for x in active_lsms) else "inactive", - "detail": "policy-enforcement-path" - }, - { - "name": "BPF LSM", - "status": "active" if "bpf" in active_lsms else "inactive", - "detail": "dynamic-policy-hook" - }, - { - "name": "seccomp filter gate", - "status": "active" if (seccomp_modes.get("filter", 0) + seccomp_modes.get("strict", 0)) > 0 else "inactive", - "detail": f"filter:{seccomp_modes.get('filter', 0)} strict:{seccomp_modes.get('strict', 0)}" - }, - { - "name": "Yama ptrace scope", - "status": "hardened" if yama_scope in {"2", "3"} else ("relaxed" if yama_scope in {"0", "1"} else "unknown"), - "detail": yama_scope or "n/a" - } - ] - - # Neural graph model: nodes=processes, edges=behavior interactions. - node_pool = {} - for row in syscall_nodes[:16]: - pid = int(row.get("pid") or 0) - if pid <= 0: - continue - node_pool[pid] = { - "pid": pid, - "ppid": int(row.get("ppid") or 0), - "name": str(row.get("name") or "unknown"), - "user": str(row.get("user") or ""), - "syscall_pressure": int(row.get("syscall_pressure") or 0), - "fd_count": int(row.get("fd_count") or 0), - "seccomp_mode": str(row.get("seccomp_mode") or "unknown"), - "connections": 0, - "unique_peers": 0, - "memory_percent": float(row.get("memory_percent") or 0.0), - "rss_bytes": int(row.get("rss_bytes") or 0), - } - for row in network_tracing[:16]: - pid = int(row.get("pid") or 0) - if pid <= 0: - continue - if pid not in node_pool: - node_pool[pid] = { - "pid": pid, - "ppid": 0, - "name": str(row.get("name") or "unknown"), - "user": "", - "syscall_pressure": 0, - "fd_count": 0, - "seccomp_mode": "unknown", - "connections": 0, - "unique_peers": 0, - "memory_percent": 0.0, - "rss_bytes": 0, - } - node_pool[pid]["connections"] = int(row.get("connections") or 0) - node_pool[pid]["unique_peers"] = int(row.get("unique_peers") or 0) - - edges = [] - edge_keys = set() - network_by_pid = {int(r.get("pid") or 0): r for r in network_tracing} - node_pids = sorted(node_pool.keys()) - - def _add_edge(src_pid, dst_pid, edge_type, weight): - src = int(src_pid or 0) - dst = int(dst_pid or 0) - if src <= 0 or dst <= 0 or src == dst: - return - if src not in node_pool or dst not in node_pool: - return - pair = tuple(sorted((src, dst))) - key = (pair[0], pair[1], edge_type) - if key in edge_keys: - return - edge_keys.add(key) - edges.append({ - "source": src, - "target": dst, - "type": edge_type, - "weight": float(max(0.1, min(1.0, weight))) - }) - - # IPC edges: parent-child links inside the sampled set. - for pid, node in node_pool.items(): - ppid = int(node.get("ppid") or 0) - if ppid in node_pool: - _add_edge(pid, ppid, "ipc", 0.72) - - # Syscalls edges: close-pressure processes likely competing on kernel hooks. - sorted_by_pressure = sorted(node_pool.values(), key=lambda n: n.get("syscall_pressure", 0), reverse=True) - for i in range(len(sorted_by_pressure) - 1): - a = sorted_by_pressure[i] - b = sorted_by_pressure[i + 1] - diff = abs(int(a.get("syscall_pressure", 0)) - int(b.get("syscall_pressure", 0))) - weight = 1.0 - min(0.8, diff / 100.0) - _add_edge(int(a.get("pid")), int(b.get("pid")), "syscalls", weight) - - # Network edges: connect nodes that share at least one peer sample. - for i in range(len(node_pids)): - for j in range(i + 1, len(node_pids)): - pa = node_pids[i] - pb = node_pids[j] - ra = network_by_pid.get(pa) or {} - rb = network_by_pid.get(pb) or {} - sa = set(ra.get("peer_sample") or []) - sb = set(rb.get("peer_sample") or []) - if sa and sb and (sa & sb): - _add_edge(pa, pb, "network", 0.88) - - # File access edges: processes with high FD count and same user. - for i in range(len(node_pids)): - for j in range(i + 1, len(node_pids)): - na = node_pool[node_pids[i]] - nb = node_pool[node_pids[j]] - if not na.get("user") or na.get("user") != nb.get("user"): - continue - fa = int(na.get("fd_count") or 0) - fb = int(nb.get("fd_count") or 0) - if fa >= 16 and fb >= 16: - _add_edge(int(na.get("pid")), int(nb.get("pid")), "file_access", 0.64) - - nodes = list(node_pool.values())[:18] - edges = edges[:64] - - try: - vm = psutil.virtual_memory() - swap = psutil.swap_memory() - meminfo_kb = _parse_meminfo_kb() - strip_rows, mem_summary = _build_memory_visual_rows(meminfo_kb, syscall_nodes, vm, swap) - memory_visual = { - "layout": "strips", - "rows": strip_rows, - "summary": mem_summary, - } - except Exception: - memory_visual = { - "layout": "strips", - "rows": [], - "summary": { - "total_mb": 0, - "used_percent": 0.0, - "available_mb": 0, - "swap_percent": 0.0, - "source": "error", - }, - } - - return { - "timestamp": datetime.utcnow().isoformat() + "Z", - "syscalls_interception": syscall_nodes, - "network_tracing": network_tracing, - "security_hooks": security_hooks, - "neural_graph": { - "nodes": nodes, - "edges": edges - }, - "memory_visual": memory_visual, - "meta": { - "processes_sampled": len(syscall_nodes), - "network_processes": len(network_tracing), - "seccomp_filter_percent": round( - (seccomp_modes.get("filter", 0) + seccomp_modes.get("strict", 0)) - * 100.0 / max(1, sum(seccomp_modes.values())), - 2 - ), - "mode": "live-heuristic-v1" - } - } - -@app.route('/api/kernel-dna') -def kernel_dna(): - """API endpoint for Kernel DNA visualization data""" - try: - data = get_kernel_dna_data() - return jsonify(data) - except Exception as e: - return jsonify({'error': str(e)}), 500 - -@app.route('/api/crypto-realtime') -def crypto_realtime(): - """Realtime-ish crypto interaction feed for crypto visualization.""" - try: - return jsonify(collect_crypto_realtime()) - except Exception as e: - return jsonify({'error': str(e)}), 500 - -@app.route('/api/security-realtime') -def security_realtime(): - """Realtime-ish security interaction feed for security visualization.""" - try: - return jsonify(collect_security_realtime()) - except Exception as e: - return jsonify({'error': str(e)}), 500 - -@app.route('/api/processes-realtime') -def processes_realtime(): - """Realtime-ish processes interaction feed for processes visualization.""" - try: - return jsonify(collect_processes_realtime()) - except Exception as e: - return jsonify({'error': str(e)}), 500 - -@app.route('/api/frontend-logs', methods=['POST', 'OPTIONS']) -def ingest_frontend_logs(): - """Receive frontend logs in ECS-like JSON and append to local JSONL file.""" - if request.method == 'OPTIONS': - return ('', 204) - - payload = request.get_json(silent=True) - if payload is None: - return jsonify({"error": "Invalid JSON payload"}), 400 - - events = payload.get("events", payload if isinstance(payload, list) else [payload]) - if not isinstance(events, list): - return jsonify({"error": "Expected event object or list of events"}), 400 - if len(events) > 100: - return jsonify({"error": "Batch too large"}), 413 +#!/usr/bin/env python3 +""" +WSGI entry point for Linux Kernel Visualization Backend. - accepted = 0 - for raw in events: - if not isinstance(raw, dict): - continue - write_frontend_event(raw) - accepted += 1 +Development: python app.py +Production: gunicorn -w 1 -b 0.0.0.0:8000 "kernel_ai.webapp:app" + or: gunicorn ... "app:app" +""" +from kernel_ai.config import Config +from kernel_ai.webapp import app, create_app - return jsonify({"status": "ok", "accepted": accepted}) +__all__ = ["app", "create_app"] -@app.errorhandler(404) -def not_found(error): - return jsonify({'error': 'Not found'}), 404 -@app.errorhandler(500) -def internal_error(error): - return jsonify({'error': 'Internal server error'}), 500 +if __name__ == "__main__": + from kernel_ai.webapp import get_system_info -if __name__ == '__main__': system_info = get_system_info() - print("🚀 Linux Kernel Visualization Backend") print(f"📍 Platform: {system_info['platform']}") print(f"🐧 Kernel: {system_info['kernel']}") print(f"🌐 Server: http://127.0.0.1:5001") - print(f"📊 API endpoints:") + print("📊 API endpoints:") print(f" - {Config.API_PREFIX}/syscalls-realtime") print(f" - {Config.API_PREFIX}/kernel-data") print(f" - {Config.API_PREFIX}/process-kernel-map") print(f" - {Config.API_PREFIX}/nginx-files") print(f" - {Config.API_PREFIX}/execution-context") - print(f" - /health") - + print(" - /health") + app.run( - host='0.0.0.0', + host="0.0.0.0", port=5001, debug=Config.DEBUG, - threaded=True + threaded=True, ) - diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..b096af3 --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,14 @@ +# Optional: use with gunicorn -c gunicorn.conf.py ... +# For Prometheus with multiple workers, set before starting gunicorn: +# export PROMETHEUS_MULTIPROC_DIR=/tmp/prometheus_kernel_ai +# rm -rf "$PROMETHEUS_MULTIPROC_DIR" && mkdir -p "$PROMETHEUS_MULTIPROC_DIR" + + +def child_exit(server, worker): + """Required for prometheus_client multiprocess mode (gunicorn -w N > 1).""" + try: + from prometheus_client import multiprocess + + multiprocess.mark_process_dead(worker.pid) + except ImportError: + pass diff --git a/kernel_ai/__init__.py b/kernel_ai/__init__.py new file mode 100644 index 0000000..e74858c --- /dev/null +++ b/kernel_ai/__init__.py @@ -0,0 +1,5 @@ +"""Linux Kernel Visualization Backend package.""" + +from kernel_ai.webapp import app, create_app + +__all__ = ["app", "create_app"] diff --git a/kernel_ai/__pycache__/__init__.cpython-310.pyc b/kernel_ai/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cdf68191b332e133bf641e04e1856bb443ef1549 GIT binary patch literal 289 zcmY*SK~BRk5OnOcMHQ9$1Ybkdga;smxPrJKamZz5Vk=tJu`MSN&>L^y6}+Z*Upesw zPHYMX7-?r_rJY&7S}hsj?QpxlmM`dxZ2J}u|t7d1MmVs2Yru?KzR`4WL2x-|wqXq-y=yEAbulpN%qj3i-V@mV)Vd32u1Dqo`nGcF1n2iFB;Q*%InM`B)TD!B>F!Ndq z>@sF@JA1^e7&CiaAgBaYRt;*b7Stj0gw-L_2pYT@G+7hSJy2G^M0E4q6EwTt;%ke9 zGS1qoh_-2(b~mWI7cyCpWr_rNYVw5b7f^YP!XzX&KIemInQp49!(>O;mXmM!=HEzAS;B#bioNx zH9{89NfdU2kPk~Lwi%lcmV^-2l1y2bJm>LBnIiefr7}0@^a4tA?mPalPrS)!;LcRb zcTc7x$9L!dA~k0^B-5Ge4bRlx+#L^yHypX-#5p6*u?rXVoYRr78q?Y2jobIhY%=k` zA-NBLOZDfVm!x@5M)Bpd-Zhso7f)%lwFp>SHq%eCSh6adYzI^Y8F*LQ#YXuxa0Qq7 zPE|fETDL$cJB2+F(Yb_`MFlMvQAvd@d6tM3S5`_T@GAF;lA`^8TpR0gS@?28;;Vw? Ptau0ps^A80={x#g4(ry~ literal 0 HcmV?d00001 diff --git a/kernel_ai/__pycache__/hooks.cpython-310.pyc b/kernel_ai/__pycache__/hooks.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ca1421c956ecaeca7a57ef9ad0b85c626cd123a GIT binary patch literal 1172 zcmah}%We}f6tyRhrjw*E5E7z`k-DHknIx!!Raz*a4q_G?PNUIPH9K5 zqJO|w@GES}dbX_i1tcV{owQ0N5SD!B_}cfJYx{b5xojh7U#_o%?>U5i=)r8WVDJ*U zTm?hXF`}4SAs$)B7DhBfvsdUiLvvkJ$tRV)ke`sy*$s(2bB3IsNoT#)IqFs&pZG)O z3>l$J$V%-ekf91ri~d*$7AcR!v5&QL+O%v2dJDQ-0rL@^8tE(a8Gphw1A^IP_lOMw zshDs`AFLL=Ohd$Q7kF9blu!!niOtg()^(ygAqw_Bc0p1hy%f7 zvS`Rp$vKg}2x65yfT{`uAFiE8^%;M`EcB&J^1+A30WnZ&Q0q$E$Ir<<*Q)pLf34X2UkBBKy4Qwrjh8)Xx-c7|yGjL>L0pv`|6H|s^x^R~feicY& z!DKTFSS$l*>x1z;%6-pEbA9~_FehRrXV~;n2t!$DjyN5MY)d~mnb%~fgiDz}G;BQa literal 0 HcmV?d00001 diff --git a/kernel_ai/__pycache__/prometheus_setup.cpython-310.pyc b/kernel_ai/__pycache__/prometheus_setup.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9261d7a2cf9297c2334c559d09ea4449625e1850 GIT binary patch literal 2616 zcma)8&2JPp6t_LIv$LDs%@-d5N;`)f*j7m>MJ*K&pb(l!Aw*;;VlJb}+Pj%BU$wnJ zwwp_X1W4u7Q+rB!Q77BT7`f*CWk zqmo;S%5FItaYv$xTZyV}HLAHajA+3fy@TSaiP+fIXBg>+TJ9(X*qRh~plZm^DUpyxqTIGnWuA-OMuBz8_EsqX#Ip)6%FWpQ(>KKm!!9Iez% zW#3{VO=7_mZmZ&^NaCQirHY(=-C;u3Ej4-d=1OB>rQtR1t}b}XR~idz4K;o>2}9PD z3BQ$}1zWBromeuiN{euK(&m1oM%yfAa1Z8%K!jA4s1wQ{^8Ybb?OOL(-&&cW|phk32J; z;vU*8(4%xJlSN=;Jw}gli_l~A_#+$S8?R41LEp<(nfk=~$?jrq6vmx()=D^=^IKpT zfF-l3vymkCgyYB5nHwMmI01xCJGjqc=X&Uidyb!`vt4_zq6u0aXj=C*c!f5E%seo_ zJK%RM?HA^XDlpCy-kk?y_x;0bh5?JE6DQIMVj+Eike+waARP>y{lzo+;^1#Ed>wNW zPI-=a6}pW4b7l_wYK{kS`}EvB#$y)F_(7lZfZLbk0f?P+Hr-MuhCcQ+_h=yeb-2d~ zt&0vjLM%-FCB%HyJsZS<^!7K+UI>$>ABy?e{cqlzf>zYzhLow-x3=(P0-AOw8@h8A z8;#Wg5aPL^ENz>FDjOmrO@de|3k)MWLY*9z;?YhXPM}OPiK*y*Ff7T@?hSDTc69m+ z+TR@d`ODwC@=~99bgBL04_O;9&j-vem)f8=v|k6zZv*D90b^e7uQ@berY|cah+B26 zitC-`JtoClK0-id<|8#=B*e!a#Diy0_*lm}_)Qr^OjT0GTV68{U#=ZXjbv8$z!Y5W zgPwC*m^wZLico*Rn;VSsaTwP}Rk6*$c_hz` zSWMOh9*;LDJ8LFRL!s{LTdK~|%KVc%_sa6LAROP00QB;6TKq4%|W04B(e)LXzzAd3% zRENDGWv_g7Woh}!7t0H(z>^MCA{|g1er%8`w8F%fs&+7fRrLTc;A{p|BR;CP={(J6 zG&!e9rq73(%k&w2Dsw7jx_CB$d^qETYu-kP-5_mDxYX>$SEyIvPIyxfVFG6r~B5 u3%X8b=MJHh>6NKuUx*S4aXPR6K!m=DiEMEADNx11cBujmZ-dKM3jYBrwcF7E literal 0 HcmV?d00001 diff --git a/kernel_ai/__pycache__/state.cpython-310.pyc b/kernel_ai/__pycache__/state.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3d51de1e9106d6cf7d388b78768dcf9485b3ce54 GIT binary patch literal 1070 zcmZuv%Wl&^6tyRb>%3DoNQgytV2L(-K!u207pjJ&O5B$>BiA!&O&m{{nWRm(d;%L5 zS-_I7>3+7X`Uj{8#I>DNZN-dyUvtiV$*fu}X>h(=Ux$8P(|#t-{Zjo! z(FuxlPY0cg&^YJibS>1pJjr_nf*#$%!t@M+LO>*v7>k0pmSWZx$TPvxi3wKm0k60) zywZ|YTI9TPiYo)ILUW%ruS)fAHBuqf@5rkYG|;TtRePVafC{m35fa)QPo;kv(Wa2T zq|KLsADmP1s!3=hebF2;-sIGeWH<&Wl{^fDwRr;rYF$fL`3?(4H(TIYrYa^NNHd;e=ip@EIv=t?+3uh0yS*jW>AL;i z{z>MoJI9WVA+zfoyIH6Tl@9v5?h^6z!3vJx!GUGid&)SYFzk#!gDHM!+ya$~+ZX@c zP_`=!yGsA>&BBe7FSjoGW RTU1PJ;}0^DHzVKB{{c9_LQMbw literal 0 HcmV?d00001 diff --git a/kernel_ai/__pycache__/webapp.cpython-310.pyc b/kernel_ai/__pycache__/webapp.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..38a9cac3180fe6d6d5ee2afc5fb997321aa930ab GIT binary patch literal 133393 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 rb>RtRE09)6co1csix}$}SKg>+|_QW_yww4 ziz{xVoqf)1Js9)};`Z~ccbFfc`45K87e@1mBuP#rmCVo+%Ogr;bf9@G<1I-$*U@Ub z&c=()a$K6TvQAfR!=I=kDfO(hzFSxgrOncgPXJ`S2k^tAa+NYrXpUR%RMWcf(nUz& ztS|=PPFxa(fnHqHwiRf9n2NW0Qs}=5eFXXl0A1-8fZL1j%}%vE8#Sk>Zn{V@`Cxta E3o}9s&lT6=V#6Qude3K9dB^3sNl8t=O1F_g*PhnLvc@e zsjl@g89-8kK_&$zLr8{Ugh@Y>GLj06GAS|{LoyB%Oa_=tBAJ2{OiE0qk<7qJCWA~) zAvq0am<%zgA~_4^m<%&HkK_VeWHQ3!5|Ybsg-Mx74arsbgh_?TH6(SIWirY{M{*rL zWirO(29nR0 z(@efcavvTrnPKt+k{@A#$w?+ZA$bTtGdU&lzxBc|un0?=8Z5uk;8%FW|D1-E6{WTs zJuW`Fzp`4rySz|sx*%kAeO)(wdwqSjy3p_)(GYHEhPLMhRa1(p?cxL5Lb}2+Ondg3 zcw+kg`g-+>V@A7GyWx8>tj!kxp`@y*asNH1DSTye4VOL=`>Q4J^}fg1Qu@ z6WR@NVCMKtI`enr5h^d0*XkRb^ILVX)`}9BlORSCgs0eXBWXE^w_zObXf28w+fgZ* zJeZ;RL2Wre0ZMpFGzZ$NLv9^Hv^0{{NNJ3$A-a(jjq1MiED;3t_JTD`zYEDq+uLYM zqj#nDG7y;b8JhVGaYm#k@w^MC?Ufh=i~>}VomMwZ|F z;A|w9p3YJ^+&Keul`|OkeQhct4~d_m8(Epk8%M0eN0iYuQ~3;PEvL$4?H6jyb`YV7 zl?=N0{1Y87ECM5m(DBOq$3zwN9+NsB6J%krwtcJa*lt(3&czxX4R*}bI*ZZQQZ2Hu zSW|tkH_TAJ*EHb2>Ct<*n55s-Xt`$Uox>0Yj@G-86%DoY=%B}Wc2haa$)XyGbEf8b zRDo2JEZoatM_5fd9FDP<$4NI0aO8fzfNFNCO%@&#?V}#tZn}$N(p|XO&BaBG?WRg( zMI*W;cm2>iPI(?~xgK4eFQH11YLZ3m8!oK0!`;kCnRsXB5*_(3Qf0C*e>WsmbQkK5w;9Cw<)xL?xNm8Bbzy071$*Sah6uMjtWx;& zfs1PDJdVSAX$*U4Y?O^8vO$qECv*+{f2SB+EXZ1{or(Jl13b$x;+_r147MdH#RbD~ z&4xg#J@L8Oj(fOS;kK9iQAw?#q@GPuGbO37k=(MPu8jFPfL(x;N#HqlJ8UMMciH9TlK;x?UU!4}@~N+~y_6>^2%qFTuR KKVu|6r2Ypnt_AY| literal 0 HcmV?d00001 diff --git a/kernel_ai/api/rest.py b/kernel_ai/api/rest.py new file mode 100644 index 0000000..283e480 --- /dev/null +++ b/kernel_ai/api/rest.py @@ -0,0 +1,132 @@ +""" +REST API under ``/api``. Implementations are in ``kernel_ai.webapp`` (lazy import). +""" +from flask import Blueprint + +bp = Blueprint("api", __name__, url_prefix="/api") + + +def _core(): + from kernel_ai import webapp as core + + return core + + +@bp.route("/syscalls-realtime") +def syscalls_realtime(): + return _core().syscalls_realtime() + + +@bp.route("/kernel-data") +def kernel_data(): + return _core().kernel_data() + + +@bp.route("/process-kernel-map") +def process_kernel_map(): + return _core().process_kernel_map() + + +@bp.route("/processes") +def get_processes(): + return _core().get_processes() + + +@bp.route("/nginx-files") +def nginx_files(): + return _core().nginx_files() + + +@bp.route("/active-connections") +def active_connections(): + return _core().active_connections() + + +@bp.route("/traceroute") +def traceroute_info(): + return _core().traceroute_info() + + +@bp.route("/network-stack-realtime") +def network_stack_realtime(): + return _core().network_stack_realtime() + + +@bp.route("/devices-realtime") +def devices_realtime(): + return _core().devices_realtime() + + +@bp.route("/filesystem-blocks") +def filesystem_blocks(): + return _core().filesystem_blocks() + + +@bp.route("/isolation-context") +def isolation_context(): + return _core().isolation_context() + + +@bp.route("/process//threads") +def get_process_threads(pid): + return _core().get_process_threads(pid) + + +@bp.route("/process//cpu") +def get_process_cpu(pid): + return _core().get_process_cpu(pid) + + +@bp.route("/process//fds") +def get_process_fds(pid): + return _core().get_process_fds(pid) + + +@bp.route("/processes-detailed") +def get_processes_detailed(): + return _core().get_processes_detailed() + + +@bp.route("/ipc-links") +def get_ipc_links(): + return _core().get_ipc_links() + + +@bp.route("/proc-matrix") +def get_proc_matrix(): + return _core().get_proc_matrix() + + +@bp.route("/proc-timeline") +def get_proc_timeline(): + return _core().get_proc_timeline() + + +@bp.route("/execution-context") +def get_execution_context(): + return _core().get_execution_context() + + +@bp.route("/kernel-dna") +def kernel_dna(): + return _core().kernel_dna() + + +@bp.route("/crypto-realtime") +def crypto_realtime(): + return _core().crypto_realtime() + + +@bp.route("/security-realtime") +def security_realtime(): + return _core().security_realtime() + + +@bp.route("/processes-realtime") +def processes_realtime(): + return _core().processes_realtime() + + +@bp.route("/frontend-logs", methods=["POST", "OPTIONS"]) +def ingest_frontend_logs(): + return _core().ingest_frontend_logs() diff --git a/kernel_ai/collectors/__init__.py b/kernel_ai/collectors/__init__.py new file mode 100644 index 0000000..016a432 --- /dev/null +++ b/kernel_ai/collectors/__init__.py @@ -0,0 +1,15 @@ +"""Low-level readers for /proc, /sys, and related paths (injectable in tests).""" + +from kernel_ai.collectors.proc_fs import ( + read_diskstats, + read_interrupt_lines, + read_tty_irq_total, + safe_read_text, +) + +__all__ = [ + "read_diskstats", + "read_interrupt_lines", + "read_tty_irq_total", + "safe_read_text", +] diff --git a/kernel_ai/collectors/__pycache__/__init__.cpython-310.pyc b/kernel_ai/collectors/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ef0382170dc0e79320c04b720b32974da58b33c7 GIT binary patch literal 409 zcmYk2u};G<5Qd#JZ2{2^h=GB@V=J`@3mZc0tytKyoJ@Qzrj8x#D^MPXji-S*D-*B4 zgxw;+N%zVBo&4X~b}%?#A?6?NYL+ne8O8rdgSZOmM*)(ROeQkbNu8Ey-6=bDR%Ug# z>?Ul!c&n8mbZ*@cXl2m1N82jV29$dMvLtjn z^kRXqYv40okNa&OK-vnRJzb$Qs6nW_vRa4n*7-c{2@CJy`&}m@i|7W#t3qplH|F+Z zG2;8YuniSXndMorbDRqGdkWsgpLYSEj3R)%*|d8XwUuj)m*Ml7?*(M+Bt7i^07h1M ALjV8( literal 0 HcmV?d00001 diff --git a/kernel_ai/collectors/__pycache__/proc_fs.cpython-310.pyc b/kernel_ai/collectors/__pycache__/proc_fs.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..83d0c2c784f3f43f6184a5a9432ceed796b61708 GIT binary patch literal 2496 zcmZuz&u<$=6yDiicGiyLx+zsu6$Vsj+%`6)MYN?uz#$m{tDi)HqVnH{#hU?7O2n&+)> zsU(#faW7=FE%;FsD<8KcQyX@%ky=uT)D>Ly zM6~BYgC=w_j(C$`%=j_k7jt_Xv$qD$@GWH|I)BV!_D}ZDGb56aZL(u*n>)q>!!(#< z8@91!5oj$NmkoKQPl$n^hwyy>6v9 zXbY15AEzJqu3H>I2YNmM}HDeY;>#3`!$;GOh+Hr?R zsV!jn)PVyBoiulM`Ey(*ow>(FJCG6*lywyEM*%Fsb*pL$<(-3F6rNm;n zIb&oe_O`|HJ49BsH?>2=g>Gp$t8AFz%^6!{R3eK&Cu_cVZ?*f#;jZV5ni24C#M z*DduC+~NWFNe2JQ%kX0_M7jgMABjBx{Pggvd%lpI>V(x;c z)4`zB##5xl@zymD(wY4h9zpR)(~iHFyIg#3V$X9?g+TEpn3s6vLI-B%HWv9W;4lKbx&yp2vB;UjT;SD} zF|Xb^;1$ay)?*^hvV8Un+%i!HkXvkuc|V$S1~9QWauR3T+A+kH!~jM)TjT>^Zufk* z1dJ)X!kABvWsEaeI>MOZ@aj_BTi>87D-Y|Ei@*=*mrLJJAH?->pa!%h;`W$M02K&9 zucKs5pup{wGlCrSAIFf?QfiR8+1k4pC(eQaF&Q%>jQg(H0x0?C5)KT7Rk$meIH*A*5Z#83z6i7BB`^n&mo?<&4E!I==jML^b)oXi zE=-_)4+36784F;7@^8>sNAq^~d<22#?M8=-2KHC=O??#lnz#V@IF0~&R@~ludn6Wuk%`61e=KGw9Q35zF7q-d%2C T8f6iTZ4`5! str | None: + """Read a small text file; return None on error.""" + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + return f.read().strip() + except (OSError, PermissionError, UnicodeError): + return None + + +def read_diskstats() -> dict: + """Parse /proc/diskstats into name -> combined sectors (read+write) counters.""" + stats: dict[str, int] = {} + try: + with open("/proc/diskstats", "r", encoding="utf-8", errors="replace") as f: + for line in f: + parts = line.split() + if len(parts) < 14: + continue + name = parts[2] + if name.startswith("loop") or name.startswith("ram"): + continue + try: + sectors_read = int(parts[5]) + sectors_written = int(parts[9]) + stats[name] = sectors_read + sectors_written + except ValueError: + continue + except OSError: + pass + return stats + + +def read_tty_irq_total() -> int: + """Rough TTY/serial IRQ activity from /proc/interrupts.""" + total = 0 + try: + with open("/proc/interrupts", "r", encoding="utf-8", errors="replace") as f: + for line in f: + lower = line.lower() + if "tty" not in lower and "serial" not in lower: + continue + parts = line.split() + for token in parts[1:9]: + if token.isdigit(): + total += int(token) + except OSError: + pass + return total + + +def read_interrupt_lines(): + """Return list of (line_lower, irq_sum_per_line) for /proc/interrupts.""" + out = [] + try: + with open("/proc/interrupts", "r", encoding="utf-8", errors="replace") as f: + for line in f: + if ":" not in line: + continue + raw = line.strip() + parts = raw.split() + if len(parts) < 2: + continue + irq_sum = 0 + for token in parts[1:]: + if token.isdigit(): + irq_sum += int(token) + else: + break + out.append((raw.lower(), irq_sum)) + except OSError: + pass + return out diff --git a/kernel_ai/config.py b/kernel_ai/config.py new file mode 100644 index 0000000..372b4f2 --- /dev/null +++ b/kernel_ai/config.py @@ -0,0 +1,18 @@ +"""Application configuration.""" +import os +from pathlib import Path + +# Project root (parent of ``kernel_ai`` package) +PROJECT_ROOT = Path(__file__).resolve().parent.parent + + +class Config: + """Flask config loaded via app.config.from_object(Config).""" + + DEBUG = os.getenv("FLASK_DEBUG", "False").lower() == "true" + ENV = os.getenv("FLASK_ENV", "production" if not DEBUG else "development") + STATIC_FOLDER = "static" + TEMPLATES_FOLDER = "templates" + API_PREFIX = "/api" + SEND_FILE_MAX_AGE_DEFAULT = 0 if not DEBUG else 31536000 + PROJECT_ROOT = PROJECT_ROOT diff --git a/kernel_ai/hooks.py b/kernel_ai/hooks.py new file mode 100644 index 0000000..20874fe --- /dev/null +++ b/kernel_ai/hooks.py @@ -0,0 +1,34 @@ +"""Global Flask hooks (CORS, cache headers).""" +from flask import current_app + + +def register_hooks(app): + """Register after_request handler for CORS and static/HTML cache control.""" + + @app.after_request + def add_headers(response): + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Content-Type" + + if response.content_type and "text/html" in response.content_type: + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + + if response.content_type and ( + "text/javascript" in response.content_type + or "application/javascript" in response.content_type + or "text/css" in response.content_type + or "image/" in response.content_type + ): + if current_app.config["DEBUG"]: + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + else: + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + if "Pragma" in response.headers: + del response.headers["Pragma"] + return response diff --git a/kernel_ai/http/__init__.py b/kernel_ai/http/__init__.py new file mode 100644 index 0000000..e67abff --- /dev/null +++ b/kernel_ai/http/__init__.py @@ -0,0 +1,5 @@ +"""HTTP route registration.""" + +from kernel_ai.http.register import register_http_routes + +__all__ = ["register_http_routes"] diff --git a/kernel_ai/http/__pycache__/__init__.cpython-310.pyc b/kernel_ai/http/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3f5e9130a49ed4d35f29d551e04cae41f9f633eb GIT binary patch literal 271 zcmd1j<>g`kf|d*CGi!nLV-N=!FabFZKwK;ZBvKfn7*ZHhm~t3%nWC5&8B&5b-YN{4zTja8L*^lZenmV)weYy+lGO3tY&jh3aXJol=u9OYJ0A;+qMqpG5F zIpoG<`3Tf(Eddn$8r=0EL=_s9_!Z+7CV&Y>3-p#O$U9UUgd-9NlqR=7a1CWRkfF)a z(3?tINzJ&*xly^5&f7R^IQ?%#xh^++B3`+lOt>oA-T;+ g*k?@CBgUFS^W;v5-x^vMWi4h}UdClMOeyLlf5}sY8~^|S literal 0 HcmV?d00001 diff --git a/kernel_ai/http/register.py b/kernel_ai/http/register.py new file mode 100644 index 0000000..ff960f8 --- /dev/null +++ b/kernel_ai/http/register.py @@ -0,0 +1,9 @@ +"""Register Flask blueprints (called after all view implementations are defined in webapp).""" + + +def register_http_routes(app): + from kernel_ai.api.rest import bp as api_bp + from kernel_ai.views.pages import bp as pages_bp + + app.register_blueprint(pages_bp) + app.register_blueprint(api_bp) diff --git a/kernel_ai/prometheus_setup.py b/kernel_ai/prometheus_setup.py new file mode 100644 index 0000000..a2caef0 --- /dev/null +++ b/kernel_ai/prometheus_setup.py @@ -0,0 +1,76 @@ +"""Prometheus metrics registration (optional dependency).""" +import os +import time + +from flask import Response, g, jsonify, request + +try: + from prometheus_client import ( + CONTENT_TYPE_LATEST, + CollectorRegistry, + Counter, + Histogram, + generate_latest, + multiprocess, + ) + + _PROMETHEUS_AVAILABLE = True +except ImportError: + _PROMETHEUS_AVAILABLE = False + CONTENT_TYPE_LATEST = "text/plain; version=0.0.4; charset=utf-8" + + +def init_prometheus(app): + """Register before/after request hooks and /metrics on the given Flask app.""" + if not _PROMETHEUS_AVAILABLE: + + @app.route("/metrics") + def prometheus_metrics_disabled(): + return jsonify( + {"error": "prometheus_client not installed; pip install prometheus-client"} + ), 503 + + return + + request_count = Counter( + "http_requests_total", + "Total HTTP requests", + ["method", "endpoint", "status"], + ) + request_latency = Histogram( + "http_request_duration_seconds", + "HTTP request latency in seconds", + buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, float("inf")), + ) + + @app.before_request + def _prometheus_before_request(): + g._prom_start = time.perf_counter() + + @app.after_request + def _prometheus_after_request(response): + start = getattr(g, "_prom_start", None) + if start is not None: + request_latency.observe(time.perf_counter() - start) + ep = request.endpoint + rule = request.url_rule.rule if request.url_rule else None + endpoint_label = ep or rule or "unmatched" + try: + request_count.labels( + method=request.method, + endpoint=endpoint_label, + status=str(response.status_code), + ).inc() + except Exception: + pass + return response + + @app.route("/metrics") + def prometheus_metrics(): + if os.environ.get("PROMETHEUS_MULTIPROC_DIR", "").strip(): + registry = CollectorRegistry() + multiprocess.MultiProcessCollector(registry) + data = generate_latest(registry) + else: + data = generate_latest() + return Response(data, mimetype=CONTENT_TYPE_LATEST) diff --git a/kernel_ai/state.py b/kernel_ai/state.py new file mode 100644 index 0000000..e8e4d94 --- /dev/null +++ b/kernel_ai/state.py @@ -0,0 +1,50 @@ +"""Process-wide mutable state (caches, deltas for realtime metrics).""" +import os +from threading import Lock + +TRACEROUTE_CACHE = {} +TRACEROUTE_CACHE_TTL_SECONDS = 60 +NETWORK_STACK_PREV = { + "timestamp": None, + "tcpext_retrans": None, + "ip_in": None, + "ip_out": None, + "ip_discards": None, + "iface_rx": None, + "iface_tx": None, + "iface_drops": None, +} +DEVICES_PREV = { + "timestamp": None, + "disk_sectors": {}, + "net_bytes": {}, + "tty_irq_total": None, + "irq_by_key": {}, +} +FILESYSTEM_PREV = { + "timestamp": None, + "write_bytes": None, +} +CRYPTO_PREV = { + "timestamp": None, + "active_flows": 0, +} +ENTROPY_PREV = { + "timestamp": None, + "disk_read_bytes": None, + "disk_write_bytes": None, + "net_sent_bytes": None, + "net_recv_bytes": None, + "interrupt_total": None, +} +EXEC_CONTEXT_PREV = { + "timestamp": None, + "irq_totals": {}, + "softirq_totals": {}, +} +SECURITY_PREV = { + "timestamp": None, + "events": 0, +} +FRONTEND_LOG_WRITE_LOCK = Lock() +FRONTEND_LOG_FILE = os.getenv("FRONTEND_LOG_FILE", "/opt/ring0/kernel-ai/logs/frontend-events.jsonl") diff --git a/kernel_ai/views/__init__.py b/kernel_ai/views/__init__.py new file mode 100644 index 0000000..9b55b73 --- /dev/null +++ b/kernel_ai/views/__init__.py @@ -0,0 +1,5 @@ +"""HTML and public page blueprints.""" + +from kernel_ai.views.pages import bp as pages_bp + +__all__ = ["pages_bp"] diff --git a/kernel_ai/views/__pycache__/__init__.cpython-310.pyc b/kernel_ai/views/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..80983f603ad9e192f7826c80a36d2df09d9e1d41 GIT binary patch literal 270 zcmYjLy-ve05VoE4M-?5rbn=>kxDQYvMuyUbPFYS)ZABJt;@SzQNIVcv1G85qUV*9S zmVuM*yT9+gyKX+85w!QOPjkfhor?d)u((GLO9VwKqNrk)9$1x8qOyioxyqkNF?!<< zyT=Wet>SK|t=V%Y8_jDwXy;Aahw=v%NI@g3U7V(2fZA<~QJes%ZVwO_&)T=zf;8oe z(XXMruzdQ#X9$TYtp)fXev+CAepjM%y}%pIN?ZkQrTNQHN?fD_fN4z+P`Xo`t`D6W QtiGeTAI1ndxnVPQ25A{b@c;k- literal 0 HcmV?d00001 diff --git a/kernel_ai/views/__pycache__/pages.cpython-310.pyc b/kernel_ai/views/__pycache__/pages.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..37bb6a29c4967ef8ce703a4390e8096afdc24be6 GIT binary patch literal 2562 zcmbW3&u`l{6vrvqj%~%x?=(%)rfRotbr)MZ?6R#WhV{}z8w~5VgD;9Avat}!5=c3z zh3%C7A?<1CU$dTd-D&6EcG`PSHjxcc7exRGzmLTCzK?t)9e2AHgXjJGKY|~cjQxqm z#is_wchJ;|!5C*l#tm+UMr006g9($@MD5%h)_I*bUbA6C)ZZ-dg|`N8^44p1ZVa15 z+CVJ6NTfxi1EkBBh_s0;16kp#L@Xj}K-T#Nkwqe#KyL6YA{`<(fo$^~B3&Z8KyLBd zM3#u`0lC9%BFjYX0=dUOA+kc`Qy};GXGB(sd=BIR?-5xe@({=ue4ofV@4sNZLH4`# zB1nauc*jD0V+Ug{UhUgza-^mz6_Ne)r{~Z1?UC@pbOb#0(!jT02BA;`%lah{XLb-J zp@_s7U&Ui(hry`;Klb6_iI8Ivx?V6i6GvW>93Fmar=vjGQOqZyu;Xz!wa)@6l>PIM z&y@Wj^s*`Xi)Ctir*XiQ?N5D()f-rU;iC48y#0MR5s3`OY2F~wKE9Ygu*S@U_5_+* zfsR(2MaFN;V81fRxOO1%Bk;6PsOp({TPv0I;$7X3rFh3AR22KW@5D*!K*-}So#M;B z7tDS^cFrkGgK|jBAerV37Y8rGNQK@yM%|Qc^kX>x(h5jx{eVTBvUB#vcnd1u83(;$ zP~M;=At4vK7JM$d(5*uIfEimR+1Zv82II-AzAvXq8u#@|mWGz7RCb`7hukF(ZsEbz z9$d`uf(D!$NkW$4*A29mutO_rI>qn`Vb^8>F^n6EW6z&n8V#3qS4TrD+nyz@gg=o% zI=xofw~MsrKCY5D&c3aspp~_qxq}L+Z_LNg9DgkNHHdLvM?@>znI)gd*cVC(b*=38 zitM$MtE9gU;U8)-Xl0h8JyghlQxE#z3D|%rj*f{|cB`c12ct9!E2t=|dvtSYC2m6W zue27lvc1wutg5EG982}!9{gJCK`XmcUK~{geWXH46>Wir$666u`hbc=6wB-F)Y(E6 z9lupSuM1QKapaq9zaluxo)lM%l;y2Q#UFsjcoC~d(A8O|VVSL3Yr!&F^(t-2_y?d} B9Ekt` literal 0 HcmV?d00001 diff --git a/kernel_ai/views/pages.py b/kernel_ai/views/pages.py new file mode 100644 index 0000000..fd59bfa --- /dev/null +++ b/kernel_ai/views/pages.py @@ -0,0 +1,82 @@ +""" +Site pages: index, subsystem HTML, health, static files. + +View implementations live in ``kernel_ai.webapp``; this module only wires URLs (lazy import avoids cycles). +""" +from flask import Blueprint + +bp = Blueprint("pages", __name__) + + +def _core(): + from kernel_ai import webapp as core + + return core + + +@bp.route("/") +def index(): + return _core().index() + + +@bp.route("/linux-crypto-subsystem") +def linux_crypto_subsystem_page(): + return _core().linux_crypto_subsystem_page() + + +@bp.route("/crypto") +def crypto_page_legacy(): + return _core().crypto_page_legacy() + + +@bp.route("/linux-security-subsystem") +def linux_security_subsystem_page(): + return _core().linux_security_subsystem_page() + + +@bp.route("/security") +def security_page_legacy(): + return _core().security_page_legacy() + + +@bp.route("/linux-processes-subsystem") +def linux_processes_subsystem_page(): + return _core().linux_processes_subsystem_page() + + +@bp.route("/processes") +def processes_page_legacy(): + return _core().processes_page_legacy() + + +@bp.route("/linux-crypto-subsystem.html") +def linux_crypto_subsystem_html(): + return _core().linux_crypto_subsystem_html() + + +@bp.route("/linux-security-subsystem.html") +def linux_security_subsystem_html(): + return _core().linux_security_subsystem_html() + + +@bp.route("/linux-processes-subsystem.html") +def linux_processes_subsystem_html(): + return _core().linux_processes_subsystem_html() + + +@bp.route("/linux-memory-subsystem") +def linux_memory_subsystem_page(): + return _core().linux_memory_subsystem_page() + + +@bp.route("/linux-memory-subsystem.html") +def linux_memory_subsystem_html(): + return _core().linux_memory_subsystem_html() + + +@bp.route("/health") +def health_check(): + return _core().health_check() + + +# /static/ is served by Flask's built-in static handler (see ``app.static_folder`` in webapp). diff --git a/kernel_ai/webapp.py b/kernel_ai/webapp.py new file mode 100644 index 0000000..3048aa1 --- /dev/null +++ b/kernel_ai/webapp.py @@ -0,0 +1,4941 @@ +#!/usr/bin/env python3 +""" +Linux Kernel Visualization Backend +Organized version with proper project structure +""" + +import os +import sys +import json +import time +import random +import platform +import subprocess +import re +import ipaddress +import shutil +from datetime import datetime +from flask import Flask, jsonify, render_template, send_from_directory, request, redirect +import psutil + +from kernel_ai.config import Config, PROJECT_ROOT +from kernel_ai.hooks import register_hooks +from kernel_ai.http.register import register_http_routes +from kernel_ai.prometheus_setup import init_prometheus +from kernel_ai.collectors import proc_fs as _proc_fs +from kernel_ai.state import ( + CRYPTO_PREV, + DEVICES_PREV, + ENTROPY_PREV, + EXEC_CONTEXT_PREV, + FILESYSTEM_PREV, + FRONTEND_LOG_FILE, + FRONTEND_LOG_WRITE_LOCK, + NETWORK_STACK_PREV, + SECURITY_PREV, + TRACEROUTE_CACHE, + TRACEROUTE_CACHE_TTL_SECONDS, +) + +# Use ``_proc_fs.*`` directly so tests can monkeypatch ``kernel_ai.collectors.proc_fs``. + +# Gunicorn -w N: set PROMETHEUS_MULTIPROC_DIR before workers import this module (e.g. in gunicorn.conf.py). +if os.environ.get("PROMETHEUS_MULTIPROC_DIR", "").strip(): + _prom_mpdir = os.environ["PROMETHEUS_MULTIPROC_DIR"].strip() + os.makedirs(_prom_mpdir, exist_ok=True) + +# Try to import OpenAI (optional) +try: + import openai + OPENAI_AVAILABLE = True +except ImportError: + OPENAI_AVAILABLE = False + +_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +app = Flask( + __name__, + static_folder=os.path.join(_ROOT, "static"), + template_folder=os.path.join(_ROOT, "templates"), +) +app.config.from_object(Config) +init_prometheus(app) +register_hooks(app) + + +def safe_trim(value, limit=2048): + """Trim large strings to keep log payload size bounded.""" + if value is None: + return "" + text = str(value) + if len(text) <= limit: + return text + return text[:limit] + "...[truncated]" + +def write_frontend_event(event_payload): + """Write one frontend event as JSON line for Elastic Agent tail input.""" + event = { + "@timestamp": datetime.utcnow().isoformat() + "Z", + "service.name": "kernel-ai-frontend", + "event.dataset": "kernel_ai.frontend", + "event.kind": "event", + "log.level": safe_trim(event_payload.get("level", "info"), 16).lower(), + "message": safe_trim(event_payload.get("message", "")), + "url.path": safe_trim(event_payload.get("path", ""), 512), + "url.full": safe_trim(event_payload.get("url", ""), 2048), + "user_agent.original": safe_trim(event_payload.get("userAgent", ""), 1024), + "session.id": safe_trim(event_payload.get("sessionId", ""), 128), + "error.stack_trace": safe_trim(event_payload.get("stack", ""), 12000), + "event.module": safe_trim(event_payload.get("module", "frontend"), 128), + "tags": event_payload.get("tags", []), + "meta": event_payload.get("meta", {}) + } + os.makedirs(os.path.dirname(FRONTEND_LOG_FILE), exist_ok=True) + line = json.dumps(event, ensure_ascii=False) + with FRONTEND_LOG_WRITE_LOCK: + with open(FRONTEND_LOG_FILE, "a", encoding="utf-8") as f: + f.write(line + "\n") + +def resolve_binary(cmd_name): + """Resolve executable path even when service PATH misses sbin directories.""" + found = shutil.which(cmd_name) + if found: + return found + for base in ("/usr/sbin", "/usr/bin", "/sbin", "/bin"): + candidate = os.path.join(base, cmd_name) + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None + + +def get_system_info(): + """Get system information""" + return { + 'platform': platform.system(), + 'kernel': platform.release(), + 'python_version': platform.python_version(), + 'cpu_count': psutil.cpu_count(), + 'memory_total': psutil.virtual_memory().total + } + +# System call number to name mapping (common Linux syscalls) +SYSCALL_NAMES = { + 0: 'read', 1: 'write', 2: 'open', 3: 'close', 4: 'stat', 5: 'fstat', + 6: 'lstat', 7: 'poll', 8: 'lseek', 9: 'mmap', 10: 'mprotect', + 11: 'munmap', 12: 'brk', 13: 'rt_sigaction', 14: 'rt_sigprocmask', + 15: 'rt_sigreturn', 16: 'ioctl', 17: 'pread64', 18: 'pwrite64', + 19: 'readv', 20: 'writev', 21: 'access', 22: 'pipe', 23: 'select', + 24: 'sched_yield', 25: 'mremap', 26: 'msync', 27: 'mincore', + 28: 'madvise', 29: 'shmget', 30: 'shmat', 31: 'shmctl', 32: 'dup', + 33: 'dup2', 34: 'pause', 35: 'nanosleep', 36: 'getitimer', + 37: 'alarm', 38: 'setitimer', 39: 'getpid', 40: 'sendfile', + 41: 'socket', 42: 'connect', 43: 'accept', 44: 'sendto', 45: 'recvfrom', + 46: 'sendmsg', 47: 'recvmsg', 48: 'shutdown', 49: 'bind', 50: 'listen', + 51: 'getsockname', 52: 'getpeername', 53: 'socketpair', 54: 'setsockopt', + 55: 'getsockopt', 56: 'clone', 57: 'fork', 58: 'vfork', 59: 'execve', + 60: 'exit', 61: 'wait4', 62: 'kill', 63: 'uname', 64: 'semget', + 65: 'semop', 66: 'semctl', 67: 'shmdt', 68: 'msgget', 69: 'msgsnd', + 70: 'msgrcv', 71: 'msgctl', 72: 'fcntl', 73: 'flock', 74: 'fsync', + 75: 'fdatasync', 76: 'truncate', 77: 'ftruncate', 78: 'getdents', + 79: 'getcwd', 80: 'chdir', 81: 'fchdir', 82: 'rename', 83: 'mkdir', + 84: 'rmdir', 85: 'creat', 86: 'link', 87: 'unlink', 88: 'symlink', + 89: 'readlink', 90: 'chmod', 91: 'fchmod', 92: 'chown', 93: 'fchown', + 94: 'lchown', 95: 'umask', 96: 'gettimeofday', 97: 'getrlimit', + 98: 'getrusage', 99: 'sysinfo', 100: 'times', 101: 'ptrace', + 102: 'getuid', 103: 'syslog', 104: 'getgid', 105: 'setuid', 106: 'setgid', + 107: 'geteuid', 108: 'getegid', 109: 'setpgid', 110: 'getppid', + 111: 'getpgrp', 112: 'setsid', 113: 'setreuid', 114: 'setregid', + 115: 'getgroups', 116: 'setgroups', 117: 'setresuid', 118: 'getresuid', + 119: 'setresgid', 120: 'getresgid', 121: 'getpgid', 122: 'setfsuid', + 123: 'setfsgid', 124: 'getsid', 125: 'capget', 126: 'capset', + 127: 'rt_sigpending', 128: 'rt_sigtimedwait', 129: 'rt_sigqueueinfo', + 130: 'rt_sigsuspend', 131: 'sigaltstack', 132: 'utime', 133: 'mknod', + 134: 'uselib', 135: 'personality', 136: 'ustat', 137: 'statfs', + 138: 'fstatfs', 139: 'sysfs', 140: 'getpriority', 141: 'setpriority', + 142: 'sched_setparam', 143: 'sched_getparam', 144: 'sched_setscheduler', + 145: 'sched_getscheduler', 146: 'sched_get_priority_max', + 147: 'sched_get_priority_min', 148: 'sched_rr_get_interval', + 149: 'mlock', 150: 'munlock', 151: 'mlockall', 152: 'munlockall', + 153: 'vhangup', 154: 'modify_ldt', 155: 'pivot_root', 156: 'prctl', + 157: 'arch_prctl', 158: 'adjtimex', 159: 'setrlimit', 160: 'chroot', + 161: 'sync', 162: 'acct', 163: 'settimeofday', 164: 'mount', + 165: 'umount2', 166: 'swapon', 167: 'swapoff', 168: 'reboot', + 169: 'sethostname', 170: 'setdomainname', 171: 'iopl', 172: 'ioperm', + 173: 'create_module', 174: 'init_module', 175: 'delete_module', + 176: 'get_kernel_syms', 177: 'query_module', 178: 'quotactl', + 179: 'nfsservctl', 180: 'getpmsg', 181: 'putpmsg', 182: 'afs_syscall', + 183: 'tuxcall', 184: 'security', 185: 'gettid', 186: 'readahead', + 187: 'setxattr', 188: 'lsetxattr', 189: 'fsetxattr', 190: 'getxattr', + 191: 'lgetxattr', 192: 'fgetxattr', 193: 'listxattr', 194: 'llistxattr', + 195: 'flistxattr', 196: 'removexattr', 197: 'lremovexattr', + 198: 'fremovexattr', 199: 'tkill', 200: 'time', 201: 'futex', + 202: 'sched_setaffinity', 203: 'sched_getaffinity', 204: 'set_thread_area', + 205: 'io_setup', 206: 'io_destroy', 207: 'io_getevents', 208: 'io_submit', + 209: 'io_cancel', 210: 'get_thread_area', 211: 'lookup_dcookie', + 212: 'epoll_create', 213: 'epoll_ctl_old', 214: 'epoll_wait_old', + 215: 'remap_file_pages', 216: 'getdents64', 217: 'set_tid_address', + 218: 'restart_syscall', 219: 'semtimedop', 220: 'fadvise64', + 221: 'timer_create', 222: 'timer_settime', 223: 'timer_gettime', + 224: 'timer_getoverrun', 225: 'timer_delete', 226: 'clock_settime', + 227: 'clock_gettime', 228: 'clock_getres', 229: 'clock_nanosleep', + 230: 'exit_group', 231: 'epoll_wait', 232: 'epoll_ctl', 233: 'tgkill', + 234: 'utimes', 235: 'vserver', 236: 'mbind', 237: 'set_mempolicy', + 238: 'get_mempolicy', 239: 'mq_open', 240: 'mq_unlink', 241: 'mq_timedsend', + 242: 'mq_timedreceive', 243: 'mq_notify', 244: 'mq_getsetattr', + 245: 'kexec_load', 246: 'waitid', 247: 'add_key', 248: 'request_key', + 249: 'keyctl', 250: 'ioprio_set', 251: 'ioprio_get', 252: 'inotify_init', + 253: 'inotify_add_watch', 254: 'inotify_rm_watch', 255: 'migrate_pages', + 256: 'openat', 257: 'mkdirat', 258: 'mknodat', 259: 'fchownat', + 260: 'futimesat', 261: 'newfstatat', 262: 'unlinkat', 263: 'renameat', + 264: 'linkat', 265: 'symlinkat', 266: 'readlinkat', 267: 'fchmodat', + 268: 'faccessat', 269: 'pselect6', 270: 'ppoll', 271: 'unshare', + 272: 'set_robust_list', 273: 'get_robust_list', 274: 'splice', + 275: 'tee', 276: 'sync_file_range', 277: 'vmsplice', 278: 'move_pages', + 279: 'utimensat', 280: 'epoll_pwait', 281: 'signalfd', 282: 'timerfd_create', + 283: 'eventfd', 284: 'fallocate', 285: 'timerfd_settime', + 286: 'timerfd_gettime', 287: 'accept4', 288: 'signalfd4', 289: 'eventfd2', + 290: 'epoll_create1', 291: 'dup3', 292: 'pipe2', 293: 'inotify_init1', + 294: 'preadv', 295: 'pwritev', 296: 'rt_tgsigqueueinfo', 297: 'perf_event_open', + 298: 'recvmmsg', 299: 'fanotify_init', 300: 'fanotify_mark', + 301: 'prlimit64', 302: 'name_to_handle_at', 303: 'open_by_handle_at', + 304: 'clock_adjtime', 305: 'syncfs', 306: 'sendmmsg', 307: 'setns', + 308: 'getcpu', 309: 'process_vm_readv', 310: 'process_vm_writev', + 311: 'kcmp', 312: 'finit_module', 313: 'sched_setattr', 314: 'sched_getattr', + 315: 'renameat2', 316: 'seccomp', 317: 'getrandom', 318: 'memfd_create', + 319: 'kexec_file_load', 320: 'bpf', 321: 'execveat', 322: 'userfaultfd', + 323: 'membarrier', 324: 'mlock2', 325: 'copy_file_range', 326: 'preadv2', + 327: 'pwritev2', 328: 'pkey_mprotect', 329: 'pkey_alloc', 330: 'pkey_free', + 331: 'statx', 332: 'io_pgetevents', 333: 'rseq', 334: 'pidfd_send_signal', + 335: 'io_uring_setup', 336: 'io_uring_enter', 337: 'io_uring_register', + 338: 'open_tree', 339: 'move_mount', 340: 'fsopen', 341: 'fsconfig', + 342: 'fsmount', 343: 'fspick', 344: 'pidfd_open', 345: 'clone3', + 346: 'close_range', 347: 'openat2', 348: 'pidfd_getfd', 349: 'faccessat2', + 350: 'process_madvise', 351: 'epoll_pwait2', 352: 'mount_setattr', + 353: 'quotactl_fd', 354: 'landlock_create_ruleset', 355: 'landlock_add_rule', + 356: 'landlock_restrict_self', 357: 'memfd_secret', 358: 'process_mrelease', + 359: 'futex_waitv', 360: 'set_mempolicy_home_node', 361: 'cachestat', + 362: 'fchmodat2', 363: 'map_shadow_stack', 364: 'futex_wake', 365: 'futex_wait', + 366: 'futex_requeue', 367: 'futex_wake_op', 368: 'futex_lock_pi', + 369: 'futex_unlock_pi', 370: 'futex_trylock_pi', 371: 'futex_wait_requeue_pi', + 372: 'futex_cmp_requeue_pi', 373: 'futex_wake_requeue_pi', 374: 'futex_waitv', + 375: 'futex_wake', 376: 'futex_wait', 377: 'futex_requeue', 378: 'futex_wake_op', + 379: 'futex_lock_pi', 380: 'futex_unlock_pi', 381: 'futex_trylock_pi', + 382: 'futex_wait_requeue_pi', 383: 'futex_cmp_requeue_pi', 384: 'futex_wake_requeue_pi', + 385: 'futex_waitv', 386: 'futex_wake', 387: 'futex_wait', 388: 'futex_requeue', + 389: 'futex_wake_op', 390: 'futex_lock_pi', 391: 'futex_unlock_pi', + 392: 'futex_trylock_pi', 393: 'futex_wait_requeue_pi', 394: 'futex_cmp_requeue_pi', + 395: 'futex_wake_requeue_pi' +} + +# Max PIDs to scan for /proc/[pid]/syscall (tasks currently blocked in a syscall). +KERNEL_DNA_MAX_PROCS = int(os.environ.get('KERNEL_DNA_MAX_PROCS', '1200')) + + +def _kernel_dna_read_proc_vmstat(): + """Parse /proc/vmstat into a dict of int counters.""" + vm = {} + try: + with open('/proc/vmstat', 'r', encoding='utf-8', errors='replace') as f: + for line in f: + parts = line.split() + if len(parts) >= 2: + vm[parts[0]] = int(parts[1]) + except (OSError, ValueError): + pass + return vm + + +def _kernel_dna_vmstat_activity_nucleotides(): + """Real VM counters when no per-task syscall sample is available.""" + result = [] + vm = _kernel_dna_read_proc_vmstat() + mapping = [ + ('pgfault', 'mm'), + ('pgmajfault', 'mm'), + ('pswpin', 'mm'), + ('pswpout', 'mm'), + ('oom_kill', 'mm'), + ('nr_dirty', 'mm'), + ('nr_written', 'mm'), + ('pgscan_kswapd', 'mm'), + ('pgscan_direct', 'mm'), + ('workingset_refault', 'mm'), + ] + for key, sub in mapping: + if key in vm and vm[key] > 0: + result.append({'name': f'vm:{key}', 'count': vm[key], 'subsystem': sub}) + return result + + +def _kernel_dna_block_device_activity_nucleotides(): + """Cumulative I/O from /sys/block//stat.""" + result = [] + tr = tw = tsr = tsw = 0 + try: + for name in os.listdir('/sys/block'): + if name.startswith(('loop', 'ram')): + continue + stat_path = os.path.join('/sys/block', name, 'stat') + if not os.path.isfile(stat_path): + continue + with open(stat_path, 'r', encoding='utf-8', errors='replace') as f: + st = f.read().split() + if len(st) < 7: + continue + tr += int(st[0]) + tsr += int(st[2]) + tw += int(st[4]) + tsw += int(st[6]) + except (OSError, ValueError, IndexError): + pass + if tr > 0: + result.append({'name': 'disk:read_ios', 'count': tr, 'subsystem': 'fs'}) + if tw > 0: + result.append({'name': 'disk:write_ios', 'count': tw, 'subsystem': 'fs'}) + if tsr > 0: + result.append({'name': 'disk:sectors_read', 'count': tsr, 'subsystem': 'fs'}) + if tsw > 0: + result.append({'name': 'disk:sectors_written', 'count': tsw, 'subsystem': 'fs'}) + return result + + +def _kernel_dna_sockstat_activity_nucleotides(): + """Socket counts from /proc/net/sockstat.""" + result = [] + try: + with open('/proc/net/sockstat', 'r', encoding='utf-8', errors='replace') as f: + for line in f: + parts = line.split() + if line.startswith('TCP:') and len(parts) >= 3: + result.append({'name': 'net:tcp_inuse', 'count': int(parts[2]), 'subsystem': 'net'}) + elif line.startswith('UDP:') and len(parts) >= 3: + result.append({'name': 'net:udp_inuse', 'count': int(parts[2]), 'subsystem': 'net'}) + except (OSError, ValueError, IndexError): + pass + return result + + +def _kernel_dna_softirq_nucleotides(limit=8): + """Per-vector softirq totals from /proc/softirqs.""" + out = [] + try: + with open('/proc/softirqs', 'r', encoding='utf-8', errors='replace') as f: + lines = f.readlines() + if len(lines) < 2: + return out + for line in lines[1 : 1 + limit]: + parts = line.split() + if len(parts) < 2: + continue + vec = parts[0].rstrip(':') + total = sum(int(x) for x in parts[1:] if x.isdigit()) + if total > 0: + out.append({ + 'type': 'interrupt', + 'code': 'T', + 'name': f'softirq:{vec}', + 'count': total, + 'subsystem': map_interrupt_to_subsystem(vec), + 'timestamp': datetime.now().isoformat(), + }) + except (OSError, ValueError): + pass + return out + + +def get_real_system_calls(): + """Blocked-in-syscall sample from /proc/[pid]/syscall; else real vmstat + block + sockstat (no random on Linux).""" + try: + if platform.system() != 'Linux': + return get_mock_system_calls() + + try: + proc_dirs = [d for d in os.listdir('/proc') if d.isdigit()] + except PermissionError: + proc_dirs = [] + + sampled = sorted(proc_dirs, key=int)[: min(KERNEL_DNA_MAX_PROCS, len(proc_dirs))] + + syscall_counts = {} + for pid in sampled: + try: + syscall_path = f'/proc/{pid}/syscall' + if not os.path.exists(syscall_path): + continue + with open(syscall_path, 'r', encoding='utf-8', errors='replace') as f: + line = f.read().strip() + if not line or line in ('-1', 'running'): + continue + parts = line.split() + if not parts: + continue + try: + syscall_num = int(parts[0]) + except ValueError: + continue + syscall_name = SYSCALL_NAMES.get(syscall_num, f'syscall_{syscall_num}') + syscall_counts[syscall_name] = syscall_counts.get(syscall_name, 0) + 1 + except (PermissionError, FileNotFoundError, IOError, ValueError): + continue + + if syscall_counts: + syscalls = [] + for name, count in sorted(syscall_counts.items(), key=lambda x: x[1], reverse=True)[:20]: + syscalls.append({ + 'name': name, + 'count': count, + 'subsystem': map_syscall_to_subsystem(name), + }) + return syscalls + + merged = [] + merged.extend(_kernel_dna_vmstat_activity_nucleotides()) + merged.extend(_kernel_dna_block_device_activity_nucleotides()) + merged.extend(_kernel_dna_sockstat_activity_nucleotides()) + if merged: + merged.sort(key=lambda x: x['count'], reverse=True) + return merged[:20] + return [] + + except Exception as e: + print(f"Error getting system calls: {e}") + import traceback + traceback.print_exc() + return [] if platform.system() == 'Linux' else get_mock_system_calls() + +def get_mock_system_calls(): + """Mock data for system calls""" + return [ + {'name': 'read', 'count': '166 643218'}, + {'name': 'write', 'count': '964 016161'}, + {'name': 'open', 'count': '972 983879'}, + {'name': 'close', 'count': '989 612075'}, + {'name': 'mmap', 'count': '819 540732'}, + {'name': 'fork', 'count': '512 826219'}, + {'name': 'execve', 'count': '025 461491'}, + {'name': 'socket', 'count': '838 475394'}, + {'name': 'connect', 'count': '632 094939'}, + {'name': 'accept', 'count': '417 205788'} + ] + +def get_kernel_subsystem_status(): + """Get real kernel subsystem status from /proc filesystem""" + try: + if platform.system() != 'Linux': + return get_mock_kernel_subsystems() + + subsystems = {} + + # 1. Memory Management - from /proc/meminfo + try: + with open('/proc/meminfo', 'r') as f: + meminfo = {} + for line in f: + if ':' in line: + key, value = line.split(':', 1) + meminfo[key.strip()] = value.strip() + + # Calculate memory usage percentage + mem_total_kb = int(meminfo.get('MemTotal', '0').replace(' kB', '')) + mem_available_kb = int(meminfo.get('MemAvailable', '0').replace(' kB', '')) + mem_free_kb = int(meminfo.get('MemFree', '0').replace(' kB', '')) + + if mem_total_kb > 0: + mem_used_kb = mem_total_kb - mem_available_kb + memory_usage = int((mem_used_kb / mem_total_kb) * 100) + else: + memory_usage = 0 + + # Count processes using memory (rough estimate from active pages) + active_kb = int(meminfo.get('Active', '0').replace(' kB', '')) + processes_estimate = max(10, min(100, active_kb // 50000)) # Rough estimate + + subsystems['memory_management'] = { + 'status': 'active', + 'usage': memory_usage, + 'processes': processes_estimate + } + except (IOError, ValueError, KeyError) as e: + print(f"Error reading meminfo: {e}") + subsystems['memory_management'] = { + 'status': 'active', + 'usage': 75, + 'processes': 25 + } + + # 2. Process Scheduler - from /proc/stat + try: + with open('/proc/stat', 'r') as f: + stat_data = {} + for line in f: + if line.startswith('cpu '): + parts = line.split() + # CPU stats: user, nice, system, idle, iowait, irq, softirq, steal, guest, guest_nice + if len(parts) >= 5: + user_time = int(parts[1]) + system_time = int(parts[3]) + idle_time = int(parts[4]) + total_time = user_time + system_time + idle_time + + if total_time > 0: + cpu_usage = int(((user_time + system_time) / total_time) * 100) + else: + cpu_usage = 0 + elif line.startswith('processes '): + total_processes = int(line.split()[1]) + elif line.startswith('ctxt '): + context_switches = int(line.split()[1]) + + # Estimate scheduler activity from context switches + # More context switches = more scheduler activity + scheduler_usage = min(100, max(50, cpu_usage)) + + # Get current running processes + try: + with open('/proc/loadavg', 'r') as f: + loadavg = f.read().strip().split() + running_processes = int(float(loadavg[3].split('/')[0])) + except: + running_processes = len(psutil.pids()) if 'psutil' in sys.modules else 50 + + subsystems['process_scheduler'] = { + 'status': 'active', + 'usage': scheduler_usage, + 'processes': running_processes + } + except (IOError, ValueError, KeyError) as e: + print(f"Error reading /proc/stat: {e}") + subsystems['process_scheduler'] = { + 'status': 'active', + 'usage': 85, + 'processes': 45 + } + + # 3. File System - from /proc/mounts and /proc/filesystems + try: + # Count mounted filesystems + with open('/proc/mounts', 'r') as f: + mount_count = len([line for line in f if line.strip() and not line.startswith('#')]) + + # Count filesystem types + with open('/proc/filesystems', 'r') as f: + fs_types = len([line for line in f if line.strip() and not line.startswith('#')]) + + # Estimate filesystem activity from I/O wait + try: + with open('/proc/stat', 'r') as f: + for line in f: + if line.startswith('cpu '): + parts = line.split() + if len(parts) >= 6: + iowait = int(parts[5]) + # Use iowait as indicator of filesystem activity + fs_usage = min(100, max(20, iowait // 100)) + else: + fs_usage = 60 + break + except: + fs_usage = 60 + + # Estimate processes using filesystem + fs_processes = max(5, min(50, mount_count * 2)) + + subsystems['file_system'] = { + 'status': 'active', + 'usage': fs_usage, + 'processes': fs_processes + } + except (IOError, ValueError) as e: + print(f"Error reading filesystem info: {e}") + subsystems['file_system'] = { + 'status': 'active', + 'usage': 60, + 'processes': 15 + } + + # 4. Network Stack - from /proc/net/sockstat and /proc/net/tcp + try: + network_usage = 30 + network_processes = 8 + + # Try to read socket statistics + try: + with open('/proc/net/sockstat', 'r') as f: + for line in f: + if line.startswith('TCP:'): + # Format: TCP: inuse 26 orphan 0 tw 44 alloc 28 mem 3 + parts = line.split() + # Find indices of key values + try: + inuse_idx = parts.index('inuse') + 1 if 'inuse' in parts else -1 + alloc_idx = parts.index('alloc') + 1 if 'alloc' in parts else -1 + + if inuse_idx > 0 and inuse_idx < len(parts): + tcp_inuse = int(parts[inuse_idx]) + else: + tcp_inuse = 0 + + if alloc_idx > 0 and alloc_idx < len(parts): + tcp_alloc = int(parts[alloc_idx]) + else: + tcp_alloc = tcp_inuse + 10 # Fallback + + if tcp_alloc > 0: + network_usage = min(100, max(20, int((tcp_inuse / tcp_alloc) * 100))) + else: + network_usage = 30 + + network_processes = max(8, min(50, tcp_inuse // 2)) + except (ValueError, IndexError): + # Fallback parsing + network_usage = 30 + network_processes = 12 + break + except FileNotFoundError: + # Fallback: count TCP connections from /proc/net/tcp + try: + with open('/proc/net/tcp', 'r') as f: + tcp_connections = len([line for line in f if line.strip() and not line.startswith('sl')]) + network_usage = min(100, max(20, tcp_connections // 10)) + network_processes = max(8, min(50, tcp_connections // 5)) + except: + pass + + subsystems['network_stack'] = { + 'status': 'active', + 'usage': network_usage, + 'processes': network_processes + } + except (IOError, ValueError) as e: + print(f"Error reading network info: {e}") + subsystems['network_stack'] = { + 'status': 'active', + 'usage': 50, + 'processes': 12 + } + + return subsystems + + except Exception as e: + print(f"Error getting subsystem status: {e}") + import traceback + traceback.print_exc() + return get_mock_kernel_subsystems() + +def get_mock_kernel_subsystems(): + """Mock data for kernel subsystems""" + return { + 'memory_management': {'status': 'active', 'usage': 75, 'processes': 25}, + 'process_scheduler': {'status': 'active', 'usage': 85, 'processes': 45}, + 'file_system': {'status': 'active', 'usage': 60, 'processes': 15}, + 'network_stack': {'status': 'active', 'usage': 50, 'processes': 12} + } + +def get_process_kernel_map(): + """Get process to kernel subsystem mapping""" + try: + if not OPENAI_AVAILABLE: + return get_mock_process_kernel_map() + + # Try to use OpenAI API + if not hasattr(openai, 'api_key') or not openai.api_key: + return get_mock_process_kernel_map() + + # Here would be OpenAI API logic + # For now return mock data + return get_mock_process_kernel_map() + + except Exception as e: + print(f"Error getting process map: {e}") + return get_mock_process_kernel_map() + +def get_mock_process_kernel_map(): + """Mock data for process mapping""" + return { + "systemd": ["kernel/sched/core.c", "kernel/time/timekeeping.c"], + "sshd": ["kernel/security/security.c", "kernel/audit/audit.c"], + "nginx": ["kernel/net/socket.c", "kernel/net/core/sock.c"], + "python3": ["kernel/fs/read_write.c", "kernel/mm/memory.c"], + "bash": ["kernel/exec.c", "kernel/fork.c"], + "cron": ["kernel/time/timer.c", "kernel/sched/clock.c"] + } + +def get_proc_matrix_data(): + """Build Matrix view data - processes and their resource usage""" + matrix = [] + + # Collect processes with required fields + processes = [] + for proc in psutil.process_iter( + ['pid', 'name', 'cpu_percent', 'memory_info', 'io_counters', 'num_fds'] + ): + try: + info = proc.info + pid = info['pid'] + + # CPU usage (may be 0 on first call) + cpu_percent = info.get('cpu_percent') or 0.0 + + # Memory: resident set size in MB + mem_mb = 0.0 + mem_info = info.get('memory_info') + if mem_info: + mem_mb = mem_info.rss / 1024 / 1024 + + # IO: sum of read/write bytes in MB + io_total_mb = 0.0 + io_counters = info.get('io_counters') + if io_counters: + io_total_mb = ( + io_counters.read_bytes + io_counters.write_bytes + ) / 1024 / 1024 + + # NET: count TCP entries from /proc/[pid]/net/tcp + net_connections = 0 + tcp_path = f'/proc/{pid}/net/tcp' + try: + if os.path.exists(tcp_path): + with open(tcp_path, 'r') as f: + lines = f.readlines() + # subtract header + net_connections = max(0, len(lines) - 1) + except (IOError, PermissionError): + pass + + # FD: number of file descriptors + num_fds = info.get('num_fds') or 0 + + processes.append({ + 'pid': pid, + 'name': info.get('name') or 'unknown', + 'cpu': float(cpu_percent), + 'mem': float(mem_mb), + 'io': float(io_total_mb), + 'net': int(net_connections), + 'fd': int(num_fds), + }) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + # Sort by CPU usage and take top 20 for clarity + processes.sort(key=lambda p: p['cpu'], reverse=True) + matrix = processes[:20] + + return matrix + +# API Endpoints + +def index(): + """Main page""" + return send_from_directory(str(PROJECT_ROOT), "index.html") + +def linux_crypto_subsystem_page(): + """SEO-friendly Linux crypto subsystem page.""" + return render_template('linux-crypto-subsystem.html') + +def crypto_page_legacy(): + """Legacy path redirect to Linux crypto subsystem page.""" + return redirect('/linux-crypto-subsystem', code=301) + +def linux_security_subsystem_page(): + """SEO-friendly Linux security subsystem page.""" + return render_template('linux-security-subsystem.html') + +def security_page_legacy(): + """Legacy path redirect to Linux security subsystem page.""" + return redirect('/linux-security-subsystem', code=301) + +def linux_processes_subsystem_page(): + """SEO-friendly Linux processes subsystem page.""" + return render_template('linux-processes-subsystem.html') + +def processes_page_legacy(): + """Legacy path redirect to Linux processes subsystem page.""" + return redirect('/linux-processes-subsystem', code=301) + + +def linux_crypto_subsystem_html(): + return redirect('/linux-crypto-subsystem', code=301) + + +def linux_security_subsystem_html(): + return redirect('/linux-security-subsystem', code=301) + + +def linux_processes_subsystem_html(): + return redirect('/linux-processes-subsystem', code=301) + + +def linux_memory_subsystem_page(): + """SEO-friendly Linux memory subsystem page.""" + return render_template('linux-memory-subsystem.html') + + +def linux_memory_subsystem_html(): + return redirect('/linux-memory-subsystem', code=301) + +def syscalls_realtime(): + """API for real-time system calls""" + try: + data = { + 'timestamp': datetime.now().isoformat(), + 'syscalls': get_real_system_calls(), + 'cpu_usage': psutil.cpu_percent(interval=1), + 'memory_usage': psutil.virtual_memory().percent, + 'system_info': get_system_info() + } + return jsonify(data) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +def kernel_data(): + """API for kernel data""" + try: + data = { + 'timestamp': datetime.now().isoformat(), + 'syscalls': get_real_system_calls(), + 'subsystems': get_kernel_subsystem_status(), + 'processes': len(psutil.pids()), + 'system_stats': { + 'cpu_count': psutil.cpu_count(), + 'memory_total': psutil.virtual_memory().total, + 'disk_usage': psutil.disk_usage('/').percent + } + } + return jsonify(data) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +def process_kernel_map(): + """API for process to kernel subsystem mapping""" + try: + data = get_process_kernel_map() + return jsonify(data) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +def get_processes(): + """API for getting all Linux processes""" + try: + processes = [] + for proc in psutil.process_iter(['pid', 'name', 'status', 'memory_info']): + try: + memory_info = proc.info['memory_info'] + memory_mb = memory_info.rss / 1024 / 1024 # Convert to MB + processes.append({ + 'pid': proc.info['pid'], + 'name': proc.info['name'], + 'status': proc.info['status'], + 'memory_mb': round(memory_mb, 1) + }) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + return jsonify({'processes': processes}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +def health_check(): + """Application health check""" + return jsonify({ + 'status': 'healthy', + 'timestamp': datetime.now().isoformat(), + 'system_info': get_system_info() + }) + +# Static files handling +def static_files(filename): + """Serve static files""" + return send_from_directory(app.config['STATIC_FOLDER'], filename) + +# Error handling +# Active connections functions +# Nginx files functions +def get_nginx_open_files(): + """Get open files for Nginx process""" + try: + import psutil + nginx_processes = [] + for proc in psutil.process_iter(["pid", "name", "open_files"]): + try: + if proc.info["name"] and "nginx" in proc.info["name"].lower(): + nginx_processes.append(proc.info["pid"]) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + if nginx_processes: + # Get open files for first nginx process + proc = psutil.Process(nginx_processes[0]) + open_files = proc.open_files() + + # Filter and format file paths + files = [] + for file in open_files: + if file.path: + # Extract relative path from full path + if "/etc/nginx/" in file.path: + rel_path = file.path.split("/etc/nginx/")[-1] + files.append({"path": f"nginx/{rel_path}", "type": "config"}) + elif "/var/log/nginx/" in file.path: + rel_path = file.path.split("/var/log/nginx/")[-1] + files.append({"path": f"nginx/logs/{rel_path}", "type": "log"}) + else: + files.append({"path": file.path, "type": "other"}) + + return files[:10] # Limit to 10 files + else: + return get_mock_nginx_files() + + except Exception as e: + print(f"Error getting nginx files: {e}") + return get_mock_nginx_files() + +def get_mock_nginx_files(): + """Mock data for nginx files""" + return [ + {"path": "nginx/nginx.conf", "type": "config"}, + {"path": "nginx/sites-enabled/default", "type": "config"}, + {"path": "nginx/conf.d/default.conf", "type": "config"}, + {"path": "nginx/logs/access.log", "type": "log"}, + {"path": "nginx/logs/error.log", "type": "log"} + ] + +def nginx_files(): + """API for nginx open files""" + try: + files = get_nginx_open_files() + return jsonify({"files": files}) + except Exception as e: + return jsonify({"error": str(e)}), 500 +def get_active_connections(): + """Get active network connections""" + try: + connections = [] + # Get TCP connections + with open("/proc/net/tcp", "r") as f: + lines = f.readlines()[1:] # Skip header + for line in lines: + parts = line.strip().split() + if len(parts) >= 4: + local_addr = parts[1] + remote_addr = parts[2] + state = parts[3] + + # Convert hex addresses to readable format + # IP addresses in /proc/net/tcp are stored in little-endian format + def hex_to_ip(hex_str): + # Reverse the hex string to convert from little-endian + hex_bytes = [hex_str[i:i+2] for i in range(0, 8, 2)] + hex_bytes.reverse() + return ".".join([str(int(b, 16)) for b in hex_bytes]) + + local_ip = hex_to_ip(local_addr.split(":")[0]) + local_port = int(local_addr.split(":")[1], 16) + + if remote_addr != "00000000:0000": # Not listening + remote_ip = hex_to_ip(remote_addr.split(":")[0]) + remote_port = int(remote_addr.split(":")[1], 16) + + connections.append({ + "local": f"{local_ip}:{local_port}", + "remote": f"{remote_ip}:{remote_port}", + "state": state, + "type": "TCP" + }) + + # Limit to first 20 connections for display + return connections[:20] + + except Exception as e: + print(f"Error getting active connections: {e}") + return get_mock_active_connections() + +def get_mock_active_connections(): + """Mock data for active connections""" + return [ + {"local": "127.0.0.1:22", "remote": "192.168.1.100:54321", "state": "01", "type": "TCP"}, + {"local": "0.0.0.0:80", "remote": "10.0.0.50:12345", "state": "01", "type": "TCP"}, + {"local": "127.0.0.1:3306", "remote": "172.16.0.10:65432", "state": "01", "type": "TCP"}, + {"local": "0.0.0.0:443", "remote": "203.0.113.0:54321", "state": "01", "type": "TCP"}, + {"local": "127.0.0.1:5001", "remote": "192.168.1.101:12345", "state": "01", "type": "TCP"} + ] + +def _tcp_state_name(code): + states = { + "01": "ESTABLISHED", + "02": "SYN_SENT", + "03": "SYN_RECV", + "04": "FIN_WAIT1", + "05": "FIN_WAIT2", + "06": "TIME_WAIT", + "07": "CLOSE", + "08": "CLOSE_WAIT", + "09": "LAST_ACK", + "0A": "LISTEN", + "0B": "CLOSING" + } + return states.get(str(code).upper(), str(code).upper()) + +def _get_default_iface(): + try: + with open("/proc/net/route", "r") as f: + lines = f.readlines()[1:] + for line in lines: + parts = line.strip().split() + if len(parts) < 11: + continue + iface = parts[0] + destination = parts[1] + flags = int(parts[3], 16) + if destination == "00000000" and (flags & 0x2): + return iface + except (OSError, ValueError): + pass + # Fallback: first non-loopback interface. + try: + pernic = psutil.net_io_counters(pernic=True) + for iface in pernic.keys(): + if iface != "lo": + return iface + except Exception: + pass + return "lo" + +def _parse_netstat_tcpext(): + try: + with open("/proc/net/netstat", "r") as f: + lines = [line.strip() for line in f if line.strip()] + for i in range(0, len(lines) - 1, 2): + header = lines[i].split() + values = lines[i + 1].split() + if not header or header[0] != "TcpExt:": + continue + if not values or values[0] != "TcpExt:": + continue + fields = header[1:] + nums = values[1:] + if len(fields) != len(nums): + continue + mapping = {} + for name, val in zip(fields, nums): + try: + mapping[name] = int(val) + except ValueError: + mapping[name] = 0 + return mapping + except OSError: + return {} + return {} + +def _parse_snmp_section(section_name): + try: + with open("/proc/net/snmp", "r") as f: + lines = [line.strip() for line in f if line.strip()] + for i in range(0, len(lines) - 1, 2): + header = lines[i].split() + values = lines[i + 1].split() + expected_prefix = f"{section_name}:" + if not header or header[0] != expected_prefix: + continue + if not values or values[0] != expected_prefix: + continue + fields = header[1:] + nums = values[1:] + if len(fields) != len(nums): + continue + out = {} + for name, val in zip(fields, nums): + try: + out[name] = int(val) + except ValueError: + out[name] = 0 + return out + except OSError: + return {} + return {} + +def _get_ss_tcp_metrics(): + """Extract cwnd/rtt/retrans and tx queue from ss -tin (best effort).""" + ss_cmd = resolve_binary("ss") + if not ss_cmd: + return {} + try: + result = subprocess.run( + [ss_cmd, "-tin"], + capture_output=True, + text=True, + timeout=2, + check=False + ) + lines = (result.stdout or "").splitlines() + except (subprocess.TimeoutExpired, OSError): + return {} + + for idx, line in enumerate(lines): + if not line.strip().startswith("ESTAB"): + continue + metrics = {} + parts = line.split() + # ESTAB Recv-Q Send-Q Local:Port Peer:Port + if len(parts) >= 4: + try: + metrics["tx_queue"] = int(parts[2]) + metrics["rx_queue"] = int(parts[1]) + except ValueError: + pass + + details = lines[idx + 1] if (idx + 1) < len(lines) else "" + rtt_match = re.search(r'rtt:(\d+(?:\.\d+)?)/', details) + cwnd_match = re.search(r'cwnd:(\d+)', details) + retrans_match = re.search(r'retrans:(\d+)(?:/\d+)?', details) + if rtt_match: + metrics["rtt_ms"] = float(rtt_match.group(1)) + if cwnd_match: + metrics["cwnd"] = int(cwnd_match.group(1)) + if retrans_match: + metrics["retrans_now"] = int(retrans_match.group(1)) + if metrics: + return metrics + return {} + +def _read_major_minor_from_devfile(devfile_path): + value = _proc_fs.safe_read_text(devfile_path) + if not value or ":" not in value: + return (None, None) + major_s, minor_s = value.split(":", 1) + try: + return (int(major_s), int(minor_s)) + except ValueError: + return (None, None) + +def _driver_from_symlink(base_path): + link_path = os.path.join(base_path, "device", "driver") + try: + if os.path.islink(link_path): + return os.path.basename(os.path.realpath(link_path)) + except OSError: + pass + return None + +def _detect_bus(sys_path, category): + if category == "net": + return "net" + real = "" + try: + real = os.path.realpath(sys_path).lower() + except OSError: + real = str(sys_path).lower() + if "/usb" in real: + return "usb" + if "/pci" in real: + return "pcie" + if "/virtual" in real: + return "virtual" + return "pcie" + +def _irq_total_for_tokens(interrupt_lines, tokens): + if not tokens: + return 0 + token_set = [t.lower() for t in tokens if t] + total = 0 + for line_lower, irq_total in interrupt_lines: + if any(tok in line_lower for tok in token_set): + total += irq_total + return total + +def _subsystem_for_category(category): + mapping = { + "block": "block -> VFS", + "net": "network -> net stack", + "char": "char -> tty/mem", + "misc": "misc -> kernel core", + "usb": "usb core -> usbfs", + "input": "input -> evdev", + "gpu": "drm -> graphics" + } + return mapping.get(category, "kernel core") + +def _user_interaction_for_category(category): + mapping = { + "block": "open/read/write/ioctl", + "net": "socket/send/recv", + "char": "read/write/ioctl", + "misc": "ioctl/control", + "usb": "udev/hotplug/ioctl", + "input": "events -> userspace", + "gpu": "drm ioctl/mmap" + } + return mapping.get(category, "syscall/ioctl") + +def _collect_block_devices(disk_now, dt): + devices = [] + for name, sectors_total in disk_now.items(): + prev = DEVICES_PREV["disk_sectors"].get(name) + delta_sectors = max(0, sectors_total - prev) if prev is not None else 0 + bps = (delta_sectors * 512) / dt + sys_path = os.path.join("/sys/block", name) + major, minor = _read_major_minor_from_devfile(os.path.join(sys_path, "dev")) + devices.append({ + "name": name, + "category": "block", + "bus": _detect_bus(sys_path, "block"), + "sys_path": sys_path, + "driver": _driver_from_symlink(sys_path), + "major": major, + "minor": minor, + "throughput_bps": bps, + "irq_tokens": [name], + "subsystem": _subsystem_for_category("block"), + "user_interaction": _user_interaction_for_category("block") + }) + return devices + +def _collect_net_devices(dt): + devices = [] + net_now = {} + try: + pernic = psutil.net_io_counters(pernic=True) + for iface, counters in pernic.items(): + total_bytes = counters.bytes_recv + counters.bytes_sent + net_now[iface] = total_bytes + prev = DEVICES_PREV["net_bytes"].get(iface) + delta = max(0, total_bytes - prev) if prev is not None else 0 + bps = delta / dt + sys_path = os.path.join("/sys/class/net", iface) + major, minor = _read_major_minor_from_devfile(os.path.join(sys_path, "dev")) + devices.append({ + "name": iface, + "category": "net", + "bus": "net", + "sys_path": sys_path, + "driver": _driver_from_symlink(sys_path), + "major": major, + "minor": minor, + "throughput_bps": bps, + "irq_tokens": [iface], + "errors": int(counters.errin + counters.errout), + "drops": int(counters.dropin + counters.dropout), + "subsystem": _subsystem_for_category("net"), + "user_interaction": _user_interaction_for_category("net") + }) + except Exception: + return [], {} + return devices, net_now + +def _collect_char_devices(): + devices = [] + seeds = [("tty0", "/sys/class/tty/tty0"), ("null", "/sys/devices/virtual/mem/null"), ("random", "/sys/devices/virtual/mem/random")] + for name, sys_path in seeds: + major, minor = _read_major_minor_from_devfile(os.path.join(sys_path, "dev")) + devices.append({ + "name": name, + "category": "char", + "bus": _detect_bus(sys_path, "char"), + "sys_path": sys_path, + "driver": _driver_from_symlink(sys_path), + "major": major, + "minor": minor, + "throughput_bps": 0.0, + "irq_tokens": [name, "tty"] if "tty" in name else [name], + "subsystem": _subsystem_for_category("char"), + "user_interaction": _user_interaction_for_category("char") + }) + return devices + +def _collect_misc_input_gpu_usb(): + out = [] + + misc_path = "/sys/class/misc" + if os.path.isdir(misc_path): + for name in sorted(os.listdir(misc_path))[:4]: + sys_path = os.path.join(misc_path, name) + major, minor = _read_major_minor_from_devfile(os.path.join(sys_path, "dev")) + out.append({ + "name": name, + "category": "misc", + "bus": _detect_bus(sys_path, "misc"), + "sys_path": sys_path, + "driver": _driver_from_symlink(sys_path), + "major": major, + "minor": minor, + "throughput_bps": 0.0, + "irq_tokens": [name], + "subsystem": _subsystem_for_category("misc"), + "user_interaction": _user_interaction_for_category("misc") + }) + + input_path = "/sys/class/input" + if os.path.isdir(input_path): + for name in sorted(os.listdir(input_path)): + if not name.startswith("event"): + continue + sys_path = os.path.join(input_path, name) + major, minor = _read_major_minor_from_devfile(os.path.join(sys_path, "dev")) + out.append({ + "name": name, + "category": "input", + "bus": _detect_bus(sys_path, "input"), + "sys_path": sys_path, + "driver": _driver_from_symlink(sys_path), + "major": major, + "minor": minor, + "throughput_bps": 0.0, + "irq_tokens": [name, "input"], + "subsystem": _subsystem_for_category("input"), + "user_interaction": _user_interaction_for_category("input") + }) + if len([d for d in out if d["category"] == "input"]) >= 4: + break + + drm_path = "/sys/class/drm" + if os.path.isdir(drm_path): + for name in sorted(os.listdir(drm_path)): + if not re.match(r"^card\d+$", name): + continue + sys_path = os.path.join(drm_path, name) + major, minor = _read_major_minor_from_devfile(os.path.join(sys_path, "dev")) + out.append({ + "name": name, + "category": "gpu", + "bus": _detect_bus(sys_path, "gpu"), + "sys_path": sys_path, + "driver": _driver_from_symlink(sys_path), + "major": major, + "minor": minor, + "throughput_bps": 0.0, + "irq_tokens": [name, "drm", "gpu"], + "subsystem": _subsystem_for_category("gpu"), + "user_interaction": _user_interaction_for_category("gpu") + }) + if len([d for d in out if d["category"] == "gpu"]) >= 2: + break + + usb_path = "/sys/bus/usb/devices" + if os.path.isdir(usb_path): + for name in sorted(os.listdir(usb_path)): + if ":" in name or name in ("usb1", "usb2", "usb3", "usb4"): + continue + sys_path = os.path.join(usb_path, name) + if not os.path.isdir(sys_path): + continue + out.append({ + "name": name, + "category": "usb", + "bus": "usb", + "sys_path": sys_path, + "driver": _driver_from_symlink(sys_path), + "major": None, + "minor": None, + "throughput_bps": 0.0, + "irq_tokens": [name, "usb"], + "subsystem": _subsystem_for_category("usb"), + "user_interaction": _user_interaction_for_category("usb") + }) + if len([d for d in out if d["category"] == "usb"]) >= 4: + break + + return out + +def get_devices_realtime(): + now = time.time() + prev_ts = DEVICES_PREV["timestamp"] + dt = max(0.001, now - prev_ts) if prev_ts else 1.0 + disk_now = _proc_fs.read_diskstats() + block_devices = _collect_block_devices(disk_now, dt) + net_devices, net_now = _collect_net_devices(dt) + char_devices = _collect_char_devices() + extra_devices = _collect_misc_input_gpu_usb() + + devices = block_devices + net_devices + char_devices + extra_devices + interrupt_lines = _proc_fs.read_interrupt_lines() + + max_bps = max([d.get("throughput_bps", 0.0) for d in devices] + [1.0]) + for d in devices: + key = f"{d.get('category','unknown')}::{d.get('name','unknown')}" + irq_total = _irq_total_for_tokens(interrupt_lines, d.get("irq_tokens", [])) + prev_irq = DEVICES_PREV["irq_by_key"].get(key) + irq_per_sec = 0.0 if prev_irq is None else max(0.0, (irq_total - prev_irq) / dt) + + throughput = float(d.get("throughput_bps", 0.0)) + synthetic = irq_per_sec * 4096.0 + weighted = max(throughput, synthetic) + d["throughput_bps"] = round(throughput, 2) + d["throughput_mb_s"] = round(throughput / (1024 * 1024), 4) + d["irq_total"] = int(irq_total) + d["irq_per_sec"] = round(irq_per_sec, 2) + d["load_norm"] = round(min(1.0, weighted / max_bps), 4) + d["layer_path"] = [ + "Physical layer", + "Driver layer", + "Kernel subsystem", + "User interaction" + ] + d["driver"] = d.get("driver") or "n/a" + + devices.sort(key=lambda d: (d.get("load_norm", 0.0), d.get("throughput_bps", 0.0), d.get("irq_per_sec", 0.0)), reverse=True) + top_devices = devices[:20] + + DEVICES_PREV["timestamp"] = now + DEVICES_PREV["disk_sectors"] = disk_now + DEVICES_PREV["net_bytes"] = net_now + DEVICES_PREV["tty_irq_total"] = _proc_fs.read_tty_irq_total() + DEVICES_PREV["irq_by_key"] = { + f"{d.get('category','unknown')}::{d.get('name','unknown')}": d.get("irq_total", 0) + for d in top_devices + } + + bus_counts = {"pcie": 0, "usb": 0, "virtual": 0, "net": 0} + category_counts = {} + for d in top_devices: + bus_counts[d.get("bus", "pcie")] = bus_counts.get(d.get("bus", "pcie"), 0) + 1 + c = d.get("category", "unknown") + category_counts[c] = category_counts.get(c, 0) + 1 + + return { + "timestamp": datetime.now().isoformat(), + "layout": { + "name": "Hardware Bus Map", + "layers": ["Physical layer", "Driver layer", "Kernel subsystem", "User interaction"], + "buses": ["pcie", "usb", "virtual", "net"] + }, + "devices": top_devices, + "meta": { + "count": len(top_devices), + "max_throughput_bps": round(max_bps, 2), + "bus_counts": bus_counts, + "category_counts": category_counts + } + } + +def get_filesystem_blocks(): + now = time.time() + try: + usage = psutil.disk_usage("/") + except Exception: + usage = None + + used_percent = float(usage.percent) if usage else 0.0 + total_gb = round((usage.total / (1024 ** 3)), 2) if usage else 0.0 + used_gb = round((usage.used / (1024 ** 3)), 2) if usage else 0.0 + free_gb = round((usage.free / (1024 ** 3)), 2) if usage else 0.0 + + io = psutil.disk_io_counters() + write_bytes = int(io.write_bytes) if io else 0 + prev_ts = FILESYSTEM_PREV["timestamp"] + prev_write = FILESYSTEM_PREV["write_bytes"] + dt = max(0.001, now - prev_ts) if prev_ts else 1.0 + write_bps = 0.0 if prev_write is None else max(0.0, (write_bytes - prev_write) / dt) + + rows = 20 + cols = 34 + total_blocks = rows * cols + used_ratio_global = max(0.0, min(1.0, used_percent / 100.0)) + + # Logical filesystem zones for a visible map layout. + zone_defs = [ + {"id": "root", "name": "/", "path": "/", "base": 1.5, "bias": 0.00}, + {"id": "var", "name": "/var", "path": "/var", "base": 1.6, "bias": 0.10}, + {"id": "home", "name": "/home", "path": "/home", "base": 1.35, "bias": 0.06}, + {"id": "usr", "name": "/usr", "path": "/usr", "base": 1.45, "bias": 0.08}, + {"id": "etc", "name": "/etc", "path": "/etc", "base": 1.0, "bias": -0.03}, + {"id": "tmp", "name": "/tmp", "path": "/tmp", "base": 1.0, "bias": -0.02}, + {"id": "dev", "name": "/dev", "path": "/dev", "base": 0.85, "bias": -0.06}, + ] + + activity_counts = {z["id"]: 0 for z in zone_defs} + + # Best-effort activity sampling from open file descriptors by path prefix. + try: + processes = list(psutil.process_iter(["pid"]))[:90] + zone_paths = sorted([(z["path"], z["id"]) for z in zone_defs], key=lambda x: len(x[0]), reverse=True) + for proc in processes: + try: + open_files = proc.open_files()[:28] + except (psutil.AccessDenied, psutil.NoSuchProcess, psutil.ZombieProcess, OSError): + continue + for of in open_files: + fpath = str(getattr(of, "path", "") or "") + if not fpath.startswith("/"): + continue + for prefix, zone_id in zone_paths: + if prefix == "/": + continue + if fpath == prefix or fpath.startswith(prefix + "/"): + activity_counts[zone_id] += 1 + break + else: + activity_counts["root"] += 1 + except Exception: + pass + + weighted = [] + for z in zone_defs: + act = float(activity_counts.get(z["id"], 0)) + z["activity"] = act + weighted.append(max(0.2, z["base"] + act * 0.08)) + + total_weight = sum(weighted) or 1.0 + row_counts = [max(1, int(round(rows * w / total_weight))) for w in weighted] + # Normalize row counts to exact total rows. + while sum(row_counts) > rows: + idx = max(range(len(row_counts)), key=lambda i: row_counts[i]) + if row_counts[idx] > 1: + row_counts[idx] -= 1 + else: + break + while sum(row_counts) < rows: + idx = max(range(len(weighted)), key=lambda i: weighted[i]) + row_counts[idx] += 1 + + writing_ratio = min(0.20, write_bps / (300 * 1024 * 1024)) + writing_blocks_total = int(round(total_blocks * writing_ratio)) + writing_blocks_total = max(0, min(total_blocks, writing_blocks_total)) + + blocks = [] + zones = [] + cursor_row = 0 + zone_used_total = 0 + zone_writing_total = 0 + zone_scores = [] + for z in zone_defs: + zone_scores.append(z["activity"] + 1.0) + score_sum = sum(zone_scores) or 1.0 + + seed = int(now * 3) + for idx, z in enumerate(zone_defs): + row_span = row_counts[idx] + row_start = cursor_row + row_end = min(rows - 1, cursor_row + row_span - 1) + cursor_row += row_span + + zone_cells = max(1, (row_end - row_start + 1) * cols) + local_used_ratio = max(0.05, min(0.98, used_ratio_global + z["bias"] + min(0.18, z["activity"] / 120.0))) + zone_used = int(round(zone_cells * local_used_ratio)) + zone_used = max(0, min(zone_cells, zone_used)) + zone_used_total += zone_used + + zone_write_share = zone_scores[idx] / score_sum + zone_writing = int(round(writing_blocks_total * zone_write_share)) + zone_writing = max(0, min(zone_used, zone_writing)) + zone_writing_total += zone_writing + inode_pressure = int(max(0, min( + 100, + round((z["activity"] * 2.6) + (zone_writing * 0.9) + (local_used_ratio * 38.0)) + ))) + + cell_index = 0 + for r in range(row_start, row_end + 1): + for c in range(cols): + state = "used" if cell_index < zone_used else "free" + blocks.append({ + "r": r, + "c": c, + "i": r * cols + c, + "zone_id": z["id"], + "state": state + }) + cell_index += 1 + + if zone_writing > 0 and zone_used > 0: + # Convert some used cells into writing cells within this zone segment. + zone_block_indices = [ + i for i, b in enumerate(blocks) + if b["zone_id"] == z["id"] and b["state"] == "used" + ] + used_len = len(zone_block_indices) + for n in range(min(zone_writing, used_len)): + pick = (seed * 31 + idx * 67 + n * 43) % used_len + blocks[zone_block_indices[pick]]["state"] = "writing" + + zones.append({ + "id": z["id"], + "name": z["name"], + "path": z["path"], + "row_start": row_start, + "row_end": row_end, + "activity": int(z["activity"]), + "used_percent": round(local_used_ratio * 100.0, 1), + "writing_blocks": zone_writing, + "inode_pressure": inode_pressure + }) + + writing_blocks = sum(1 for b in blocks if b["state"] == "writing") + + FILESYSTEM_PREV["timestamp"] = now + FILESYSTEM_PREV["write_bytes"] = write_bytes + + inode_pressure_global = 0 + if zones: + inode_pressure_global = int(round(sum(int(z.get("inode_pressure", 0)) for z in zones) / len(zones))) + + return { + "timestamp": datetime.now().isoformat(), + "rows": rows, + "cols": cols, + "zones": zones, + "blocks": blocks, + "meta": { + "total_gb": total_gb, + "used_gb": used_gb, + "free_gb": free_gb, + "used_percent": round(used_percent, 2), + "write_bps": round(write_bps, 2), + "writing_blocks": writing_blocks, + "inode_pressure": inode_pressure_global + } + } + +def get_network_stack_realtime(): + now = time.time() + iface = _get_default_iface() + pernic = psutil.net_io_counters(pernic=True) + iface_stats = pernic.get(iface) + all_connections = get_active_connections() + interesting = [ + c for c in all_connections + if not c["remote"].startswith("127.0.0.1") and not c["remote"].startswith("0.0.0.0") + ] + flow = interesting[0] if interesting else (all_connections[0] if all_connections else None) + if flow: + flow = { + "local": flow.get("local"), + "remote": flow.get("remote"), + "type": str(flow.get("type", "TCP")).upper(), + "state_code": flow.get("state", "00"), + "state_name": _tcp_state_name(flow.get("state", "00")) + } + + tcpext = _parse_netstat_tcpext() + ip_stats = _parse_snmp_section("Ip") + tcp_stats = _parse_snmp_section("Tcp") + ss_metrics = _get_ss_tcp_metrics() + + retrans_total = tcpext.get("RetransSegs", 0) + ip_in_total = ip_stats.get("InReceives", 0) + ip_out_total = ip_stats.get("OutRequests", 0) + ip_discards_total = ip_stats.get("InDiscards", 0) + ip_stats.get("OutDiscards", 0) + + established = 0 + try: + with open("/proc/net/tcp", "r") as f: + for line in f.readlines()[1:]: + parts = line.strip().split() + if len(parts) >= 4 and parts[3] == "01": + established += 1 + except OSError: + established = 0 + + prev_ts = NETWORK_STACK_PREV["timestamp"] + dt = max(0.001, now - prev_ts) if prev_ts else 1.0 + + def rate(curr, prev): + if prev is None: + return 0.0 + return max(0.0, (curr - prev) / dt) + + retrans_per_sec = rate(retrans_total, NETWORK_STACK_PREV["tcpext_retrans"]) + ip_in_per_sec = rate(ip_in_total, NETWORK_STACK_PREV["ip_in"]) + ip_out_per_sec = rate(ip_out_total, NETWORK_STACK_PREV["ip_out"]) + ip_drop_per_sec = rate(ip_discards_total, NETWORK_STACK_PREV["ip_discards"]) + + rx_per_sec = 0.0 + tx_per_sec = 0.0 + iface_drop_per_sec = 0.0 + rx_bytes = iface_stats.bytes_recv if iface_stats else 0 + tx_bytes = iface_stats.bytes_sent if iface_stats else 0 + iface_drops = (iface_stats.dropin + iface_stats.dropout) if iface_stats else 0 + if NETWORK_STACK_PREV["iface_rx"] is not None: + rx_per_sec = max(0.0, (rx_bytes - NETWORK_STACK_PREV["iface_rx"]) / dt) + if NETWORK_STACK_PREV["iface_tx"] is not None: + tx_per_sec = max(0.0, (tx_bytes - NETWORK_STACK_PREV["iface_tx"]) / dt) + if NETWORK_STACK_PREV["iface_drops"] is not None: + iface_drop_per_sec = max(0.0, (iface_drops - NETWORK_STACK_PREV["iface_drops"]) / dt) + + NETWORK_STACK_PREV["timestamp"] = now + NETWORK_STACK_PREV["tcpext_retrans"] = retrans_total + NETWORK_STACK_PREV["ip_in"] = ip_in_total + NETWORK_STACK_PREV["ip_out"] = ip_out_total + NETWORK_STACK_PREV["ip_discards"] = ip_discards_total + NETWORK_STACK_PREV["iface_rx"] = rx_bytes + NETWORK_STACK_PREV["iface_tx"] = tx_bytes + NETWORK_STACK_PREV["iface_drops"] = iface_drops + + packets_per_sec = ip_in_per_sec + ip_out_per_sec + drop_ratio = (ip_drop_per_sec / packets_per_sec) if packets_per_sec > 0 else 0.0 + throughput_mb_s = (rx_per_sec + tx_per_sec) / (1024 * 1024) + + retrans_prob = min(0.75, retrans_per_sec / 600.0) + drop_prob = min(0.75, (ip_drop_per_sec / 500.0) + (drop_ratio * 8.0)) + packet_speed = max(1.4, min(4.8, 1.8 + throughput_mb_s / 8.0)) + + socket_activity = min(1.0, (len(all_connections) / 80.0) + (retrans_per_sec / 250.0)) + tcp_activity = min(1.0, (ss_metrics.get("cwnd", 0) / 80.0) + (retrans_per_sec / 300.0)) + ip_activity = min(1.0, packets_per_sec / 15000.0) + netfilter_activity = min(1.0, (ip_drop_per_sec / 120.0) + (drop_ratio * 6.0)) + driver_activity = min(1.0, ((rx_per_sec + tx_per_sec) / (60 * 1024 * 1024)) + (iface_drop_per_sec / 40.0)) + nic_activity = min(1.0, ((rx_per_sec + tx_per_sec) / (80 * 1024 * 1024))) + + return { + "timestamp": datetime.now().isoformat(), + "flow": flow, + "layer_metrics": { + "userspace": { + "active_processes": len(psutil.pids()) + }, + "socket_api": { + "active_sockets": len(all_connections), + "established": established, + "retransmits_per_sec": round(retrans_per_sec, 2) + }, + "tcp_udp": { + "established": established, + "retrans_per_sec": round(retrans_per_sec, 2), + "cwnd": int(ss_metrics.get("cwnd", 0)), + "rtt_ms": round(float(ss_metrics.get("rtt_ms", 0.0)), 2), + "tx_queue": int(ss_metrics.get("tx_queue", 0)) + }, + "ip": { + "in_packets_per_sec": round(ip_in_per_sec, 2), + "out_packets_per_sec": round(ip_out_per_sec, 2), + "drop_per_sec": round(ip_drop_per_sec, 3), + "drop_ratio": round(drop_ratio, 5) + }, + "netfilter": { + "drop_per_sec": round(ip_drop_per_sec, 3), + "drop_ratio": round(drop_ratio, 5) + }, + "driver": { + "iface": iface, + "rx_mb_s": round(rx_per_sec / (1024 * 1024), 3), + "tx_mb_s": round(tx_per_sec / (1024 * 1024), 3), + "tx_queue": int(ss_metrics.get("tx_queue", 0)), + "drops_per_sec": round(iface_drop_per_sec, 3) + }, + "nic": { + "iface": iface, + "rx_errors": int(getattr(iface_stats, "errin", 0)) if iface_stats else 0, + "tx_errors": int(getattr(iface_stats, "errout", 0)) if iface_stats else 0, + "drops_total": int(iface_drops) + } + }, + "layer_activity": { + "userspace": min(1.0, len(psutil.pids()) / 400.0), + "socket": round(socket_activity, 4), + "tcp": round(tcp_activity, 4), + "ip": round(ip_activity, 4), + "netfilter": round(netfilter_activity, 4), + "driver": round(driver_activity, 4), + "nic": round(nic_activity, 4) + }, + "signals": { + "drop_probability": round(drop_prob, 4), + "retransmit_probability": round(retrans_prob, 4), + "packet_speed": round(packet_speed, 3) + }, + "throughput_mb_s": round(throughput_mb_s, 3), + "tcp_counters": { + "in_segs": int(tcp_stats.get("InSegs", 0)), + "out_segs": int(tcp_stats.get("OutSegs", 0)), + "retrans_segs_total": int(retrans_total) + } + } + +def _parse_cgroup_path(pid): + cgroup_text = _proc_fs.safe_read_text(f"/proc/{pid}/cgroup") + if not cgroup_text: + return "/" + chosen = "/" + for line in cgroup_text.splitlines(): + parts = line.split(":") + if len(parts) != 3: + continue + _, controllers, path = parts + path = path.strip() or "/" + # Prefer cgroup v2 unified hierarchy entry "0::/path" + if controllers == "": + return path + if path and path != "/": + chosen = path + return chosen + +def _read_namespace_inode(pid, ns_name): + ns_link = f"/proc/{pid}/ns/{ns_name}" + try: + target = os.readlink(ns_link) + except (OSError, PermissionError): + return None + match = re.search(r'\[(\d+)\]', target) + return match.group(1) if match else target + +def _read_cgroup_v2_stats(cgroup_path): + root = "/sys/fs/cgroup" + rel = cgroup_path.lstrip("/") + base = os.path.join(root, rel) if rel else root + + cpu_max_text = _proc_fs.safe_read_text(os.path.join(base, "cpu.max")) + cpu_quota_cores = None + if cpu_max_text: + parts = cpu_max_text.split() + if len(parts) >= 2 and parts[0] != "max": + try: + quota = float(parts[0]) + period = float(parts[1]) + if period > 0: + cpu_quota_cores = round(quota / period, 2) + except ValueError: + cpu_quota_cores = None + + mem_current = _proc_fs.safe_read_text(os.path.join(base, "memory.current")) + mem_max = _proc_fs.safe_read_text(os.path.join(base, "memory.max")) + pids_current = _proc_fs.safe_read_text(os.path.join(base, "pids.current")) + pids_max = _proc_fs.safe_read_text(os.path.join(base, "pids.max")) + io_stat_text = _proc_fs.safe_read_text(os.path.join(base, "io.stat")) + + memory_current_mb = None + memory_max_mb = None + try: + if mem_current is not None: + memory_current_mb = round(int(mem_current) / (1024 * 1024), 1) + except ValueError: + memory_current_mb = None + + try: + if mem_max and mem_max != "max": + memory_max_mb = round(int(mem_max) / (1024 * 1024), 1) + except ValueError: + memory_max_mb = None + + io_bytes = None + if io_stat_text: + total = 0 + for line in io_stat_text.splitlines(): + rbytes_match = re.search(r'rbytes=(\d+)', line) + wbytes_match = re.search(r'wbytes=(\d+)', line) + if rbytes_match: + total += int(rbytes_match.group(1)) + if wbytes_match: + total += int(wbytes_match.group(1)) + io_bytes = total + + return { + "cpu_quota_cores": cpu_quota_cores, + "memory_current_mb": memory_current_mb, + "memory_max_mb": memory_max_mb, + "pids_current": int(pids_current) if pids_current and pids_current.isdigit() else None, + "pids_max": None if pids_max in (None, "max") else (int(pids_max) if pids_max.isdigit() else None), + "io_total_mb": round(io_bytes / (1024 * 1024), 1) if io_bytes is not None else None + } + +def get_isolation_context(): + """Aggregate namespace and cgroup context for UI layer.""" + namespace_keys = ["mnt", "pid", "net", "ipc", "uts", "user"] + namespace_labels = { + "mnt": "MNT", + "pid": "PID", + "net": "NET", + "ipc": "IPC", + "uts": "UTS", + "user": "USER" + } + namespace_counts = {k: {} for k in namespace_keys} + cgroup_aggregates = {} + total_scanned = 0 + + for proc in psutil.process_iter(["pid", "name", "memory_info"]): + try: + pid = proc.info["pid"] + total_scanned += 1 + + cgroup_path = _parse_cgroup_path(pid) + agg = cgroup_aggregates.setdefault(cgroup_path, { + "path": cgroup_path, + "process_count": 0, + "memory_mb_sum": 0.0, + "sample_processes": [] + }) + agg["process_count"] += 1 + + mem_info = proc.info.get("memory_info") + if mem_info: + agg["memory_mb_sum"] += (mem_info.rss / (1024 * 1024)) + + if len(agg["sample_processes"]) < 4: + process_name = proc.info.get("name") or "unknown" + agg["sample_processes"].append(process_name) + + for ns_name in namespace_keys: + inode = _read_namespace_inode(pid, ns_name) + if inode: + ns_map = namespace_counts[ns_name] + ns_map[inode] = ns_map.get(inode, 0) + 1 + except (psutil.NoSuchProcess, psutil.AccessDenied, KeyError): + continue + + namespaces = [] + for ns_name in namespace_keys: + entries = namespace_counts[ns_name] + unique_count = len(entries) + dominant_inode = None + dominant_count = 0 + if entries: + dominant_inode, dominant_count = max(entries.items(), key=lambda kv: kv[1]) + activity = round((dominant_count / total_scanned), 3) if total_scanned > 0 else 0 + namespaces.append({ + "id": ns_name, + "label": namespace_labels[ns_name], + "unique_count": unique_count, + "dominant_inode": dominant_inode, + "dominant_count": dominant_count, + "activity": activity + }) + + top_cgroups = sorted( + cgroup_aggregates.values(), + key=lambda x: (x["process_count"], x["memory_mb_sum"]), + reverse=True + )[:4] + + for item in top_cgroups: + stats = _read_cgroup_v2_stats(item["path"]) + item["memory_mb_sum"] = round(item["memory_mb_sum"], 1) + item.update(stats) + + return { + "timestamp": datetime.now().isoformat(), + "processes_scanned": total_scanned, + "namespaces": namespaces, + "top_cgroups": top_cgroups + } + +def get_route_hint(remote_ip): + """Fallback path hint using Linux routing table when traceroute tools are absent.""" + ip_cmd = resolve_binary("ip") + if not ip_cmd: + return { + "remote_ip": remote_ip, + "tool": None, + "reached": False, + "hop_count": 0, + "hops": [], + "note": "Path tools unavailable on host" + } + + try: + result = subprocess.run( + [ip_cmd, "-o", "route", "get", remote_ip], + capture_output=True, + text=True, + timeout=2, + check=False + ) + line = (result.stdout or "").strip() + if not line: + return { + "remote_ip": remote_ip, + "tool": "ip-route", + "reached": False, + "hop_count": 0, + "hops": [], + "note": "No route information available" + } + + via_match = re.search(r'\svia\s(\d{1,3}(?:\.\d{1,3}){3})', line) + dev_match = re.search(r'\sdev\s([A-Za-z0-9_.:-]+)', line) + src_match = re.search(r'\ssrc\s(\d{1,3}(?:\.\d{1,3}){3})', line) + + hops = [] + if via_match: + hops.append({ + "hop": 1, + "target": via_match.group(1), + "rtt_ms": None + }) + hops.append({ + "hop": 2, + "target": remote_ip, + "rtt_ms": None + }) + else: + hops.append({ + "hop": 1, + "target": remote_ip, + "rtt_ms": None + }) + + note_parts = ["Traceroute not installed, showing kernel route hint"] + if dev_match: + note_parts.append(f"dev={dev_match.group(1)}") + if src_match: + note_parts.append(f"src={src_match.group(1)}") + + return { + "remote_ip": remote_ip, + "tool": "ip-route", + "reached": False, + "hop_count": len(hops), + "hops": hops, + "note": ", ".join(note_parts) + } + except (subprocess.TimeoutExpired, OSError): + return { + "remote_ip": remote_ip, + "tool": "ip-route", + "reached": False, + "hop_count": 0, + "hops": [], + "note": "Route hint lookup timed out" + } + +def get_traceroute_info(remote_ip, max_hops=8): + """Get traceroute/tracepath information for a remote IP with short timeout.""" + try: + target_ip = ipaddress.ip_address(remote_ip) + # Skip loopback/local addresses - traceroute is not meaningful here. + if target_ip.is_loopback or target_ip.is_unspecified: + return { + "remote_ip": remote_ip, + "tool": None, + "reached": False, + "hop_count": 0, + "hops": [], + "note": "Local address, traceroute skipped" + } + except ValueError: + raise ValueError("Invalid IP address") + + now = time.time() + cached = TRACEROUTE_CACHE.get(remote_ip) + if cached and (now - cached["timestamp"]) < TRACEROUTE_CACHE_TTL_SECONDS: + return cached["data"] + + traceroute_cmd = resolve_binary("traceroute") + tracepath_cmd = resolve_binary("tracepath") + cmd = None + tool = None + if traceroute_cmd: + cmd = [traceroute_cmd, "-n", "-m", str(max_hops), "-q", "1", "-w", "1", remote_ip] + tool = "traceroute" + elif tracepath_cmd: + cmd = [tracepath_cmd, "-n", "-m", str(max_hops), remote_ip] + tool = "tracepath" + else: + data = get_route_hint(remote_ip) + TRACEROUTE_CACHE[remote_ip] = {"timestamp": now, "data": data} + return data + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=7, + check=False + ) + output = (result.stdout or "").strip() + if not output and result.stderr: + output = result.stderr.strip() + except subprocess.TimeoutExpired: + return { + "remote_ip": remote_ip, + "tool": tool, + "reached": False, + "hop_count": 0, + "hops": [], + "note": "Traceroute timed out" + } + + hops = [] + for raw_line in output.splitlines(): + line = raw_line.strip() + hop_match = re.match(r'^(\d+)\s+', line) + if not hop_match: + tracepath_match = re.match(r'^(\d+):\s+', line) + if not tracepath_match: + continue + hop_idx = int(tracepath_match.group(1)) + else: + hop_idx = int(hop_match.group(1)) + + if "*" in line and re.search(r'\*\s*\*\s*\*', line): + hops.append({ + "hop": hop_idx, + "target": "*", + "rtt_ms": None + }) + continue + + ip_match = re.search(r'(\d{1,3}(?:\.\d{1,3}){3})', line) + rtt_match = re.search(r'(\d+(?:\.\d+)?)\s*ms', line) + hops.append({ + "hop": hop_idx, + "target": ip_match.group(1) if ip_match else "?", + "rtt_ms": float(rtt_match.group(1)) if rtt_match else None + }) + + reached = any(h.get("target") == remote_ip for h in hops) + data = { + "remote_ip": remote_ip, + "tool": tool, + "reached": reached, + "hop_count": len(hops), + "hops": hops[:max_hops], + "note": None + } + TRACEROUTE_CACHE[remote_ip] = {"timestamp": now, "data": data} + return data + +def active_connections(): + """API for active network connections""" + try: + connections = get_active_connections() + return jsonify({"connections": connections}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +def traceroute_info(): + """API endpoint for traceroute path to remote IP.""" + try: + remote_ip = request.args.get("ip", "").strip() + if not remote_ip: + return jsonify({"error": "Missing 'ip' query parameter"}), 400 + + data = get_traceroute_info(remote_ip) + return jsonify(data) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + except Exception as e: + return jsonify({"error": str(e)}), 500 + +def network_stack_realtime(): + """Live telemetry for Network Stack visualization.""" + try: + return jsonify(get_network_stack_realtime()) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +def devices_realtime(): + """Live telemetry for Devices belt visualization.""" + try: + return jsonify(get_devices_realtime()) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +def filesystem_blocks(): + """Live block-map style filesystem telemetry.""" + try: + return jsonify(get_filesystem_blocks()) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +def isolation_context(): + """API endpoint for cgroups + namespaces design layer.""" + try: + return jsonify(get_isolation_context()) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +def get_process_threads(pid): + """API for getting thread information for a specific process""" + try: + thread_info = get_process_threads_info(pid) + return jsonify(thread_info) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +def get_process_cpu(pid): + """API for getting CPU statistics for a specific process""" + try: + cpu_info = get_process_cpu_info(pid) + return jsonify(cpu_info) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +def get_process_fds(pid): + """API for getting file descriptors for a specific process""" + try: + fds_info = get_process_fds_info(pid) + return jsonify(fds_info) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +def get_processes_detailed(): + """API for getting all processes with detailed information (threads, CPU, FDs)""" + try: + processes = [] + for proc in psutil.process_iter(['pid', 'name', 'status', 'memory_info', 'cpu_percent', 'num_threads', 'num_fds']): + try: + memory_info = proc.info.get('memory_info') + if memory_info is None: + # Some transient/zombie processes may have incomplete info. + continue + memory_mb = float(memory_info.rss) / 1024 / 1024 + + # Get num_fds with fallback + num_fds = proc.info.get('num_fds') + if num_fds is None or num_fds == 0: + # Try to count from /proc/[pid]/fd directly + try: + pid = proc.info['pid'] + fd_dir = f'/proc/{pid}/fd' + if os.path.exists(fd_dir): + num_fds = len([f for f in os.listdir(fd_dir) if f.isdigit()]) + else: + num_fds = 0 + except (OSError, PermissionError): + num_fds = 0 + if num_fds is None: + num_fds = 0 + + # Get process name - use cmdline for nginx to get full name like "nginx: master process" + process_name = proc.info.get('name') or f'pid-{proc.info.get("pid", "unknown")}' + try: + cmdline = proc.cmdline() + if cmdline and len(cmdline) > 0: + # For nginx, cmdline[0] is "nginx:" and we want the full description + if cmdline[0] == 'nginx:' and len(cmdline) > 1: + process_name = f"nginx: {cmdline[1]}" + except (psutil.AccessDenied, psutil.NoSuchProcess): + pass + + # Get cmdline for better process identification + cmdline_str = '' + try: + cmdline = proc.cmdline() + if cmdline: + cmdline_str = ' '.join(cmdline) + except (psutil.AccessDenied, psutil.NoSuchProcess): + pass + + processes.append({ + 'pid': proc.info['pid'], + 'name': process_name, + 'cmdline': cmdline_str, # Add cmdline for better identification + 'status': proc.info.get('status', 'unknown'), + 'memory_mb': round(memory_mb, 1), + 'cpu_percent': round(float(proc.info.get('cpu_percent', 0) or 0), 1), + 'num_threads': int(proc.info.get('num_threads', 0) or 0), + 'num_fds': int(num_fds or 0) + }) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + except Exception: + # Never fail the whole endpoint because of one malformed/transient process. + continue + + return jsonify({'processes': processes}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +def get_ipc_links_summary(max_pairs=120, max_nodes=24): + """Collect IPC relationships by shared sockets, pipes and shared memory mappings.""" + socket_inode_re = re.compile(r"^socket:\[(\d+)\]$") + pipe_inode_re = re.compile(r"^pipe:\[(\d+)\]$") + # /proc//maps sample: + # address perms offset dev inode pathname + # We use mappings with shared perms (e.g. rw-s) and real inode/path. + socket_owners = {} + pipe_owners = {} + shm_owners = {} + namespace_keys = ["mnt", "pid", "net", "ipc", "uts", "user"] + namespace_owners = {} + + for proc_dir in os.listdir("/proc"): + if not proc_dir.isdigit(): + continue + pid = int(proc_dir) + try: + with open(f"/proc/{pid}/comm", "r", encoding="utf-8", errors="replace") as f: + proc_name = f.read().strip() + except (OSError, PermissionError): + continue + if not proc_name: + continue + + fd_dir = f"/proc/{pid}/fd" + try: + fd_entries = os.listdir(fd_dir) + except (OSError, PermissionError): + continue + + for fd_entry in fd_entries: + fd_path = f"{fd_dir}/{fd_entry}" + try: + target = os.readlink(fd_path) + except (OSError, PermissionError): + continue + + sm = socket_inode_re.match(target) + if sm: + inode = int(sm.group(1)) + socket_owners.setdefault(inode, set()).add((pid, proc_name)) + continue + + pm = pipe_inode_re.match(target) + if pm: + inode = int(pm.group(1)) + pipe_owners.setdefault(inode, set()).add((pid, proc_name)) + + maps_path = f"/proc/{pid}/maps" + try: + with open(maps_path, "r", encoding="utf-8", errors="replace") as maps_file: + for map_line in maps_file: + parts = map_line.strip().split(None, 5) + if len(parts) < 5: + continue + perms = parts[1] + dev = parts[3] + inode_text = parts[4] + map_path = parts[5] if len(parts) > 5 else "" + + if len(perms) < 4 or perms[3] != "s": + continue + if not inode_text.isdigit(): + continue + inode = int(inode_text) + if inode <= 0: + continue + if not map_path: + continue + # Skip anonymous pseudo-regions like [heap], [stack], [anon] + if map_path.startswith("["): + continue + + shm_key = f"{dev}:{inode}:{map_path}" + shm_owners.setdefault(shm_key, set()).add((pid, proc_name)) + except (OSError, PermissionError): + continue + + for ns_name in namespace_keys: + ns_inode = _read_namespace_inode(pid, ns_name) + if not ns_inode: + continue + ns_key = f"{ns_name}:{ns_inode}" + namespace_owners.setdefault(ns_key, set()).add((pid, proc_name)) + + pair_totals = {} + pair_socket = {} + pair_pipe = {} + pair_shm = {} + pair_namespace = {} + degree_total = {} + degree_socket = {} + degree_pipe = {} + degree_shm = {} + degree_namespace = {} + + def add_pair_counts(name_a, name_b, kind): + if name_a == name_b: + key = (name_a, name_b) + else: + key = tuple(sorted((name_a, name_b))) + pair_totals[key] = pair_totals.get(key, 0) + 1 + if kind == "socket": + pair_socket[key] = pair_socket.get(key, 0) + 1 + elif kind == "pipe": + pair_pipe[key] = pair_pipe.get(key, 0) + 1 + elif kind == "shm": + pair_shm[key] = pair_shm.get(key, 0) + 1 + elif kind == "namespace": + pair_namespace[key] = pair_namespace.get(key, 0) + 1 + + for nm in (name_a, name_b): + degree_total[nm] = degree_total.get(nm, 0) + 1 + if kind == "socket": + degree_socket[nm] = degree_socket.get(nm, 0) + 1 + elif kind == "pipe": + degree_pipe[nm] = degree_pipe.get(nm, 0) + 1 + elif kind == "shm": + degree_shm[nm] = degree_shm.get(nm, 0) + 1 + elif kind == "namespace": + degree_namespace[nm] = degree_namespace.get(nm, 0) + 1 + + def consume_inode_owners(owner_map, kind): + for _inode, owners in owner_map.items(): + unique = sorted({(pid, name) for pid, name in owners}) + if len(unique) < 2: + continue + for i in range(len(unique)): + for j in range(i + 1, len(unique)): + add_pair_counts(unique[i][1], unique[j][1], kind) + + consume_inode_owners(socket_owners, "socket") + consume_inode_owners(pipe_owners, "pipe") + consume_inode_owners(shm_owners, "shm") + consume_inode_owners(namespace_owners, "namespace") + + sorted_pairs = sorted(pair_totals.items(), key=lambda kv: kv[1], reverse=True)[:max_pairs] + pair_links = [] + for (left, right), weight in sorted_pairs: + pair_links.append({ + "left": left, + "right": right, + "weight": int(weight), + "socket_weight": int(pair_socket.get((left, right), pair_socket.get((right, left), 0))), + "pipe_weight": int(pair_pipe.get((left, right), pair_pipe.get((right, left), 0))), + "shm_weight": int(pair_shm.get((left, right), pair_shm.get((right, left), 0))), + "ns_weight": int(pair_namespace.get((left, right), pair_namespace.get((right, left), 0))), + }) + + sorted_nodes = sorted(degree_total.items(), key=lambda kv: kv[1], reverse=True)[:max_nodes] + process_nodes = [] + for name, degree in sorted_nodes: + process_nodes.append({ + "name": name, + "degree": int(degree), + "socket_degree": int(degree_socket.get(name, 0)), + "pipe_degree": int(degree_pipe.get(name, 0)), + "shm_degree": int(degree_shm.get(name, 0)), + "ns_degree": int(degree_namespace.get(name, 0)), + }) + + return { + "process_nodes": process_nodes, + "pair_links": pair_links, + "stats": { + "shared_socket_inodes": int(sum(1 for owners in socket_owners.values() if len({pid for pid, _ in owners}) > 1)), + "shared_pipe_inodes": int(sum(1 for owners in pipe_owners.values() if len({pid for pid, _ in owners}) > 1)), + "shared_memory_regions": int(sum(1 for owners in shm_owners.values() if len({pid for pid, _ in owners}) > 1)), + "shared_namespace_groups": int(sum(1 for owners in namespace_owners.values() if len({pid for pid, _ in owners}) > 1)), + "pair_count": len(pair_links), + "node_count": len(process_nodes), + } + } + + +def get_ipc_links(): + """API: shared IPC/socket links across processes.""" + try: + max_pairs = request.args.get("max_pairs", default=120, type=int) + max_nodes = request.args.get("max_nodes", default=24, type=int) + max_pairs = max(20, min(300, max_pairs)) + max_nodes = max(8, min(64, max_nodes)) + return jsonify(get_ipc_links_summary(max_pairs=max_pairs, max_nodes=max_nodes)) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +def get_process_threads_info(pid): + """Get thread information for a specific process""" + try: + proc = psutil.Process(pid) + threads = proc.threads() + + # Also read from /proc/[pid]/status for additional info + thread_count = proc.num_threads() + + try: + with open(f'/proc/{pid}/status', 'r') as f: + status_data = {} + for line in f: + if ':' in line: + key, value = line.split(':', 1) + status_data[key.strip()] = value.strip() + + voluntary_switches = int(status_data.get('voluntary_ctxt_switches', 0)) + nonvoluntary_switches = int(status_data.get('nonvoluntary_ctxt_switches', 0)) + except: + voluntary_switches = 0 + nonvoluntary_switches = 0 + + return { + 'pid': pid, + 'thread_count': thread_count, + 'threads': [ + { + 'id': t.id, + 'user_time': t.user_time, + 'system_time': t.system_time + } for t in threads + ], + 'voluntary_ctxt_switches': voluntary_switches, + 'nonvoluntary_ctxt_switches': nonvoluntary_switches + } + except (psutil.NoSuchProcess, psutil.AccessDenied) as e: + return {'error': str(e)} + except Exception as e: + return {'error': str(e)} + +def get_process_cpu_info(pid): + """Get CPU statistics for a specific process""" + try: + proc = psutil.Process(pid) + + # Get CPU times + cpu_times = proc.cpu_times() + cpu_percent = proc.cpu_percent(interval=0.1) + + # Get CPU affinity if available + try: + cpu_affinity = proc.cpu_affinity() + except: + cpu_affinity = [] + + # Get nice value + try: + nice = proc.nice() + except: + nice = None + + return { + 'pid': pid, + 'cpu_percent': round(cpu_percent, 1), + 'cpu_times': { + 'user': round(cpu_times.user, 2), + 'system': round(cpu_times.system, 2), + 'children_user': round(cpu_times.children_user, 2) if hasattr(cpu_times, 'children_user') else 0, + 'children_system': round(cpu_times.children_system, 2) if hasattr(cpu_times, 'children_system') else 0 + }, + 'cpu_affinity': cpu_affinity, + 'nice': nice + } + except (psutil.NoSuchProcess, psutil.AccessDenied) as e: + return {'error': str(e)} + except Exception as e: + return {'error': str(e)} + +def get_process_fds_info(pid): + """Get file descriptors information for a specific process""" + try: + proc = psutil.Process(pid) + + # Get number of file descriptors + try: + num_fds = proc.num_fds() + except (psutil.AccessDenied, AttributeError): + # Try to count from /proc/[pid]/fd + try: + fd_dir = f'/proc/{pid}/fd' + if os.path.exists(fd_dir): + num_fds = len([f for f in os.listdir(fd_dir) if f.isdigit()]) + else: + num_fds = 0 + except: + num_fds = 0 + + # Get open files + open_files = [] + try: + for fd in proc.open_files(): + open_files.append({ + 'path': fd.path, + 'fd': fd.fd if hasattr(fd, 'fd') else None + }) + except (psutil.AccessDenied, psutil.NoSuchProcess, AttributeError): + # Fallback: try to read from /proc/[pid]/fd directly + try: + fd_dir = f'/proc/{pid}/fd' + if os.path.exists(fd_dir): + for fd_num in os.listdir(fd_dir): + if fd_num.isdigit(): + try: + fd_path = os.readlink(f'{fd_dir}/{fd_num}') + # Filter out special files (sockets, pipes, etc.) + # Also filter out IP addresses (which might appear as socket paths) + # Check if it looks like an IP address (e.g., "0.0.0.0", "127.0.0.1") + ip_pattern = re.compile(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}') + if (fd_path.startswith('/') and + not fd_path.startswith('socket:') and + not fd_path.startswith('pipe:') and + not fd_path.startswith('anon_inode:') and + not ip_pattern.match(fd_path)): # Filter IP addresses + open_files.append({ + 'path': fd_path, + 'fd': int(fd_num) + }) + except (OSError, ValueError): + pass + except (OSError, PermissionError): + pass + + # Get connections (sockets) + connections = [] + try: + for conn in proc.connections(): + connections.append({ + 'fd': conn.fd if hasattr(conn, 'fd') else None, + 'family': str(conn.family), + 'type': str(conn.type), + 'local_address': f"{conn.laddr.ip}:{conn.laddr.port}" if conn.laddr else None, + 'remote_address': f"{conn.raddr.ip}:{conn.raddr.port}" if conn.raddr else None, + 'status': conn.status + }) + except (psutil.AccessDenied, psutil.NoSuchProcess, AttributeError): + pass + + return { + 'pid': pid, + 'num_fds': num_fds, + 'open_files': open_files[:20], # Limit to 20 + 'connections': connections[:20] # Limit to 20 + } + except (psutil.NoSuchProcess, psutil.AccessDenied) as e: + return {'error': f'Access denied or process not found: {str(e)}'} + except Exception as e: + return {'error': f'Error getting FDs: {str(e)}'} + + +def get_proc_matrix(): + """API: Matrix view data (processes vs CPU / MEM / IO / NET / FD)""" + try: + matrix = get_proc_matrix_data() + return jsonify({ + 'matrix': matrix, + 'timestamp': datetime.now().isoformat(), + }) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +def get_proc_timeline(): + """API: Timeline view data - events for a specific process""" + try: + from flask import request + pid = request.args.get('pid', type=int) + if not pid: + return jsonify({'error': 'PID parameter required'}), 400 + + timeline = [] + + # Check if process exists + try: + proc = psutil.Process(pid) + except psutil.NoSuchProcess: + return jsonify({'error': f'Process {pid} not found'}), 404 + + # Get process info + proc_info = proc.as_dict(['pid', 'name', 'create_time', 'status']) + base_ts = float(proc_info['create_time']) + # Ordered real-derived events; timestamps are monotonic from process start (precise times not in /proc). + ordered_events = [] + ordered_events.append({'type': 'exec', 'pid': pid}) + + # Event: mmap (from /proc/[pid]/maps) + try: + maps_path = f'/proc/{pid}/maps' + if os.path.exists(maps_path): + with open(maps_path, 'r') as f: + map_count = len(f.readlines()) + if map_count > 0: + ordered_events.append({ + 'type': 'mmap', + 'pid': pid, + 'count': map_count + }) + except (IOError, PermissionError): + pass + + # Event: read/write (from /proc/[pid]/io) + try: + io_path = f'/proc/{pid}/io' + if os.path.exists(io_path): + with open(io_path, 'r') as f: + io_data = {} + for line in f: + if ':' in line: + key, value = line.split(':', 1) + io_data[key.strip()] = int(value.strip()) + + if io_data.get('read_bytes', 0) > 0: + ordered_events.append({ + 'type': 'read', + 'pid': pid, + 'bytes': io_data.get('read_bytes', 0) + }) + + if io_data.get('write_bytes', 0) > 0: + ordered_events.append({ + 'type': 'write', + 'pid': pid, + 'bytes': io_data.get('write_bytes', 0) + }) + except (IOError, PermissionError): + pass + + # Event: connect/accept (from /proc/[pid]/net/tcp) + try: + tcp_path = f'/proc/{pid}/net/tcp' + if os.path.exists(tcp_path): + with open(tcp_path, 'r') as f: + lines = f.readlines() + if len(lines) > 1: + for line in lines[1:]: + parts = line.split() + if len(parts) >= 4: + state = parts[3] + if state == '01': + ordered_events.append({'type': 'connect', 'pid': pid}) + elif state == '0A': + ordered_events.append({'type': 'accept', 'pid': pid}) + except (IOError, PermissionError): + pass + + step = 0.35 + timeline = [] + for i, ev in enumerate(ordered_events): + ev = dict(ev) + ev['timestamp'] = datetime.fromtimestamp(base_ts + i * step).isoformat() + timeline.append(ev) + + return jsonify({ + 'timeline': timeline, + 'pid': pid, + 'name': proc_info.get('name', 'unknown'), + 'timestamp': datetime.now().isoformat(), + 'timeline_time_basis': 'Events are ordered from process start; 0.35s steps separate rows for the helix (kernel does not expose per-event wall times for these signals).', + }) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +def get_execution_context(): + """Get execution context data for Ring-1 visualization""" + try: + if platform.system() != 'Linux': + return jsonify({ + 'mode': 'kernel', + 'cpu_state': 'running', + 'syscall_active': False, + 'syscall_name': None, + 'interrupts': [], + 'preempted': False, + 'preempted_pid': None + }) + + # Determine mode (user/kernel) by checking active processes + mode = 'user' # Default + syscall_active = False + syscall_name = None + active_pid = None + active_syscalls = [] # List of processes with active syscalls: [{pid, syscall_name}] + + # Check for active syscalls + try: + proc_dirs = [d for d in os.listdir('/proc') if d.isdigit()] + sampled_procs = proc_dirs[:100] # Sample more processes + + syscall_count = 0 + for pid in sampled_procs: + try: + syscall_path = f'/proc/{pid}/syscall' + if os.path.exists(syscall_path): + with open(syscall_path, 'r') as f: + line = f.read().strip() + if line and line != '-1': + parts = line.split() + if parts: + syscall_num = int(parts[0]) + if syscall_num > 0: + syscall_count += 1 + current_syscall_name = SYSCALL_NAMES.get(syscall_num, f'syscall_{syscall_num}') + + # Add to list of active syscalls + active_syscalls.append({ + 'pid': int(pid), + 'syscall_name': current_syscall_name + }) + + if not syscall_active: # Get first active syscall for main display + syscall_active = True + syscall_name = current_syscall_name + active_pid = int(pid) + mode = 'kernel' # Syscall means kernel mode + except (ValueError, IOError, PermissionError): + continue + + # If we found syscalls, we're in kernel mode + if syscall_count > 0: + mode = 'kernel' + except PermissionError: + pass + + # Get CPU state from /proc/stat + cpu_state = 'running' + try: + with open('/proc/stat', 'r') as f: + cpu_line = f.readline() + if cpu_line.startswith('cpu '): + parts = cpu_line.split() + if len(parts) >= 5: + idle_time = int(parts[4]) + total_time = sum(int(p) for p in parts[1:11] if p.isdigit()) + if total_time > 0: + idle_percent = (idle_time / total_time) * 100 + if idle_percent > 90: + cpu_state = 'idle' + elif idle_percent > 50: + cpu_state = 'sleeping' + except (IOError, ValueError, IndexError): + pass + + # Get recent interrupts and associate with processes + interrupts = [] + # Get list of ALL processes (not just active syscalls) for better distribution + all_process_pids = [] + try: + proc_dirs = [d for d in os.listdir('/proc') if d.isdigit()] + # Get all process PIDs (limit to reasonable number) + all_process_pids = [int(pid) for pid in proc_dirs[:200] if pid.isdigit()] + except PermissionError: + pass + + # Track previous interrupt counts to detect new interrupts + previous_interrupt_counts = {} + try: + # Try to read previous counts from a simple cache (in-memory) + # For now, we'll just report all interrupts and let frontend handle distribution + with open('/proc/interrupts', 'r') as f: + lines = f.readlines() + # Parse interrupt counts + for line in lines[1:]: # Skip header + if line.strip(): + parts = line.split() + if len(parts) > 1: + # Check if any CPU has non-zero interrupts + for i in range(1, min(len(parts), 5)): # Check first 4 CPUs + try: + count = int(parts[i]) + # Report interrupts more frequently (every 10 instead of 100) + if count > 0: + # Extract IRQ number + irq_num = parts[0].rstrip(':') + + # Associate with a process - use CPU and IRQ to select process + # This ensures consistent mapping: same CPU+IRQ = same process + associated_pid = None + if all_process_pids: + # Use CPU and IRQ to create a consistent hash for process selection + hash_value = (i - 1) * 100 + int(irq_num) if irq_num.isdigit() else (i - 1) * 100 + process_index = hash_value % len(all_process_pids) + associated_pid = all_process_pids[process_index] + + interrupts.append({ + 'cpu': i - 1, + 'irq': irq_num, + 'count': count, + 'pid': associated_pid, # Always associate with a process + 'timestamp': datetime.now().isoformat() + }) + break # Only one per IRQ line + except (ValueError, IndexError): + continue + except (IOError, PermissionError): + pass + + # Build IRQ/SoftIRQ stack data with rates for a compact "IRQ stack" UI panel. + now_ts = time.time() + prev_ts = EXEC_CONTEXT_PREV.get("timestamp") + dt = (now_ts - prev_ts) if prev_ts else None + if dt is not None and dt <= 0: + dt = None + + irq_totals_now = {} + irq_rows = [] + try: + with open('/proc/interrupts', 'r') as f: + lines = f.readlines() + for raw in lines[1:]: + if ":" not in raw: + continue + left, right = raw.split(":", 1) + irq_name = left.strip() + tokens = right.split() + if not tokens: + continue + + counts = [] + idx = 0 + while idx < len(tokens) and tokens[idx].isdigit(): + counts.append(int(tokens[idx])) + idx += 1 + if not counts: + continue + total = sum(counts) + desc = " ".join(tokens[idx:]).strip() or irq_name + key = f"{irq_name}:{desc}" + irq_totals_now[key] = total + + prev_total = EXEC_CONTEXT_PREV["irq_totals"].get(key) + per_sec = 0.0 + if dt and prev_total is not None: + per_sec = max(0.0, (total - prev_total) / dt) + + top_cpu = None + if counts: + top_cpu = int(max(range(len(counts)), key=lambda i: counts[i])) + + irq_rows.append({ + "irq": irq_name, + "label": desc, + "total": int(total), + "per_sec": round(per_sec, 2), + "top_cpu": top_cpu, + "subsystem": map_interrupt_to_subsystem(desc) + }) + except (IOError, PermissionError): + pass + + softirq_totals_now = {} + softirq_rows = [] + try: + with open('/proc/softirqs', 'r') as f: + lines = f.readlines() + for raw in lines[1:]: + if ":" not in raw: + continue + left, right = raw.split(":", 1) + name = left.strip() + counts = [] + for tok in right.split(): + if tok.isdigit(): + counts.append(int(tok)) + if not counts: + continue + total = sum(counts) + softirq_totals_now[name] = total + prev_total = EXEC_CONTEXT_PREV["softirq_totals"].get(name) + per_sec = 0.0 + if dt and prev_total is not None: + per_sec = max(0.0, (total - prev_total) / dt) + softirq_rows.append({ + "name": name, + "total": int(total), + "per_sec": round(per_sec, 2) + }) + except (IOError, PermissionError): + pass + + irq_rows.sort(key=lambda row: (row["per_sec"], row["total"]), reverse=True) + softirq_rows.sort(key=lambda row: (row["per_sec"], row["total"]), reverse=True) + hard_top = irq_rows[:5] + soft_top = softirq_rows[:4] + + hard_total_rate = sum(row["per_sec"] for row in irq_rows) + soft_total_rate = sum(row["per_sec"] for row in softirq_rows) + net_softirq_rate = 0.0 + block_softirq_rate = 0.0 + timer_softirq_rate = 0.0 + for row in softirq_rows: + nm = row["name"].upper() + if nm in ("NET_RX", "NET_TX"): + net_softirq_rate += row["per_sec"] + elif nm == "BLOCK": + block_softirq_rate += row["per_sec"] + elif nm == "TIMER": + timer_softirq_rate += row["per_sec"] + + EXEC_CONTEXT_PREV["timestamp"] = now_ts + EXEC_CONTEXT_PREV["irq_totals"] = irq_totals_now + EXEC_CONTEXT_PREV["softirq_totals"] = softirq_totals_now + + # Check for preempted processes (simplified - check if process is in 'R' state but not on CPU) + preempted = False + preempted_pid = None + try: + # This is a simplified check - in reality, preemption detection is more complex + # We check if there are processes in 'R' state (runnable but not running) + proc_dirs = [d for d in os.listdir('/proc') if d.isdigit()] + for pid in proc_dirs[:20]: # Check first 20 + try: + stat_path = f'/proc/{pid}/stat' + if os.path.exists(stat_path): + with open(stat_path, 'r') as f: + stat_data = f.read().split() + if len(stat_data) > 2: + state = stat_data[2] + # 'R' = running/runnable, but if it's not the active one, it might be preempted + if state == 'R' and active_pid and int(pid) != active_pid: + preempted = True + preempted_pid = int(pid) + break + except (ValueError, IOError, PermissionError, IndexError): + continue + except PermissionError: + pass + + return jsonify({ + 'mode': mode, + 'cpu_state': cpu_state, + 'syscall_active': syscall_active, + 'syscall_name': syscall_name, + 'active_pid': active_pid, + 'active_syscalls': active_syscalls, # List of processes with active syscalls + 'interrupts': interrupts[:10], # Limit to 10 most recent + 'irq_stack': { + 'hard': hard_top, + 'soft': soft_top, + 'summary': { + 'hard_total_per_sec': round(hard_total_rate, 2), + 'soft_total_per_sec': round(soft_total_rate, 2), + 'net_softirq_per_sec': round(net_softirq_rate, 2), + 'block_softirq_per_sec': round(block_softirq_rate, 2), + 'timer_softirq_per_sec': round(timer_softirq_rate, 2) + } + }, + 'preempted': preempted, + 'preempted_pid': preempted_pid, + 'cpu_count': psutil.cpu_count(), + 'timestamp': datetime.now().isoformat() + }) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +def get_kernel_dna_data(): + """ + Collect Kernel DNA data: syscalls, interrupts, context switches, locks + Returns data structured for DNA visualization + """ + dna_data = { + 'nucleotides': [], # List of events: syscall, interrupt, context_switch, lock + 'genes': [], # Kernel subsystems segments + 'mutations': [], # Anomalies detected + 'timestamp': datetime.now().isoformat() + } + + # 1. Collect syscalls (A nucleotides) + try: + syscalls = get_real_system_calls() + for syscall in syscalls[:20]: # Limit to 20 most frequent + dna_data['nucleotides'].append({ + 'type': 'syscall', + 'code': 'A', + 'name': syscall['name'], + 'count': syscall.get('count', 0), + 'subsystem': syscall.get('subsystem') or map_syscall_to_subsystem(syscall['name']), + 'timestamp': datetime.now().isoformat() + }) + except Exception as e: + print(f"Error collecting syscalls: {e}") + + # 2. Collect interrupts (T nucleotides) + try: + with open('/proc/interrupts', 'r') as f: + interrupt_lines = f.readlines() + # Skip header line + for line in interrupt_lines[1:11]: # First 10 interrupt lines + parts = line.strip().split() + if len(parts) > 1: + interrupt_name = parts[0].rstrip(':') + total_count = sum(int(x) for x in parts[1:] if x.isdigit()) + if total_count > 0: + dna_data['nucleotides'].append({ + 'type': 'interrupt', + 'code': 'T', + 'name': interrupt_name, + 'count': total_count, + 'subsystem': map_interrupt_to_subsystem(interrupt_name), + 'timestamp': datetime.now().isoformat() + }) + except (IOError, ValueError, PermissionError) as e: + print(f"Error collecting interrupts: {e}") + dna_data['nucleotides'].extend(_kernel_dna_softirq_nucleotides()) + + # 3. Collect context switches (C nucleotides) + try: + with open('/proc/stat', 'r') as f: + for line in f: + if line.startswith('ctxt '): + ctxt_count = int(line.split()[1]) + # Calculate context switches per second (simplified) + dna_data['nucleotides'].append({ + 'type': 'context_switch', + 'code': 'C', + 'name': 'context_switch', + 'count': ctxt_count, + 'subsystem': 'sched', + 'timestamp': datetime.now().isoformat() + }) + break + except (IOError, ValueError, PermissionError) as e: + print(f"Error collecting context switches: {e}") + + # 4. Collect locks (G nucleotides) - from /proc/locks + try: + with open('/proc/locks', 'r') as f: + lock_lines = f.readlines() + lock_count = len(lock_lines) + if lock_count > 0: + dna_data['nucleotides'].append({ + 'type': 'lock', + 'code': 'G', + 'name': 'mutex/lock', + 'count': lock_count, + 'subsystem': 'kernel', + 'timestamp': datetime.now().isoformat() + }) + except (IOError, PermissionError) as e: + # Fallback: estimate locks based on process count + try: + process_count = len(psutil.pids()) + estimated_locks = process_count // 10 + dna_data['nucleotides'].append({ + 'type': 'lock', + 'code': 'G', + 'name': 'mutex/lock', + 'count': estimated_locks, + 'subsystem': 'kernel', + 'timestamp': datetime.now().isoformat() + }) + except: + pass + + # 5. Define gene segments (kernel subsystems) + dna_data['genes'] = [ + {'name': 'sched', 'start': 0, 'end': 0.2, 'color': '#58b6d8'}, + {'name': 'net', 'start': 0.2, 'end': 0.4, 'color': '#4a9eff'}, + {'name': 'fs', 'start': 0.4, 'end': 0.6, 'color': '#6bcf7f'}, + {'name': 'mm', 'start': 0.6, 'end': 0.8, 'color': '#ffa94d'}, + {'name': 'drivers', 'start': 0.8, 'end': 1.0, 'color': '#ff6b9d'} + ] + + # 6. Detect mutations (anomalies) + mutations = [] + + # Check for syscall flood + syscall_count = sum(1 for n in dna_data['nucleotides'] if n['type'] == 'syscall') + if syscall_count > 15: + mutations.append({ + 'type': 'syscall_flood', + 'severity': 'high', + 'message': f'Syscall flood detected: {syscall_count} active syscalls', + 'position': 0.3 + }) + + # Check for abnormal context switching + ctxt_switches = [n for n in dna_data['nucleotides'] if n['type'] == 'context_switch'] + if ctxt_switches and ctxt_switches[0]['count'] > 1000000: + mutations.append({ + 'type': 'abnormal_context_switch', + 'severity': 'medium', + 'message': 'Abnormal context switching rate detected', + 'position': 0.5 + }) + + # Check for lock contention + locks = [n for n in dna_data['nucleotides'] if n['type'] == 'lock'] + if locks and locks[0]['count'] > 100: + mutations.append({ + 'type': 'lock_contention', + 'severity': 'medium', + 'message': f'High lock contention: {locks[0]["count"]} active locks', + 'position': 0.7 + }) + + dna_data['mutations'] = mutations + + return dna_data + +def map_syscall_to_subsystem(syscall_name): + """Map syscall name to kernel subsystem""" + if not syscall_name: + return 'kernel' + if syscall_name.startswith('vm:'): + return 'mm' + if syscall_name.startswith('disk:'): + return 'fs' + if syscall_name.startswith('net:'): + return 'net' + syscall_lower = syscall_name.lower() + if any(x in syscall_lower for x in ['read', 'write', 'open', 'close', 'stat', 'fsync']): + return 'fs' + elif any(x in syscall_lower for x in ['socket', 'connect', 'send', 'recv', 'bind']): + return 'net' + elif any(x in syscall_lower for x in ['mmap', 'munmap', 'brk', 'mprotect']): + return 'mm' + elif any(x in syscall_lower for x in ['clone', 'fork', 'exec', 'wait', 'exit']): + return 'sched' + else: + return 'kernel' + +def map_interrupt_to_subsystem(interrupt_name): + """Map interrupt name to kernel subsystem""" + irq_lower = interrupt_name.lower() + if 'timer' in irq_lower: + return 'sched' + elif any(x in irq_lower for x in ['eth', 'network', 'wifi']): + return 'net' + elif any(x in irq_lower for x in ['keyboard', 'mouse', 'usb']): + return 'drivers' + else: + return 'kernel' + +def infer_crypto_protocol(local_port, remote_port, process_name): + """Infer protocol likely using kernel crypto from socket and process context.""" + tls_ports = {443, 465, 636, 853, 993, 995, 2376, 6443, 8443, 9443} + ssh_ports = {22} + wg_ports = {51820} + p_name = (process_name or "").lower() + ports = {int(local_port or 0), int(remote_port or 0)} + + if ports & ssh_ports or "sshd" in p_name or "ssh" in p_name: + return "SSH", "ChaCha20-Poly1305" + if ports & wg_ports or "wg" in p_name or "wireguard" in p_name: + return "WireGuard", "ChaCha20" + if ports & tls_ports or any(x in p_name for x in ["nginx", "haproxy", "curl", "wget", "openssl", "stunnel", "traefik"]): + return "TLS", "AES-GCM/SHA256" + return "Crypto API", "AES/SHA" + +def is_likely_crypto_actor(process_name, local_port, remote_port, protocol): + """Heuristic gate to avoid flooding with unrelated sockets.""" + p_name = (process_name or "").lower() + interesting_ports = {22, 443, 465, 636, 853, 993, 995, 2376, 6443, 8443, 9443, 51820} + process_tokens = [ + "nginx", "haproxy", "envoy", "caddy", "apache", "httpd", "traefik", + "sshd", "ssh", "wg", "wireguard", "openssl", "stunnel", "curl", "wget", + "python", "gunicorn", "uvicorn" + ] + if protocol in ("TLS", "SSH", "WireGuard"): + return True + if int(local_port or 0) in interesting_ports or int(remote_port or 0) in interesting_ports: + return True + return any(token in p_name for token in process_tokens) + +def infer_tls_terminator(process_name, local_port, protocol, tls_listener_names): + """Guess where TLS termination happens.""" + if protocol != "TLS": + return "n/a" + p_name = (process_name or "").lower() + if p_name and p_name != "unknown": + return p_name + if int(local_port or 0) in {443, 8443, 9443, 6443} and tls_listener_names: + top = next(iter(tls_listener_names)) + return f"listener:{top}" + if int(local_port or 0) in {443, 8443, 9443, 6443}: + return "unknown" + return "upstream-or-external-lb" + +def parse_proc_crypto_entries(): + """Parse /proc/crypto into a list of dict entries.""" + entries = [] + try: + with open("/proc/crypto", "r", encoding="utf-8", errors="ignore") as f: + raw = f.read() + except Exception: + return entries + + blocks = [block.strip() for block in raw.split("\n\n") if block.strip()] + for block in blocks: + item = {} + for line in block.splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + item[key.strip().lower()] = value.strip() + if item: + entries.append(item) + return entries + +def collect_algorithm_competition(requested_algorithm="aes"): + """ + Build algorithm implementation competition using kernel crypto registry. + The winner is the implementation with highest priority. + """ + entries = parse_proc_crypto_entries() + requested = (requested_algorithm or "aes").lower() + req_type_allow = { + "aes": {"skcipher", "aead", "cipher"}, + "sha": {"shash", "ahash", "hash"}, + "chacha20": {"skcipher", "aead", "cipher"} + } + req_tokens = { + "aes": ["aes"], + "sha": ["sha"], + "chacha20": ["chacha20", "xchacha20", "chacha"] + } + allowed_types = req_type_allow.get(requested, {"skcipher", "aead", "cipher", "shash", "ahash", "hash"}) + tokens = req_tokens.get(requested, [requested]) + candidates = [] + + for entry in entries: + name = str(entry.get("name", "")).lower() + driver = str(entry.get("driver", "")).lower() + alg_type = str(entry.get("type", "")).lower() + + if not any(token in name or token in driver for token in tokens): + continue + if alg_type and alg_type not in allowed_types: + continue + + try: + priority = int(entry.get("priority", "0") or 0) + except ValueError: + priority = 0 + + impl_name = driver or name or "unknown-impl" + candidates.append({ + "name": impl_name, + "priority": priority, + "type": alg_type or "unknown", + "source": "kernel" + }) + + # Deduplicate by implementation name, keep the highest priority variant. + dedup = {} + for item in candidates: + existing = dedup.get(item["name"]) + if existing is None or item["priority"] > existing["priority"]: + dedup[item["name"]] = item + candidates = list(dedup.values()) + candidates.sort(key=lambda x: x["priority"], reverse=True) + + if not candidates: + # Fallback keeps the UX informative on hosts without readable /proc/crypto. + fallback_map = { + "aes": [ + {"name": "aesni-intel", "priority": 300, "type": "skcipher", "source": "mock"}, + {"name": "aes-avx", "priority": 200, "type": "skcipher", "source": "mock"}, + {"name": "aes-generic", "priority": 100, "type": "skcipher", "source": "mock"} + ], + "sha": [ + {"name": "sha256-avx2", "priority": 240, "type": "shash", "source": "mock"}, + {"name": "sha256-ssse3", "priority": 180, "type": "shash", "source": "mock"}, + {"name": "sha256-generic", "priority": 100, "type": "shash", "source": "mock"} + ], + "chacha20": [ + {"name": "chacha20-neon", "priority": 260, "type": "skcipher", "source": "mock"}, + {"name": "chacha20-simd", "priority": 220, "type": "skcipher", "source": "mock"}, + {"name": "chacha20-generic", "priority": 100, "type": "skcipher", "source": "mock"} + ] + } + candidates = fallback_map.get(requested, fallback_map["aes"]) + + selected = candidates[0] if candidates else None + return { + "request": requested.upper(), + "implementations": candidates[:8], + "selected": selected, + "selection_policy": "max-priority" + } + +def collect_kernel_crypto_clients(items): + """Infer major kernel crypto clients from active process/protocol context.""" + client_rules = [ + ("kTLS", ["nginx", "haproxy", "envoy", "caddy", "apache", "httpd", "traefik"], "TLS"), + ("WireGuard", ["wg", "wireguard"], "WireGuard"), + ("IPsec/XFRM", ["charon", "strongswan", "ipsec", "racoon"], "TLS"), + ("dm-crypt", ["cryptsetup", "dmcrypt", "luks"], "CRYPTO API"), + ("fscrypt", ["fscrypt"], "CRYPTO API"), + ("AF_ALG", ["openssl", "python", "curl", "wget"], "CRYPTO API") + ] + results = [] + lowered_items = [] + for item in items: + lowered_items.append({ + "process": str(item.get("process", "")).lower(), + "protocol": str(item.get("protocol", "")), + "source_kind": str(item.get("source_kind", "")) + }) + + for name, tokens, proto_hint in client_rules: + flows = 0 + for item in lowered_items: + proc = item["process"] + proto = item["protocol"] + if any(token in proc for token in tokens): + flows += 1 + elif proto_hint and proto == proto_hint: + flows += 1 + status = "active" if flows > 0 else "idle" + results.append({ + "name": name, + "status": status, + "active_flows": int(flows) + }) + return results + +def collect_sync_async_queue(items): + """Estimate sync/async crypto execution pressure from active flows.""" + active_items = [i for i in items if str(i.get("status", "")).upper() != "LISTEN"] + async_items = [ + i for i in active_items + if str(i.get("source_kind", "")) == "connection" + or str(i.get("protocol", "")).upper() in {"TLS", "WIREGUARD", "SSH"} + ] + sync_items = max(len(active_items) - len(async_items), 0) + sum( + 1 for i in items if str(i.get("source_kind", "")) == "process" + ) + queue_depth = max(len(async_items) - 1, 0) + queue_latency_ms = round(0.35 + min(5.5, queue_depth * 0.42 + len(active_items) * 0.08), 2) + return { + "sync_ops_est": int(sync_items), + "async_ops_est": int(len(async_items)), + "queue_depth_est": int(queue_depth), + "queue_latency_ms_est": queue_latency_ms, + "mode": "heuristic" + } + +def collect_hw_offload_status(entries, algorithm_competitions): + """Estimate hardware acceleration availability from /proc/crypto drivers.""" + names = [] + for entry in entries: + n = str(entry.get("name", "")).lower() + d = str(entry.get("driver", "")).lower() + if n: + names.append(n) + if d: + names.append(d) + + def has_token(tokens): + return any(any(token in item for token in tokens) for item in names) + + selected_impls = { + key: str(value.get("selected", {}).get("name", "")).lower() + for key, value in (algorithm_competitions or {}).items() + } + selected_joined = " ".join(selected_impls.values()) + + engines = [ + { + "engine": "AES-NI / CPU INSTR", + "available": has_token(["aesni", "vaes"]), + "active": ("aesni" in selected_joined or "vaes" in selected_joined) + }, + { + "engine": "SIMD (AVX/NEON)", + "available": has_token(["avx", "sse", "simd", "neon"]), + "active": any(token in selected_joined for token in ["avx", "simd", "neon", "sse"]) + }, + { + "engine": "ARM CRYPTO EXT", + "available": has_token(["arm64", "ce", "neon"]), + "active": "arm64" in selected_joined + }, + { + "engine": "QAT OFFLOAD", + "available": has_token(["qat"]), + "active": "qat" in selected_joined + }, + { + "engine": "VIRTIO-CRYPTO", + "available": has_token(["virtio"]), + "active": "virtio" in selected_joined + } + ] + result = [] + for item in engines: + if item["active"]: + status = "active" + elif item["available"]: + status = "available" + else: + status = "unavailable" + result.append({ + "engine": item["engine"], + "status": status + }) + return result + +def read_sysctl_int(path, default=0): + """Read integer sysctl/proc file value safely.""" + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + raw = f.read().strip() + return int(raw or default) + except Exception: + return int(default) + +def read_proc_interrupt_total(): + """Read total interrupts count from /proc/stat.""" + try: + with open("/proc/stat", "r", encoding="utf-8", errors="ignore") as f: + for line in f: + if line.startswith("intr "): + parts = line.strip().split() + if len(parts) >= 2: + return int(parts[1]) + except Exception: + return 0 + return 0 + +def collect_entropy_cloud_status(): + """ + Collect Linux random subsystem entropy status and source activity. + This is a best-effort realtime heuristic for UI visualization. + """ + now = time.time() + entropy_bits = read_sysctl_int("/proc/sys/kernel/random/entropy_avail", 0) + pool_size_bits = read_sysctl_int("/proc/sys/kernel/random/poolsize", 256) + read_threshold = read_sysctl_int("/proc/sys/kernel/random/read_wakeup_threshold", 128) + write_threshold = read_sysctl_int("/proc/sys/kernel/random/write_wakeup_threshold", 64) + + try: + disk = psutil.disk_io_counters() + except Exception: + disk = None + try: + net = psutil.net_io_counters() + except Exception: + net = None + intr_total = read_proc_interrupt_total() + + prev_ts = ENTROPY_PREV.get("timestamp") + dt = max(now - prev_ts, 0.001) if prev_ts else None + + disk_read_now = int(getattr(disk, "read_bytes", 0) or 0) + disk_write_now = int(getattr(disk, "write_bytes", 0) or 0) + net_sent_now = int(getattr(net, "bytes_sent", 0) or 0) + net_recv_now = int(getattr(net, "bytes_recv", 0) or 0) + + if dt: + disk_delta = max( + (disk_read_now - int(ENTROPY_PREV.get("disk_read_bytes") or disk_read_now)) + + (disk_write_now - int(ENTROPY_PREV.get("disk_write_bytes") or disk_write_now)), + 0 + ) + net_delta = max( + (net_sent_now - int(ENTROPY_PREV.get("net_sent_bytes") or net_sent_now)) + + (net_recv_now - int(ENTROPY_PREV.get("net_recv_bytes") or net_recv_now)), + 0 + ) + intr_delta = max(intr_total - int(ENTROPY_PREV.get("interrupt_total") or intr_total), 0) + else: + disk_delta = 0 + net_delta = 0 + intr_delta = 0 + + ENTROPY_PREV["timestamp"] = now + ENTROPY_PREV["disk_read_bytes"] = disk_read_now + ENTROPY_PREV["disk_write_bytes"] = disk_write_now + ENTROPY_PREV["net_sent_bytes"] = net_sent_now + ENTROPY_PREV["net_recv_bytes"] = net_recv_now + ENTROPY_PREV["interrupt_total"] = intr_total + + def scale_intensity(rate_value, scale): + return int(max(0, min(100, (float(rate_value) / float(scale)) * 100.0))) + + disk_rate = (disk_delta / dt) if dt else 0 + net_rate = (net_delta / dt) if dt else 0 + intr_rate = (intr_delta / dt) if dt else 0 + + irq_intensity = scale_intensity(intr_rate, 25000) + disk_intensity = scale_intensity(disk_rate, 80 * 1024 * 1024) + net_intensity = scale_intensity(net_rate, 120 * 1024 * 1024) + hwrng_intensity = 68 if entropy_bits > max(read_threshold, 128) else 34 + + sources = [ + { + "source": "interrupt timing", + "intensity": irq_intensity, + "status": "active" if irq_intensity >= 25 else "low" + }, + { + "source": "disk IO", + "intensity": disk_intensity, + "status": "active" if disk_intensity >= 18 else "low" + }, + { + "source": "network timing", + "intensity": net_intensity, + "status": "active" if net_intensity >= 18 else "low" + }, + { + "source": "hardware RNG", + "intensity": hwrng_intensity, + "status": "active" if hwrng_intensity >= 50 else "limited" + } + ] + + source_avg = int(sum(s["intensity"] for s in sources) / max(len(sources), 1)) + entropy_pct = max(0.0, min(1.0, float(entropy_bits) / max(float(pool_size_bits), 1.0))) + particle_density = max(16, min(84, int(18 + entropy_pct * 42 + source_avg * 0.35))) + key_birth_rate = round(0.6 + entropy_pct * 9.4 + source_avg * 0.06, 2) + + crng_state = "ready" if entropy_bits >= max(read_threshold, 128) else "warming" + random_state = "stable" if entropy_bits >= max(write_threshold, 64) else "refilling" + + return { + "entropy_pool_bits": int(entropy_bits), + "entropy_pool_size_bits": int(pool_size_bits), + "crng_state": crng_state, + "random_subsystem_state": random_state, + "particle_density": int(particle_density), + "key_birth_rate_est": float(key_birth_rate), + "sources": sources, + "read_wakeup_threshold": int(read_threshold), + "write_wakeup_threshold": int(write_threshold), + "mode": "live-heuristic" + } + +def collect_algorithm_requesters(items, kernel_clients): + """Infer likely requestor objects that trigger algorithm competition.""" + algo_map = {"aes": {}, "sha": {}, "chacha20": {}} + client_boost_rules = { + "aes": {"kTLS", "dm-crypt", "AF_ALG", "IPsec/XFRM"}, + "sha": {"kTLS", "AF_ALG", "IPsec/XFRM"}, + "chacha20": {"WireGuard", "AF_ALG"} + } + + for item in items or []: + process_name = str(item.get("process", "unknown")).lower() or "unknown" + protocol = str(item.get("protocol", "")).upper() + algorithm = str(item.get("algorithm", "")).upper() + status = str(item.get("status", "")).upper() + if status == "LISTEN": + continue + + matched_algorithms = set() + if "AES" in algorithm or protocol == "TLS": + matched_algorithms.add("aes") + if "SHA" in algorithm or protocol == "TLS": + matched_algorithms.add("sha") + if "CHACHA" in algorithm or protocol in {"WIREGUARD", "SSH"}: + matched_algorithms.add("chacha20") + if not matched_algorithms and protocol == "CRYPTO API": + matched_algorithms.update(["aes", "sha"]) + + for algo_key in matched_algorithms: + key = f"process:{process_name}" + bucket = algo_map[algo_key].setdefault(key, { + "name": process_name, + "kind": "process", + "score": 0 + }) + bucket["score"] += 1 + + for client in kernel_clients or []: + name = str(client.get("name", "")).strip() + flows = int(client.get("active_flows", 0) or 0) + if not name or flows <= 0: + continue + for algo_key, allowed_clients in client_boost_rules.items(): + if name not in allowed_clients: + continue + key = f"client:{name}" + bucket = algo_map[algo_key].setdefault(key, { + "name": name, + "kind": "kernel-client", + "score": 0 + }) + # Kernel clients are presented as primary requestor objects. + bucket["score"] += max(2, flows) + + result = {} + for algo_key, raw in algo_map.items(): + ranked = sorted(raw.values(), key=lambda x: x.get("score", 0), reverse=True) + if not ranked: + ranked = [{ + "name": "user/kernel request", + "kind": "generic", + "score": 1 + }] + result[algo_key] = ranked[:4] + return result + +def build_crypto_decision_pipelines(algorithm_competitions, kernel_clients, hw_offload, algorithm_requesters): + """Build visual decision pipeline metadata for each algorithm family.""" + hw_active = [h.get("engine") for h in (hw_offload or []) if h.get("status") == "active"] + hw_available = [h.get("engine") for h in (hw_offload or []) if h.get("status") == "available"] + capability_hint = ", ".join(hw_active[:2] or hw_available[:2]) if (hw_active or hw_available) else "generic-cpu-only" + + tfm_lookup_map = { + "AES": "crypto_alloc_skcipher(aes)", + "SHA": "crypto_alloc_shash(sha*)", + "CHACHA20": "crypto_alloc_skcipher(chacha20)" + } + + pipelines = {} + for key, comp in (algorithm_competitions or {}).items(): + request = str(comp.get("request", key)).upper() + impls = comp.get("implementations", []) or [] + shortlist = [str(x.get("name", "unknown")) for x in impls[:3]] + requesters = list((algorithm_requesters or {}).get(key, [])) + top_requester = requesters[0] if requesters else {"name": "user/kernel request", "kind": "generic"} + request_origin = f"{top_requester.get('kind', 'generic')}: {top_requester.get('name', 'unknown')}" + selected_driver = str((comp.get("selected") or {}).get("name", "unknown")) + fallback_driver = next((name for name in shortlist if "generic" in name.lower()), shortlist[-1] if shortlist else "none") + selected_is_generic = "generic" in selected_driver.lower() + selected_source = str((comp.get("selected") or {}).get("source", "kernel")).lower() + fallback_active = selected_is_generic and len(shortlist) > 1 + + pipelines[key] = { + "request": request, + "request_origin": request_origin, + "requesters": requesters, + "tfm_lookup": tfm_lookup_map.get(request, f"crypto_lookup({request.lower()})"), + "impl_shortlist": shortlist, + "priority_check": "max priority wins", + "capability_check": capability_hint, + "selected_driver": selected_driver, + "fallback_driver": fallback_driver, + "fallback_active": bool(fallback_active), + "fallback_reason": "higher-priority impl unavailable or unsupported" if fallback_active else "not-triggered", + "source": selected_source + } + return pipelines + +def collect_crypto_realtime(): + """ + Build a near-realtime list of processes likely interacting with kernel crypto. + This is heuristic-based and derived from active network/process context. + """ + items = [] + tls_listener_by_port = {} + tls_listener_names = set() + unknown_pid_flows = 0 + + try: + connections = psutil.net_connections(kind="inet") + except Exception: + connections = [] + + # Build TLS listener map first. This helps attribute ESTABLISHED sockets that + # may not expose pid under restricted privileges. + tls_ports = {443, 8443, 9443, 6443} + for conn in connections: + status = str(getattr(conn, "status", "") or "") + if status != "LISTEN": + continue + laddr = getattr(conn, "laddr", None) + local_port = getattr(laddr, "port", 0) if laddr else 0 + if int(local_port or 0) not in tls_ports: + continue + pid = getattr(conn, "pid", None) + pid_i = int(pid or 0) + process_name = "unknown" + if pid_i: + try: + process_name = psutil.Process(pid_i).name().lower() + except Exception: + process_name = f"pid-{pid_i}" + tls_listener_by_port[int(local_port)] = {"pid": pid_i, "process": process_name} + tls_listener_names.add(process_name) + items.append({ + "process": process_name, + "pid": pid_i, + "protocol": "TLS", + "algorithm": "AES-GCM/SHA256", + "endpoint": f"0.0.0.0:{int(local_port)}", + "local_port": int(local_port), + "remote_port": 0, + "status": "LISTEN", + "tls_terminator": process_name, + "source_kind": "listener" + }) + + for conn in connections: + pid = getattr(conn, "pid", None) + status = str(getattr(conn, "status", "") or "") + if status not in ("ESTABLISHED", "SYN_SENT", "SYN_RECV"): + continue + + laddr = getattr(conn, "laddr", None) + raddr = getattr(conn, "raddr", None) + local_ip = getattr(laddr, "ip", "") if laddr else "" + local_port = getattr(laddr, "port", 0) if laddr else 0 + remote_ip = getattr(raddr, "ip", "") if raddr else "" + remote_port = getattr(raddr, "port", 0) if raddr else 0 + + pid_i = int(pid or 0) + process_name = "unknown" + if pid_i: + try: + proc = psutil.Process(pid_i) + process_name = proc.name() + except Exception: + process_name = f"pid-{pid_i}" + else: + unknown_pid_flows += 1 + listener_meta = tls_listener_by_port.get(int(local_port or 0)) + if listener_meta: + process_name = listener_meta.get("process") or "unknown" + + protocol, algorithm = infer_crypto_protocol(local_port, remote_port, process_name) + if not is_likely_crypto_actor(process_name, local_port, remote_port, protocol): + continue + + tls_terminator = infer_tls_terminator(process_name, local_port, protocol, tls_listener_names) + endpoint = f"{remote_ip}:{remote_port}" if remote_ip else f"{local_ip}:{local_port}" + + items.append({ + "process": process_name.lower(), + "pid": pid_i, + "protocol": protocol, + "algorithm": algorithm, + "endpoint": endpoint, + "local_port": int(local_port or 0), + "remote_port": int(remote_port or 0), + "status": status, + "tls_terminator": tls_terminator, + "source_kind": "connection" + }) + + # If no sockets are available, still expose likely crypto actors. + if not items: + for proc in psutil.process_iter(attrs=["pid", "name"]): + try: + name = str(proc.info.get("name", "")).lower() + except Exception: + continue + if any(token in name for token in ["nginx", "sshd", "curl", "openssl", "kube", "vpn", "python"]): + protocol, algorithm = infer_crypto_protocol(0, 0, name) + items.append({ + "process": name, + "pid": int(proc.info.get("pid") or 0), + "protocol": protocol, + "algorithm": algorithm, + "endpoint": "-", + "local_port": 0, + "remote_port": 0, + "status": "RUNNING", + "tls_terminator": "n/a", + "source_kind": "process" + }) + if len(items) >= 12: + break + + # Deduplicate near-identical rows. + deduped = {} + for item in items: + key = ( + item.get("process"), + int(item.get("pid") or 0), + item.get("protocol"), + item.get("algorithm"), + item.get("endpoint"), + item.get("status"), + item.get("source_kind") + ) + if key not in deduped: + deduped[key] = item + items = list(deduped.values()) + + now = time.time() + prev_ts = CRYPTO_PREV["timestamp"] + prev_flows = CRYPTO_PREV["active_flows"] + active_flows = len(items) + CRYPTO_PREV["timestamp"] = now + CRYPTO_PREV["active_flows"] = active_flows + + if prev_ts: + dt = max(now - prev_ts, 0.001) + flow_delta = abs(active_flows - prev_flows) + ops_per_sec = round((active_flows * 90) + (flow_delta / dt) * 60, 2) + else: + ops_per_sec = round(active_flows * 90, 2) + + unique_processes = [] + for item in items: + p = item["process"] + if p not in unique_processes: + unique_processes.append(p) + + algorithm_competitions = { + "aes": collect_algorithm_competition("aes"), + "sha": collect_algorithm_competition("sha"), + "chacha20": collect_algorithm_competition("chacha20") + } + proc_crypto_entries = parse_proc_crypto_entries() + kernel_clients = collect_kernel_crypto_clients(items) + hw_offload = collect_hw_offload_status(proc_crypto_entries, algorithm_competitions) + crypto_stage1 = { + "kernel_clients": kernel_clients, + "sync_async": collect_sync_async_queue(items), + "hw_offload": hw_offload + } + algorithm_requesters = collect_algorithm_requesters(items, kernel_clients) + crypto_decision_pipelines = build_crypto_decision_pipelines( + algorithm_competitions=algorithm_competitions, + kernel_clients=kernel_clients, + hw_offload=hw_offload, + algorithm_requesters=algorithm_requesters + ) + entropy_cloud = collect_entropy_cloud_status() + + return { + "items": items[:24], + "processes": unique_processes[:16], + "meta": { + "ops_per_sec": ops_per_sec, + "tls_sessions": sum(1 for i in items if i.get("protocol") == "TLS"), + "active_flows": active_flows, + "unknown_pid_flows": int(unknown_pid_flows), + "tls_terminators": sorted(list(tls_listener_names))[:8], + "algorithm_competition": algorithm_competitions["aes"], + "algorithm_competitions": algorithm_competitions, + "algorithm_requesters": algorithm_requesters, + "crypto_stage1": crypto_stage1, + "entropy_cloud": entropy_cloud, + "crypto_decision_pipeline": crypto_decision_pipelines.get("aes", {}), + "crypto_decision_pipelines": crypto_decision_pipelines, + "source": "live-heuristic-v2", + "timestamp": datetime.utcnow().isoformat() + "Z" + } + } + +def collect_security_realtime(): + """ + Stage-1 security subsystem telemetry: + - Threat decision pipeline + - Process trust graph + - Attack surface map + """ + now = time.time() + process_rows = [] + suspicious_tokens = { + "nmap", "masscan", "hydra", "sqlmap", "metasploit", "msfconsole", + "netcat", "nc", "ncat", "socat", "john", "hashcat", "strace", "gdb" + } + trusted_tokens = { + "systemd", "sshd", "nginx", "python", "containerd", "dockerd", + "kubelet", "cron", "rsyslogd", "dbus-daemon" + } + ptrace_like = {"strace", "gdb", "ltrace"} + + def classify_trust(score): + if score >= 70: + return "blocked" + if score >= 48: + return "suspicious" + if score >= 28: + return "observe" + return "trusted" + + # Process sample and heuristic score. + for proc in psutil.process_iter(["pid", "name", "username", "memory_percent", "status", "num_threads"]): + try: + pid = int(proc.info.get("pid") or 0) + name = str(proc.info.get("name") or "unknown").lower() + mem = float(proc.info.get("memory_percent") or 0.0) + threads = int(proc.info.get("num_threads") or 0) + status = str(proc.info.get("status") or "unknown") + user = str(proc.info.get("username") or "") + + score = 12 + if any(tok in name for tok in suspicious_tokens): + score += 38 + if any(tok in name for tok in trusted_tokens): + score -= 10 + if user == "root": + score += 14 + if threads > 120: + score += 8 + if mem > 8.0: + score += 8 + if status in {"zombie", "stopped"}: + score += 10 + score = max(0, min(100, score)) + trust = classify_trust(score) + + process_rows.append({ + "pid": pid, + "name": name, + "trust": trust, + "risk_score": score, + "threads": threads, + "mem_percent": round(mem, 2), + "status": status, + "user": user + }) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + except Exception: + continue + + # Focus on the most security-relevant rows. + process_rows.sort(key=lambda p: (p["risk_score"], p["mem_percent"], p["threads"]), reverse=True) + trust_graph = process_rows[:12] + + # Build threat pipeline lanes from top rows. + request_candidates = [ + "open /etc/shadow", + "connect tcp:443", + "exec /usr/bin/sudo", + "ptrace attach", + "bpf program load", + "write /usr/lib/systemd/*" + ] + hook_candidates = [ + "security_file_open", + "security_socket_connect", + "security_bprm_check", + "seccomp-bpf", + "cgroup device policy", + "audit hook" + ] + lanes = [] + for idx, row in enumerate(trust_graph[:10]): + req = request_candidates[idx % len(request_candidates)] + hook = hook_candidates[idx % len(hook_candidates)] + score = int(row.get("risk_score") or 0) + if score >= 70: + verdict = "deny" + elif score >= 45: + verdict = "audit" + else: + verdict = "allow" + lanes.append({ + "process": row.get("name", "unknown"), + "pid": int(row.get("pid", 0)), + "request": req, + "hook": hook, + "verdict": verdict, + "reason": "risk-score-policy", + "risk_score": score + }) + + # Attack surface metrics. + try: + listen_ports = len([ + c for c in psutil.net_connections(kind="inet") + if str(getattr(c, "status", "") or "") == "LISTEN" + ]) + except Exception: + listen_ports = 0 + + try: + with open("/proc/modules", "r", encoding="utf-8") as f: + loaded_modules = sum(1 for _ in f) + except Exception: + loaded_modules = 0 + + ptrace_processes = sum(1 for p in process_rows if any(tok in p.get("name", "") for tok in ptrace_like)) + root_processes = sum(1 for p in process_rows if p.get("user") == "root") + suspicious_processes = sum(1 for p in process_rows if p.get("trust") in {"suspicious", "blocked"}) + + setuid_bins = 0 + try: + out = subprocess.check_output( + "find /usr/bin /usr/sbin -xdev -perm -4000 -type f 2>/dev/null | wc -l", + shell=True, + text=True, + timeout=1.8 + ).strip() + setuid_bins = int(out or 0) + except Exception: + setuid_bins = 0 + + attack_surface = [ + {"name": "open-listen-ports", "value": int(listen_ports), "severity": "high" if listen_ports > 40 else "medium"}, + {"name": "setuid-binaries", "value": int(setuid_bins), "severity": "high" if setuid_bins > 70 else "medium"}, + {"name": "loaded-kernel-modules", "value": int(loaded_modules), "severity": "medium" if loaded_modules > 180 else "low"}, + {"name": "ptrace-capable-processes", "value": int(ptrace_processes), "severity": "high" if ptrace_processes > 0 else "low"}, + {"name": "root-processes", "value": int(root_processes), "severity": "medium" if root_processes > 120 else "low"}, + {"name": "suspicious-processes", "value": int(suspicious_processes), "severity": "high" if suspicious_processes > 6 else "medium"} + ] + + # Stage 3: kernel security tools insights. + def _read_text(path): + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + return str(f.read().strip()) + except Exception: + return "" + + # LSM status matrix (best effort, distro dependent). + apparmor_raw = _read_text("/sys/module/apparmor/parameters/enabled") + selinux_enforce = _read_text("/sys/fs/selinux/enforce") + selinux_mode = _read_text("/sys/fs/selinux/enforce") + selinux_policy = _read_text("/sys/fs/selinux/policyvers") + yama_scope = _read_text("/proc/sys/kernel/yama/ptrace_scope") + bpf_unpriv = _read_text("/proc/sys/kernel/unprivileged_bpf_disabled") + landlock_present = os.path.exists("/sys/kernel/security/landlock") + ima_present = os.path.exists("/sys/kernel/security/ima") + + # Check for BPF LSM (modern trend). + bpf_lsm_present = os.path.exists("/sys/kernel/security/bpf") + try: + lsm_list_raw = _read_text("/sys/kernel/security/lsm") + active_lsms = [x.strip() for x in lsm_list_raw.split(",")] if lsm_list_raw else [] + stacking_enabled = len([x for x in active_lsms if x in {"selinux", "apparmor", "bpf"}]) > 1 + except Exception: + active_lsms = [] + stacking_enabled = False + + lsm_status = [ + { + "name": "AppArmor", + "status": "enforcing" if apparmor_raw.lower().startswith("y") else ("disabled" if apparmor_raw else "unknown"), + "detail": apparmor_raw or "n/a", + "type": "policy_engine" + }, + { + "name": "SELinux", + "status": "enforcing" if selinux_enforce == "1" else ("disabled" if selinux_enforce == "0" else "unknown"), + "detail": selinux_enforce or "n/a", + "type": "policy_engine", + "policy_version": selinux_policy or "n/a" + }, + { + "name": "BPF LSM", + "status": "present" if bpf_lsm_present else "absent", + "detail": "eBPF-based LSM" if bpf_lsm_present else "n/a", + "type": "policy_engine" + }, + { + "name": "LSM Stacking", + "status": "enabled" if stacking_enabled else "disabled", + "detail": ",".join(active_lsms[:3]) if active_lsms else "n/a", + "type": "stacking" + }, + { + "name": "Yama ptrace", + "status": "hardened" if yama_scope in {"2", "3"} else ("relaxed" if yama_scope in {"0", "1"} else "unknown"), + "detail": yama_scope or "n/a", + "type": "restriction" + }, + { + "name": "unprivileged bpf", + "status": "blocked" if bpf_unpriv == "1" else ("allowed" if bpf_unpriv == "0" else "unknown"), + "detail": bpf_unpriv or "n/a", + "type": "restriction" + }, + { + "name": "Landlock", + "status": "present" if landlock_present else "absent", + "detail": "sysfs" if landlock_present else "n/a", + "type": "restriction" + }, + { + "name": "IMA/EVM", + "status": "present" if ima_present else "absent", + "detail": "sysfs" if ima_present else "n/a", + "type": "integrity" + } + ] + + # LSM engines detail for security core visualization. + lsm_engines = [] + if apparmor_raw.lower().startswith("y"): + lsm_engines.append({ + "name": "AppArmor", + "type": "policy_engine", + "status": "enforcing", + "hooks": ["file_open", "bprm_check", "socket_connect"], + "decisions_per_sec": random.randint(8, 45) + }) + if selinux_enforce == "1": + lsm_engines.append({ + "name": "SELinux", + "type": "policy_engine", + "status": "enforcing", + "hooks": ["file_open", "bprm_check", "socket_connect", "inode_create"], + "decisions_per_sec": random.randint(12, 52) + }) + if bpf_lsm_present: + lsm_engines.append({ + "name": "BPF LSM", + "type": "policy_engine", + "status": "enforcing", + "hooks": ["file_open", "bprm_check", "socket_connect"], + "decisions_per_sec": random.randint(5, 28) + }) + + # Capabilities drift (CapEff/CapPrm from /proc//status). + # Full capabilities map (all 40+ capabilities). + all_capabilities_map = { + 0: "CAP_CHOWN", 1: "CAP_DAC_OVERRIDE", 2: "CAP_DAC_READ_SEARCH", 3: "CAP_FOWNER", + 4: "CAP_FSETID", 5: "CAP_KILL", 6: "CAP_SETGID", 7: "CAP_SETUID", + 8: "CAP_SETPCAP", 9: "CAP_LINUX_IMMUTABLE", 10: "CAP_NET_BIND_SERVICE", + 11: "CAP_NET_BROADCAST", 12: "CAP_NET_ADMIN", 13: "CAP_NET_RAW", 14: "CAP_IPC_LOCK", + 15: "CAP_IPC_OWNER", 16: "CAP_SYS_MODULE", 17: "CAP_SYS_RAWIO", 18: "CAP_SYS_CHROOT", + 19: "CAP_SYS_PTRACE", 20: "CAP_SYS_PACCT", 21: "CAP_SYS_ADMIN", 22: "CAP_SYS_BOOT", + 23: "CAP_SYS_NICE", 24: "CAP_SYS_RESOURCE", 25: "CAP_SYS_TIME", 26: "CAP_SYS_TTY_CONFIG", + 27: "CAP_MKNOD", 28: "CAP_LEASE", 29: "CAP_AUDIT_WRITE", 30: "CAP_AUDIT_CONTROL", + 31: "CAP_SETFCAP", 32: "CAP_MAC_OVERRIDE", 33: "CAP_MAC_ADMIN", 34: "CAP_SYSLOG", + 35: "CAP_WAKE_ALARM", 36: "CAP_BLOCK_SUSPEND", 37: "CAP_AUDIT_READ", 38: "CAP_PERFMON", + 39: "CAP_BPF", 40: "CAP_CHECKPOINT_RESTORE" + } + dangerous_caps = { + 12: "CAP_NET_ADMIN", + 16: "CAP_SYS_MODULE", + 17: "CAP_SYS_RAWIO", + 19: "CAP_SYS_PTRACE", + 21: "CAP_SYS_ADMIN", + 39: "CAP_BPF" + } + capabilities_rows = [] + seccomp_counts = {"none": 0, "strict": 0, "filter": 0, "unknown": 0} + seccomp_processes = [] # For security core visualization. + capabilities_processes = [] # For security core visualization. + + # Common syscalls for seccomp visualization. + common_syscalls = [ + "read", "write", "open", "close", "stat", "fstat", "lstat", "poll", "lseek", + "mmap", "mprotect", "munmap", "brk", "rt_sigaction", "rt_sigprocmask", + "rt_sigreturn", "ioctl", "pread64", "pwrite64", "readv", "writev", + "access", "pipe", "select", "sched_yield", "mremap", "msync", "mincore", + "madvise", "shmget", "shmat", "shmctl", "dup", "dup2", "pause", "nanosleep", + "getitimer", "alarm", "setitimer", "getpid", "sendfile", "socket", "connect", + "accept", "sendto", "recvfrom", "sendmsg", "recvmsg", "shutdown", "bind", + "listen", "getsockname", "getpeername", "socketpair", "setsockopt", "getsockopt", + "clone", "fork", "vfork", "execve", "exit", "wait4", "kill", "uname", + "semget", "semop", "semctl", "shmdt", "msgget", "msgsnd", "msgrcv", "msgctl", + "fcntl", "flock", "fsync", "fdatasync", "truncate", "ftruncate", "getdents", + "getcwd", "chdir", "fchdir", "rename", "mkdir", "rmdir", "creat", "link", + "unlink", "symlink", "readlink", "chmod", "fchmod", "chown", "fchown", + "lchown", "umask", "gettimeofday", "getrlimit", "getrusage", "sysinfo", + "times", "ptrace", "getuid", "syslog", "getgid", "setuid", "setgid", + "geteuid", "getegid", "setpgid", "getppid", "getpgrp", "setsid", "setreuid", + "setregid", "getgroups", "setgroups", "setresuid", "getresuid", "setresgid", + "getresgid", "getpgid", "setfsuid", "setfsgid", "getsid", "capget", "capset", + "rt_sigpending", "rt_sigtimedwait", "rt_sigqueueinfo", "rt_sigsuspend", + "sigaltstack", "utime", "mknod", "uselib", "personality", "ustat", "statfs", + "fstatfs", "sysfs", "getpriority", "setpriority", "sched_setparam", + "sched_getparam", "sched_setscheduler", "sched_getscheduler", + "sched_get_priority_max", "sched_get_priority_min", "sched_rr_get_interval", + "mlock", "munlock", "mlockall", "munlockall", "vhangup", "modify_ldt", + "pivot_root", "prctl", "arch_prctl", "adjtimex", "setrlimit", "chroot", + "sync", "acct", "settimeofday", "mount", "umount2", "swapon", "swapoff", + "reboot", "sethostname", "setdomainname", "iopl", "ioperm", "create_module", + "init_module", "delete_module", "get_kernel_syms", "query_module", "quotactl", + "nfsservctl", "getpmsg", "putpmsg", "afs_syscall", "tuxcall", "security", + "gettid", "readahead", "setxattr", "lsetxattr", "fsetxattr", "getxattr", + "lgetxattr", "fgetxattr", "listxattr", "llistxattr", "flistxattr", + "removexattr", "lremovexattr", "fremovexattr", "tkill", "time", "futex", + "sched_setaffinity", "sched_getaffinity", "set_thread_area", "io_setup", + "io_destroy", "io_getevents", "io_submit", "io_cancel", "get_thread_area", + "lookup_dcookie", "epoll_create", "epoll_ctl_old", "epoll_wait_old", + "remap_file_pages", "getdents64", "set_tid_address", "restart_syscall", + "semtimedop", "fadvise64", "timer_create", "timer_settime", "timer_gettime", + "timer_getoverrun", "timer_delete", "clock_settime", "clock_gettime", + "clock_getres", "clock_nanosleep", "exit_group", "epoll_wait", "epoll_ctl", + "tgkill", "utimes", "vserver", "mbind", "set_mempolicy", "get_mempolicy", + "mq_open", "mq_unlink", "mq_timedsend", "mq_timedreceive", "mq_notify", + "mq_getsetattr", "kexec_load", "waitid", "add_key", "request_key", "keyctl", + "ioprio_set", "ioprio_get", "inotify_init", "inotify_add_watch", + "inotify_rm_watch", "migrate_pages", "openat", "mkdirat", "mknodat", + "fchownat", "futimesat", "newfstatat", "unlinkat", "renameat", "linkat", + "symlinkat", "readlinkat", "fchmodat", "faccessat", "pselect6", "ppoll", + "unshare", "set_robust_list", "get_robust_list", "splice", "tee", + "sync_file_range", "vmsplice", "move_pages", "utimensat", "epoll_pwait", + "signalfd", "timerfd_create", "eventfd", "fallocate", "timerfd_settime", + "timerfd_gettime", "accept4", "signalfd4", "eventfd2", "epoll_create1", + "dup3", "pipe2", "inotify_init1", "preadv", "pwritev", "rt_tgsigqueueinfo", + "perf_event_open", "recvmmsg", "fanotify_init", "fanotify_mark", + "prlimit64", "name_to_handle_at", "open_by_handle_at", "clock_adjtime", + "syncfs", "sendmmsg", "setns", "getcpu", "process_vm_readv", + "process_vm_writev", "kcmp", "finit_module", "sched_setattr", + "sched_getattr", "renameat2", "seccomp", "getrandom", "memfd_create", + "kexec_file_load", "bpf", "execveat", "userfaultfd", "membarrier", + "mlock2", "copy_file_range", "preadv2", "pwritev2", "pkey_mprotect", + "pkey_alloc", "pkey_free", "statx", "io_pgetevents", "rseq" + ] + + for row in process_rows[:180]: + pid = int(row.get("pid") or 0) + if pid <= 0: + continue + cap_eff_hex = "" + cap_prm_hex = "" + seccomp_mode = "unknown" + try: + with open(f"/proc/{pid}/status", "r", encoding="utf-8", errors="ignore") as f: + for ln in f: + if ln.startswith("CapEff:"): + cap_eff_hex = ln.split(":", 1)[1].strip() + elif ln.startswith("CapPrm:"): + cap_prm_hex = ln.split(":", 1)[1].strip() + elif ln.startswith("Seccomp:"): + seccomp_raw = ln.split(":", 1)[1].strip() + if seccomp_raw == "0": + seccomp_mode = "none" + elif seccomp_raw == "1": + seccomp_mode = "strict" + elif seccomp_raw == "2": + seccomp_mode = "filter" + else: + seccomp_mode = "unknown" + except Exception: + pass + + seccomp_counts[seccomp_mode] = seccomp_counts.get(seccomp_mode, 0) + 1 + + # Collect seccomp details for security core visualization. + if seccomp_mode in {"filter", "strict"}: + # Heuristic: generate allowed/blocked syscalls based on process type. + allowed_syscalls = [] + blocked_syscalls = [] + proc_name_lower = str(row.get("name", "")).lower() + if "nginx" in proc_name_lower or "apache" in proc_name_lower: + allowed_syscalls = ["read", "write", "open", "close", "socket", "accept", "send", "recv", "epoll_wait", "fstat"] + blocked_syscalls = ["ptrace", "mount", "umount", "sys_module", "bpf", "keyctl"] + elif "sshd" in proc_name_lower: + allowed_syscalls = ["read", "write", "open", "close", "socket", "accept", "send", "recv", "fork", "execve"] + blocked_syscalls = ["mount", "umount", "sys_module", "bpf"] + elif "docker" in proc_name_lower or "containerd" in proc_name_lower: + allowed_syscalls = ["read", "write", "open", "close", "socket", "clone", "unshare", "mount", "umount"] + blocked_syscalls = ["sys_module", "bpf"] + else: + # Generic: allow common syscalls, block dangerous ones. + allowed_syscalls = common_syscalls[:40] # First 40 common syscalls + blocked_syscalls = ["ptrace", "mount", "umount", "sys_module", "bpf", "keyctl", "kexec_load"] + + seccomp_processes.append({ + "pid": pid, + "name": row.get("name", "unknown"), + "mode": seccomp_mode, + "allowed_syscalls": allowed_syscalls[:20], # Limit for visualization + "blocked_syscalls": blocked_syscalls, + "sandbox_level": "strict" if seccomp_mode == "strict" else "filter" + }) + + if not cap_eff_hex: + continue + try: + cap_eff_val = int(cap_eff_hex, 16) + cap_prm_val = int(cap_prm_hex or "0", 16) + except Exception: + continue + + # Collect all capabilities (not just dangerous ones) for security core visualization. + all_caps = [all_capabilities_map.get(bit, f"CAP_{bit}") for bit in range(41) if (cap_eff_val & (1 << bit))] + matched = [name for bit, name in dangerous_caps.items() if (cap_eff_val & (1 << bit))] + + # Store capabilities as "keys" for visualization. + capabilities_processes.append({ + "pid": pid, + "name": row.get("name", "unknown"), + "user": row.get("user", ""), + "capabilities": all_caps[:15], # Limit for visualization + "dangerous_caps": matched, + "cap_eff_hex": cap_eff_hex, + "has_keys": len(all_caps) > 0 + }) + + if not matched: + continue + risk = min(100, 20 + len(matched) * 16 + (10 if row.get("user") == "root" else 0)) + capabilities_rows.append({ + "pid": pid, + "name": row.get("name", "unknown"), + "user": row.get("user", ""), + "seccomp": seccomp_mode, + "cap_eff": cap_eff_hex, + "cap_prm": cap_prm_hex or "0", + "dangerous": matched[:4], + "risk_score": int(risk) + }) + + capabilities_rows.sort(key=lambda x: (x.get("risk_score", 0), len(x.get("dangerous", []))), reverse=True) + capabilities_drift = capabilities_rows[:8] + + # Seccomp coverage summary + top unsandboxed risky processes. + total_seccomp_sample = max(1, sum(seccomp_counts.values())) + unsandboxed = [r for r in capabilities_rows if r.get("seccomp") == "none"] + unsandboxed.sort(key=lambda x: x.get("risk_score", 0), reverse=True) + seccomp_coverage = { + "none": int(seccomp_counts.get("none", 0)), + "strict": int(seccomp_counts.get("strict", 0)), + "filter": int(seccomp_counts.get("filter", 0)), + "unknown": int(seccomp_counts.get("unknown", 0)), + "coverage_percent": round((seccomp_counts.get("filter", 0) + seccomp_counts.get("strict", 0)) * 100.0 / total_seccomp_sample, 2), + "high_risk_unsandboxed": [ + { + "pid": int(r.get("pid", 0)), + "name": str(r.get("name", "unknown")), + "risk_score": int(r.get("risk_score", 0)) + } + for r in unsandboxed[:6] + ] + } + + prev_ts = SECURITY_PREV["timestamp"] + prev_events = int(SECURITY_PREV["events"] or 0) + current_events = len(lanes) + SECURITY_PREV["timestamp"] = now + SECURITY_PREV["events"] = current_events + if prev_ts: + dt = max(0.001, now - prev_ts) + decisions_per_sec = round((current_events / dt) + abs(current_events - prev_events) * 0.6, 2) + else: + decisions_per_sec = float(current_events) + + return { + "timestamp": datetime.utcnow().isoformat() + "Z", + "pipeline": { + "stages": [ + "request event", + "LSM/seccomp hook", + "policy verdict" + ], + "lanes": lanes + }, + "trust_graph": trust_graph, + "attack_surface": attack_surface, + "security_tools": { + "lsm_status": lsm_status, + "capabilities_drift": capabilities_drift, + "seccomp_coverage": seccomp_coverage + }, + "security_core": { + "lsm_engines": lsm_engines, + "seccomp_processes": seccomp_processes[:12], # Top 12 for visualization + "capabilities_processes": capabilities_processes[:12], # Top 12 for visualization + "stacking_enabled": stacking_enabled, + "active_lsms": active_lsms + }, + "meta": { + "decisions_per_sec": decisions_per_sec, + "events": current_events, + "trusted": sum(1 for p in trust_graph if p.get("trust") == "trusted"), + "observe": sum(1 for p in trust_graph if p.get("trust") == "observe"), + "suspicious": sum(1 for p in trust_graph if p.get("trust") == "suspicious"), + "blocked": sum(1 for p in trust_graph if p.get("trust") == "blocked"), + "seccomp_coverage_percent": seccomp_coverage.get("coverage_percent", 0.0), + "mode": "live-heuristic-v2" + } + } + +def _parse_meminfo_kb(): + """Linux /proc/meminfo values in kB (same units as psutil docs).""" + out = {} + try: + with open("/proc/meminfo", "r", encoding="utf-8", errors="ignore") as f: + for line in f: + parts = line.split() + if len(parts) >= 2 and parts[1].isdigit(): + out[parts[0].rstrip(":")] = int(parts[1]) + except Exception: + pass + return out + + +def _memory_strip_blocks(kind, kb_k, mem_total_kb, n_blocks, seed0): + """Variable-width blocks in one horizontal strip; heat ~ share of RAM in this category.""" + mem_total_kb = max(1, int(mem_total_kb)) + kb_k = max(0, int(kb_k)) + weights = [] + for i in range(n_blocks): + v = (((seed0 + i * 104729) % 1000) + 40) / 1040.0 + weights.append(v) + sw = sum(weights) + share = kb_k / float(mem_total_kb) + blocks = [] + for i in range(n_blocks): + w = weights[i] / sw + heat = min( + 1.0, + 0.05 + min(0.92, share * 2.0) + (((seed0 + i * 31) % 15) / 120.0), + ) + blocks.append({"w": round(w, 6), "heat": round(heat, 4), "kind": kind}) + return blocks + + +def _build_memory_visual_rows(meminfo_kb, syscall_nodes, vm, swap): + """ + Rows of horizontal strips: each row ≈ one kernel/accounting bucket from /proc/meminfo. + Block widths are stylistic subdivisions; row mass is proportional to kb / MemTotal. + """ + mi = meminfo_kb or {} + mt = max(1, int(mi.get("MemTotal") or 0)) + if mt <= 1: + try: + mt = max(1, int(getattr(vm, "total", 0) / 1024)) + except Exception: + mt = 1 + + seed_base = (mt % 100000) + int(mi.get("Active", 0) or 0) % 50000 + + row_specs = [ + ("buffers", "buffers", mi.get("Buffers", 0)), + ("cached", "page cache", mi.get("Cached", 0)), + ("anon", "anonymous (heap/stack)", mi.get("AnonPages", 0)), + ] + # Slab: split reclaimable vs unreclaimable when both exist (Linux 2.6.19+). + if mi.get("SReclaimable") is not None and mi.get("SUnreclaim") is not None: + row_specs.append(("sreclaim", "slab reclaimable", mi.get("SReclaimable", 0))) + row_specs.append(("sunreclaim", "slab unreclaimable", mi.get("SUnreclaim", 0))) + else: + row_specs.append(("slab", "slab / kmalloc", mi.get("Slab", 0))) + row_specs.extend( + [ + ("shmem", "shmem / tmpfs", mi.get("Shmem", 0)), + ("mapped", "file mappings", mi.get("Mapped", 0)), + ] + ) + dirty_wb = int(mi.get("Dirty", 0) or 0) + int(mi.get("Writeback", 0) or 0) + int( + mi.get("WritebackTmp", 0) or 0 + ) + if dirty_wb > 0: + row_specs.append(("dirty_wb", "dirty + writeback", dirty_wb)) + ah = int(mi.get("AnonHugePages", 0) or 0) + if ah > 0: + row_specs.append(("anon_huge", "transparent huge pages (anon)", ah)) + shm_h = int(mi.get("ShmemHugePages", 0) or 0) + if shm_h > 0: + row_specs.append(("shmem_huge", "huge pages (shmem)", shm_h)) + vmu = int(mi.get("VmallocUsed", 0) or 0) + if vmu > 0: + row_specs.append(("vmalloc", "vmalloc used", vmu)) + ac = int(mi.get("Active", 0) or 0) + iac = int(mi.get("Inactive", 0) or 0) + if ac > 0: + row_specs.append(("active", "active (LRU)", ac)) + if iac > 0: + row_specs.append(("inactive", "inactive (LRU)", iac)) + swap_tot = int(mi.get("SwapTotal", 0) or 0) + swap_free = int(mi.get("SwapFree", 0) or 0) + swap_used = max(0, swap_tot - swap_free) + if swap_tot > 0: + row_specs.append(("swap", "swap occupied", swap_used)) + + pt = int(mi.get("PageTables", 0) or 0) + ks = int(mi.get("KernelStack", 0) or 0) + if pt + ks > 0: + row_specs.append(("kmeta", "pagetables + kernel stacks", pt + ks)) + + rows = [] + for sk, label, kb in row_specs: + kb = int(kb or 0) + if kb <= 0 and sk != "swap": + continue + if sk == "swap" and kb <= 0: + continue + sk_seed = sum(ord(c) for c in sk) * 31 + len(sk) + n_blocks = 22 + (seed_base % 11) + (sk_seed % 9) + seed0 = seed_base + (sk_seed % 100000) + blocks = _memory_strip_blocks(sk, kb, mt, n_blocks, seed0) + rows.append( + { + "id": sk, + "label": label, + "kb": kb, + "pct_of_ram": round(100.0 * kb / float(mt), 2) if mt else 0.0, + "blocks": blocks, + } + ) + + top_tasks = sorted( + syscall_nodes, + key=lambda x: int(x.get("rss_bytes") or 0), + reverse=True, + )[:6] + if top_tasks: + tr_bytes = sum(int(x.get("rss_bytes") or 0) for x in top_tasks) or 1 + tr_kb = max(1, int(tr_bytes / 1024)) + task_blocks = [] + for p in top_tasks: + rss = int(p.get("rss_bytes") or 0) + if rss <= 0: + continue + w = rss / float(tr_bytes) + mp = float(p.get("memory_percent") or 0.0) + heat = min(1.0, 0.2 + (mp / 100.0) * 0.75 + (rss / float(tr_bytes)) * 0.15) + task_blocks.append( + { + "w": round(w, 6), + "heat": round(heat, 4), + "kind": "task", + "pid": int(p.get("pid") or 0), + "name": str(p.get("name") or "")[:14], + } + ) + if task_blocks: + sw = sum(b["w"] for b in task_blocks) + if sw > 0: + for b in task_blocks: + b["w"] = round(b["w"] / sw, 6) + rows.append( + { + "id": "tasks", + "label": "sampled tasks RSS (top)", + "kb": tr_kb, + "pct_of_ram": round(100.0 * tr_kb / float(mt), 2) if mt else 0.0, + "blocks": task_blocks, + } + ) + + dirty_kb = int(mi.get("Dirty", 0) or 0) + wb_kb = int(mi.get("Writeback", 0) or 0) + sr_kb = int(mi.get("SReclaimable", 0) or 0) + su_kb = int(mi.get("SUnreclaim", 0) or 0) + slab_total_kb = int(mi.get("Slab", 0) or 0) or (sr_kb + su_kb) + summary = { + "total_mb": round(mt / 1024.0, 1), + "used_percent": round(vm.percent, 1) if vm else 0.0, + "available_mb": round((mi.get("MemAvailable", 0) or 0) / 1024.0, 1), + "swap_percent": round(swap.percent, 1) if swap else 0.0, + "buffers_mb": round((mi.get("Buffers", 0) or 0) / 1024.0, 1), + "cached_mb": round((mi.get("Cached", 0) or 0) / 1024.0, 1), + "anon_mb": round((mi.get("AnonPages", 0) or 0) / 1024.0, 1), + "slab_mb": round(slab_total_kb / 1024.0, 1), + "sreclaimable_mb": round(sr_kb / 1024.0, 1), + "sunreclaim_mb": round(su_kb / 1024.0, 1), + "dirty_mb": round(dirty_kb / 1024.0, 2), + "writeback_mb": round(wb_kb / 1024.0, 2), + "dirty_writeback_mb": round(dirty_wb / 1024.0, 2), + "anon_huge_mb": round(ah / 1024.0, 2), + "shmem_huge_mb": round(shm_h / 1024.0, 2), + "vmalloc_mb": round(vmu / 1024.0, 2), + "active_mb": round(ac / 1024.0, 1), + "inactive_mb": round(iac / 1024.0, 1), + "swap_used_mb": round(swap_used / 1024.0, 1) if swap_tot else 0.0, + "source": "proc_meminfo+psutil+v2", + } + return rows, summary + + +def collect_processes_realtime(): + """ + Processes subsystem telemetry focused on: + - syscall interception signals + - network tracing + - security hooks + """ + lsm_raw = "" + try: + with open("/sys/kernel/security/lsm", "r", encoding="utf-8", errors="ignore") as f: + lsm_raw = str(f.read().strip()) + except Exception: + lsm_raw = "" + active_lsms = [x.strip() for x in lsm_raw.split(",") if x.strip()] + + yama_scope = "" + try: + with open("/proc/sys/kernel/yama/ptrace_scope", "r", encoding="utf-8", errors="ignore") as f: + yama_scope = str(f.read().strip()) + except Exception: + yama_scope = "" + + syscall_nodes = [] + seccomp_modes = {"none": 0, "strict": 0, "filter": 0, "unknown": 0} + for proc in psutil.process_iter(["pid", "ppid", "name", "username", "cpu_percent", "memory_percent", "num_threads"]): + try: + pid = int(proc.info.get("pid") or 0) + if pid <= 0: + continue + ppid = int(proc.info.get("ppid") or 0) + name = str(proc.info.get("name") or "unknown") + user = str(proc.info.get("username") or "") + cpu = float(proc.info.get("cpu_percent") or 0.0) + mem = float(proc.info.get("memory_percent") or 0.0) + threads = int(proc.info.get("num_threads") or 0) + rss = 0 + try: + rss = int(getattr(proc.memory_info(), "rss", 0) or 0) + except Exception: + rss = 0 + fd_count = 0 + try: + fd_count = int(proc.num_fds() or 0) + except Exception: + fd_count = 0 + seccomp_mode = "unknown" + with open(f"/proc/{pid}/status", "r", encoding="utf-8", errors="ignore") as f: + for ln in f: + if ln.startswith("Seccomp:"): + raw = ln.split(":", 1)[1].strip() + if raw == "0": + seccomp_mode = "none" + elif raw == "1": + seccomp_mode = "strict" + elif raw == "2": + seccomp_mode = "filter" + else: + seccomp_mode = "unknown" + break + seccomp_modes[seccomp_mode] = seccomp_modes.get(seccomp_mode, 0) + 1 + syscall_pressure = min(100, int(cpu * 1.5 + threads * 0.35 + mem * 0.8)) + syscall_nodes.append({ + "pid": pid, + "ppid": ppid, + "name": name, + "user": user, + "fd_count": fd_count, + "syscall_pressure": syscall_pressure, + "seccomp_mode": seccomp_mode, + "memory_percent": round(mem, 2), + "rss_bytes": rss, + }) + except Exception: + continue + syscall_nodes.sort(key=lambda x: x.get("syscall_pressure", 0), reverse=True) + syscall_nodes = syscall_nodes[:14] + + network_nodes = {} + try: + for conn in psutil.net_connections(kind="inet"): + pid = int(getattr(conn, "pid", 0) or 0) + if pid <= 0: + continue + remote_ip = "" + try: + raddr = getattr(conn, "raddr", None) + if raddr and len(raddr) >= 1: + remote_ip = str(raddr[0]) + except Exception: + remote_ip = "" + status = str(getattr(conn, "status", "") or "").upper() + bucket = network_nodes.get(pid) + if not bucket: + proc_name = "unknown" + try: + proc_name = psutil.Process(pid).name() + except Exception: + proc_name = "unknown" + bucket = { + "pid": pid, + "name": proc_name, + "connections": 0, + "remote_ips": set(), + "states": {} + } + network_nodes[pid] = bucket + bucket["connections"] += 1 + if remote_ip: + bucket["remote_ips"].add(remote_ip) + if status: + bucket["states"][status] = bucket["states"].get(status, 0) + 1 + except Exception: + pass + + network_tracing = [] + for _, row in network_nodes.items(): + states_sorted = sorted(row["states"].items(), key=lambda kv: kv[1], reverse=True) + top_state = states_sorted[0][0] if states_sorted else "UNKNOWN" + network_tracing.append({ + "pid": int(row["pid"]), + "name": str(row["name"]), + "connections": int(row["connections"]), + "unique_peers": int(len(row["remote_ips"])), + "peer_sample": sorted(list(row["remote_ips"]))[:4], + "top_state": top_state + }) + network_tracing.sort(key=lambda x: (x.get("connections", 0), x.get("unique_peers", 0)), reverse=True) + network_tracing = network_tracing[:14] + + security_hooks = [ + { + "name": "LSM stack", + "status": "active" if active_lsms else "unknown", + "detail": ",".join(active_lsms[:4]) if active_lsms else "n/a" + }, + { + "name": "SELinux/AppArmor engines", + "status": "active" if any(x in {"selinux", "apparmor"} for x in active_lsms) else "inactive", + "detail": "policy-enforcement-path" + }, + { + "name": "BPF LSM", + "status": "active" if "bpf" in active_lsms else "inactive", + "detail": "dynamic-policy-hook" + }, + { + "name": "seccomp filter gate", + "status": "active" if (seccomp_modes.get("filter", 0) + seccomp_modes.get("strict", 0)) > 0 else "inactive", + "detail": f"filter:{seccomp_modes.get('filter', 0)} strict:{seccomp_modes.get('strict', 0)}" + }, + { + "name": "Yama ptrace scope", + "status": "hardened" if yama_scope in {"2", "3"} else ("relaxed" if yama_scope in {"0", "1"} else "unknown"), + "detail": yama_scope or "n/a" + } + ] + + # Neural graph model: nodes=processes, edges=behavior interactions. + node_pool = {} + for row in syscall_nodes[:16]: + pid = int(row.get("pid") or 0) + if pid <= 0: + continue + node_pool[pid] = { + "pid": pid, + "ppid": int(row.get("ppid") or 0), + "name": str(row.get("name") or "unknown"), + "user": str(row.get("user") or ""), + "syscall_pressure": int(row.get("syscall_pressure") or 0), + "fd_count": int(row.get("fd_count") or 0), + "seccomp_mode": str(row.get("seccomp_mode") or "unknown"), + "connections": 0, + "unique_peers": 0, + "memory_percent": float(row.get("memory_percent") or 0.0), + "rss_bytes": int(row.get("rss_bytes") or 0), + } + for row in network_tracing[:16]: + pid = int(row.get("pid") or 0) + if pid <= 0: + continue + if pid not in node_pool: + node_pool[pid] = { + "pid": pid, + "ppid": 0, + "name": str(row.get("name") or "unknown"), + "user": "", + "syscall_pressure": 0, + "fd_count": 0, + "seccomp_mode": "unknown", + "connections": 0, + "unique_peers": 0, + "memory_percent": 0.0, + "rss_bytes": 0, + } + node_pool[pid]["connections"] = int(row.get("connections") or 0) + node_pool[pid]["unique_peers"] = int(row.get("unique_peers") or 0) + + edges = [] + edge_keys = set() + network_by_pid = {int(r.get("pid") or 0): r for r in network_tracing} + node_pids = sorted(node_pool.keys()) + + def _add_edge(src_pid, dst_pid, edge_type, weight): + src = int(src_pid or 0) + dst = int(dst_pid or 0) + if src <= 0 or dst <= 0 or src == dst: + return + if src not in node_pool or dst not in node_pool: + return + pair = tuple(sorted((src, dst))) + key = (pair[0], pair[1], edge_type) + if key in edge_keys: + return + edge_keys.add(key) + edges.append({ + "source": src, + "target": dst, + "type": edge_type, + "weight": float(max(0.1, min(1.0, weight))) + }) + + # IPC edges: parent-child links inside the sampled set. + for pid, node in node_pool.items(): + ppid = int(node.get("ppid") or 0) + if ppid in node_pool: + _add_edge(pid, ppid, "ipc", 0.72) + + # Syscalls edges: close-pressure processes likely competing on kernel hooks. + sorted_by_pressure = sorted(node_pool.values(), key=lambda n: n.get("syscall_pressure", 0), reverse=True) + for i in range(len(sorted_by_pressure) - 1): + a = sorted_by_pressure[i] + b = sorted_by_pressure[i + 1] + diff = abs(int(a.get("syscall_pressure", 0)) - int(b.get("syscall_pressure", 0))) + weight = 1.0 - min(0.8, diff / 100.0) + _add_edge(int(a.get("pid")), int(b.get("pid")), "syscalls", weight) + + # Network edges: connect nodes that share at least one peer sample. + for i in range(len(node_pids)): + for j in range(i + 1, len(node_pids)): + pa = node_pids[i] + pb = node_pids[j] + ra = network_by_pid.get(pa) or {} + rb = network_by_pid.get(pb) or {} + sa = set(ra.get("peer_sample") or []) + sb = set(rb.get("peer_sample") or []) + if sa and sb and (sa & sb): + _add_edge(pa, pb, "network", 0.88) + + # File access edges: processes with high FD count and same user. + for i in range(len(node_pids)): + for j in range(i + 1, len(node_pids)): + na = node_pool[node_pids[i]] + nb = node_pool[node_pids[j]] + if not na.get("user") or na.get("user") != nb.get("user"): + continue + fa = int(na.get("fd_count") or 0) + fb = int(nb.get("fd_count") or 0) + if fa >= 16 and fb >= 16: + _add_edge(int(na.get("pid")), int(nb.get("pid")), "file_access", 0.64) + + nodes = list(node_pool.values())[:18] + edges = edges[:64] + + try: + vm = psutil.virtual_memory() + swap = psutil.swap_memory() + meminfo_kb = _parse_meminfo_kb() + strip_rows, mem_summary = _build_memory_visual_rows(meminfo_kb, syscall_nodes, vm, swap) + memory_visual = { + "layout": "strips", + "rows": strip_rows, + "summary": mem_summary, + } + except Exception: + memory_visual = { + "layout": "strips", + "rows": [], + "summary": { + "total_mb": 0, + "used_percent": 0.0, + "available_mb": 0, + "swap_percent": 0.0, + "source": "error", + }, + } + + return { + "timestamp": datetime.utcnow().isoformat() + "Z", + "syscalls_interception": syscall_nodes, + "network_tracing": network_tracing, + "security_hooks": security_hooks, + "neural_graph": { + "nodes": nodes, + "edges": edges + }, + "memory_visual": memory_visual, + "meta": { + "processes_sampled": len(syscall_nodes), + "network_processes": len(network_tracing), + "seccomp_filter_percent": round( + (seccomp_modes.get("filter", 0) + seccomp_modes.get("strict", 0)) + * 100.0 / max(1, sum(seccomp_modes.values())), + 2 + ), + "mode": "live-heuristic-v1" + } + } + +def kernel_dna(): + """API endpoint for Kernel DNA visualization data""" + try: + data = get_kernel_dna_data() + return jsonify(data) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +def crypto_realtime(): + """Realtime-ish crypto interaction feed for crypto visualization.""" + try: + return jsonify(collect_crypto_realtime()) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +def security_realtime(): + """Realtime-ish security interaction feed for security visualization.""" + try: + return jsonify(collect_security_realtime()) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +def processes_realtime(): + """Realtime-ish processes interaction feed for processes visualization.""" + try: + return jsonify(collect_processes_realtime()) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +def ingest_frontend_logs(): + """Receive frontend logs in ECS-like JSON and append to local JSONL file.""" + if request.method == 'OPTIONS': + return ('', 204) + + payload = request.get_json(silent=True) + if payload is None: + return jsonify({"error": "Invalid JSON payload"}), 400 + + events = payload.get("events", payload if isinstance(payload, list) else [payload]) + if not isinstance(events, list): + return jsonify({"error": "Expected event object or list of events"}), 400 + if len(events) > 100: + return jsonify({"error": "Batch too large"}), 413 + + accepted = 0 + for raw in events: + if not isinstance(raw, dict): + continue + write_frontend_event(raw) + accepted += 1 + + return jsonify({"status": "ok", "accepted": accepted}) + + +register_http_routes(app) + + +@app.errorhandler(404) +def not_found(error): + return jsonify({'error': 'Not found'}), 404 + +@app.errorhandler(500) +def internal_error(error): + return jsonify({'error': 'Internal server error'}), 500 + + +def create_app(): + """Application factory. Currently returns the module singleton ``app`` (refactor in progress).""" + return app