#!/usr/bin/env python3
# ============================================================================
#  NetSight Local Agent v1.1
#  Real-time network diagnostics from your local machine
#
#  Part of NetSight Toolbox - https://netsight.mutebefehl.de
#  Copyright (c) 2025 MuteBefehl (https://mutebefehl.de)
#
#  DISCLAIMER / HAFTUNGSAUSSCHLUSS:
#  This software is provided "as is" without warranty of any kind, express or
#  implied. The author assumes no liability for damages arising from the use
#  of this software. Use at your own risk. This agent runs ICMP ping and
#  traceroute commands on your local machine and exposes results via a local
#  HTTP server on port 9876 (localhost only). No data is sent to external
#  servers - all communication stays between this agent and your browser.
#
#  Diese Software wird ohne jegliche Gewaehrleistung bereitgestellt. Der
#  Autor uebernimmt keine Haftung fuer Schaeden, die durch die Nutzung
#  dieser Software entstehen. Nutzung auf eigene Gefahr.
#
#  Requirements: Python 3.6+ (standard library only, no dependencies)
#  Usage:        python3 netsight-agent.py
# ============================================================================

import http.server
import json
import subprocess
import sys
import platform
import re
import time
import socket
import urllib.parse
import socketserver
import os

VERSION = '1.3'
PORT = 9876

def print_banner(port, plat, pyver):
    title = f'NetSight Local Agent v{VERSION}'
    sub = 'Network diagnostics from your machine'
    w = max(len(title), len(sub)) + 4
    line = '+' + '-' * w + '+'
    print()
    print(f'  \033[1;36m{line}')
    print(f'  | {title:^{w-2}} |')
    print(f'  | {sub:^{w-2}} |')
    print(f'  {line}\033[0m')
    print()
    print(f'  \033[1mStatus:\033[0m    Running')
    print(f'  \033[1mPort:\033[0m      {port}')
    print(f'  \033[1mPlatform:\033[0m  {plat}')
    print(f'  \033[1mPython:\033[0m    {pyver}')
    print()
    print(f'  Open \033[4;1mhttps://netsight.mutebefehl.de/pingplotter\033[0m in your browser.')
    print(f'  \033[33mNote: Use Chrome or Firefox. Safari does not support local agents.\033[0m')
    print(f'  Press Ctrl+C to stop.')
    print()
    print(f'  \033[90m(c) 2025 MuteBefehl - https://mutebefehl.de\033[0m')
    print()


# ---------------------------------------------------------------------------
#  Server
# ---------------------------------------------------------------------------

HOST = '127.0.0.1'


class ThreadedHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
    """HTTP server that handles each request in a new thread."""
    daemon_threads = True


class AgentHandler(http.server.BaseHTTPRequestHandler):
    """Handles incoming HTTP requests from the NetSight browser UI."""

    server_version = 'NetSight-Agent/' + VERSION

    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        path = parsed.path
        params = dict(urllib.parse.parse_qsl(parsed.query))

        self._send_cors_headers(200)

        if path == '/status':
            self._json({'ok': True, 'platform': platform.system(), 'version': VERSION})
        elif path == '/ping':
            self._json(do_ping(params.get('host', '')))
        elif path == '/trace':
            self._json(do_traceroute(params.get('host', '')))
        elif path == '/trace-quick':
            maxhop = min(int(params.get('maxhop', '30')), 64)
            self._json(do_trace_quick(params.get('host', ''), maxhop))
        else:
            self._json({'error': 'Unknown endpoint. Available: /status, /ping, /trace, /trace-quick'})

    def do_OPTIONS(self):
        self._send_cors_headers(204)

    def _send_cors_headers(self, code):
        self.send_response(code)
        self.send_header('Access-Control-Allow-Origin', 'https://netsight.mutebefehl.de')
        self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', '*')
        self.send_header('Content-Type', 'application/json')
        self.send_header('Cache-Control', 'no-store')
        self.end_headers()

    def _json(self, data):
        self.wfile.write(json.dumps(data).encode())

    _ping_count = 0

    def log_message(self, fmt, *args):
        msg = str(args[0])
        if '/ping?' in msg:
            AgentHandler._ping_count += 1
            if AgentHandler._ping_count % 20 == 0:
                ts = time.strftime('%H:%M:%S')
                sys.stdout.write(f'  \033[90m[{ts}]\033[0m \033[36m{AgentHandler._ping_count} pings processed\033[0m\n')
                sys.stdout.flush()
            return
        ts = time.strftime('%H:%M:%S')
        sys.stdout.write(f'  \033[90m[{ts}]\033[0m {msg}\n')
        sys.stdout.flush()


# ---------------------------------------------------------------------------
#  Validation
# ---------------------------------------------------------------------------

def validate_host(host):
    """Validate hostname/IP to prevent command injection."""
    if not host or len(host) > 253:
        return False
    return bool(re.match(r'^[a-zA-Z0-9][a-zA-Z0-9\-.:a-fA-F]*[a-zA-Z0-9]$', host)) or \
           bool(re.match(r'^[a-zA-Z0-9]$', host)) or \
           bool(re.match(r'^::?[a-fA-F0-9.:]+$', host))


# ---------------------------------------------------------------------------
#  Ping
# ---------------------------------------------------------------------------

