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 %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% set active_mode = 'gsm' %}
+ {% include 'partials/nav.html' with context %}
+
+
+
+
+
+ 0
+ TOWERS
+
+
+ 0
+ DEVICES
+
+
+ 0
+ ROGUES
+
+
+ 0
+ SIGNALS
+
+
+ -
+ CROWD
+
+
+
+
--:--:-- UTC
+
+ Analytics Overview
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Track device movement by analyzing Timing Advance transitions and cell handovers.
+ Estimates velocity and direction based on TA delta and cell sector patterns.
+
+
+
+
+
- km/h
+
Avg Velocity
+
+
+
+
+
+
+
+
+ Aggregate TMSI pings per cell sector to estimate crowd density.
+ Visualizes hotspots and congestion patterns across towers.
+
+
+
+
+
+
+
+
+ Analyze 60-day historical data to identify recurring patterns in device behavior.
+ Detects work locations, commute routes, and daily routines.
+
+
+
+
+
+
+
+
+ Validate neighbor cell lists against expected network topology.
+ Detects inconsistencies that may indicate rogue towers.
+
+
+
+
+
+
+
+
+ Correlate uplink and downlink timing to identify communication patterns.
+ Maps device-to-device interactions and network flows.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
GSM SCANNER
+
+
+ Device 0
+ Device 1
+ Device 2
+ Device 3
+
+
+ Americas
+ Europe
+ Asia
+
+ START
+
+
+
+
+
+
+
+
+
+
+
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
+
APRS
diff --git a/templates/partials/nav.html b/templates/partials/nav.html
index 5e615b3..0251d7a 100644
--- a/templates/partials/nav.html
+++ b/templates/partials/nav.html
@@ -67,6 +67,7 @@
{{ mode_item('rtlamr', 'Meters', ' ') }}
{{ mode_item('adsb', 'Aircraft', ' ', '/adsb/dashboard') }}
{{ mode_item('ais', 'Vessels', ' ', '/ais/dashboard') }}
+ {{ mode_item('gsm', 'GSM SPY', ' ', '/gsm_spy/dashboard') }}
{{ mode_item('aprs', 'APRS', ' ') }}
{{ mode_item('listening', 'Listening Post', ' ') }}
{{ mode_item('spystations', 'Spy Stations', ' ') }}
diff --git a/utils/constants.py b/utils/constants.py
index f252938..f51124e 100644
--- a/utils/constants.py
+++ b/utils/constants.py
@@ -274,3 +274,14 @@ MAX_DEAUTH_ALERTS_AGE_SECONDS = 300 # 5 minutes
# Deauth detector sniff timeout (seconds)
DEAUTH_SNIFF_TIMEOUT = 0.5
+
+
+# =============================================================================
+# GSM SPY (Cellular Intelligence)
+# =============================================================================
+
+# Maximum age for GSM tower/device data in DataStore (seconds)
+MAX_GSM_AGE_SECONDS = 300 # 5 minutes
+
+# Timing Advance conversion to meters
+GSM_TA_METERS_PER_UNIT = 554
diff --git a/utils/database.py b/utils/database.py
index 6467c27..97713ff 100644
--- a/utils/database.py
+++ b/utils/database.py
@@ -352,6 +352,134 @@ def init_db() -> None:
ON tscm_cases(status, created_at)
''')
+ # =====================================================================
+ # GSM (Global System for Mobile) Intelligence Tables
+ # =====================================================================
+
+ # gsm_cells - Known cell towers (OpenCellID cache)
+ conn.execute('''
+ CREATE TABLE IF NOT EXISTS gsm_cells (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ mcc INTEGER NOT NULL,
+ mnc INTEGER NOT NULL,
+ lac INTEGER NOT NULL,
+ cid INTEGER NOT NULL,
+ lat REAL,
+ lon REAL,
+ azimuth INTEGER,
+ range_meters INTEGER,
+ samples INTEGER,
+ radio TEXT,
+ operator TEXT,
+ first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ last_verified TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ metadata TEXT,
+ UNIQUE(mcc, mnc, lac, cid)
+ )
+ ''')
+
+ # gsm_rogues - Detected rogue towers / IMSI catchers
+ conn.execute('''
+ CREATE TABLE IF NOT EXISTS gsm_rogues (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ arfcn INTEGER NOT NULL,
+ mcc INTEGER,
+ mnc INTEGER,
+ lac INTEGER,
+ cid INTEGER,
+ signal_strength REAL,
+ reason TEXT NOT NULL,
+ threat_level TEXT DEFAULT 'medium',
+ detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ location_lat REAL,
+ location_lon REAL,
+ acknowledged BOOLEAN DEFAULT 0,
+ notes TEXT,
+ metadata TEXT
+ )
+ ''')
+
+ # gsm_signals - 60-day archive of signal observations
+ conn.execute('''
+ CREATE TABLE IF NOT EXISTS gsm_signals (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ imsi TEXT,
+ tmsi TEXT,
+ mcc INTEGER,
+ mnc INTEGER,
+ lac INTEGER,
+ cid INTEGER,
+ ta_value INTEGER,
+ signal_strength REAL,
+ arfcn INTEGER,
+ timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ metadata TEXT
+ )
+ ''')
+
+ # gsm_tmsi_log - 24-hour raw pings for crowd density
+ conn.execute('''
+ CREATE TABLE IF NOT EXISTS gsm_tmsi_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ tmsi TEXT NOT NULL,
+ lac INTEGER,
+ cid INTEGER,
+ ta_value INTEGER,
+ timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ ''')
+
+ # gsm_velocity_log - 1-hour buffer for movement tracking
+ conn.execute('''
+ CREATE TABLE IF NOT EXISTS gsm_velocity_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ device_id TEXT NOT NULL,
+ prev_ta INTEGER,
+ curr_ta INTEGER,
+ prev_cid INTEGER,
+ curr_cid INTEGER,
+ timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ estimated_velocity REAL,
+ metadata TEXT
+ )
+ ''')
+
+ # GSM indexes for performance
+ conn.execute('''
+ CREATE INDEX IF NOT EXISTS idx_gsm_cells_location
+ ON gsm_cells(lat, lon)
+ ''')
+
+ conn.execute('''
+ CREATE INDEX IF NOT EXISTS idx_gsm_cells_identity
+ ON gsm_cells(mcc, mnc, lac, cid)
+ ''')
+
+ conn.execute('''
+ CREATE INDEX IF NOT EXISTS idx_gsm_rogues_severity
+ ON gsm_rogues(threat_level, detected_at)
+ ''')
+
+ conn.execute('''
+ CREATE INDEX IF NOT EXISTS idx_gsm_signals_cell_time
+ ON gsm_signals(cid, lac, timestamp)
+ ''')
+
+ conn.execute('''
+ CREATE INDEX IF NOT EXISTS idx_gsm_signals_device
+ ON gsm_signals(imsi, tmsi, timestamp)
+ ''')
+
+ conn.execute('''
+ CREATE INDEX IF NOT EXISTS idx_gsm_tmsi_log_time
+ ON gsm_tmsi_log(timestamp)
+ ''')
+
+ conn.execute('''
+ CREATE INDEX IF NOT EXISTS idx_gsm_velocity_log_device
+ ON gsm_velocity_log(device_id, timestamp)
+ ''')
+
# =====================================================================
# DSC (Digital Selective Calling) Tables
# =====================================================================
@@ -1205,127 +1333,127 @@ def get_all_known_devices(
]
-def delete_known_device(identifier: str) -> bool:
- """Remove a device from the known-good registry."""
- with get_db() as conn:
- cursor = conn.execute(
- 'DELETE FROM tscm_known_devices WHERE identifier = ?',
- (identifier.upper(),)
- )
- return cursor.rowcount > 0
-
-
-# =============================================================================
-# TSCM Schedule Functions
-# =============================================================================
-
-def create_tscm_schedule(
- name: str,
- cron_expression: str,
- sweep_type: str = 'standard',
- baseline_id: int | None = None,
- zone_name: str | None = None,
- enabled: bool = True,
- notify_on_threat: bool = True,
- notify_email: str | None = None,
- last_run: str | None = None,
- next_run: str | None = None,
-) -> int:
- """Create a new TSCM sweep schedule."""
- with get_db() as conn:
- cursor = conn.execute('''
- INSERT INTO tscm_schedules
- (name, baseline_id, zone_name, cron_expression, sweep_type,
- enabled, last_run, next_run, notify_on_threat, notify_email)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- ''', (
- name,
- baseline_id,
- zone_name,
- cron_expression,
- sweep_type,
- 1 if enabled else 0,
- last_run,
- next_run,
- 1 if notify_on_threat else 0,
- notify_email,
- ))
- return cursor.lastrowid
-
-
-def get_tscm_schedule(schedule_id: int) -> dict | None:
- """Get a TSCM schedule by ID."""
- with get_db() as conn:
- cursor = conn.execute(
- 'SELECT * FROM tscm_schedules WHERE id = ?',
- (schedule_id,)
- )
- row = cursor.fetchone()
- return dict(row) if row else None
-
-
-def get_all_tscm_schedules(
- enabled: bool | None = None,
- limit: int = 200
-) -> list[dict]:
- """Get all TSCM schedules."""
- conditions = []
- params = []
-
- if enabled is not None:
- conditions.append('enabled = ?')
- params.append(1 if enabled else 0)
-
- where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else ''
- params.append(limit)
-
- with get_db() as conn:
- cursor = conn.execute(f'''
- SELECT * FROM tscm_schedules
- {where_clause}
- ORDER BY id DESC
- LIMIT ?
- ''', params)
- return [dict(row) for row in cursor]
-
-
-def update_tscm_schedule(schedule_id: int, **fields) -> bool:
- """Update a TSCM schedule."""
- if not fields:
- return False
-
- updates = []
- params = []
-
- for key, value in fields.items():
- updates.append(f'{key} = ?')
- params.append(value)
-
- params.append(schedule_id)
-
- with get_db() as conn:
- cursor = conn.execute(
- f'UPDATE tscm_schedules SET {", ".join(updates)} WHERE id = ?',
- params
- )
- return cursor.rowcount > 0
-
-
-def delete_tscm_schedule(schedule_id: int) -> bool:
- """Delete a TSCM schedule."""
- with get_db() as conn:
- cursor = conn.execute(
- 'DELETE FROM tscm_schedules WHERE id = ?',
- (schedule_id,)
- )
- return cursor.rowcount > 0
-
-
-def is_known_good_device(identifier: str, location: str | None = None) -> dict | None:
- """Check if a device is in the known-good registry for a location."""
- with get_db() as conn:
- if location:
- cursor = conn.execute('''
+def delete_known_device(identifier: str) -> bool:
+ """Remove a device from the known-good registry."""
+ with get_db() as conn:
+ cursor = conn.execute(
+ 'DELETE FROM tscm_known_devices WHERE identifier = ?',
+ (identifier.upper(),)
+ )
+ return cursor.rowcount > 0
+
+
+# =============================================================================
+# TSCM Schedule Functions
+# =============================================================================
+
+def create_tscm_schedule(
+ name: str,
+ cron_expression: str,
+ sweep_type: str = 'standard',
+ baseline_id: int | None = None,
+ zone_name: str | None = None,
+ enabled: bool = True,
+ notify_on_threat: bool = True,
+ notify_email: str | None = None,
+ last_run: str | None = None,
+ next_run: str | None = None,
+) -> int:
+ """Create a new TSCM sweep schedule."""
+ with get_db() as conn:
+ cursor = conn.execute('''
+ INSERT INTO tscm_schedules
+ (name, baseline_id, zone_name, cron_expression, sweep_type,
+ enabled, last_run, next_run, notify_on_threat, notify_email)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ''', (
+ name,
+ baseline_id,
+ zone_name,
+ cron_expression,
+ sweep_type,
+ 1 if enabled else 0,
+ last_run,
+ next_run,
+ 1 if notify_on_threat else 0,
+ notify_email,
+ ))
+ return cursor.lastrowid
+
+
+def get_tscm_schedule(schedule_id: int) -> dict | None:
+ """Get a TSCM schedule by ID."""
+ with get_db() as conn:
+ cursor = conn.execute(
+ 'SELECT * FROM tscm_schedules WHERE id = ?',
+ (schedule_id,)
+ )
+ row = cursor.fetchone()
+ return dict(row) if row else None
+
+
+def get_all_tscm_schedules(
+ enabled: bool | None = None,
+ limit: int = 200
+) -> list[dict]:
+ """Get all TSCM schedules."""
+ conditions = []
+ params = []
+
+ if enabled is not None:
+ conditions.append('enabled = ?')
+ params.append(1 if enabled else 0)
+
+ where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else ''
+ params.append(limit)
+
+ with get_db() as conn:
+ cursor = conn.execute(f'''
+ SELECT * FROM tscm_schedules
+ {where_clause}
+ ORDER BY id DESC
+ LIMIT ?
+ ''', params)
+ return [dict(row) for row in cursor]
+
+
+def update_tscm_schedule(schedule_id: int, **fields) -> bool:
+ """Update a TSCM schedule."""
+ if not fields:
+ return False
+
+ updates = []
+ params = []
+
+ for key, value in fields.items():
+ updates.append(f'{key} = ?')
+ params.append(value)
+
+ params.append(schedule_id)
+
+ with get_db() as conn:
+ cursor = conn.execute(
+ f'UPDATE tscm_schedules SET {", ".join(updates)} WHERE id = ?',
+ params
+ )
+ return cursor.rowcount > 0
+
+
+def delete_tscm_schedule(schedule_id: int) -> bool:
+ """Delete a TSCM schedule."""
+ with get_db() as conn:
+ cursor = conn.execute(
+ 'DELETE FROM tscm_schedules WHERE id = ?',
+ (schedule_id,)
+ )
+ return cursor.rowcount > 0
+
+
+def is_known_good_device(identifier: str, location: str | None = None) -> dict | None:
+ """Check if a device is in the known-good registry for a location."""
+ with get_db() as conn:
+ if location:
+ cursor = conn.execute('''
SELECT * FROM tscm_known_devices
WHERE identifier = ? AND (location = ? OR scope = 'global')
''', (identifier.upper(), location))
diff --git a/utils/dependencies.py b/utils/dependencies.py
index 256b995..144771f 100644
--- a/utils/dependencies.py
+++ b/utils/dependencies.py
@@ -443,6 +443,38 @@ TOOL_DEPENDENCIES = {
}
}
}
+ },
+ 'gsm': {
+ 'name': 'GSM Intelligence',
+ 'tools': {
+ 'grgsm_scanner': {
+ 'required': True,
+ 'description': 'gr-gsm scanner for finding GSM towers',
+ 'install': {
+ 'apt': 'Build gr-gsm from source: https://github.com/ptrkrysik/gr-gsm',
+ 'brew': 'brew install gr-gsm (may require manual build)',
+ 'manual': 'https://github.com/ptrkrysik/gr-gsm'
+ }
+ },
+ 'grgsm_livemon': {
+ 'required': True,
+ 'description': 'gr-gsm live monitor for decoding GSM signals',
+ 'install': {
+ 'apt': 'Included with gr-gsm package',
+ 'brew': 'Included with gr-gsm',
+ 'manual': 'Included with gr-gsm'
+ }
+ },
+ 'tshark': {
+ 'required': True,
+ 'description': 'Wireshark CLI for parsing GSM packets',
+ 'install': {
+ 'apt': 'sudo apt-get install tshark',
+ 'brew': 'brew install wireshark',
+ 'manual': 'https://www.wireshark.org/download.html'
+ }
+ }
+ }
}
}