diff --git a/app.py b/app.py index 373b368..b2b0ad4 100644 --- a/app.py +++ b/app.py @@ -39,6 +39,7 @@ from utils.constants import ( MAX_VESSEL_AGE_SECONDS, MAX_DSC_MESSAGE_AGE_SECONDS, MAX_DEAUTH_ALERTS_AGE_SECONDS, + MAX_GSM_AGE_SECONDS, QUEUE_MAX_SIZE, ) import logging @@ -105,7 +106,7 @@ def inject_offline_settings(): 'enabled': get_setting('offline.enabled', False), 'assets_source': get_setting('offline.assets_source', 'cdn'), 'fonts_source': get_setting('offline.fonts_source', 'cdn'), - 'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'), + 'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'), 'tile_server_url': get_setting('offline.tile_server_url', '') } } @@ -181,6 +182,15 @@ deauth_detector = None deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) deauth_detector_lock = threading.Lock() +# GSM Spy +gsm_spy_process = None +gsm_spy_monitor_process = None # For grgsm_livemon when monitoring specific tower +gsm_spy_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) +gsm_spy_lock = threading.Lock() +gsm_spy_active_device = None +gsm_spy_selected_arfcn = None +gsm_spy_region = 'Americas' # Default band + # ============================================ # GLOBAL STATE DICTIONARIES # ============================================ @@ -213,6 +223,16 @@ dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_ # Deauth alerts - using DataStore for automatic cleanup deauth_alerts = DataStore(max_age_seconds=MAX_DEAUTH_ALERTS_AGE_SECONDS, name='deauth_alerts') +# GSM Spy data stores +gsm_spy_towers = DataStore( + max_age_seconds=MAX_GSM_AGE_SECONDS, + name='gsm_spy_towers' +) +gsm_spy_devices = DataStore( + max_age_seconds=MAX_GSM_AGE_SECONDS, + name='gsm_spy_devices' +) + # Satellite state satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated) @@ -225,6 +245,8 @@ cleanup_manager.register(adsb_aircraft) cleanup_manager.register(ais_vessels) cleanup_manager.register(dsc_messages) cleanup_manager.register(deauth_alerts) +cleanup_manager.register(gsm_spy_towers) +cleanup_manager.register(gsm_spy_devices) # ============================================ # SDR DEVICE REGISTRY @@ -278,13 +300,13 @@ def get_sdr_device_status() -> dict[int, str]: # ============================================ @app.before_request -def require_login(): - # Routes that don't require login (to avoid infinite redirect loop) - allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check'] - - # Allow audio streaming endpoints without session auth - if request.path.startswith('/listening/audio/'): - return None +def require_login(): + # Routes that don't require login (to avoid infinite redirect loop) + allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check'] + + # Allow audio streaming endpoints without session auth + if request.path.startswith('/listening/audio/'): + return None # Controller API endpoints use API key auth, not session auth # Allow agent push/pull endpoints without session login @@ -652,6 +674,7 @@ 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 aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process + global gsm_spy_process, gsm_spy_monitor_process # Import adsb and ais modules to reset their state from routes import adsb as adsb_module @@ -663,7 +686,8 @@ def kill_all() -> Response: 'rtl_fm', 'multimon-ng', 'rtl_433', 'airodump-ng', 'aireplay-ng', 'airmon-ng', 'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher', - 'hcitool', 'bluetoothctl' + 'hcitool', 'bluetoothctl', 'grgsm_scanner', 'grgsm_livemon', + 'tshark' ] for proc in processes_to_kill: @@ -727,6 +751,24 @@ def kill_all() -> Response: except Exception: pass + # Reset GSM Spy state + with gsm_spy_lock: + if gsm_spy_process: + try: + safe_terminate(gsm_spy_process, 'grgsm_scanner') + killed.append('grgsm_scanner') + except Exception: + pass + gsm_spy_process = None + + if gsm_spy_monitor_process: + try: + safe_terminate(gsm_spy_monitor_process, 'grgsm_livemon') + killed.append('grgsm_livemon') + except Exception: + pass + gsm_spy_monitor_process = None + # Clear SDR device registry with sdr_device_registry_lock: sdr_device_registry.clear() diff --git a/config.py b/config.py index 029ef2c..25efa72 100644 --- a/config.py +++ b/config.py @@ -200,6 +200,14 @@ UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6) ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin') ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin') +# GSM Spy settings +GSM_OPENCELLID_API_KEY = _get_env('GSM_OPENCELLID_API_KEY', 'pk.68c92ecb85886de7b50ed5a4c73f9504') +GSM_OPENCELLID_API_URL = _get_env('GSM_OPENCELLID_API_URL', 'https://opencellid.org/cell/get') +GSM_API_DAILY_LIMIT = _get_env_int('GSM_API_DAILY_LIMIT', 1000) +GSM_TA_METERS_PER_UNIT = _get_env_int('GSM_TA_METERS_PER_UNIT', 554) +GSM_UPDATE_INTERVAL = _get_env_float('GSM_UPDATE_INTERVAL', 2.0) +GSM_MAX_AGE_SECONDS = _get_env_int('GSM_MAX_AGE_SECONDS', 300) + def configure_logging() -> None: """Configure application logging.""" logging.basicConfig( diff --git a/routes/__init__.py b/routes/__init__.py index 8436739..6c45d3c 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -26,6 +26,7 @@ def register_blueprints(app): from .offline import offline_bp from .updater import updater_bp from .sstv import sstv_bp + from .gsm_spy import gsm_spy_bp app.register_blueprint(pager_bp) app.register_blueprint(sensor_bp) @@ -51,6 +52,7 @@ def register_blueprints(app): app.register_blueprint(offline_bp) # Offline mode settings app.register_blueprint(updater_bp) # GitHub update checking app.register_blueprint(sstv_bp) # ISS SSTV decoder + app.register_blueprint(gsm_spy_bp) # GSM cellular intelligence # Initialize TSCM state with queue and lock from app import app as app_module diff --git a/routes/gsm_spy.py b/routes/gsm_spy.py new file mode 100644 index 0000000..71c3498 --- /dev/null +++ b/routes/gsm_spy.py @@ -0,0 +1,1171 @@ +"""GSM Spy route handlers for cellular tower and device tracking.""" + +from __future__ import annotations + +import json +import logging +import queue +import re +import subprocess +import threading +import time +from datetime import datetime, timedelta +from typing import Any + +import requests +from flask import Blueprint, Response, jsonify, render_template, request + +import app as app_module +import config +from config import SHARED_OBSERVER_LOCATION_ENABLED +from utils.database import get_db +from utils.sse import format_sse +from utils.validation import validate_device_index + +logger = logging.getLogger('intercept.gsm_spy') + +gsm_spy_bp = Blueprint('gsm_spy', __name__, url_prefix='/gsm_spy') + +# Regional band configurations (G-01) +REGIONAL_BANDS = { + 'Americas': { + 'GSM850': {'start': 869e6, 'end': 894e6, 'arfcn_start': 128, 'arfcn_end': 251}, + 'PCS1900': {'start': 1930e6, 'end': 1990e6, 'arfcn_start': 512, 'arfcn_end': 810} + }, + 'Europe': { + 'EGSM900': {'start': 925e6, 'end': 960e6, 'arfcn_start': 0, 'arfcn_end': 124} + }, + 'Asia': { + 'EGSM900': {'start': 925e6, 'end': 960e6, 'arfcn_start': 0, 'arfcn_end': 124}, + 'DCS1800': {'start': 1805e6, 'end': 1880e6, 'arfcn_start': 512, 'arfcn_end': 885} + } +} + +# Module state tracking +gsm_using_service = False +gsm_connected = False +gsm_towers_found = 0 +gsm_devices_tracked = 0 + + +# ============================================ +# API Usage Tracking Helper Functions +# ============================================ + +def get_api_usage_today(): + """Get OpenCellID API usage count for today.""" + from utils.database import get_setting + today = datetime.now().date().isoformat() + usage_date = get_setting('gsm.opencellid.usage_date', '') + + # Reset counter if new day + if usage_date != today: + from utils.database import set_setting + set_setting('gsm.opencellid.usage_date', today) + set_setting('gsm.opencellid.usage_count', 0) + return 0 + + return get_setting('gsm.opencellid.usage_count', 0) + + +def increment_api_usage(): + """Increment OpenCellID API usage counter.""" + from utils.database import set_setting + current = get_api_usage_today() + set_setting('gsm.opencellid.usage_count', current + 1) + return current + 1 + + +def can_use_api(): + """Check if we can make an API call within daily limit.""" + current_usage = get_api_usage_today() + return current_usage < config.GSM_API_DAILY_LIMIT + + +@gsm_spy_bp.route('/dashboard') +def dashboard(): + """Render GSM Spy dashboard.""" + return render_template( + 'gsm_spy_dashboard.html', + shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED + ) + + +@gsm_spy_bp.route('/start', methods=['POST']) +def start_scanner(): + """Start GSM scanner (G-01 BTS Scanner).""" + global gsm_towers_found, gsm_connected + + with app_module.gsm_spy_lock: + if app_module.gsm_spy_process: + return jsonify({'error': 'Scanner already running'}), 400 + + data = request.get_json() or {} + device_index = data.get('device', 0) + region = data.get('region', 'Americas') + + # Validate device index + try: + device_index = validate_device_index(device_index) + except ValueError as e: + return jsonify({'error': str(e)}), 400 + + # Claim SDR device to prevent conflicts + from app import claim_sdr_device + claim_error = claim_sdr_device(device_index, 'GSM Spy') + if claim_error: + return jsonify({'error': claim_error}), 409 + + # Get frequency range for region + bands = REGIONAL_BANDS.get(region, REGIONAL_BANDS['Americas']) + + # Build grgsm_scanner command + # Example: grgsm_scanner -d 0 --freq-range 869000000:894000000 + freq_ranges = [] + for band_name, band_info in bands.items(): + freq_ranges.append(f"{int(band_info['start'])}:{int(band_info['end'])}") + + freq_range_arg = ','.join(freq_ranges) + + try: + cmd = [ + 'grgsm_scanner', + '-d', str(device_index), + '--freq-range', freq_range_arg + ] + + logger.info(f"Starting GSM scanner: {' '.join(cmd)}") + + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + bufsize=1 + ) + + app_module.gsm_spy_process = process + app_module.gsm_spy_active_device = device_index + app_module.gsm_spy_region = region + + # Start output parsing thread + scanner_thread_obj = threading.Thread( + target=scanner_thread, + args=(process,), + daemon=True + ) + scanner_thread_obj.start() + + gsm_connected = True + + return jsonify({ + 'status': 'started', + 'device': device_index, + 'region': region + }) + + except FileNotFoundError: + from app import release_sdr_device + release_sdr_device(device_index) + return jsonify({'error': 'grgsm_scanner not found. Please install gr-gsm.'}), 500 + except Exception as e: + from app import release_sdr_device + release_sdr_device(device_index) + logger.error(f"Error starting GSM scanner: {e}") + return jsonify({'error': str(e)}), 500 + + +@gsm_spy_bp.route('/monitor', methods=['POST']) +def start_monitor(): + """Start monitoring specific tower (G-02 Decoding).""" + with app_module.gsm_spy_lock: + if app_module.gsm_spy_monitor_process: + return jsonify({'error': 'Monitor already running'}), 400 + + data = request.get_json() or {} + arfcn = data.get('arfcn') + device_index = data.get('device', app_module.gsm_spy_active_device or 0) + + if not arfcn: + return jsonify({'error': 'ARFCN required'}), 400 + + try: + # grgsm_livemon -a ARFCN -d DEVICE | tshark -i lo -Y "gsm_a.rr.timing_advance || gsm_a.tmsi || gsm_a.imsi" + grgsm_cmd = [ + 'grgsm_livemon', + '-a', str(arfcn), + '-d', str(device_index) + ] + + tshark_cmd = [ + 'tshark', + '-i', 'lo', + '-Y', 'gsm_a.rr.timing_advance || gsm_a.tmsi || gsm_a.imsi', + '-T', 'fields', + '-e', 'gsm_a.rr.timing_advance', + '-e', 'gsm_a.tmsi', + '-e', 'gsm_a.imsi', + '-e', 'gsm_a.lac', + '-e', 'gsm_a.cellid' + ] + + logger.info(f"Starting GSM monitor: {' '.join(grgsm_cmd)} | {' '.join(tshark_cmd)}") + + # Start grgsm_livemon + grgsm_proc = subprocess.Popen( + grgsm_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + + # Start tshark + tshark_proc = subprocess.Popen( + tshark_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + bufsize=1 + ) + + app_module.gsm_spy_monitor_process = tshark_proc + app_module.gsm_spy_selected_arfcn = arfcn + + # Start monitoring thread + monitor_thread_obj = threading.Thread( + target=monitor_thread, + args=(tshark_proc,), + daemon=True + ) + monitor_thread_obj.start() + + return jsonify({ + 'status': 'monitoring', + 'arfcn': arfcn, + 'device': device_index + }) + + except FileNotFoundError as e: + return jsonify({'error': f'Tool not found: {e}'}), 500 + except Exception as e: + logger.error(f"Error starting monitor: {e}") + return jsonify({'error': str(e)}), 500 + + +@gsm_spy_bp.route('/stop', methods=['POST']) +def stop_scanner(): + """Stop GSM scanner and monitor.""" + global gsm_connected + + with app_module.gsm_spy_lock: + killed = [] + + if app_module.gsm_spy_process: + try: + app_module.gsm_spy_process.terminate() + app_module.gsm_spy_process.wait(timeout=5) + killed.append('scanner') + except Exception: + try: + app_module.gsm_spy_process.kill() + except Exception: + pass + app_module.gsm_spy_process = None + + if app_module.gsm_spy_monitor_process: + try: + app_module.gsm_spy_monitor_process.terminate() + app_module.gsm_spy_monitor_process.wait(timeout=5) + killed.append('monitor') + except Exception: + try: + app_module.gsm_spy_monitor_process.kill() + except Exception: + pass + app_module.gsm_spy_monitor_process = None + + # Release SDR device + if app_module.gsm_spy_active_device is not None: + from app import release_sdr_device + release_sdr_device(app_module.gsm_spy_active_device) + logger.info(f"Released SDR device {app_module.gsm_spy_active_device}") + + app_module.gsm_spy_active_device = None + app_module.gsm_spy_selected_arfcn = None + gsm_connected = False + + return jsonify({'status': 'stopped', 'killed': killed}) + + +@gsm_spy_bp.route('/stream') +def stream(): + """SSE stream for real-time GSM updates.""" + def generate(): + """Generate SSE events.""" + last_keepalive = time.time() + + while True: + try: + # Check if scanner is still running + if not app_module.gsm_spy_process and not app_module.gsm_spy_monitor_process: + yield format_sse({'type': 'disconnected'}) + break + + # Try to get data from queue + try: + data = app_module.gsm_spy_queue.get(timeout=1) + yield format_sse(data) + last_keepalive = time.time() + except queue.Empty: + # Send keepalive if needed + if time.time() - last_keepalive > 30: + yield format_sse({'type': 'keepalive'}) + last_keepalive = time.time() + + except GeneratorExit: + break + except Exception as e: + logger.error(f"Error in GSM stream: {e}") + yield format_sse({'type': 'error', 'message': str(e)}) + break + + return Response( + generate(), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no' + } + ) + + +@gsm_spy_bp.route('/status') +def status(): + """Get current GSM Spy status.""" + api_usage = get_api_usage_today() + return jsonify({ + 'running': app_module.gsm_spy_process is not None, + 'monitoring': app_module.gsm_spy_monitor_process is not None, + 'towers_found': gsm_towers_found, + 'devices_tracked': gsm_devices_tracked, + 'device': app_module.gsm_spy_active_device, + 'region': app_module.gsm_spy_region, + 'selected_arfcn': app_module.gsm_spy_selected_arfcn, + 'api_usage_today': api_usage, + 'api_limit': config.GSM_API_DAILY_LIMIT, + 'api_remaining': config.GSM_API_DAILY_LIMIT - api_usage + }) + + +@gsm_spy_bp.route('/lookup_cell', methods=['POST']) +def lookup_cell(): + """Lookup cell tower via OpenCellID (G-05).""" + data = request.get_json() or {} + mcc = data.get('mcc') + mnc = data.get('mnc') + lac = data.get('lac') + cid = data.get('cid') + + if not all([mcc, mnc, lac, cid]): + return jsonify({'error': 'MCC, MNC, LAC, and CID required'}), 400 + + try: + # Check local cache first + with get_db() as conn: + result = conn.execute(''' + SELECT lat, lon, azimuth, range_meters, operator, radio + FROM gsm_cells + WHERE mcc = ? AND mnc = ? AND lac = ? AND cid = ? + ''', (mcc, mnc, lac, cid)).fetchone() + + if result: + return jsonify({ + 'source': 'cache', + 'lat': result['lat'], + 'lon': result['lon'], + 'azimuth': result['azimuth'], + 'range': result['range_meters'], + 'operator': result['operator'], + 'radio': result['radio'] + }) + + # Check API usage limit + if not can_use_api(): + current_usage = get_api_usage_today() + return jsonify({ + 'error': 'OpenCellID API daily limit reached', + 'usage_today': current_usage, + 'limit': config.GSM_API_DAILY_LIMIT + }), 429 + + # Call OpenCellID API + api_url = config.GSM_OPENCELLID_API_URL + params = { + 'key': config.GSM_OPENCELLID_API_KEY, + 'mcc': mcc, + 'mnc': mnc, + 'lac': lac, + 'cellid': cid, + 'format': 'json' + } + + response = requests.get(api_url, params=params, timeout=10) + + if response.status_code == 200: + cell_data = response.json() + + # Increment API usage counter + usage_count = increment_api_usage() + logger.info(f"OpenCellID API call #{usage_count} today") + + # Cache the result + conn.execute(''' + INSERT OR REPLACE INTO gsm_cells + (mcc, mnc, lac, cid, lat, lon, azimuth, range_meters, samples, radio, operator, last_verified) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ''', ( + mcc, mnc, lac, cid, + cell_data.get('lat'), + cell_data.get('lon'), + cell_data.get('azimuth'), + cell_data.get('range'), + cell_data.get('samples'), + cell_data.get('radio'), + cell_data.get('operator') + )) + conn.commit() + + return jsonify({ + 'source': 'api', + 'lat': cell_data.get('lat'), + 'lon': cell_data.get('lon'), + 'azimuth': cell_data.get('azimuth'), + 'range': cell_data.get('range'), + 'operator': cell_data.get('operator'), + 'radio': cell_data.get('radio') + }) + else: + return jsonify({'error': 'Cell not found in OpenCellID'}), 404 + + except Exception as e: + logger.error(f"Error looking up cell: {e}") + return jsonify({'error': str(e)}), 500 + + +@gsm_spy_bp.route('/detect_rogue', methods=['POST']) +def detect_rogue(): + """Analyze and flag rogue towers (G-07).""" + data = request.get_json() or {} + tower_info = data.get('tower') + + if not tower_info: + return jsonify({'error': 'Tower info required'}), 400 + + try: + is_rogue = False + reasons = [] + + # Check if tower exists in OpenCellID + mcc = tower_info.get('mcc') + mnc = tower_info.get('mnc') + lac = tower_info.get('lac') + cid = tower_info.get('cid') + + if all([mcc, mnc, lac, cid]): + with get_db() as conn: + result = conn.execute(''' + SELECT id FROM gsm_cells + WHERE mcc = ? AND mnc = ? AND lac = ? AND cid = ? + ''', (mcc, mnc, lac, cid)).fetchone() + + if not result: + is_rogue = True + reasons.append('Tower not found in OpenCellID database') + + # Check signal strength anomalies + signal = tower_info.get('signal_strength', 0) + if signal > -50: # Suspiciously strong signal + is_rogue = True + reasons.append(f'Unusually strong signal: {signal} dBm') + + # If rogue, insert into database + if is_rogue: + with get_db() as conn: + conn.execute(''' + INSERT INTO gsm_rogues + (arfcn, mcc, mnc, lac, cid, signal_strength, reason, threat_level) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + tower_info.get('arfcn'), + mcc, mnc, lac, cid, + signal, + '; '.join(reasons), + 'high' if len(reasons) > 1 else 'medium' + )) + conn.commit() + + return jsonify({ + 'is_rogue': is_rogue, + 'reasons': reasons + }) + + except Exception as e: + logger.error(f"Error detecting rogue: {e}") + return jsonify({'error': str(e)}), 500 + + +@gsm_spy_bp.route('/towers') +def get_towers(): + """Get all detected towers.""" + towers = [] + for key, tower_data in app_module.gsm_spy_towers.items(): + towers.append(tower_data) + return jsonify(towers) + + +@gsm_spy_bp.route('/devices') +def get_devices(): + """Get all tracked devices (IMSI/TMSI).""" + devices = [] + for key, device_data in app_module.gsm_spy_devices.items(): + devices.append(device_data) + return jsonify(devices) + + +@gsm_spy_bp.route('/rogues') +def get_rogues(): + """Get all detected rogue towers.""" + try: + with get_db() as conn: + results = conn.execute(''' + SELECT * FROM gsm_rogues + WHERE acknowledged = 0 + ORDER BY detected_at DESC + LIMIT 50 + ''').fetchall() + + rogues = [dict(row) for row in results] + return jsonify(rogues) + except Exception as e: + logger.error(f"Error fetching rogues: {e}") + return jsonify({'error': str(e)}), 500 + + +# ============================================ +# Advanced Features (G-08 through G-12) +# ============================================ + +@gsm_spy_bp.route('/velocity', methods=['GET']) +def get_velocity_data(): + """Get velocity vectoring data for tracked devices (G-08).""" + try: + device_id = request.args.get('device_id') + minutes = int(request.args.get('minutes', 60)) # Last 60 minutes by default + + with get_db() as conn: + # Get velocity log entries + query = ''' + SELECT * FROM gsm_velocity_log + WHERE timestamp >= datetime('now', '-' || ? || ' minutes') + ''' + params = [minutes] + + if device_id: + query += ' AND device_id = ?' + params.append(device_id) + + query += ' ORDER BY timestamp DESC LIMIT 100' + + results = conn.execute(query, params).fetchall() + velocity_data = [dict(row) for row in results] + + return jsonify(velocity_data) + except Exception as e: + logger.error(f"Error fetching velocity data: {e}") + return jsonify({'error': str(e)}), 500 + + +@gsm_spy_bp.route('/velocity/calculate', methods=['POST']) +def calculate_velocity(): + """Calculate velocity for a device based on TA transitions (G-08).""" + data = request.get_json() or {} + device_id = data.get('device_id') + + if not device_id: + return jsonify({'error': 'device_id required'}), 400 + + try: + with get_db() as conn: + # Get last two TA readings for this device + results = conn.execute(''' + SELECT ta_value, cid, timestamp + FROM gsm_signals + WHERE (imsi = ? OR tmsi = ?) + ORDER BY timestamp DESC + LIMIT 2 + ''', (device_id, device_id)).fetchall() + + if len(results) < 2: + return jsonify({'velocity': 0, 'message': 'Insufficient data'}) + + curr = dict(results[0]) + prev = dict(results[1]) + + # Calculate distance change (TA * 554 meters) + curr_distance = curr['ta_value'] * config.GSM_TA_METERS_PER_UNIT + prev_distance = prev['ta_value'] * config.GSM_TA_METERS_PER_UNIT + distance_change = abs(curr_distance - prev_distance) + + # Calculate time difference + curr_time = datetime.fromisoformat(curr['timestamp']) + prev_time = datetime.fromisoformat(prev['timestamp']) + time_diff_seconds = (curr_time - prev_time).total_seconds() + + # Calculate velocity (m/s) + if time_diff_seconds > 0: + velocity = distance_change / time_diff_seconds + else: + velocity = 0 + + # Store in velocity log + conn.execute(''' + INSERT INTO gsm_velocity_log + (device_id, prev_ta, curr_ta, prev_cid, curr_cid, estimated_velocity) + VALUES (?, ?, ?, ?, ?, ?) + ''', (device_id, prev['ta_value'], curr['ta_value'], + prev['cid'], curr['cid'], velocity)) + conn.commit() + + return jsonify({ + 'device_id': device_id, + 'velocity_mps': round(velocity, 2), + 'velocity_kmh': round(velocity * 3.6, 2), + 'distance_change_m': round(distance_change, 2), + 'time_diff_s': round(time_diff_seconds, 2) + }) + + except Exception as e: + logger.error(f"Error calculating velocity: {e}") + return jsonify({'error': str(e)}), 500 + + +@gsm_spy_bp.route('/crowd_density', methods=['GET']) +def get_crowd_density(): + """Get crowd density data by sector (G-09).""" + try: + hours = int(request.args.get('hours', 1)) # Last 1 hour by default + cid = request.args.get('cid') # Optional: specific cell + + with get_db() as conn: + # Count unique TMSI per cell in time window + query = ''' + SELECT + cid, + lac, + COUNT(DISTINCT tmsi) as unique_devices, + COUNT(*) as total_pings, + MIN(timestamp) as first_seen, + MAX(timestamp) as last_seen + FROM gsm_tmsi_log + WHERE timestamp >= datetime('now', '-' || ? || ' hours') + ''' + params = [hours] + + if cid: + query += ' AND cid = ?' + params.append(cid) + + query += ' GROUP BY cid, lac ORDER BY unique_devices DESC' + + results = conn.execute(query, params).fetchall() + density_data = [] + + for row in results: + density_data.append({ + 'cid': row['cid'], + 'lac': row['lac'], + 'unique_devices': row['unique_devices'], + 'total_pings': row['total_pings'], + 'first_seen': row['first_seen'], + 'last_seen': row['last_seen'], + 'density_level': 'high' if row['unique_devices'] > 20 else + 'medium' if row['unique_devices'] > 10 else 'low' + }) + + return jsonify(density_data) + + except Exception as e: + logger.error(f"Error fetching crowd density: {e}") + return jsonify({'error': str(e)}), 500 + + +@gsm_spy_bp.route('/life_patterns', methods=['GET']) +def get_life_patterns(): + """Get life pattern analysis for a device (G-10).""" + try: + device_id = request.args.get('device_id') + if not device_id: + return jsonify({'error': 'device_id required'}), 400 + + with get_db() as conn: + # Get historical signal data + results = conn.execute(''' + SELECT + strftime('%H', timestamp) as hour, + strftime('%w', timestamp) as day_of_week, + cid, + lac, + COUNT(*) as occurrences + FROM gsm_signals + WHERE (imsi = ? OR tmsi = ?) + AND timestamp >= datetime('now', '-60 days') + GROUP BY hour, day_of_week, cid, lac + ORDER BY occurrences DESC + ''', (device_id, device_id)).fetchall() + + patterns = [] + for row in results: + patterns.append({ + 'hour': int(row['hour']), + 'day_of_week': int(row['day_of_week']), + 'cid': row['cid'], + 'lac': row['lac'], + 'occurrences': row['occurrences'], + 'day_name': ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][int(row['day_of_week'])] + }) + + # Identify regular patterns + regular_locations = [] + for pattern in patterns[:5]: # Top 5 most frequent + if pattern['occurrences'] >= 3: # Seen at least 3 times + regular_locations.append({ + 'cid': pattern['cid'], + 'typical_time': f"{pattern['day_name']} {pattern['hour']:02d}:00", + 'frequency': pattern['occurrences'] + }) + + return jsonify({ + 'device_id': device_id, + 'patterns': patterns, + 'regular_locations': regular_locations, + 'total_observations': sum(p['occurrences'] for p in patterns) + }) + + except Exception as e: + logger.error(f"Error analyzing life patterns: {e}") + return jsonify({'error': str(e)}), 500 + + +@gsm_spy_bp.route('/neighbor_audit', methods=['GET']) +def neighbor_audit(): + """Audit neighbor cell lists for consistency (G-11).""" + try: + cid = request.args.get('cid') + if not cid: + return jsonify({'error': 'cid required'}), 400 + + with get_db() as conn: + # Get tower info with metadata (neighbor list stored in metadata JSON) + result = conn.execute(''' + SELECT metadata FROM gsm_cells WHERE cid = ? + ''', (cid,)).fetchone() + + if not result or not result['metadata']: + return jsonify({ + 'cid': cid, + 'status': 'no_data', + 'message': 'No neighbor list data available' + }) + + # Parse metadata JSON + metadata = json.loads(result['metadata']) + neighbor_list = metadata.get('neighbors', []) + + # Audit consistency + issues = [] + for neighbor_cid in neighbor_list: + # Check if neighbor exists in database + neighbor_exists = conn.execute(''' + SELECT id FROM gsm_cells WHERE cid = ? + ''', (neighbor_cid,)).fetchone() + + if not neighbor_exists: + issues.append({ + 'type': 'missing_neighbor', + 'cid': neighbor_cid, + 'message': f'Neighbor CID {neighbor_cid} not found in database' + }) + + return jsonify({ + 'cid': cid, + 'neighbor_count': len(neighbor_list), + 'neighbors': neighbor_list, + 'issues': issues, + 'status': 'suspicious' if issues else 'normal' + }) + + except Exception as e: + logger.error(f"Error auditing neighbors: {e}") + return jsonify({'error': str(e)}), 500 + + +@gsm_spy_bp.route('/traffic_correlation', methods=['GET']) +def traffic_correlation(): + """Correlate uplink/downlink traffic for pairing analysis (G-12).""" + try: + cid = request.args.get('cid') + minutes = int(request.args.get('minutes', 5)) + + with get_db() as conn: + # Get recent signal activity for this cell + results = conn.execute(''' + SELECT + imsi, + tmsi, + ta_value, + timestamp, + metadata + FROM gsm_signals + WHERE cid = ? + AND timestamp >= datetime('now', '-' || ? || ' minutes') + ORDER BY timestamp DESC + ''', (cid, minutes)).fetchall() + + correlations = [] + seen_devices = set() + + for row in results: + device_id = row['imsi'] or row['tmsi'] + if device_id and device_id not in seen_devices: + seen_devices.add(device_id) + + # Simple correlation: count bursts + burst_count = conn.execute(''' + SELECT COUNT(*) as bursts + FROM gsm_signals + WHERE (imsi = ? OR tmsi = ?) + AND cid = ? + AND timestamp >= datetime('now', '-' || ? || ' minutes') + ''', (device_id, device_id, cid, minutes)).fetchone() + + correlations.append({ + 'device_id': device_id, + 'burst_count': burst_count['bursts'], + 'last_seen': row['timestamp'], + 'ta_value': row['ta_value'], + 'activity_level': 'high' if burst_count['bursts'] > 10 else + 'medium' if burst_count['bursts'] > 5 else 'low' + }) + + return jsonify({ + 'cid': cid, + 'time_window_minutes': minutes, + 'active_devices': len(correlations), + 'correlations': correlations + }) + + except Exception as e: + logger.error(f"Error correlating traffic: {e}") + return jsonify({'error': str(e)}), 500 + + +# ============================================ +# Helper Functions +# ============================================ + +def parse_grgsm_scanner_output(line: str) -> dict[str, Any] | None: + """Parse grgsm_scanner output line.""" + try: + # Example output: "ARFCN: 123, Freq: 935.2MHz, CID: 1234, LAC: 567, MCC: 310, MNC: 260, PWR: -85dBm" + # This is a placeholder - actual format depends on grgsm_scanner output + + # Simple regex patterns + arfcn_match = re.search(r'ARFCN[:\s]+(\d+)', line) + freq_match = re.search(r'Freq[:\s]+([\d.]+)', line) + cid_match = re.search(r'CID[:\s]+(\d+)', line) + lac_match = re.search(r'LAC[:\s]+(\d+)', line) + mcc_match = re.search(r'MCC[:\s]+(\d+)', line) + mnc_match = re.search(r'MNC[:\s]+(\d+)', line) + pwr_match = re.search(r'PWR[:\s]+([-\d.]+)', line) + + if arfcn_match: + data = { + 'type': 'tower', + 'arfcn': int(arfcn_match.group(1)), + 'frequency': float(freq_match.group(1)) if freq_match else None, + 'cid': int(cid_match.group(1)) if cid_match else None, + 'lac': int(lac_match.group(1)) if lac_match else None, + 'mcc': int(mcc_match.group(1)) if mcc_match else None, + 'mnc': int(mnc_match.group(1)) if mnc_match else None, + 'signal_strength': float(pwr_match.group(1)) if pwr_match else None, + 'timestamp': datetime.now().isoformat() + } + return data + + except Exception as e: + logger.debug(f"Failed to parse scanner line: {line} - {e}") + + return None + + +def parse_tshark_output(line: str) -> dict[str, Any] | None: + """Parse tshark filtered GSM output.""" + try: + # tshark output format: ta_value\ttmsi\timsi\tlac\tcid + parts = line.strip().split('\t') + + if len(parts) >= 5: + data = { + 'type': 'device', + 'ta_value': int(parts[0]) if parts[0] else None, + 'tmsi': parts[1] if parts[1] else None, + 'imsi': parts[2] if parts[2] else None, + 'lac': int(parts[3]) if parts[3] else None, + 'cid': int(parts[4]) if parts[4] else None, + 'timestamp': datetime.now().isoformat() + } + + # Calculate distance from TA + if data['ta_value'] is not None: + data['distance_meters'] = data['ta_value'] * config.GSM_TA_METERS_PER_UNIT + + return data + + except Exception as e: + logger.debug(f"Failed to parse tshark line: {line} - {e}") + + return None + + +def auto_start_monitor(tower_data): + """Automatically start monitoring the strongest tower found.""" + try: + arfcn = tower_data.get('arfcn') + if not arfcn: + logger.warning("Cannot auto-monitor: no ARFCN in tower data") + return + + logger.info(f"Auto-monitoring strongest tower: ARFCN {arfcn}, Signal {tower_data.get('signal_strength')} dBm") + + # Brief delay to ensure scanner has stabilized + time.sleep(2) + + with app_module.gsm_spy_lock: + if app_module.gsm_spy_monitor_process: + logger.info("Monitor already running, skipping auto-start") + return + + device_index = app_module.gsm_spy_active_device or 0 + + # Start grgsm_livemon + grgsm_cmd = [ + 'grgsm_livemon', + '-a', str(arfcn), + '-d', str(device_index) + ] + + tshark_cmd = [ + 'tshark', + '-i', 'lo', + '-Y', 'gsm_a.rr.timing_advance || gsm_a.tmsi || gsm_a.imsi', + '-T', 'fields', + '-e', 'gsm_a.rr.timing_advance', + '-e', 'gsm_a.tmsi', + '-e', 'gsm_a.imsi', + '-e', 'gsm_a.lac', + '-e', 'gsm_a.cellid' + ] + + logger.info(f"Starting auto-monitor: {' '.join(grgsm_cmd)} | {' '.join(tshark_cmd)}") + + # Start grgsm_livemon (we don't capture its output) + grgsm_proc = subprocess.Popen( + grgsm_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + + # Start tshark + tshark_proc = subprocess.Popen( + tshark_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + bufsize=1 + ) + + app_module.gsm_spy_monitor_process = tshark_proc + app_module.gsm_spy_selected_arfcn = arfcn + + # Start monitoring thread + monitor_thread_obj = threading.Thread( + target=monitor_thread, + args=(tshark_proc,), + daemon=True + ) + monitor_thread_obj.start() + + # Send SSE notification + try: + app_module.gsm_spy_queue.put_nowait({ + 'type': 'auto_monitor_started', + 'arfcn': arfcn, + 'tower': tower_data + }) + except queue.Full: + pass + + logger.info(f"Auto-monitoring started for ARFCN {arfcn}") + + except Exception as e: + logger.error(f"Error in auto-monitoring: {e}") + + +def scanner_thread(process): + """Thread to read grgsm_scanner output.""" + global gsm_towers_found + + strongest_tower = None + auto_monitor_triggered = False + + try: + for line in process.stdout: + if not line: + continue + + parsed = parse_grgsm_scanner_output(line) + if parsed: + # Store in DataStore + key = f"{parsed.get('mcc')}_{parsed.get('mnc')}_{parsed.get('lac')}_{parsed.get('cid')}" + app_module.gsm_spy_towers[key] = parsed + + # Track strongest tower for auto-monitoring + signal_strength = parsed.get('signal_strength', -999) + if strongest_tower is None or signal_strength > strongest_tower.get('signal_strength', -999): + strongest_tower = parsed + + # Queue for SSE stream + try: + app_module.gsm_spy_queue.put_nowait(parsed) + except queue.Full: + pass + + gsm_towers_found += 1 + + # Auto-monitor strongest tower after finding 3+ towers + if gsm_towers_found >= 3 and not auto_monitor_triggered and strongest_tower: + auto_monitor_triggered = True + threading.Thread( + target=auto_start_monitor, + args=(strongest_tower,), + daemon=True + ).start() + + except Exception as e: + logger.error(f"Scanner thread error: {e}") + finally: + logger.info("Scanner thread terminated") + + +def monitor_thread(process): + """Thread to read grgsm_livemon | tshark output.""" + global gsm_devices_tracked + + try: + for line in process.stdout: + if not line: + continue + + parsed = parse_tshark_output(line) + if parsed: + # Store in DataStore + key = parsed.get('tmsi') or parsed.get('imsi') or str(time.time()) + app_module.gsm_spy_devices[key] = parsed + + # Queue for SSE stream + try: + app_module.gsm_spy_queue.put_nowait(parsed) + except queue.Full: + pass + + # Store in database for historical analysis + try: + with get_db() as conn: + # gsm_signals table + conn.execute(''' + INSERT INTO gsm_signals + (imsi, tmsi, lac, cid, ta_value, arfcn) + VALUES (?, ?, ?, ?, ?, ?) + ''', ( + parsed.get('imsi'), + parsed.get('tmsi'), + parsed.get('lac'), + parsed.get('cid'), + parsed.get('ta_value'), + app_module.gsm_spy_selected_arfcn + )) + + # gsm_tmsi_log table for crowd density + if parsed.get('tmsi'): + conn.execute(''' + INSERT INTO gsm_tmsi_log + (tmsi, lac, cid, ta_value) + VALUES (?, ?, ?, ?) + ''', ( + parsed.get('tmsi'), + parsed.get('lac'), + parsed.get('cid'), + parsed.get('ta_value') + )) + + # Velocity calculation (G-08) + device_id = parsed.get('imsi') or parsed.get('tmsi') + if device_id and parsed.get('ta_value') is not None: + # Get previous TA reading + prev_reading = conn.execute(''' + SELECT ta_value, cid, timestamp + FROM gsm_signals + WHERE (imsi = ? OR tmsi = ?) + ORDER BY timestamp DESC + LIMIT 1 OFFSET 1 + ''', (device_id, device_id)).fetchone() + + if prev_reading: + # Calculate velocity + curr_ta = parsed.get('ta_value') + prev_ta = prev_reading['ta_value'] + curr_distance = curr_ta * config.GSM_TA_METERS_PER_UNIT + prev_distance = prev_ta * config.GSM_TA_METERS_PER_UNIT + distance_change = abs(curr_distance - prev_distance) + + # Time difference + prev_time = datetime.fromisoformat(prev_reading['timestamp']) + curr_time = datetime.now() + time_diff_seconds = (curr_time - prev_time).total_seconds() + + if time_diff_seconds > 0: + velocity = distance_change / time_diff_seconds + + # Store velocity + conn.execute(''' + INSERT INTO gsm_velocity_log + (device_id, prev_ta, curr_ta, prev_cid, curr_cid, estimated_velocity) + VALUES (?, ?, ?, ?, ?, ?) + ''', ( + device_id, + prev_ta, + curr_ta, + prev_reading['cid'], + parsed.get('cid'), + velocity + )) + + conn.commit() + except Exception as e: + logger.error(f"Error storing device data: {e}") + + gsm_devices_tracked += 1 + + except Exception as e: + logger.error(f"Monitor thread error: {e}") + finally: + logger.info("Monitor thread terminated") diff --git a/setup.sh b/setup.sh index e09e9c7..f237e88 100755 --- a/setup.sh +++ b/setup.sh @@ -533,6 +533,52 @@ install_macos_packages() { progress "Installing gpsd" brew_install gpsd + # gr-gsm for GSM Intelligence + if ! cmd_exists grgsm_scanner; then + echo + info "gr-gsm provides GSM cellular signal decoding..." + if ask_yes_no "Do you want to install gr-gsm?"; then + progress "Installing gr-gsm" + brew_install gnuradio + (brew_install gr-gsm) || { + warn "gr-gsm not available in Homebrew, attempting manual build..." + # Manual build instructions + if ask_yes_no "Attempt to build gr-gsm from source? (requires CMake and build tools)"; then + info "Cloning gr-gsm repository..." + git clone https://github.com/ptrkrysik/gr-gsm.git /tmp/gr-gsm + cd /tmp/gr-gsm + mkdir build && cd build + cmake .. + make -j$(sysctl -n hw.ncpu) + sudo make install + cd ~ + rm -rf /tmp/gr-gsm + ok "gr-gsm installed successfully" + else + warn "Skipping gr-gsm source build. GSM Spy feature will not work." + fi + } + else + warn "Skipping gr-gsm installation. GSM Spy feature will not work." + fi + else + ok "gr-gsm already installed" + fi + + # Wireshark (tshark) for packet analysis + if ! cmd_exists tshark; then + echo + info "tshark is used for GSM packet parsing..." + if ask_yes_no "Do you want to install tshark?"; then + progress "Installing Wireshark (tshark)" + brew_install wireshark + else + warn "Skipping tshark installation." + fi + else + ok "tshark already installed" + fi + progress "Installing Ubertooth tools (optional)" if ! cmd_exists ubertooth-btle; then echo @@ -961,6 +1007,87 @@ install_debian_packages() { progress "Installing gpsd" apt_install gpsd gpsd-clients || true + # gr-gsm for GSM Intelligence + if ! cmd_exists grgsm_scanner; then + echo + info "gr-gsm provides GSM cellular signal decoding..." + if ask_yes_no "Do you want to install gr-gsm?"; then + progress "Installing GNU Radio and gr-gsm" + # Try to install gr-gsm directly from package repositories + apt_install gnuradio gnuradio-dev gr-osmosdr gr-gsm || { + warn "gr-gsm package not available in repositories. Attempting source build..." + + # Fallback: Build from source + progress "Building gr-gsm from source" + apt_install git cmake libboost-all-dev libcppunit-dev swig \ + doxygen liblog4cpp5-dev python3-scipy python3-numpy \ + libvolk-dev libuhd-dev libfftw3-dev || true + + info "Cloning gr-gsm repository..." + if [ -d /tmp/gr-gsm ]; then + rm -rf /tmp/gr-gsm + fi + + git clone https://github.com/ptrkrysik/gr-gsm.git /tmp/gr-gsm || { + warn "Failed to clone gr-gsm repository. GSM Spy will not be available." + return 0 + } + + cd /tmp/gr-gsm + mkdir -p build && cd build + + # Try to find GNU Radio cmake files + if [ -d /usr/lib/x86_64-linux-gnu/cmake/gnuradio ]; then + export CMAKE_PREFIX_PATH="/usr/lib/x86_64-linux-gnu/cmake/gnuradio:$CMAKE_PREFIX_PATH" + fi + + info "Running CMake configuration..." + if cmake .. 2>/dev/null; then + info "Compiling gr-gsm (this may take several minutes)..." + if make -j$(nproc) 2>/dev/null; then + $SUDO make install + $SUDO ldconfig + cd ~ + rm -rf /tmp/gr-gsm + ok "gr-gsm built and installed successfully" + else + warn "gr-gsm compilation failed. GSM Spy feature will not work." + cd ~ + rm -rf /tmp/gr-gsm + fi + else + warn "gr-gsm CMake configuration failed. GNU Radio 3.8+ may not be available." + cd ~ + rm -rf /tmp/gr-gsm + fi + } + + # Verify installation + if cmd_exists grgsm_scanner; then + ok "gr-gsm installed successfully" + else + warn "gr-gsm installation incomplete. GSM Spy feature will not work." + fi + else + warn "Skipping gr-gsm installation." + fi + else + ok "gr-gsm already installed" + fi + + # Wireshark (tshark) + if ! cmd_exists tshark; then + echo + info "Installing tshark for GSM packet analysis..." + apt_install tshark || true + # Allow non-root capture + $SUDO dpkg-reconfigure wireshark-common 2>/dev/null || true + $SUDO usermod -a -G wireshark $USER 2>/dev/null || true + ok "tshark installed. You may need to re-login for wireshark group permissions." + else + ok "tshark already installed" + fi + progress "Installing Python packages" apt_install python3-venv python3-pip || true # Install Python packages via apt (more reliable than pip on modern Debian/Ubuntu) diff --git a/static/css/gsm_spy_dashboard.css b/static/css/gsm_spy_dashboard.css new file mode 100644 index 0000000..c256a34 --- /dev/null +++ b/static/css/gsm_spy_dashboard.css @@ -0,0 +1,622 @@ +/* GSM SPY Dashboard Styles */ + +:root { + --font-mono: 'IBM Plex Mono', 'JetBrains Mono', 'Courier New', monospace; + --bg-dark: #0b1118; + --bg-panel: #101823; + --bg-panel-hover: #1a2331; + --border-color: #263246; + --accent-green: #38c180; + --accent-cyan: #4aa3ff; + --accent-red: #e25d5d; + --accent-yellow: #ffa500; + --text-primary: #e8e8e8; + --text-secondary: #888; + --text-dim: #555; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: var(--font-mono); + background: var(--bg-dark); + color: var(--text-primary); + overflow: hidden; + font-size: 12px; +} + +/* Radar background and scanline */ +.radar-bg { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px); + background-size: 50px 50px; + pointer-events: none; + z-index: 0; +} + +.scanline { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 2px; + background: var(--accent-cyan); + opacity: 0.3; + animation: scan 3s linear infinite; + pointer-events: none; + z-index: 1; +} + +@keyframes scan { + from { transform: translateY(0); } + to { transform: translateY(100vh); } +} + +/* Header */ +.header { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 60px; + background: var(--bg-panel); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + z-index: 100; +} + +.logo { + font-size: 24px; + font-weight: 700; + color: var(--accent-cyan); + letter-spacing: 2px; +} + +.status-bar { + display: flex; + gap: 15px; + align-items: center; +} + +.status-indicator { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-dim); +} + +.status-dot.active { + background: var(--accent-green); + animation: pulse-dot 2s ease-in-out infinite; +} + +.status-dot.error { + background: var(--accent-red); +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Stats strip */ +.stats-strip { + position: fixed; + top: 60px; + left: 0; + right: 0; + height: 50px; + background: var(--bg-panel); + border-bottom: 1px solid var(--border-color); + display: flex; + gap: 20px; + padding: 0 20px; + align-items: center; + z-index: 99; +} + +.strip-stat { + display: flex; + flex-direction: column; + align-items: center; +} + +.strip-value { + font-size: 20px; + font-weight: 700; + color: var(--accent-green); + line-height: 1.2; +} + +.strip-label { + font-size: 9px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Dashboard layout */ +.dashboard { + position: fixed; + top: 110px; + bottom: 80px; + left: 0; + right: 0; + display: grid; + grid-template-columns: 280px 1fr 300px; + gap: 10px; + padding: 10px; +} + +/* Sidebar panels */ +.left-sidebar, .right-sidebar { + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: auto; +} + +.panel { + background: var(--bg-panel); + border: 1px solid var(--border-color); + border-radius: 4px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.panel-header { + padding: 10px 12px; + font-size: 11px; + font-weight: 700; + border-bottom: 1px solid var(--border-color); + color: var(--accent-cyan); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.panel-content { + padding: 12px; +} + +/* Signal source panel */ +.signal-source select, +.region-selector select { + width: 100%; + background: var(--bg-dark); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 3px; + padding: 8px; + font-family: var(--font-mono); + font-size: 11px; +} + +.region-selector { + margin-top: 10px; +} + +.region-selector label { + display: block; + margin-bottom: 5px; + font-size: 10px; + color: var(--text-secondary); +} + +.band-info { + margin-top: 8px; + padding: 8px; + background: var(--bg-dark); + border-radius: 3px; + font-size: 10px; + color: var(--text-secondary); +} + +/* Selected tower info */ +.selected-info { + padding: 12px; + font-size: 11px; +} + +.selected-info.empty { + color: var(--text-dim); + text-align: center; + padding: 20px; +} + +.selected-info > div { + margin-bottom: 8px; +} + +.selected-info strong { + color: var(--accent-cyan); +} + +/* Tower and device lists */ +.tower-list, .device-list, .alert-list { + max-height: 300px; + overflow-y: auto; +} + +.tower-item, .device-item, .alert-item { + padding: 10px 12px; + border-bottom: 1px solid var(--border-color); + cursor: pointer; + transition: background 0.2s; + font-size: 11px; +} + +.tower-item:hover, .device-item:hover { + background: var(--bg-panel-hover); +} + +.tower-item:last-child, .device-item:last-child, .alert-item:last-child { + border-bottom: none; +} + +.tower-item.rogue { + border-left: 3px solid var(--accent-red); +} + +.tower-item-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 5px; +} + +.tower-cid { + font-weight: 700; + color: var(--accent-cyan); +} + +.tower-signal { + font-size: 10px; + color: var(--text-secondary); +} + +.tower-operator { + font-size: 10px; + color: var(--text-dim); +} + +.device-item-id { + font-weight: 700; + color: var(--accent-green); + margin-bottom: 5px; +} + +.device-ta { + font-size: 10px; + color: var(--text-secondary); +} + +.alert-item { + background: rgba(226, 93, 93, 0.1); + border-left: 3px solid var(--accent-red); + cursor: default; +} + +.alert-item strong { + color: var(--accent-red); +} + +.alert-item small { + display: block; + margin-top: 5px; + color: var(--text-dim); + font-size: 9px; +} + +/* Map container */ +.map-container { + position: relative; + border: 1px solid var(--border-color); + border-radius: 4px; + overflow: hidden; +} + +#gsmMap { + width: 100%; + height: 100%; + background: var(--bg-dark); +} + +/* Map markers */ +.tower-marker { + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--accent-green); + border: 2px solid white; + box-shadow: 0 0 8px rgba(56, 195, 128, 0.6); +} + +.tower-marker.rogue { + background: var(--accent-red); + box-shadow: 0 0 8px rgba(226, 93, 93, 0.8); + animation: blink 1s infinite; +} + +@keyframes blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0.3; } +} + +.device-blip { + animation: pulse-blip 5s ease-out forwards; +} + +@keyframes pulse-blip { + 0% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(3); + } +} + +/* Controls bar */ +.controls-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 80px; + background: var(--bg-panel); + border-top: 1px solid var(--border-color); + display: flex; + gap: 20px; + padding: 15px 20px; + align-items: center; + z-index: 99; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 5px; +} + +.control-group-label { + font-size: 9px; + color: var(--text-secondary); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.control-group-items { + display: flex; + gap: 10px; + align-items: center; +} + +/* Input fields */ +input[type="text"], input[type="number"], select { + background: var(--bg-dark); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 3px; + padding: 8px 10px; + font-family: var(--font-mono); + font-size: 11px; + min-width: 120px; +} + +input[type="text"]:focus, input[type="number"]:focus, select:focus { + outline: none; + border-color: var(--accent-cyan); +} + +/* Buttons */ +button { + background: var(--accent-cyan); + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + transition: all 0.2s; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +button:hover { + opacity: 0.8; + transform: translateY(-1px); +} + +button:active { + transform: translateY(0); +} + +button.active { + background: var(--accent-red); + animation: pulse-btn 2s ease-in-out infinite; +} + +@keyframes pulse-btn { + 0%, 100% { box-shadow: 0 0 0 0 rgba(226, 93, 93, 0.7); } + 50% { box-shadow: 0 0 0 10px rgba(226, 93, 93, 0); } +} + +button:disabled { + background: var(--text-dim); + cursor: not-allowed; + opacity: 0.5; +} + +/* GPS indicator */ +.gps-indicator { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 12px; + background: var(--bg-dark); + border: 1px solid var(--border-color); + border-radius: 3px; + font-size: 10px; + color: var(--text-secondary); +} + +.gps-indicator::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-dim); +} + +.gps-indicator.active::before { + background: var(--accent-green); + animation: pulse-dot 2s ease-in-out infinite; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-dark); +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-dim); +} + +/* Empty state */ +.empty-state { + padding: 30px 20px; + text-align: center; + color: var(--text-dim); + font-size: 11px; +} + +/* Responsive adjustments */ +@media (max-width: 1400px) { + .dashboard { + grid-template-columns: 250px 1fr 280px; + } +} + +@media (max-width: 1024px) { + .dashboard { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr auto; + } + + .left-sidebar, .right-sidebar { + flex-direction: row; + overflow-x: auto; + overflow-y: visible; + } + + .panel { + min-width: 250px; + } +} + +/* Utility classes */ +.text-success { color: var(--accent-green); } +.text-danger { color: var(--accent-red); } +.text-warning { color: var(--accent-yellow); } +.text-info { color: var(--accent-cyan); } +.text-muted { color: var(--text-secondary); } + +.mt-1 { margin-top: 8px; } +.mt-2 { margin-top: 16px; } +.mb-1 { margin-bottom: 8px; } +.mb-2 { margin-bottom: 16px; } + +/* Advanced Analysis Results Panel */ +.analysis-results { + border-top: 1px solid var(--border-color); + padding: 12px; + max-height: 300px; + overflow-y: auto; +} + +.analysis-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + font-size: 11px; + font-weight: 700; + color: var(--accent-cyan); + text-transform: uppercase; +} + +.analysis-content { + font-size: 10px; + line-height: 1.6; +} + +.analysis-stat { + display: flex; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px solid rgba(255,255,255,0.05); +} + +.analysis-stat:last-child { + border-bottom: none; +} + +.analysis-stat-label { + color: var(--text-secondary); +} + +.analysis-stat-value { + color: var(--accent-green); + font-weight: 600; +} + +.analysis-device-item { + padding: 8px; + margin: 6px 0; + background: var(--bg-dark); + border-radius: 3px; + border-left: 3px solid var(--accent-cyan); +} + +.analysis-warning { + color: var(--accent-yellow); + font-size: 10px; + padding: 8px; + background: rgba(255, 165, 0, 0.1); + border-radius: 3px; + margin-top: 8px; +} diff --git a/templates/gsm_spy_dashboard.html b/templates/gsm_spy_dashboard.html new file mode 100644 index 0000000..19343b0 --- /dev/null +++ b/templates/gsm_spy_dashboard.html @@ -0,0 +1,2194 @@ + + + + + + GSM SPY // INTERCEPT - See the Invisible + + {% if offline_settings.fonts_source == 'local' %} + + {% else %} + + {% endif %} + + {% if offline_settings.assets_source == 'local' %} + + + {% else %} + + + {% endif %} + + + + + + + + + + + +
+
+ +
+ +
+
+ STANDBY +
+
+ + {% set active_mode = 'gsm' %} + {% include 'partials/nav.html' with context %} + + +
+
+
+ 0 + TOWERS +
+
+ 0 + DEVICES +
+
+ 0 + ROGUES +
+
+ 0 + SIGNALS +
+
+ - + CROWD +
+
+
+
+ STANDBY +
+
--:--:-- UTC
+ +
+
+ + +
+
+
+
Analytics Overview
+ +
+
+
+ +
+
+
📍
+
Velocity Tracking
+
+
+ Track device movement by analyzing Timing Advance transitions and cell handovers. + Estimates velocity and direction based on TA delta and cell sector patterns. +
+
+
+
0
+
Devices Tracked
+
+
+
- km/h
+
Avg Velocity
+
+
+
+ + +
+
+
👥
+
Crowd Density
+
+
+ Aggregate TMSI pings per cell sector to estimate crowd density. + Visualizes hotspots and congestion patterns across towers. +
+
+
+
0
+
Total Devices
+
+
+
0
+
Peak Sector
+
+
+
+ + +
+
+
📊
+
Life Patterns
+
+
+ Analyze 60-day historical data to identify recurring patterns in device behavior. + Detects work locations, commute routes, and daily routines. +
+
+
+
0
+
Patterns Found
+
+
+
0%
+
Confidence
+
+
+
+ + +
+
+
🔍
+
Neighbor Audit
+
+
+ Validate neighbor cell lists against expected network topology. + Detects inconsistencies that may indicate rogue towers. +
+
+
+
0
+
Neighbors
+
+
+
0
+
Anomalies
+
+
+
+ + +
+
+
📡
+
Traffic Correlation
+
+
+ Correlate uplink and downlink timing to identify communication patterns. + Maps device-to-device interactions and network flows. +
+
+
+
0
+
Paired Flows
+
+
+
0
+
Active Now
+
+
+
+
+
+
+
+ +
+ + + + +
+
+
+ + + + + +
+ +
+ GPS LOCATION +
+ + + +
+
+ + +
+ GSM SCANNER +
+ + + +
+
+
+
+ + + + + + + diff --git a/templates/index.html b/templates/index.html index 6693a39..1b72956 100644 --- a/templates/index.html +++ b/templates/index.html @@ -167,6 +167,10 @@ Vessels + + + GSM SPY +