def do_ping(host):
    """Execute a single ICMP ping and return the result."""
    if not validate_host(host):
        return {'success': False, 'error': 'Invalid host'}
    try:
        if platform.system() == 'Windows':
            cmd = ['ping', '-n', '1', '-w', '3000', host]
        else:
            cmd = ['ping', '-c', '1', '-W', '3', host]

        env = dict(os.environ, LC_ALL='C', LANG='C')
        start = time.time()
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=5, env=env)
        rtt = None
        m = re.search(r'time[=<]([\d.]+)\s*ms', result.stdout)
        if m:
            rtt = float(m.group(1))

        now = int(time.time() * 1000)
        if result.returncode == 0 and rtt is not None:
            return {'success': True, 'rtt': round(rtt, 2), 'time': now}
        return {'success': False, 'rtt': None, 'time': now}
    except subprocess.TimeoutExpired:
        return {'success': False, 'rtt': None, 'time': int(time.time() * 1000), 'error': 'timeout'}
    except Exception as e:
        return {'success': False, 'rtt': None, 'time': int(time.time() * 1000), 'error': str(e)}


# ---------------------------------------------------------------------------
#  Traceroute
# ---------------------------------------------------------------------------

def resolve_hostname(ip):
    """Reverse DNS lookup for an IP address."""
    try:
        return socket.gethostbyaddr(ip)[0]
    except Exception:
        return ip


def do_traceroute(host):
    """Execute traceroute and return parsed hop data."""
    if not validate_host(host):
        return {'error': 'Invalid host', 'hops': []}
    try:
        is_win = platform.system() == 'Windows'
        if is_win:
            cmd = ['tracert', '-d', '-w', '3000', '-h', '30', host]
        else:
            cmd = ['traceroute', '-n', '-w', '3', '-m', '30', '-q', '3', host]

        env = dict(os.environ, LC_ALL='C', LANG='C')
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=90, env=env)
        hops = parse_traceroute(result.stdout, is_win)

        for h in hops:
            if h['ip'] != '*':
                h['host'] = resolve_hostname(h['ip'])

        return {'hops': hops}
    except subprocess.TimeoutExpired:
        return {'error': 'Traceroute timed out', 'hops': []}
    except FileNotFoundError:
        alt = 'tracert' if not is_win else 'traceroute'
        return {'error': f'traceroute not found. Install it or try {alt}.', 'hops': []}
    except Exception as e:
        return {'error': str(e), 'hops': []}


def do_trace_quick(host, maxhop=30):
    """Quick traceroute with 1 probe per hop for live monitoring."""
    if not validate_host(host):
        return {'error': 'Invalid host', 'hops': []}
    try:
        is_win = platform.system() == 'Windows'
        if is_win:
            cmd = ['tracert', '-d', '-w', '2000', '-h', str(maxhop), host]
        else:
            cmd = ['traceroute', '-n', '-w', '2', '-m', str(maxhop), '-q', '1', host]
        env = dict(os.environ, LC_ALL='C', LANG='C')
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, env=env)
        hops = parse_traceroute(result.stdout, is_win)
        return {'hops': hops}
    except subprocess.TimeoutExpired:
        return {'error': 'timeout', 'hops': []}
    except Exception as e:
        return {'error': str(e), 'hops': []}


def parse_traceroute(output, is_win):
    """Parse raw traceroute output into structured hop data."""
    hops = []
    for line in output.strip().split('\n'):
        line = line.strip()
        if not line:
            continue

        m = re.match(r'^\s*(\d+)\s+(.*)', line)
        if not m:
            continue

        hop_num = int(m.group(1))
        rest = m.group(2)

        if is_win:
            rtts = []
            for r in re.finditer(r'(\d+)\s*ms|(<1)\s*ms', rest):
                if r.group(1):
                    rtts.append(float(r.group(1)))
                elif r.group(2):
                    rtts.append(0.5)
            ip_m = re.search(r'(\d+\.\d+\.\d+\.\d+)', rest)
            ip = ip_m.group(1) if ip_m else '*'
        else:
            ip_m = re.search(r'(\d+\.\d+\.\d+\.\d+)', rest)
            ip = ip_m.group(1) if ip_m else '*'
            rtts = [float(r) for r in re.findall(r'([\d.]+)\s*ms', rest)]

        stars = rest.count('*')
        total_probes = max(len(rtts) + stars, 3)
        loss = round(stars / total_probes * 100) if stars else 0
        avg_rtt = round(sum(rtts) / len(rtts), 2) if rtts else None

        hops.append({'hop': hop_num, 'ip': ip, 'host': ip, 'rtt': avg_rtt, 'loss': loss})
    return hops


# ---------------------------------------------------------------------------
#  Main
# ---------------------------------------------------------------------------

if __name__ == '__main__':
    print_banner(PORT, platform.system(), platform.python_version())
    try:
        server = ThreadedHTTPServer((HOST, PORT), AgentHandler)
    except OSError as e:
        print(f'  \033[1;31mError:\033[0m Could not start server on port {PORT}.')
        print(f'  {e}')
        print(f'  Make sure no other instance is already running.')
        sys.exit(1)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print('\n  \033[90mAgent stopped.\033[0m')
        server.server_close()
