mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Set QT_QPA_PLATFORM=offscreen for both grgsm_livemon and grgsm_scanner to prevent SIGABRT when no X11 display is available. grgsm_livemon uses GNU Radio which loads Qt plugins — without a display, Qt aborts with "could not connect to display". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1731 lines
66 KiB
Python
1731 lines
66 KiB
Python
"""GSM Spy route handlers for cellular tower and device tracking."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import queue
|
|
import re
|
|
import shutil
|
|
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.process import register_process, safe_terminate, unregister_process
|
|
from utils.sse import format_sse
|
|
from utils.validation import validate_device_index
|
|
|
|
from utils.logging import get_logger
|
|
logger = get_logger('intercept.gsm_spy')
|
|
logger.setLevel(logging.DEBUG) # GSM Spy needs verbose logging for diagnostics
|
|
|
|
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': {
|
|
'GSM800': {'start': 832e6, 'end': 862e6, 'arfcn_start': 438, 'arfcn_end': 511}, # E-GSM800 downlink
|
|
'GSM850': {'start': 869e6, 'end': 894e6, 'arfcn_start': 128, 'arfcn_end': 251}, # Also used in some EU countries
|
|
'EGSM900': {'start': 925e6, 'end': 960e6, 'arfcn_start': 0, 'arfcn_end': 124},
|
|
'DCS1800': {'start': 1805e6, 'end': 1880e6, 'arfcn_start': 512, 'arfcn_end': 885}
|
|
},
|
|
'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
|
|
|
|
# Geocoding worker state
|
|
_geocoding_worker_thread = None
|
|
|
|
|
|
# ============================================
|
|
# 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
|
|
|
|
|
|
# ============================================
|
|
# Background Geocoding Worker
|
|
# ============================================
|
|
|
|
def start_geocoding_worker():
|
|
"""Start background thread for async geocoding."""
|
|
global _geocoding_worker_thread
|
|
if _geocoding_worker_thread is None or not _geocoding_worker_thread.is_alive():
|
|
_geocoding_worker_thread = threading.Thread(
|
|
target=geocoding_worker,
|
|
daemon=True,
|
|
name='gsm-geocoding-worker'
|
|
)
|
|
_geocoding_worker_thread.start()
|
|
logger.info("Started geocoding worker thread")
|
|
|
|
|
|
def geocoding_worker():
|
|
"""Worker thread processes pending geocoding requests."""
|
|
from utils.gsm_geocoding import lookup_cell_from_api, get_geocoding_queue
|
|
|
|
geocoding_queue = get_geocoding_queue()
|
|
|
|
while True:
|
|
try:
|
|
# Wait for pending tower with timeout
|
|
tower_data = geocoding_queue.get(timeout=5)
|
|
|
|
# Check rate limit
|
|
if not can_use_api():
|
|
current_usage = get_api_usage_today()
|
|
logger.warning(f"OpenCellID API rate limit reached ({current_usage}/{config.GSM_API_DAILY_LIMIT})")
|
|
geocoding_queue.task_done()
|
|
continue
|
|
|
|
# Call API
|
|
mcc = tower_data.get('mcc')
|
|
mnc = tower_data.get('mnc')
|
|
lac = tower_data.get('lac')
|
|
cid = tower_data.get('cid')
|
|
|
|
logger.debug(f"Geocoding tower via API: MCC={mcc} MNC={mnc} LAC={lac} CID={cid}")
|
|
|
|
coords = lookup_cell_from_api(mcc, mnc, lac, cid)
|
|
|
|
if coords:
|
|
# Update tower data with coordinates
|
|
tower_data['lat'] = coords['lat']
|
|
tower_data['lon'] = coords['lon']
|
|
tower_data['source'] = 'api'
|
|
tower_data['status'] = 'resolved'
|
|
tower_data['type'] = 'tower_update'
|
|
|
|
# Add optional fields if available
|
|
if coords.get('azimuth') is not None:
|
|
tower_data['azimuth'] = coords['azimuth']
|
|
if coords.get('range_meters') is not None:
|
|
tower_data['range_meters'] = coords['range_meters']
|
|
if coords.get('operator'):
|
|
tower_data['operator'] = coords['operator']
|
|
if coords.get('radio'):
|
|
tower_data['radio'] = coords['radio']
|
|
|
|
# Update DataStore
|
|
key = f"{mcc}_{mnc}_{lac}_{cid}"
|
|
app_module.gsm_spy_towers[key] = tower_data
|
|
|
|
# Send update to SSE stream
|
|
try:
|
|
app_module.gsm_spy_queue.put_nowait(tower_data)
|
|
logger.info(f"Resolved coordinates for tower: MCC={mcc} MNC={mnc} LAC={lac} CID={cid}")
|
|
except queue.Full:
|
|
logger.warning("SSE queue full, dropping tower update")
|
|
|
|
# Increment API usage counter
|
|
usage_count = increment_api_usage()
|
|
logger.info(f"OpenCellID API call #{usage_count} today")
|
|
|
|
else:
|
|
logger.warning(f"Could not resolve coordinates for tower: MCC={mcc} MNC={mnc} LAC={lac} CID={cid}")
|
|
|
|
geocoding_queue.task_done()
|
|
|
|
# Rate limiting between API calls (be nice to OpenCellID)
|
|
time.sleep(1)
|
|
|
|
except queue.Empty:
|
|
# No pending towers, continue waiting
|
|
continue
|
|
except Exception as e:
|
|
logger.error(f"Geocoding worker error: {e}", exc_info=True)
|
|
time.sleep(1)
|
|
|
|
|
|
def arfcn_to_frequency(arfcn):
|
|
"""Convert ARFCN to downlink frequency in Hz.
|
|
|
|
Uses REGIONAL_BANDS to determine the correct band and conversion formula.
|
|
Returns frequency in Hz (e.g., 925800000 for 925.8 MHz).
|
|
"""
|
|
arfcn = int(arfcn)
|
|
|
|
# Search all bands to find which one this ARFCN belongs to
|
|
for region_bands in REGIONAL_BANDS.values():
|
|
for band_name, band_info in region_bands.items():
|
|
arfcn_start = band_info['arfcn_start']
|
|
arfcn_end = band_info['arfcn_end']
|
|
|
|
if arfcn_start <= arfcn <= arfcn_end:
|
|
# Found the right band, calculate frequency
|
|
# Downlink frequency = band_start + (arfcn - arfcn_start) * 200kHz
|
|
freq_hz = band_info['start'] + (arfcn - arfcn_start) * 200000
|
|
return int(freq_hz)
|
|
|
|
# If ARFCN not found in any band, raise error
|
|
raise ValueError(f"ARFCN {arfcn} not found in any known GSM band")
|
|
|
|
|
|
def validate_band_names(bands: list[str], region: str) -> tuple[list[str], str | None]:
|
|
"""Validate band names against REGIONAL_BANDS whitelist.
|
|
|
|
Args:
|
|
bands: List of band names from user input
|
|
region: Region name (Americas, Europe, Asia)
|
|
|
|
Returns:
|
|
Tuple of (validated_bands, error_message)
|
|
"""
|
|
if not bands:
|
|
return [], None
|
|
|
|
region_bands = REGIONAL_BANDS.get(region)
|
|
if not region_bands:
|
|
return [], f"Invalid region: {region}"
|
|
|
|
valid_band_names = set(region_bands.keys())
|
|
invalid_bands = [b for b in bands if b not in valid_band_names]
|
|
|
|
if invalid_bands:
|
|
return [], (f"Invalid bands for {region}: {', '.join(invalid_bands)}. "
|
|
f"Valid bands: {', '.join(sorted(valid_band_names))}")
|
|
|
|
return bands, None
|
|
|
|
|
|
def _start_monitoring_processes(arfcn: int, device_index: int) -> tuple[subprocess.Popen, subprocess.Popen]:
|
|
"""Start grgsm_livemon and tshark processes for monitoring an ARFCN.
|
|
|
|
Returns:
|
|
Tuple of (grgsm_process, tshark_process)
|
|
|
|
Raises:
|
|
FileNotFoundError: If grgsm_livemon or tshark not found
|
|
RuntimeError: If grgsm_livemon exits immediately
|
|
"""
|
|
frequency_hz = arfcn_to_frequency(arfcn)
|
|
frequency_mhz = frequency_hz / 1e6
|
|
|
|
# Check prerequisites
|
|
if not shutil.which('grgsm_livemon'):
|
|
raise FileNotFoundError('grgsm_livemon not found. Please install gr-gsm.')
|
|
|
|
# Start grgsm_livemon
|
|
grgsm_cmd = [
|
|
'grgsm_livemon',
|
|
'--args', f'rtl={device_index}',
|
|
'-f', f'{frequency_mhz}M'
|
|
]
|
|
env = dict(os.environ,
|
|
OSMO_FSM_DUP_CHECK_DISABLED='1',
|
|
PYTHONUNBUFFERED='1',
|
|
QT_QPA_PLATFORM='offscreen')
|
|
logger.info(f"Starting grgsm_livemon: {' '.join(grgsm_cmd)}")
|
|
grgsm_proc = subprocess.Popen(
|
|
grgsm_cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
universal_newlines=True,
|
|
env=env
|
|
)
|
|
register_process(grgsm_proc)
|
|
logger.info(f"Started grgsm_livemon (PID: {grgsm_proc.pid})")
|
|
|
|
# Wait and check it didn't die immediately
|
|
time.sleep(2)
|
|
|
|
if grgsm_proc.poll() is not None:
|
|
# Process already exited - capture stderr for diagnostics
|
|
stderr_output = ''
|
|
try:
|
|
stderr_output = grgsm_proc.stderr.read()
|
|
except Exception:
|
|
pass
|
|
exit_code = grgsm_proc.returncode
|
|
logger.error(
|
|
f"grgsm_livemon exited immediately (code: {exit_code}). "
|
|
f"stderr: {stderr_output[:500]}"
|
|
)
|
|
unregister_process(grgsm_proc)
|
|
raise RuntimeError(
|
|
f'grgsm_livemon failed (exit code {exit_code}): {stderr_output[:200]}'
|
|
)
|
|
|
|
# Start stderr reader thread for grgsm_livemon diagnostics
|
|
def read_livemon_stderr():
|
|
try:
|
|
for line in iter(grgsm_proc.stderr.readline, ''):
|
|
if line:
|
|
logger.debug(f"grgsm_livemon stderr: {line.strip()}")
|
|
except Exception:
|
|
pass
|
|
threading.Thread(target=read_livemon_stderr, daemon=True).start()
|
|
|
|
# Start tshark
|
|
if not shutil.which('tshark'):
|
|
safe_terminate(grgsm_proc)
|
|
unregister_process(grgsm_proc)
|
|
raise FileNotFoundError('tshark not found. Please install wireshark/tshark.')
|
|
|
|
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 tshark: {' '.join(tshark_cmd)}")
|
|
tshark_proc = subprocess.Popen(
|
|
tshark_cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
universal_newlines=True,
|
|
bufsize=1
|
|
)
|
|
register_process(tshark_proc)
|
|
logger.info(f"Started tshark (PID: {tshark_proc.pid})")
|
|
|
|
return grgsm_proc, tshark_proc
|
|
|
|
|
|
def _start_and_register_monitor(arfcn: int, device_index: int) -> None:
|
|
"""Start monitoring processes and register them in global state.
|
|
|
|
This is shared logic between start_monitor() and auto_start_monitor().
|
|
Must be called within gsm_spy_lock context.
|
|
|
|
Args:
|
|
arfcn: ARFCN to monitor
|
|
device_index: SDR device index
|
|
"""
|
|
# Start monitoring processes
|
|
grgsm_proc, tshark_proc = _start_monitoring_processes(arfcn, device_index)
|
|
app_module.gsm_spy_livemon_process = grgsm_proc
|
|
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()
|
|
|
|
|
|
@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_scanner_running:
|
|
return jsonify({'error': 'Scanner already running'}), 400
|
|
|
|
data = request.get_json() or {}
|
|
device_index = data.get('device', 0)
|
|
region = data.get('region', 'Americas')
|
|
selected_bands = data.get('bands', []) # Get user-selected bands
|
|
|
|
# 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,
|
|
'error_type': 'DEVICE_BUSY'
|
|
}), 409
|
|
|
|
# If no bands selected, use all bands for the region (backwards compatibility)
|
|
if selected_bands:
|
|
validated_bands, error = validate_band_names(selected_bands, region)
|
|
if error:
|
|
from app import release_sdr_device
|
|
release_sdr_device(device_index)
|
|
return jsonify({'error': error}), 400
|
|
selected_bands = validated_bands
|
|
else:
|
|
region_bands = REGIONAL_BANDS.get(region, REGIONAL_BANDS['Americas'])
|
|
selected_bands = list(region_bands.keys())
|
|
logger.warning(f"No bands specified, using all bands for {region}: {selected_bands}")
|
|
|
|
# Build grgsm_scanner command
|
|
# Example: grgsm_scanner --args="rtl=0" -b GSM900
|
|
if not shutil.which('grgsm_scanner'):
|
|
from app import release_sdr_device
|
|
release_sdr_device(device_index)
|
|
return jsonify({'error': 'grgsm_scanner not found. Please install gr-gsm.'}), 500
|
|
|
|
try:
|
|
cmd = ['grgsm_scanner']
|
|
|
|
# Add device argument (--args for RTL-SDR device selection)
|
|
cmd.extend(['--args', f'rtl={device_index}'])
|
|
|
|
# Add selected band arguments
|
|
# Map EGSM900 to GSM900 since that's what grgsm_scanner expects
|
|
for band_name in selected_bands:
|
|
# Normalize band name (EGSM900 -> GSM900, remove EGSM prefix)
|
|
normalized_band = band_name.replace('EGSM', 'GSM')
|
|
cmd.extend(['-b', normalized_band])
|
|
|
|
logger.info(f"Starting GSM scanner: {' '.join(cmd)}")
|
|
|
|
# Set a flag to indicate scanner should run
|
|
app_module.gsm_spy_active_device = device_index
|
|
app_module.gsm_spy_region = region
|
|
app_module.gsm_spy_scanner_running = True # Use as flag initially
|
|
|
|
# Reset counters for new session
|
|
gsm_towers_found = 0
|
|
gsm_devices_tracked = 0
|
|
|
|
# Start geocoding worker (if not already running)
|
|
start_geocoding_worker()
|
|
|
|
# Start scanning thread (will run grgsm_scanner in a loop)
|
|
scanner_thread_obj = threading.Thread(
|
|
target=scanner_thread,
|
|
args=(cmd, device_index),
|
|
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
|
|
|
|
# Validate ARFCN is valid integer and in known GSM band ranges
|
|
try:
|
|
arfcn = int(arfcn)
|
|
# This will raise ValueError if ARFCN is not in any known band
|
|
arfcn_to_frequency(arfcn)
|
|
except (ValueError, TypeError) as e:
|
|
return jsonify({'error': f'Invalid ARFCN: {e}'}), 400
|
|
|
|
# Validate device index
|
|
try:
|
|
device_index = validate_device_index(device_index)
|
|
except ValueError as e:
|
|
return jsonify({'error': str(e)}), 400
|
|
|
|
try:
|
|
# Start and register monitoring (shared logic)
|
|
_start_and_register_monitor(arfcn, device_index)
|
|
|
|
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, gsm_towers_found, gsm_devices_tracked
|
|
|
|
with app_module.gsm_spy_lock:
|
|
killed = []
|
|
|
|
# Stop scanner (now just a flag, thread will see it and exit)
|
|
if app_module.gsm_spy_scanner_running:
|
|
app_module.gsm_spy_scanner_running = False
|
|
killed.append('scanner')
|
|
|
|
# Terminate livemon process
|
|
if app_module.gsm_spy_livemon_process:
|
|
unregister_process(app_module.gsm_spy_livemon_process)
|
|
if safe_terminate(app_module.gsm_spy_livemon_process, timeout=5):
|
|
killed.append('livemon')
|
|
app_module.gsm_spy_livemon_process = None
|
|
|
|
# Terminate monitor process
|
|
if app_module.gsm_spy_monitor_process:
|
|
unregister_process(app_module.gsm_spy_monitor_process)
|
|
if safe_terminate(app_module.gsm_spy_monitor_process, timeout=5):
|
|
killed.append('monitor')
|
|
app_module.gsm_spy_monitor_process = None
|
|
|
|
# Release SDR device from registry
|
|
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)
|
|
app_module.gsm_spy_active_device = None
|
|
app_module.gsm_spy_selected_arfcn = None
|
|
gsm_connected = False
|
|
gsm_towers_found = 0
|
|
gsm_devices_tracked = 0
|
|
|
|
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."""
|
|
logger.info("SSE stream connected - client subscribed")
|
|
|
|
# Send current state on connect (handles reconnects and late-joining clients)
|
|
existing_towers = dict(app_module.gsm_spy_towers.items())
|
|
logger.info(f"SSE sending {len(existing_towers)} existing towers on connect")
|
|
for key, tower_data in existing_towers.items():
|
|
yield format_sse(tower_data)
|
|
|
|
last_keepalive = time.time()
|
|
idle_count = 0 # Track consecutive idle checks to handle transitions
|
|
|
|
while True:
|
|
try:
|
|
# Check if scanner/monitor are still running
|
|
# Use idle counter to avoid disconnecting during scanner→monitor transition
|
|
if not app_module.gsm_spy_scanner_running and not app_module.gsm_spy_monitor_process:
|
|
idle_count += 1
|
|
if idle_count >= 5: # 5 seconds grace period for mode transitions
|
|
logger.info("SSE stream: no active scanner or monitor, disconnecting")
|
|
yield format_sse({'type': 'disconnected'})
|
|
break
|
|
else:
|
|
idle_count = 0
|
|
|
|
# Try to get data from queue
|
|
try:
|
|
data = app_module.gsm_spy_queue.get(timeout=1)
|
|
logger.info(f"SSE sending: type={data.get('type', '?')} keys={list(data.keys())}")
|
|
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:
|
|
logger.info("SSE stream: client disconnected (GeneratorExit)")
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"Error in GSM stream: {e}")
|
|
yield format_sse({'type': 'error', 'message': str(e)})
|
|
break
|
|
|
|
response = Response(generate(), mimetype='text/event-stream')
|
|
response.headers['Cache-Control'] = 'no-cache'
|
|
response.headers['X-Accel-Buffering'] = 'no'
|
|
response.headers['Connection'] = 'keep-alive'
|
|
return response
|
|
|
|
|
|
@gsm_spy_bp.route('/status')
|
|
def status():
|
|
"""Get current GSM Spy status."""
|
|
api_usage = get_api_usage_today()
|
|
return jsonify({
|
|
'running': bool(app_module.gsm_spy_scanner_running),
|
|
'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 empty results gracefully when no device selected
|
|
return jsonify({
|
|
'device_id': None,
|
|
'patterns': [],
|
|
'message': 'No device selected'
|
|
}), 200
|
|
|
|
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 empty results gracefully when no tower selected
|
|
return jsonify({
|
|
'cid': None,
|
|
'neighbors': [],
|
|
'inconsistencies': [],
|
|
'message': 'No tower selected'
|
|
}), 200
|
|
|
|
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.
|
|
|
|
Actual output format (comma-separated key-value pairs):
|
|
ARFCN: 975, Freq: 925.2M, CID: 13522, LAC: 38722, MCC: 262, MNC: 1, Pwr: -58
|
|
"""
|
|
try:
|
|
line = line.strip()
|
|
|
|
# Skip non-data lines (progress, config, neighbour info, blank)
|
|
if not line or 'ARFCN:' not in line:
|
|
return None
|
|
|
|
# Parse "ARFCN: 975, Freq: 925.2M, CID: 13522, LAC: 38722, MCC: 262, MNC: 1, Pwr: -58"
|
|
fields = {}
|
|
for part in line.split(','):
|
|
part = part.strip()
|
|
if ':' in part:
|
|
key, _, value = part.partition(':')
|
|
fields[key.strip()] = value.strip()
|
|
|
|
if 'ARFCN' in fields and 'CID' in fields:
|
|
cid = int(fields.get('CID', 0))
|
|
mcc = int(fields.get('MCC', 0))
|
|
mnc = int(fields.get('MNC', 0))
|
|
|
|
# Only skip entries with no network identity at all (MCC=0 AND MNC=0)
|
|
# CID=0 with valid MCC/MNC is a partially decoded cell - still useful
|
|
if mcc == 0 and mnc == 0:
|
|
logger.debug(f"Skipping unidentified ARFCN (MCC=0, MNC=0): {line}")
|
|
return None
|
|
|
|
# Freq may have 'M' suffix (e.g. "925.2M")
|
|
freq_str = fields.get('Freq', '0').rstrip('Mm')
|
|
|
|
data = {
|
|
'type': 'tower',
|
|
'arfcn': int(fields['ARFCN']),
|
|
'frequency': float(freq_str),
|
|
'cid': cid,
|
|
'lac': int(fields.get('LAC', 0)),
|
|
'mcc': mcc,
|
|
'mnc': mnc,
|
|
'signal_strength': float(fields.get('Pwr', -999)),
|
|
'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 and register monitoring (shared logic)
|
|
_start_and_register_monitor(arfcn, device_index)
|
|
|
|
# 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(cmd, device_index):
|
|
"""Thread to continuously run grgsm_scanner in a loop with non-blocking I/O.
|
|
|
|
grgsm_scanner scans once and exits, so we loop it to provide
|
|
continuous updates to the dashboard.
|
|
"""
|
|
global gsm_towers_found
|
|
|
|
strongest_tower = None
|
|
auto_monitor_triggered = False # Moved outside loop - persists across scans
|
|
scan_count = 0
|
|
crash_count = 0
|
|
process = None
|
|
|
|
try:
|
|
while app_module.gsm_spy_scanner_running: # Flag check
|
|
scan_count += 1
|
|
logger.info(f"Starting GSM scan #{scan_count}")
|
|
|
|
try:
|
|
# Start scanner process
|
|
# Set OSMO_FSM_DUP_CHECK_DISABLED to prevent libosmocore
|
|
# abort on duplicate FSM registration (common with apt gr-gsm)
|
|
env = dict(os.environ,
|
|
OSMO_FSM_DUP_CHECK_DISABLED='1',
|
|
PYTHONUNBUFFERED='1',
|
|
QT_QPA_PLATFORM='offscreen')
|
|
process = subprocess.Popen(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
universal_newlines=True,
|
|
bufsize=1,
|
|
env=env
|
|
)
|
|
register_process(process)
|
|
logger.info(f"Started grgsm_scanner (PID: {process.pid})")
|
|
|
|
# Standard pattern: reader threads with queue
|
|
output_queue_local = queue.Queue()
|
|
|
|
def read_stdout():
|
|
try:
|
|
for line in iter(process.stdout.readline, ''):
|
|
if line:
|
|
output_queue_local.put(('stdout', line))
|
|
except Exception as e:
|
|
logger.error(f"stdout read error: {e}")
|
|
finally:
|
|
output_queue_local.put(('eof', None))
|
|
|
|
def read_stderr():
|
|
try:
|
|
for line in iter(process.stderr.readline, ''):
|
|
if line:
|
|
logger.debug(f"grgsm_scanner stderr: {line.strip()}")
|
|
# grgsm_scanner outputs scan results to stderr
|
|
output_queue_local.put(('stderr', line))
|
|
except Exception as e:
|
|
logger.error(f"stderr read error: {e}")
|
|
|
|
stdout_thread = threading.Thread(target=read_stdout, daemon=True)
|
|
stderr_thread = threading.Thread(target=read_stderr, daemon=True)
|
|
stdout_thread.start()
|
|
stderr_thread.start()
|
|
|
|
# Process output with timeout
|
|
scan_start = time.time()
|
|
last_output = scan_start
|
|
scan_timeout = 300 # 5 minute maximum per scan (4 bands takes ~2-3 min)
|
|
|
|
while app_module.gsm_spy_scanner_running:
|
|
# Check if process died
|
|
if process.poll() is not None:
|
|
logger.info(f"Scanner exited (code: {process.returncode})")
|
|
break
|
|
|
|
# Get output from queue with timeout
|
|
try:
|
|
msg_type, line = output_queue_local.get(timeout=1.0)
|
|
|
|
if msg_type == 'eof':
|
|
break # EOF
|
|
|
|
last_output = time.time()
|
|
stripped = line.strip()
|
|
logger.info(f"Scanner [{msg_type}]: {stripped}")
|
|
|
|
# Forward progress and status info to frontend
|
|
progress_match = re.match(r'Scanning:\s+([\d.]+)%\s+done', stripped)
|
|
if progress_match:
|
|
try:
|
|
app_module.gsm_spy_queue.put_nowait({
|
|
'type': 'progress',
|
|
'percent': float(progress_match.group(1)),
|
|
'scan': scan_count
|
|
})
|
|
except queue.Full:
|
|
pass
|
|
continue
|
|
if stripped.startswith('Try scan CCCH'):
|
|
try:
|
|
app_module.gsm_spy_queue.put_nowait({
|
|
'type': 'status',
|
|
'message': stripped,
|
|
'scan': scan_count
|
|
})
|
|
except queue.Full:
|
|
pass
|
|
|
|
parsed = parse_grgsm_scanner_output(line)
|
|
if parsed:
|
|
# Enrich with coordinates
|
|
from utils.gsm_geocoding import enrich_tower_data
|
|
enriched = enrich_tower_data(parsed)
|
|
|
|
# Store in DataStore
|
|
key = f"{enriched['mcc']}_{enriched['mnc']}_{enriched['lac']}_{enriched['cid']}"
|
|
app_module.gsm_spy_towers[key] = enriched
|
|
|
|
# Track strongest tower
|
|
signal = enriched.get('signal_strength', -999)
|
|
if strongest_tower is None or signal > strongest_tower.get('signal_strength', -999):
|
|
strongest_tower = enriched
|
|
|
|
# Queue for SSE
|
|
try:
|
|
app_module.gsm_spy_queue.put_nowait(enriched)
|
|
except queue.Full:
|
|
logger.warning("Queue full, dropping tower update")
|
|
|
|
# Thread-safe counter update
|
|
with app_module.gsm_spy_lock:
|
|
gsm_towers_found += 1
|
|
except queue.Empty:
|
|
# No output, check timeout
|
|
if time.time() - last_output > scan_timeout:
|
|
logger.warning(f"Scan timeout after {scan_timeout}s")
|
|
break
|
|
|
|
# Drain remaining queue items after process exits
|
|
while not output_queue_local.empty():
|
|
try:
|
|
msg_type, line = output_queue_local.get_nowait()
|
|
if line:
|
|
logger.info(f"Scanner [{msg_type}] (drain): {line.strip()}")
|
|
except queue.Empty:
|
|
break
|
|
|
|
# Clean up process with timeout
|
|
if process.poll() is None:
|
|
logger.info("Terminating scanner process")
|
|
safe_terminate(process, timeout=5)
|
|
else:
|
|
process.wait() # Reap zombie
|
|
|
|
exit_code = process.returncode
|
|
scan_duration = time.time() - scan_start
|
|
logger.info(f"Scan #{scan_count} complete (exit code: {exit_code}, duration: {scan_duration:.1f}s)")
|
|
|
|
# Notify frontend scan completed
|
|
try:
|
|
app_module.gsm_spy_queue.put_nowait({
|
|
'type': 'scan_complete',
|
|
'scan': scan_count,
|
|
'duration': round(scan_duration, 1),
|
|
'towers_found': gsm_towers_found
|
|
})
|
|
except queue.Full:
|
|
pass
|
|
|
|
# Detect crash pattern: process exits too quickly with no data
|
|
if scan_duration < 5 and exit_code != 0:
|
|
crash_count += 1
|
|
logger.error(
|
|
f"grgsm_scanner crashed on startup (exit code: {exit_code}). "
|
|
f"Crash count: {crash_count}. Check gr-gsm/libosmocore compatibility."
|
|
)
|
|
try:
|
|
app_module.gsm_spy_queue.put_nowait({
|
|
'type': 'error',
|
|
'message': f'grgsm_scanner crashed (exit code: {exit_code}). '
|
|
'This may be a gr-gsm/libosmocore compatibility issue. '
|
|
'Try rebuilding gr-gsm from source.',
|
|
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S')
|
|
})
|
|
except queue.Full:
|
|
pass
|
|
if crash_count >= 3:
|
|
logger.error("grgsm_scanner crashed 3 times, stopping scanner")
|
|
break
|
|
|
|
except FileNotFoundError:
|
|
logger.error(
|
|
"grgsm_scanner not found. Please install gr-gsm: "
|
|
"https://github.com/bkerler/gr-gsm"
|
|
)
|
|
# Send error to SSE stream so the UI knows
|
|
try:
|
|
app_module.gsm_spy_queue.put({
|
|
'type': 'error',
|
|
'message': 'grgsm_scanner not found. Please install gr-gsm.',
|
|
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S')
|
|
})
|
|
except Exception:
|
|
pass
|
|
break # Don't retry - binary won't appear
|
|
|
|
except Exception as e:
|
|
logger.error(f"Scanner scan error: {e}", exc_info=True)
|
|
if process and process.poll() is None:
|
|
safe_terminate(process)
|
|
|
|
# Check if should continue
|
|
if not app_module.gsm_spy_scanner_running:
|
|
break
|
|
|
|
# After first scan completes: auto-switch to monitoring if towers found
|
|
# Scanner process has exited so SDR is free for grgsm_livemon
|
|
if not auto_monitor_triggered and strongest_tower and scan_count >= 1:
|
|
auto_monitor_triggered = True
|
|
arfcn = strongest_tower.get('arfcn')
|
|
signal = strongest_tower.get('signal_strength', -999)
|
|
logger.info(
|
|
f"Scan complete with towers found. Auto-switching to monitor mode "
|
|
f"on ARFCN {arfcn} (signal: {signal} dBm)"
|
|
)
|
|
|
|
# Stop scanner loop - SDR needed for monitoring
|
|
app_module.gsm_spy_scanner_running = False
|
|
|
|
try:
|
|
app_module.gsm_spy_queue.put_nowait({
|
|
'type': 'status',
|
|
'message': f'Switching to monitor mode on ARFCN {arfcn}...'
|
|
})
|
|
except queue.Full:
|
|
pass
|
|
|
|
# Start monitoring (SDR is free since scanner process exited)
|
|
try:
|
|
with app_module.gsm_spy_lock:
|
|
if not app_module.gsm_spy_monitor_process:
|
|
_start_and_register_monitor(arfcn, device_index)
|
|
logger.info(f"Auto-monitoring started for ARFCN {arfcn}")
|
|
|
|
try:
|
|
app_module.gsm_spy_queue.put_nowait({
|
|
'type': 'auto_monitor_started',
|
|
'arfcn': arfcn,
|
|
'tower': strongest_tower
|
|
})
|
|
except queue.Full:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"Error starting auto-monitor: {e}", exc_info=True)
|
|
try:
|
|
app_module.gsm_spy_queue.put_nowait({
|
|
'type': 'error',
|
|
'message': f'Monitor failed: {e}'
|
|
})
|
|
except queue.Full:
|
|
pass
|
|
# Resume scanning if monitor failed
|
|
app_module.gsm_spy_scanner_running = True
|
|
|
|
break # Exit scanner loop (monitoring takes over)
|
|
|
|
# Wait between scans with responsive flag checking
|
|
logger.info("Waiting 5 seconds before next scan")
|
|
for i in range(5):
|
|
if not app_module.gsm_spy_scanner_running:
|
|
break
|
|
time.sleep(1)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Scanner thread fatal error: {e}", exc_info=True)
|
|
|
|
finally:
|
|
# Always cleanup
|
|
if process and process.poll() is None:
|
|
safe_terminate(process, timeout=5)
|
|
|
|
logger.info("Scanner thread terminated")
|
|
|
|
# Reset global state - but don't release SDR if monitoring took over
|
|
with app_module.gsm_spy_lock:
|
|
app_module.gsm_spy_scanner_running = False
|
|
if app_module.gsm_spy_monitor_process is None:
|
|
# No monitor running - 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)
|
|
app_module.gsm_spy_active_device = None
|
|
else:
|
|
logger.info("Monitor is running, keeping SDR device allocated")
|
|
|
|
|
|
def monitor_thread(process):
|
|
"""Thread to read tshark output using standard iter pattern."""
|
|
global gsm_devices_tracked
|
|
|
|
# Standard pattern: reader thread with queue
|
|
output_queue_local = queue.Queue()
|
|
|
|
def read_stdout():
|
|
try:
|
|
for line in iter(process.stdout.readline, ''):
|
|
if line:
|
|
output_queue_local.put(('stdout', line))
|
|
except Exception as e:
|
|
logger.error(f"tshark read error: {e}")
|
|
finally:
|
|
output_queue_local.put(('eof', None))
|
|
|
|
stdout_thread = threading.Thread(target=read_stdout, daemon=True)
|
|
stdout_thread.start()
|
|
|
|
try:
|
|
while app_module.gsm_spy_monitor_process:
|
|
# Check if process died
|
|
if process.poll() is not None:
|
|
logger.info(f"Monitor process exited (code: {process.returncode})")
|
|
break
|
|
|
|
# Get output from queue with timeout
|
|
try:
|
|
msg_type, line = output_queue_local.get(timeout=1.0)
|
|
except queue.Empty:
|
|
continue # Timeout, check flag again
|
|
|
|
if msg_type == 'eof':
|
|
break # EOF
|
|
|
|
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}")
|
|
|
|
# Thread-safe counter
|
|
with app_module.gsm_spy_lock:
|
|
gsm_devices_tracked += 1
|
|
|
|
except Exception as e:
|
|
logger.error(f"Monitor thread error: {e}", exc_info=True)
|
|
|
|
finally:
|
|
# Reap process with timeout
|
|
try:
|
|
if process.poll() is None:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
logger.warning("Monitor process didn't terminate, killing")
|
|
process.kill()
|
|
process.wait()
|
|
else:
|
|
process.wait()
|
|
logger.info(f"Monitor process exited with code {process.returncode}")
|
|
except Exception as e:
|
|
logger.error(f"Error reaping monitor process: {e}")
|
|
|
|
logger.info("Monitor thread terminated")
|