diff --git a/Dockerfile b/Dockerfile index fb48ca8..700435a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,6 +61,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Utilities curl \ procps \ + nmap \ && rm -rf /var/lib/apt/lists/* # Build dump1090-fa and acarsdec from source (packages not available in slim repos) diff --git a/README.md b/README.md index ed677d9..59d5e69 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,10 @@ Support the developer of this open-source project ``` +> Note for Linux users: The LAN SPY network discovery requires `nmap` to be +> installed. On Debian/Ubuntu install with `sudo apt install nmap` before +> running `./setup.sh` or the LAN SPY scan will not work correctly. + **1. Clone and run:** ```bash git clone https://github.com/smittix/intercept.git @@ -67,7 +71,7 @@ cd intercept sudo -E venv/bin/python intercept.py ``` -### Docker +### Docker (Alternative) ```bash git clone https://github.com/smittix/intercept.git diff --git a/app.py b/app.py index da0dc8e..651daab 100644 --- a/app.py +++ b/app.py @@ -46,6 +46,8 @@ from flask_limiter.util import get_remote_address # Track application start time for uptime calculation import time as _time +from routes import lan_spy as lan_spy_module + _app_start_time = _time.time() logger = logging.getLogger('intercept.database') @@ -96,32 +98,32 @@ def add_security_headers(response): # CONTEXT PROCESSORS # ============================================ -@app.context_processor -def inject_offline_settings(): - """Inject offline settings into all templates.""" - from utils.database import get_setting - - # Privacy-first defaults: keep dashboard assets/fonts local to avoid - # third-party tracker/storage defenses in strict browsers. - assets_source = str(get_setting('offline.assets_source', 'local') or 'local').lower() - fonts_source = str(get_setting('offline.fonts_source', 'local') or 'local').lower() - if assets_source not in ('local', 'cdn'): - assets_source = 'local' - if fonts_source not in ('local', 'cdn'): - fonts_source = 'local' - # Force local delivery for core dashboard pages. - assets_source = 'local' - fonts_source = 'local' - - return { - 'offline_settings': { - 'enabled': get_setting('offline.enabled', False), - 'assets_source': assets_source, - 'fonts_source': fonts_source, - 'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'), - 'tile_server_url': get_setting('offline.tile_server_url', '') - } - } +@app.context_processor +def inject_offline_settings(): + """Inject offline settings into all templates.""" + from utils.database import get_setting + + # Privacy-first defaults: keep dashboard assets/fonts local to avoid + # third-party tracker/storage defenses in strict browsers. + assets_source = str(get_setting('offline.assets_source', 'local') or 'local').lower() + fonts_source = str(get_setting('offline.fonts_source', 'local') or 'local').lower() + if assets_source not in ('local', 'cdn'): + assets_source = 'local' + if fonts_source not in ('local', 'cdn'): + fonts_source = 'local' + # Force local delivery for core dashboard pages. + assets_source = 'local' + fonts_source = 'local' + + return { + 'offline_settings': { + 'enabled': get_setting('offline.enabled', False), + 'assets_source': assets_source, + 'fonts_source': fonts_source, + 'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'), + 'tile_server_url': get_setting('offline.tile_server_url', '') + } + } # ============================================ @@ -190,9 +192,9 @@ def inject_offline_settings(): dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) dsc_lock = threading.Lock() -# TSCM (Technical Surveillance Countermeasures) -tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) -tscm_lock = threading.Lock() +# TSCM (Technical Surveillance Countermeasures) +tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) +tscm_lock = threading.Lock() # SubGHz Transceiver (HackRF) subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) @@ -203,6 +205,11 @@ def inject_offline_settings(): deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) deauth_detector_lock = threading.Lock() +# Lan Spy (Network Device Discovery) +lan_spy_process = None +lan_spy_lock = threading.Lock() +lan_spy_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) + # ============================================ # GLOBAL STATE DICTIONARIES # ============================================ @@ -659,110 +666,112 @@ def export_bluetooth() -> Response: }) -def _get_subghz_active() -> bool: - """Check if SubGHz manager has an active process.""" - try: - from utils.subghz import get_subghz_manager - return get_subghz_manager().active_mode != 'idle' - except Exception: - return False - - -def _get_bluetooth_health() -> tuple[bool, int]: - """Return Bluetooth active state and best-effort device count.""" - legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False) - scanner_running = False - scanner_count = 0 - - try: - from utils.bluetooth.scanner import _scanner_instance as bt_scanner - if bt_scanner is not None: - scanner_running = bool(bt_scanner.is_scanning) - scanner_count = int(bt_scanner.device_count) - except Exception: - scanner_running = False - scanner_count = 0 - - locate_running = False - try: - from utils.bt_locate import get_locate_session - session = get_locate_session() - if session and getattr(session, 'active', False): - scanner = getattr(session, '_scanner', None) - locate_running = bool(scanner and scanner.is_scanning) - except Exception: - locate_running = False - - return (legacy_running or scanner_running or locate_running), max(len(bt_devices), scanner_count) - - -def _get_wifi_health() -> tuple[bool, int, int]: - """Return WiFi active state and best-effort network/client counts.""" - legacy_running = wifi_process is not None and (wifi_process.poll() is None if wifi_process else False) - scanner_running = False - scanner_networks = 0 - scanner_clients = 0 - - try: - from utils.wifi.scanner import _scanner_instance as wifi_scanner - if wifi_scanner is not None: - status = wifi_scanner.get_status() - scanner_running = bool(status.is_scanning) - scanner_networks = int(status.networks_found or 0) - scanner_clients = int(status.clients_found or 0) - except Exception: - scanner_running = False - scanner_networks = 0 - scanner_clients = 0 - - return ( - legacy_running or scanner_running, - max(len(wifi_networks), scanner_networks), - max(len(wifi_clients), scanner_clients), - ) - - -@app.route('/health') -def health_check() -> Response: - """Health check endpoint for monitoring.""" - import time - bt_active, bt_device_count = _get_bluetooth_health() - wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health() - return jsonify({ - 'status': 'healthy', - 'version': VERSION, - 'uptime_seconds': round(time.time() - _app_start_time, 2), - 'processes': { +def _get_subghz_active() -> bool: + """Check if SubGHz manager has an active process.""" + try: + from utils.subghz import get_subghz_manager + return get_subghz_manager().active_mode != 'idle' + except Exception: + return False + + +def _get_bluetooth_health() -> tuple[bool, int]: + """Return Bluetooth active state and best-effort device count.""" + legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False) + scanner_running = False + scanner_count = 0 + + try: + from utils.bluetooth.scanner import _scanner_instance as bt_scanner + if bt_scanner is not None: + scanner_running = bool(bt_scanner.is_scanning) + scanner_count = int(bt_scanner.device_count) + except Exception: + scanner_running = False + scanner_count = 0 + + locate_running = False + try: + from utils.bt_locate import get_locate_session + session = get_locate_session() + if session and getattr(session, 'active', False): + scanner = getattr(session, '_scanner', None) + locate_running = bool(scanner and scanner.is_scanning) + except Exception: + locate_running = False + + return (legacy_running or scanner_running or locate_running), max(len(bt_devices), scanner_count) + + +def _get_wifi_health() -> tuple[bool, int, int]: + """Return WiFi active state and best-effort network/client counts.""" + legacy_running = wifi_process is not None and (wifi_process.poll() is None if wifi_process else False) + scanner_running = False + scanner_networks = 0 + scanner_clients = 0 + + try: + from utils.wifi.scanner import _scanner_instance as wifi_scanner + if wifi_scanner is not None: + status = wifi_scanner.get_status() + scanner_running = bool(status.is_scanning) + scanner_networks = int(status.networks_found or 0) + scanner_clients = int(status.clients_found or 0) + except Exception: + scanner_running = False + scanner_networks = 0 + scanner_clients = 0 + + return ( + legacy_running or scanner_running, + max(len(wifi_networks), scanner_networks), + max(len(wifi_clients), scanner_clients), + ) + + +@app.route('/health') +def health_check() -> Response: + """Health check endpoint for monitoring.""" + import time + bt_active, bt_device_count = _get_bluetooth_health() + wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health() + return jsonify({ + 'status': 'healthy', + 'version': VERSION, + 'uptime_seconds': round(time.time() - _app_start_time, 2), + 'processes': { 'pager': current_process is not None and (current_process.poll() is None if current_process else False), 'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False), 'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False), - 'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False), - 'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False), - 'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_process else False), - 'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False), - 'wifi': wifi_active, - 'bluetooth': bt_active, - 'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False), - 'subghz': _get_subghz_active(), - }, - 'data': { - 'aircraft_count': len(adsb_aircraft), - 'vessel_count': len(ais_vessels), - 'wifi_networks_count': wifi_network_count, - 'wifi_clients_count': wifi_client_count, - 'bt_devices_count': bt_device_count, - 'dsc_messages_count': len(dsc_messages), - } - }) + 'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False), + 'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False), + 'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_process else False), + 'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False), + 'wifi': wifi_active, + 'bluetooth': bt_active, + 'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False), + 'subghz': _get_subghz_active(), + 'lan_spy': lan_spy_process is not None and (lan_spy_process.poll() is None if lan_spy_process else False) + }, + 'data': { + 'aircraft_count': len(adsb_aircraft), + 'vessel_count': len(ais_vessels), + 'wifi_networks_count': wifi_network_count, + 'wifi_clients_count': wifi_client_count, + 'bt_devices_count': bt_device_count, + 'dsc_messages_count': len(dsc_messages), + 'lan_devices_count': len(lan_spy_module.lan_devices) + } + }) @app.route('/killall', methods=['POST']) -def kill_all() -> Response: +def kill_all() -> Response: """Kill all decoder, WiFi, and Bluetooth processes.""" global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process global vdl2_process global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process - + global lan_spy_process # Import adsb and ais modules to reset their state from routes import adsb as adsb_module from routes import ais as ais_module @@ -773,9 +782,9 @@ def kill_all() -> Response: 'rtl_fm', 'multimon-ng', 'rtl_433', 'airodump-ng', 'aireplay-ng', 'airmon-ng', 'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher', - 'hcitool', 'bluetoothctl', 'satdump', + 'hcitool', 'bluetoothctl', 'satdump', 'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg', - 'hackrf_transfer', 'hackrf_sweep' + 'hackrf_transfer', 'hackrf_sweep','nmap', 'arp-scan' ] for proc in processes_to_kill: @@ -786,6 +795,15 @@ def kill_all() -> Response: except (subprocess.SubprocessError, OSError): pass + with lan_spy_lock: + if lan_spy_process: + try: + lan_spy_process.terminate() + except: + pass + lan_spy_process = None + lan_spy_module.lan_spy_scan_running = False + with process_lock: current_process = None @@ -823,7 +841,7 @@ def kill_all() -> Response: dsc_process = None dsc_rtl_process = None - # Reset Bluetooth state (legacy) + # Reset Bluetooth state (legacy) with bt_lock: if bt_process: try: @@ -843,16 +861,16 @@ def kill_all() -> Response: except Exception: pass - # Reset SubGHz state - try: - from utils.subghz import get_subghz_manager - get_subghz_manager().stop_all() - except Exception: - pass - - # Clear SDR device registry - with sdr_device_registry_lock: - sdr_device_registry.clear() + # Reset SubGHz state + try: + from utils.subghz import get_subghz_manager + get_subghz_manager().stop_all() + except Exception: + pass + + # Clear SDR device registry + with sdr_device_registry_lock: + sdr_device_registry.clear() return jsonify({'status': 'killed', 'processes': killed}) diff --git a/pyproject.toml b/pyproject.toml index e1229b3..1fbd37f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "flask-sock", "websocket-client>=1.6.0", "requests>=2.28.0", + "pyyaml>=6.0" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index cb05331..7f33491 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,9 @@ flask-limiter>=2.5.4 requests>=2.28.0 Werkzeug>=3.1.5 +# LAN SPY (network device discovery and risk scoring) +pyyaml>=6.0 + # ADS-B history (optional - only needed for Postgres persistence) psycopg2-binary>=2.9.9 diff --git a/routes/__init__.py b/routes/__init__.py index 844a117..38660b9 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -22,6 +22,7 @@ def register_blueprints(app): from .listening_post import listening_post_bp from .meshtastic import meshtastic_bp from .tscm import tscm_bp, init_tscm_state + from .lan_spy import lan_spy_bp, init_lan_spy_state from .spy_stations import spy_stations_bp from .controller import controller_bp from .offline import offline_bp @@ -57,6 +58,7 @@ def register_blueprints(app): app.register_blueprint(listening_post_bp) app.register_blueprint(meshtastic_bp) app.register_blueprint(tscm_bp) + app.register_blueprint(lan_spy_bp) # LAN device discovery app.register_blueprint(spy_stations_bp) app.register_blueprint(controller_bp) # Remote agent controller app.register_blueprint(offline_bp) # Offline mode settings @@ -76,3 +78,6 @@ def register_blueprints(app): import app as app_module if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'): init_tscm_state(app_module.tscm_queue, app_module.tscm_lock) + + # Initialize LAN SPY state + init_lan_spy_state() diff --git a/routes/lan_spy.py b/routes/lan_spy.py new file mode 100644 index 0000000..a9a7f0c --- /dev/null +++ b/routes/lan_spy.py @@ -0,0 +1,280 @@ +""" +LAN SPY Routes +Flask blueprint for network scanning API and SSE event streaming. +""" + +from flask import Blueprint, jsonify, request, Response +import logging +import threading +import queue +from datetime import datetime +import json +import time + +from utils.lan_spy.scanner import NetworkScanner, update_oui_database, get_local_network +from utils.database import ( + add_device, clean_all_devices, get_all_devices, get_device, add_risk_score, get_risk_score, + record_scan, update_device_flag +) +from utils.lan_spy.risk_scoring import RiskScorer + +logger = logging.getLogger('intercept.lan_spy.routes') + +lan_spy_bp = Blueprint('lan_spy', __name__, url_prefix='/lan_spy') + +# Global state +lan_spy_state = { + 'scanner': None, + 'database': None, + 'risk_scorer': None, + 'event_queue': queue.Queue(), + 'scan_running': False, + 'scan_worker_thread': None +} + + +def init_lan_spy_state(): + """Initialize LAN SPY state on app startup.""" + try: + lan_spy_state['risk_scorer'] = RiskScorer() + + # Auto-download OUI database if missing + logger.info("Updating OUI database...") + result = update_oui_database() + logger.info(f"OUI update: {result}") + + logger.info("LAN SPY state initialized") + except Exception as e: + logger.error(f"Error initializing LAN SPY: {e}") + + +def _emit_event(event_type: str, data: dict = None): + """Queue event for SSE streaming.""" + event = { + 'type': event_type, + 'timestamp': datetime.now().isoformat() + 'Z', + 'data': data or {} + } + lan_spy_state['event_queue'].put(event) + + +def scan_worker(network: str = None): + """Background worker thread for network scanning.""" + try: + if network is None: + network = get_local_network() + + _emit_event('scan_start', {'network': network, 'status': 'Initializing scan...'}) + + # Create scanner + scanner = NetworkScanner(network=network) + lan_spy_state['scanner'] = scanner + + _emit_event('scan_status', {'message': 'Scanning network for active hosts...'}) + + # Perform scan + devices = scanner.scan() + + _emit_event('scan_status', { + 'message': f'Found {len(devices)} devices...' if devices else 'No devices found on this network' + }) + + # Store devices and calculate risk scores + device_count = 0 + for device in devices: + # Add to database + add_device(device) + device_count += 1 + + # Calculate risk score + risk_data = lan_spy_state['risk_scorer'].calculate_risk(device) + add_risk_score( + device['mac'], + device['internal_ip'], + risk_data['hardware'], + risk_data['exposure'], + risk_data['external'], + risk_data['traffic'], + risk_data['total'] + ) + + # Emit device found event + device['risk_index'] = risk_data['total'] + device['risk_badge'] = risk_data['badge'] + _emit_event('device_found', device) + + _emit_event('scan_status', { + 'message': f'Processing device {device_count}/{len(devices)}: {device.get("hostname", device.get("internal_ip"))}' + }) + + # Record scan history + elapsed = time.time() - scanner._start_time if hasattr(scanner, '_start_time') else 0 + record_scan(network, len(devices), elapsed) + + # Emit completion + _emit_event('scan_complete', { + 'devices_found': len(devices), + 'network': network + }) + + except Exception as e: + logger.error(f"Scan worker error: {e}") + _emit_event('scan_error', {'message': str(e)}) + finally: + lan_spy_state['scan_running'] = False + + +@lan_spy_bp.route('/health', methods=['GET']) +def health(): + """Health check endpoint.""" + return jsonify({'status': 'ok', 'service': 'lan_spy'}), 200 + +@lan_spy_bp.route('/devices/clean', methods=['POST']) +def clean_devices(): + """Clean all discovered devices.""" + try: + clean_all_devices() + return jsonify({'status': 'cleaned'}), 200 + except Exception as e: + logger.error(f"Error cleaning devices: {e}") + return jsonify({'error': str(e)}), 500 + +@lan_spy_bp.route('/devices', methods=['GET']) +def get_devices(): + """Get all discovered devices.""" + try: + devices = get_all_devices() + + # Add risk scores + for device in devices: + risk_data = get_risk_score(device)[0].json if get_risk_score(device) else None + if risk_data: + device['risk_index'] = risk_data['total'] + device['risk_scores'] = { + 'hardware': risk_data['hardware'], + 'exposure': risk_data['exposure'], + 'external': risk_data['external'], + 'traffic': risk_data['traffic'] + } + else: + device['risk_index'] = 0.0 + device['risk_scores'] = {} + + return jsonify({'devices': devices, 'count': len(devices)}), 200 + except Exception as e: + logger.error(f"Error getting devices: {e}") + return jsonify({'error': str(e)}), 500 + +@lan_spy_bp.route('/scan', methods=['POST']) +def start_scan(): + """Start a network scan.""" + try: + if lan_spy_state['scan_running']: + return jsonify({'error': 'Scan already running'}), 409 + + # Get network from request or auto-detect + network = request.json.get('network') if request.json else None + + lan_spy_state['scan_running'] = True + + # Start scan in background thread + thread = threading.Thread(target=scan_worker, args=(network,), daemon=True) + lan_spy_state['scan_worker_thread'] = thread + thread.start() + + return jsonify({'status': 'scan_started', 'network': network or get_local_network()}), 200 + except Exception as e: + logger.error(f"Error starting scan: {e}") + lan_spy_state['scan_running'] = False + return jsonify({'error': str(e)}), 500 + + +@lan_spy_bp.route('/scan/stop', methods=['POST']) +def stop_scan(): + """Stop the current scan.""" + try: + if lan_spy_state['scanner']: + lan_spy_state['scanner'].stop() + _emit_event('scan_stopped', {'message': 'Scan stopped by user'}) + + return jsonify({'status': 'scan_stopped'}), 200 + except Exception as e: + logger.error(f"Error stopping scan: {e}") + return jsonify({'error': str(e)}), 500 + + +@lan_spy_bp.route('/risk-score/', methods=['GET']) +def get_risk_score(device): + """Calculate risk score for a device.""" + try: + if not device: + return jsonify({'error': 'Device not found'}), 404 + + risk_data = lan_spy_state['risk_scorer'].calculate_risk(device) + return jsonify(risk_data), 200 + except Exception as e: + logger.error(f"Error calculating risk score: {e}") + return jsonify({'error': str(e)}), 500 + + +@lan_spy_bp.route('/device//tracking', methods=['POST']) +def toggle_tracking(mac): + """Toggle tracking device flag.""" + try: + value = request.json.get('value', True) if request.json else True + update_device_flag(mac, 'tracking_device', value) + return jsonify({'status': 'updated', 'mac': mac, 'tracking_device': value}), 200 + except Exception as e: + logger.error(f"Error updating tracking flag: {e}") + return jsonify({'error': str(e)}), 500 + + +@lan_spy_bp.route('/device//surveillance', methods=['POST']) +def toggle_surveillance(mac): + """Toggle surveillance device flag.""" + try: + value = request.json.get('value', True) if request.json else True + update_device_flag(mac, 'surveillance_device', value) + return jsonify({'status': 'updated', 'mac': mac, 'surveillance_device': value}), 200 + except Exception as e: + logger.error(f"Error updating surveillance flag: {e}") + return jsonify({'error': str(e)}), 500 + + +@lan_spy_bp.route('/oui/update', methods=['POST']) +def update_oui(): + """Download and update OUI database.""" + try: + _emit_event('oui_update_start', {'message': 'Downloading OUI database...'}) + result = update_oui_database() + _emit_event('oui_update_complete', result) + return jsonify(result), 200 + except Exception as e: + logger.error(f"Error updating OUI: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@lan_spy_bp.route('/events', methods=['GET']) +def events(): + """SSE event stream for real-time updates.""" + def event_stream(): + while True: + try: + # Get next event with timeout + event = lan_spy_state['event_queue'].get(timeout=30) + yield f"data: {json.dumps(event)}\n\n" + except queue.Empty: + # Keep connection alive + yield ": keepalive\n\n" + except Exception as e: + logger.error(f"Event stream error: {e}") + break + + return Response( + event_stream(), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no' + } + ) diff --git a/setup.sh b/setup.sh old mode 100755 new mode 100644 index 148fd6f..9575810 --- a/setup.sh +++ b/setup.sh @@ -245,6 +245,10 @@ check_tools() { check_required "hcxdumptool" "PMKID capture" hcxdumptool check_required "hcxpcapngtool" "PMKID/pcapng conversion" hcxpcapngtool + echo + info "Network:" + check_required "nmap" "Network scanner (required for LAN SPY)" nmap + echo info "Bluetooth:" check_required "bluetoothctl" "Bluetooth controller CLI" bluetoothctl diff --git a/static/css/lan_spy.css b/static/css/lan_spy.css new file mode 100644 index 0000000..69ebe36 --- /dev/null +++ b/static/css/lan_spy.css @@ -0,0 +1,32 @@ +#lanSpyProgressBarContainer { + width: 100%; + height: 4px; + background: #222; + margin-top: 8px; + border-radius: 2px; +} +#lanSpyProgressBar { + width: 0%; + height: 100%; + background: #00ff00; + transition: width 1s linear; +} + + +.hidden-panel { + display: none !important; +} + + +.full-width-detail { + flex: 1 !important; + width: 100% !important; + display: block !important; +} + + +.lan-spy-main-content { + display: flex; + gap: 20px; + height: calc(100vh - 100px);/ +} \ No newline at end of file diff --git a/static/css/modes/lan_spy_dashboard.css b/static/css/modes/lan_spy_dashboard.css new file mode 100644 index 0000000..7aba811 --- /dev/null +++ b/static/css/modes/lan_spy_dashboard.css @@ -0,0 +1,647 @@ +/* LAN SPY Dashboard Styling - Enhanced & Polished */ + +.lan-spy-container { + display: flex; + height: 100%; + width: 100%; + gap: 0; + background: linear-gradient(135deg, #0a0e27 0%, #0d1117 100%); + color: var(--accent-green); + font-family: 'JetBrains Mono', 'Courier New', monospace; + overflow: hidden; + box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.5); + box-sizing: border-box; +} + +/* Sidebar */ +.lan-spy-sidebar { + width: 100%; + flex-shrink: 0; + border-right: 2px solid var(--accent-green); + overflow-y: auto; + background: linear-gradient(180deg, #0d1117 0%, #0a0e27 100%); + padding: 0; + display: flex; + flex-direction: column; + box-shadow: 2px 0 12px rgba(0, 255, 65, 0.1); +} + +.lan-spy-header { + padding: 18px; + border-bottom: 2px solid var(--accent-green); + background: linear-gradient(135deg, #161b22 0%, #0d1117 100%); + box-shadow: 0 4px 12px rgba(0, 255, 65, 0.15); + box-sizing: border-box; + flex-shrink: 0; + min-height: 80px; +} + +.lan-spy-header h2 { + margin: 0 0 12px 0; + font-size: 15px; + color: var(--text-primary); + text-transform: uppercase; + letter-spacing: 2px; + font-weight: 700; + text-shadow: 0 0 10px rgba(0, 255, 65, 0.3); +} + +.lan-spy-controls { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.lan-spy-controls .btn { + flex: 1; + min-width: 80px; + padding: 7px 11px; + font-size: 11px; + border: 1.5px solid var(--accent-green); + background: var(--accent-green); + color: white; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; + font-family: 'JetBrains Mono', monospace; + border-radius: 3px; + font-weight: 600; + text-transform: uppercase; +} + +.lan-spy-controls .btn:hover { + background: var(--accent-green); + color: white; + box-shadow: 0 4px 12px rgba(0, 255, 65, 0.4), inset 0 1px 2px rgba(255, 255, 255, 0.2); +} + +.lan-spy-controls .btn:active { + transform: none; +} + +.lan-spy-controls .btn .icon { + margin-right: 4px; + display: inline-block; +} + +/* Network Input */ +.lan-spy-network-input { + padding: 14px; + border-bottom: 1px solid #1f6feb; + background: rgba(15, 23, 42, 0.8); + flex-shrink: 0; + box-sizing: border-box; +} + +.lan-spy-network-input label { + display: block; + font-size: 10px; + color: var(--text-primary); + text-transform: uppercase; + margin-bottom: 7px; + font-weight: 700; + letter-spacing: 1px; +} + +.lan-spy-network-input input { + width: 100%; + padding: 7px 10px; + background: linear-gradient(135deg, #0d1117 0%, #161b22 100%); + border: 1.5px solid #30363d; + color: var(--accent-green); + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + box-sizing: border-box; + border-radius: 3px; + transition: all 0.2s ease; +} + +.lan-spy-network-input input:focus { + outline: none; + border-color: var(--accent-green); + box-shadow: 0 0 10px rgba(0, 255, 65, 0.4), inset 0 0 5px rgba(0, 255, 65, 0.1); + background: linear-gradient(135deg, #1a2030 0%, #0d1117 100%); +} + +.lan-spy-network-input small { + display: block; + margin-top: 5px; + font-size: 9px; + color: #8b949e; + font-style: italic; +} + +/* Device List */ +.lan-spy-device-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 8px 0; + box-sizing: border-box; + min-height: 0; +} + +.lan-spy-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: #8b949e; + text-align: center; + padding: 20px; +} + +.lan-spy-empty-state p { + margin: 5px 0; + font-size: 14px; +} + +.lan-spy-empty-state p:first-child { + font-size: 32px; + margin-bottom: 10px; + opacity: 0.7; +} + +.lan-spy-empty-state small { + font-size: 10px; + color: #6e7681; +} + +.lan-spy-device-item { + padding: 12px 10px; + border-bottom: 1px solid #21262d; + border-left: 3px solid transparent; + cursor: pointer; + transition: background-color 0.15s ease, border-left-color 0.15s ease; + margin: 0 4px; + border-radius: 0 3px 3px 0; +} + +.lan-spy-device-item:hover { + background: linear-gradient(90deg, rgba(31, 111, 235, 0.15) 0%, rgba(0, 255, 65, 0.08) 100%); + border-left-color: var(--accent-green); +} + +.lan-spy-device-item.active { + background: linear-gradient(90deg, rgba(31, 111, 235, 0.25) 0%, rgba(0, 255, 65, 0.15) 100%); + border-left-color: var(--accent-green); + box-shadow: inset 2px 0 8px rgba(0, 255, 65, 0.2), 0 2px 8px rgba(0, 255, 65, 0.2); +} + +.lan-spy-device-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 10px; + margin-bottom: 6px; +} + +.lan-spy-device-primary { + font-size: 12px; + font-weight: 600; + color: var(--accent-green); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + text-shadow: 0 0 5px rgba(0, 255, 65, 0.2); +} + +.lan-spy-device-secondary { + font-size: 10px; + color: #8b949e; + display: flex; + flex-direction: column; + gap: 2px; +} + +.device-class { + color: #888; + font-weight: 500; +} + +.device-manufacturer { + color: #888; + opacity: 0.9; +} + +.lan-spy-device-badge { + display: inline-block; + padding: 3px 7px; + font-size: 9px; + border-radius: 3px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + white-space: nowrap; +} + +.lan-spy-device-badge.green { + background: linear-gradient(135deg, #238636 0%, var(--accent-green) 100%); + color: #aaffc1; + border: 1px solid var(--accent-green); +} + +.lan-spy-device-badge.amber { + background: linear-gradient(135deg, #9e6a03 0%, #bf8700 100%); + color: #ffdf5d; + border: 1px solid #d29922; +} + +.lan-spy-device-badge.red { + background: linear-gradient(135deg, #da3633 0%, #f85149 100%); + color: #ffebe6; + border: 1px solid #ff3366; +} + +/* Main Content Area */ +.lan-spy-main { + width: 100%; + height: 100%; + flex-shrink: 0; + display: flex; + flex-direction: column; + padding: 25px; + overflow-y: auto; + background: linear-gradient(135deg, #0a0e27 0%, #0d1117 100%); + gap: 20px; +} + +.lan-spy-detail-card { + flex: 1; + background: linear-gradient(135deg, #0d1117 0%, #161b22 100%); + border: 2px solid #1f6feb; + border-radius: 4px; + padding: 25px; + overflow-y: auto; + box-shadow: 0 8px 24px rgba(31, 111, 235, 0.15), inset 0 1px 2px rgba(255, 255, 255, 0.05); + box-sizing: border-box; + min-height: 0; +} + +.lan-spy-detail-card:hover { + border-color: #58a6ff; +} + +.lan-spy-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: #8b949e; + text-align: center; + gap: 10px; +} + +.lan-spy-placeholder p { + margin: 0; + font-size: 14px; +} + +.lan-spy-placeholder p:first-child { + font-size: 28px; + opacity: 0.6; +} + +/* Device Details */ +.lan-spy-detail-content { + color: var(--accent-green); +} + +.lan-spy-detail-section { + margin-bottom: 22px; + padding-bottom: 18px; + border-bottom: 1px solid #21262d; +} + +.lan-spy-detail-section:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.lan-spy-detail-title { + font-size: 11px; + color: #58a6ff; + text-transform: uppercase; + font-weight: 700; + margin-bottom: 12px; + letter-spacing: 1.5px; + padding-bottom: 8px; + border-bottom: 1px solid #1f6feb; +} + +.lan-spy-detail-row { + display: flex; + justify-content: space-between; + padding: 8px 0; + font-size: 12px; + border-bottom: 1px solid #30363d; + transition: all 0.2s ease; +} + +.lan-spy-detail-row:hover { + background: rgba(0, 255, 65, 0.03); + padding-left: 4px; +} + +.lan-spy-detail-row:last-child { + border-bottom: none; +} + +.lan-spy-detail-label { + color: #8b949e; + flex: 0 0 40%; + font-weight: 500; +} + +.lan-spy-detail-value { + color: var(--accent-green); + flex: 1; + text-align: right; + word-break: break-all; + font-family: 'JetBrains Mono', monospace; + text-shadow: 0 0 5px rgba(0, 255, 65, 0.2); +} + +.lan-spy-risk-index { + font-size: 14px; + font-weight: 700; + padding: 10px 12px; + border-radius: 4px; + display: inline-block; + margin: 12px 0; + letter-spacing: 0.5px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.lan-spy-risk-index.green { + background: linear-gradient(135deg, #238636 0%, #2ea043 100%); + color: #aaffc1; + border: 1px solid #3fb950; +} + +.lan-spy-risk-index.amber { + background: linear-gradient(135deg, #9e6a03 0%, #bf8700 100%); + color: #ffdf5d; + border: 1px solid #d29922; +} + +.lan-spy-risk-index.red { + background: linear-gradient(135deg, #da3633 0%, #f85149 100%); + color: #ffebe6; + border: 1px solid #ff3366; +} + +/* Action Buttons */ +.lan-spy-action-buttons { + display: flex; + gap: 12px; +} + +.lan-spy-action-buttons .btn { + flex: 1; + padding: 11px 16px; + background: linear-gradient(135deg, transparent 0%, rgba(0, 255, 65, 0.05) 100%); + border: 1.5px solid var(--accent-green); + color: var(--accent-green); + cursor: pointer; + border-radius: 4px; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; + text-transform: uppercase; + font-weight: 700; + letter-spacing: 0.5px; +} + +.lan-spy-action-buttons .btn:hover { + background: linear-gradient(135deg, var(--accent-green) 0%, #00dd38 100%); + color: #0d1117; + box-shadow: 0 6px 16px rgba(0, 255, 65, 0.35), inset 0 1px 2px rgba(255, 255, 255, 0.2); +} + +.lan-spy-action-buttons .btn:active { + transform: translateY(0); +} + +.lan-spy-action-buttons .btn.btn-danger { + border-color: #ff3366; + color: #ff3366; + background: linear-gradient(135deg, transparent 0%, rgba(255, 123, 114, 0.05) 100%); +} + +.lan-spy-action-buttons .btn.btn-info { + border-color: #58a6ff; + color: #58a6ff; + background: linear-gradient(135deg, transparent 0%, rgba(88, 166, 255, 0.05) 100%); +} + +.lan-spy-action-buttons .btn .icon { + margin-right: 6px; + display: inline-block; +} + +/* Status Loader (Non-blocking Corner Notification) */ +.lan-spy-loader { + position: fixed; + bottom: 24px; + right: 24px; + min-width: 290px; + max-width: 340px; + background: linear-gradient(135deg, #0d1117 0%, #161b22 100%); + border: 2px solid var(--accent-green); + border-radius: 4px; + padding: 16px; + z-index: 5000; + display: none; + flex-direction: column; + gap: 12px; + box-shadow: 0 8px 24px rgba(0, 255, 65, 0.25), inset 0 1px 2px rgba(255, 255, 255, 0.05); + animation: slideInRight 0.3s ease-out; +} + +.lan-spy-loader.visible { + display: flex; +} + +.lan-spy-loader.hiding { + animation: slideOutRight 0.3s ease-in forwards; +} + +@keyframes slideInRight { + from { + transform: translateX(420px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideOutRight { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(420px); + opacity: 0; + } +} + +.lan-spy-spinner-container { + display: flex; + align-items: center; + gap: 12px; +} + +.lan-spy-spinner { + width: 24px; + height: 24px; + border: 2.5px solid #30363d; + border-top-color: var(--accent-green); + border-radius: 50%; + animation: spin 1s linear infinite; + flex-shrink: 0; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.lan-spy-loader-text { + font-size: 13px; + color: var(--accent-green); + font-weight: 700; + font-family: 'JetBrains Mono', monospace; + text-shadow: 0 0 8px rgba(0, 255, 65, 0.3); +} + +.lan-spy-loader-subtext { + font-size: 11px; + color: #58a6ff; + font-family: 'JetBrains Mono', monospace; + margin-top: -6px; +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .lan-spy-container { + flex-direction: column; + } + + .lan-spy-sidebar { + width: 100%; + height: 40%; + border-right: none; + border-bottom: 2px solid var(--accent-green); + } + + .lan-spy-main { + width: 100%; + height: 60%; + } +} + +@media (max-width: 768px) { + .lan-spy-loader { + bottom: 16px; + right: 16px; + min-width: 270px; + max-width: 300px; + } + + .lan-spy-main { + padding: 15px; + } + + .lan-spy-detail-card { + padding: 15px; + } + + .lan-spy-detail-row { + flex-direction: column; + } + + .lan-spy-detail-value { + text-align: left; + margin-top: 4px; + } + + .lan-spy-controls { + flex-direction: column; + } + + .lan-spy-controls .btn { + width: 100%; + } + + .lan-spy-action-buttons { + flex-direction: column; + } +} + +/* Scrollbar Styling */ +.lan-spy-sidebar::-webkit-scrollbar, +.lan-spy-main::-webkit-scrollbar, +.lan-spy-device-list::-webkit-scrollbar, +.lan-spy-detail-card::-webkit-scrollbar { + width: 8px; +} + +.lan-spy-sidebar::-webkit-scrollbar-track, +.lan-spy-main::-webkit-scrollbar-track, +.lan-spy-device-list::-webkit-scrollbar-track, +.lan-spy-detail-card::-webkit-scrollbar-track { + background: rgba(13, 17, 23, 0.5); +} + +.lan-spy-sidebar::-webkit-scrollbar-thumb, +.lan-spy-main::-webkit-scrollbar-thumb, +.lan-spy-device-list::-webkit-scrollbar-thumb, +.lan-spy-detail-card::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, var(--accent-green) 0%, #00dd38 100%); + border-radius: 4px; + border: 2px solid #0d1117; +} + +.lan-spy-sidebar::-webkit-scrollbar-thumb:hover, +.lan-spy-main::-webkit-scrollbar-thumb:hover, +.lan-spy-device-list::-webkit-scrollbar-thumb:hover, +.lan-spy-detail-card::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, #58a6ff 0%, #79c0ff 100%); +} + +/* Toggle Switches for Flags */ +.lan-spy-toggle { + display: flex; + align-items: center; + gap: 12px; + margin: 12px 0; + padding: 10px; + background: rgba(0, 255, 65, 0.03); + border-radius: 3px; + border-left: 2px solid var(--accent-green); +} + +.lan-spy-toggle label { + font-size: 12px; + color: #8b949e; + margin: 0; + flex: 1; + font-weight: 500; +} + +.lan-spy-toggle input[type="checkbox"] { + width: 44px; + height: 24px; + cursor: pointer; + accent-color: var(--accent-green); + flex-shrink: 0; +} + diff --git a/static/js/core/app.js b/static/js/core/app.js index f6a7d49..9ec4830 100644 --- a/static/js/core/app.js +++ b/static/js/core/app.js @@ -167,6 +167,7 @@ function switchMode(mode) { document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none'; document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none'; document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none'; + document.getElementById('lanSpyDevices').style.display = mode === 'lan_spy' ? 'block' : 'none'; // Update output panel title based on mode const titles = { diff --git a/static/js/lan_spy.js b/static/js/lan_spy.js new file mode 100644 index 0000000..6cee70d --- /dev/null +++ b/static/js/lan_spy.js @@ -0,0 +1,591 @@ +/** + * LAN SPY Frontend + * Network device discovery and analysis + */ + +// Global state +let lanSpyDevices = []; +let lanSpyScanRunning = false; +let lanSpyScanStatus = 'Ready'; +let lanSpyEventSource = null; +let lanSpySelectedMac = null; + +/** + * Initialize LAN SPY mode + */ +function switchToLanSpy() { + console.log('Switching to LAN SPY mode'); + + // Load devices from backend + refreshLanSpyDevices(); + + // Connect to SSE stream + connectToLanSpyEvents(); +} + +/** + * Start network scan + */ +let lanSpyTimeout = null; +let lanSpyCountdownInterval = null; + +function startLanSpyScan() { + const networkInput = document.getElementById('lanSpyNetworkInput'); + const network = networkInput.value.trim() || null; + + if (network && !isValidCIDR(network)) { + showNotification('Invalid CIDR format. Use: 192.168.1.0/24', 'error'); + return; + } + + // Safety: Clear any existing timers + if (lanSpyTimeout) clearTimeout(lanSpyTimeout); + if (lanSpyCountdownInterval) clearInterval(lanSpyCountdownInterval); + + lanSpyScanRunning = true; + showLanSpyLoader('Initializing scan...', true); + + fetch('/lan_spy/scan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ network: network }) + }) + .then(r => r.json()) + .then(data => { + lanSpyScanStatus = 'Scanning...'; + let secondsRemaining = 90; + + lanSpyCountdownInterval = setInterval(() => { + secondsRemaining--; + const mins = Math.floor(secondsRemaining / 60); + const secs = secondsRemaining % 60; + const timerLabel = `${mins}:${secs.toString().padStart(2, '0')}`; + + updateLanSpyLoader(`Scanning... [${timerLabel}]`, lanSpyDevices.length); + + if (secondsRemaining <= 0) { + stopLanSpyScan(); + } + }, 1000); + }) + .catch(err => { + console.error('Scan error:', err); + showNotification('Failed to start scan', 'error'); + hideLanSpyLoader(); + lanSpyScanRunning = false; + }); +} + +/** + * Stop network scan + */ +function stopLanSpyScan() { + if (lanSpyTimeout) clearTimeout(lanSpyTimeout); + if (lanSpyCountdownInterval) clearInterval(lanSpyCountdownInterval); + + lanSpyTimeout = null; + lanSpyCountdownInterval = null; + + fetch('/lan_spy/scan/stop', { + method: 'POST' + }) + .then(r => r.json()) + .then(data => { + console.log('Scan stopped:', data); + lanSpyScanRunning = false; + showLanSpyLoader('Stopping scan...', true) + refreshLanSpyDevices(); + setTimeout(() => { + showLanSpyLoader('Scan stopped', false); + }, 3000); + }) + .catch(err => console.error('Stop error:', err)); +} + +function cleanLanSpyDevices() { + if (!confirm('Are you sure you want to clean all discovered devices? This will not affect the ongoing scan.')) { + return; + } + + fetch('/lan_spy/devices/clean', { + method: 'POST' + }) + .then(r => r.json()) + .then(data => { + refreshLanSpyDevices(); + }) + .catch(err => console.error('Clean error:', err)); +} + +/** + * Refresh device list + */ +function refreshLanSpyDevices() { + fetch('/lan_spy/devices') + .then(r => r.json()) + .then(data => { + lanSpyDevices = data.devices || []; + renderLanSpyDeviceList(); + }) + .catch(err => console.error('Refresh error:', err)); +} + +/** + * Show non-blocking corner loader notification + */ +function showLanSpyLoader(message, show = true) { + let loader = document.getElementById('lanSpyLoader'); + + if (!loader) { + // Create loader element + loader = document.createElement('div'); + loader.id = 'lanSpyLoader'; + loader.className = 'lan-spy-loader'; + loader.innerHTML = ` +
+
+
+
Loading...
+
+
+ `; + document.body.appendChild(loader); + } + + if (show) { + document.getElementById('lanSpyLoaderText').textContent = message; + loader.classList.add('visible'); + loader.classList.remove('hiding'); + } else { + hideLanSpyLoader(); + } +} + +/** + * Update loader status in real-time + */ +function updateLanSpyLoader(message, deviceCount = null) { + const loaderText = document.getElementById('lanSpyLoaderText'); + const loaderSubtext = document.getElementById('lanSpyLoaderSubtext'); + + if (loaderText) { + loaderText.textContent = message; + } +} + +/** + * Hide loader with animation + */ +function hideLanSpyLoader() { + const loader = document.getElementById('lanSpyLoader'); + if (loader) { + loader.classList.add('hiding'); + setTimeout(() => { + loader.classList.remove('visible'); + loader.classList.remove('hiding'); + }, 300); + } +} + +/** + * Connect to SSE event stream + */ +function connectToLanSpyEvents() { + if (lanSpyEventSource) { + lanSpyEventSource.close(); + } + + lanSpyEventSource = new EventSource('/lan_spy/events'); + + lanSpyEventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + switch (data.type) { + case 'scan_start': + console.log('Scan started:', data); + showLanSpyLoader('Initializing scan...', true); + lanSpyScanRunning = true; + break; + + case 'scan_status': + lanSpyScanStatus = data.data.message; + updateLanSpyLoader(data.data.message, lanSpyDevices.length); + console.log('Status:', data.data.message); + break; + + case 'scan_complete': + console.log('Server signals scan complete. Stopping frontend timers.'); + + if (lanSpyCountdownInterval) { + clearInterval(lanSpyCountdownInterval); + lanSpyCountdownInterval = null; + } + + if (lanSpyTimeout) { + clearTimeout(lanSpyTimeout); + lanSpyTimeout = null; + } + + lanSpyScanRunning = false; + lanSpyScanStatus = 'Ready'; + hideLanSpyLoader(); + + refreshLanSpyDevices(); + + showNotification(`Scan finished: ${data.data.devices_found} devices found`, 'success'); + break; + + case 'device_found': + // Real-time update: Add to local array if not already there + const deviceExists = lanSpyDevices.some(d => d.mac === data.data.mac && d.internal_ip === data.data.internal_ip); + if (!deviceExists) { + lanSpyDevices.push(data.data); + renderLanSpyDeviceList(); // Re-render the list immediately + } + updateLanSpyLoader(lanSpyScanStatus, lanSpyDevices.length); + break; + + case 'scan_error': + lanSpyScanRunning = false; + hideLanSpyLoader(); + showNotification('Scan error: ' + data.data.message, 'error'); + console.error('Scan error:', data.data); + break; + + case 'oui_update_complete': + showNotification('OUI database updated', 'success'); + console.log('OUI updated:', data.data); + break; + } + } catch (e) { + console.error('Event parsing error:', e); + } + }; + + lanSpyEventSource.onerror = (err) => { + console.error('SSE error:', err); + lanSpyEventSource.close(); + + // Reconnect after delay + setTimeout(() => { + console.log('Reconnecting to SSE stream...'); + connectToLanSpyEvents(); + }, 5000); + }; +} + +/** + * Render device list sidebar (deduplicated by MAC) + */ +function renderLanSpyDeviceList() { + const listContainer = document.getElementById('lanSpyDeviceList'); + + if (!lanSpyDevices || lanSpyDevices.length === 0) { + listContainer.innerHTML = '

🔍

No devices found

Start a scan to discover devices
'; + return; + } + + // Deduplicate devices by MAC address (keep last occurrence) + const uniqueDevices = {}; + lanSpyDevices.forEach(device => { + const compositeKey = `${device.mac}-${device.ip}`; + uniqueDevices[compositeKey] = device; + }); + + const deviceList = Object.values(uniqueDevices); + + // Sort by risk index (highest first) + deviceList.sort((a, b) => (b.risk_index || 0) - (a.risk_index || 0)); + + listContainer.innerHTML = deviceList.map(device => { + const riskBadge = getRiskBadge(device.risk_index || 0); + const isActive = lanSpySelectedMac === device.mac ? 'active' : ''; + const riskLevel = (device.risk_index || 0).toFixed(2); + + return ` +
+
+
+ ${device.hostname || device.ip} +
+
+ ${riskLevel} +
+
+
+ ${device.device_class} + ${device.manufacturer} +
+
+ `; + }).join(''); +} + +/** + * Select device to view details + */ +/** + * Select device to view details + */ +function selectLanSpyDevice(event, mac) { // Added event parameter + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + + lanSpySelectedMac = mac; + + // Update active state + document.querySelectorAll('.lan-spy-device-item').forEach(item => { + item.classList.remove('active'); + }); + + // Use event.currentTarget safely + if (event && event.currentTarget) { + event.currentTarget.classList.add('active'); + } + + displayLanSpyDeviceDetails(mac); +} + +/** + * Display selected device details + */ +function displayLanSpyDeviceDetails(mac) { + const device = lanSpyDevices.find(d => `${d.mac}-${d.ip}` === mac); + + if (!device) { + console.error('Device not found:', mac); + return; + } + + const riskColor = getRiskColor(device.risk_index || 0); + const riskBadge = getRiskBadge(device.risk_index || 0); + + const html = ` +
+ +
+
Subject Identity
+
+ MAC Address + ${device.mac} +
+
+ Hostname + ${device.hostname || 'N/A'} +
+
+ Manufacturer + ${device.mfr} +
+
+ Device Class + ${device.class} +
+
+ Signal ID + ${device.signal_id || 'N/A'} +
+
+ + +
+
Network Telemetry
+
+ Internal IP + ${device.ip} +
+
+ Bandwidth + ${device.bandwidth_utilization} +
+
+ Total Bytes + ${device.bytes_total.toLocaleString()} +
+
+ Current Flow (kbps) + ${device.current_flow_kbps.toFixed(2)} +
+
+ + +
+
External Intelligence
+
+ Primary Uplink + ${device.primary_uplink} +
+
+ Destination Country + ${device.destination_country} +
+
+ Protocol + ${device.protocol_detected} +
+
+ + + + +
+
Activity
+
+ Last Seen + ${new Date(device.last_seen).toLocaleString()} +
+
+
+ `; + + document.getElementById('lanSpyDetailCard').innerHTML = html; +} + +/** + * Toggle tracking or surveillance flag + */ +function toggleLanSpyFlag(mac, flagType, value) { + const endpoint = flagType === 'tracking' ? 'tracking' : 'surveillance'; + + fetch(`/lan_spy/device/${mac}/${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ value: value }) + }) + .then(r => r.json()) + .then(data => { + console.log('Flag updated:', data); + + // Update local device + const device = lanSpyDevices.find(d => `${d.mac}-${d.internal_ip}` === mac); + if (device) { + if (flagType === 'tracking') { + device.tracking_device = value; + } else { + device.surveillance_device = value; + } + } + + showNotification(`${flagType} device flag updated`, 'success'); + }) + .catch(err => { + console.error('Flag update error:', err); + showNotification('Failed to update flag', 'error'); + }); +} + +/** + * Kill all running processes + */ +function killAllLanSpyProcesses() { + if (!confirm('Are you sure you want to kill all LAN SPY processes?')) { + return; + } + + console.log('Killing all processes'); + stopLanSpyScan(); + showNotification('Processes terminated', 'info'); +} + +/** + * Update OUI database + */ +function updateLanSpyOUI() { + console.log('Updating OUI database'); + showLanSpyLoader('Downloading OUI database...', true); + + fetch('/lan_spy/oui/update', { + method: 'POST' + }) + .then(r => r.json()) + .then(data => { + console.log('OUI update:', data); + hideLanSpyLoader(); + if (data.status === 'success') { + showNotification('OUI database updated successfully', 'success'); + } else { + showNotification('OUI update failed: ' + data.message, 'error'); + } + }) + .catch(err => { + console.error('OUI update error:', err); + hideLanSpyLoader(); + showNotification('Failed to update OUI database', 'error'); + }); +} + +/** + * Get risk badge color (green/amber/red) + */ +function getRiskBadge(riskIndex) { + if (riskIndex < 0.40) return 'green'; + if (riskIndex < 0.70) return 'amber'; + return 'red'; +} + +/** + * Get risk color for styling + */ +function getRiskColor(riskIndex) { + const badge = getRiskBadge(riskIndex); + const colors = { + 'green': '#28a745', + 'amber': '#ffc107', + 'red': '#dc3545' + }; + return colors[badge] || '#6c757d'; +} + +/** + * Validate CIDR notation + */ +function isValidCIDR(cidr) { + const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/; + return cidrRegex.test(cidr); +} + +/** + * Show notification (using app's notification system) + */ +function showNotification(message, type = 'info') { + console.log(`[${type.toUpperCase()}] ${message}`); + + // If app has notification system, use it + if (window.showAppNotification) { + window.showAppNotification(message, type); + } +} + +// Initialize when loaded +document.addEventListener('DOMContentLoaded', () => { + console.log('LAN SPY module loaded'); +}); diff --git a/templates/index.html b/templates/index.html index a72830c..41109f8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -52,6 +52,7 @@ + @@ -293,6 +294,16 @@

WebSDR + @@ -604,6 +615,7 @@

SDR Device

{% include 'partials/modes/tscm.html' %} + {% include 'partials/modes/lan_spy.html' %} {% include 'partials/modes/analytics.html' %} {% include 'partials/modes/ais.html' %} @@ -1669,6 +1681,25 @@

Signal Strength (SNR dB-Hz)

+ + +