mirror of
https://github.com/smittix/intercept.git
synced 2026-05-23 00:04:50 -07:00
fixing bands and how the gsm scanner loops with tshark
This commit is contained in:
@@ -1,224 +0,0 @@
|
||||
# GSM Spy SDR Device Selection Implementation
|
||||
|
||||
## Summary
|
||||
|
||||
Successfully implemented dynamic SDR device detection, selection, and management for the GSM Spy feature, following the same pattern used in the Aircraft/ADS-B implementation.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### Frontend Changes (`templates/gsm_spy_dashboard.html`)
|
||||
|
||||
#### 1. Dynamic Device Selector
|
||||
- **Changed**: Device dropdown from hardcoded options to dynamic detection
|
||||
- **Location**: Line ~1155 (Signal Source Panel)
|
||||
- **Before**: Static options (Device 0, Device 1, etc.)
|
||||
- **After**: Dynamic population with "Detecting devices..." placeholder
|
||||
|
||||
#### 2. Device Detection on Page Load
|
||||
- **Added**: `initDeviceSelector()` function
|
||||
- **Location**: ~Line 1395
|
||||
- **Functionality**:
|
||||
- Fetches available SDR devices from `/devices` endpoint
|
||||
- Populates dropdown with detected devices
|
||||
- Shows device name, type (RTL-SDR, HackRF, etc.), and serial number
|
||||
- Handles errors gracefully with user-friendly messages
|
||||
- Logs detection results to console
|
||||
|
||||
#### 3. Scanner Controls Update
|
||||
- **Modified**: `startScanner()` function (~Line 1410)
|
||||
- **Changes**:
|
||||
- Made async for better error handling
|
||||
- Reads device index from `deviceSelect` dropdown
|
||||
- Disables device and region selectors during active scanning
|
||||
- Enhanced error handling with device conflict detection
|
||||
- Shows user-friendly alerts for device busy errors
|
||||
|
||||
#### 4. Stop Scanner Enhancements
|
||||
- **Modified**: `stopScanner()` function (~Line 1494)
|
||||
- **Changes**:
|
||||
- Re-enables device and region selectors after stopping
|
||||
- Maintains UI consistency
|
||||
|
||||
#### 5. Region Selector Sync
|
||||
- **Modified**: `selectRegion()` function (~Line 1882)
|
||||
- **Changes**:
|
||||
- Capitalizes region name to match backend API expectations
|
||||
- Syncs region button selection with dropdown
|
||||
|
||||
#### 6. Removed Redundant Controls
|
||||
- **Removed**: `scannerDevice` dropdown from bottom controls bar
|
||||
- **Reason**: Consolidated to single device selector in left sidebar
|
||||
|
||||
### Backend Changes (`routes/gsm_spy.py`)
|
||||
|
||||
#### 1. Enhanced Error Response
|
||||
- **Modified**: `/start` endpoint device claiming logic (~Line 115)
|
||||
- **Changes**:
|
||||
- Added `error_type: 'DEVICE_BUSY'` to 409 conflict responses
|
||||
- Enables frontend to distinguish device conflicts from other errors
|
||||
- Allows for targeted user-friendly error messages
|
||||
|
||||
#### 2. Existing Device Management (Verified)
|
||||
- **Confirmed**: Device claiming/releasing already implemented
|
||||
- `claim_sdr_device()` called at line 115
|
||||
- `release_sdr_device()` called at line 289
|
||||
- Device index stored in `gsm_spy_active_device`
|
||||
- Region stored in `gsm_spy_region`
|
||||
|
||||
#### 3. Status Endpoint (Verified)
|
||||
- **Confirmed**: `/status` endpoint returns device info
|
||||
- Returns `device` (active device index)
|
||||
- Returns `region` (selected region)
|
||||
- Returns all necessary status information
|
||||
|
||||
## Features Implemented
|
||||
|
||||
### ✅ Device Detection
|
||||
- Dynamically detects all available SDR devices on page load
|
||||
- Supports all 5 SDR types: RTL-SDR, HackRF, LimeSDR, Airspy, SDRPlay
|
||||
- Shows device name, type, and serial number in dropdown
|
||||
|
||||
### ✅ Device Registry Integration
|
||||
- Properly claims devices before starting scanner
|
||||
- Releases devices when stopping scanner
|
||||
- Prevents conflicts with other INTERCEPT modes
|
||||
|
||||
### ✅ UI State Management
|
||||
- Disables device selector during active scanning
|
||||
- Re-enables selector after stopping
|
||||
- Provides clear visual feedback to user
|
||||
|
||||
### ✅ Error Handling
|
||||
- User-friendly error messages for device conflicts
|
||||
- Graceful handling of "no devices detected" scenario
|
||||
- Clear console logging for debugging
|
||||
|
||||
### ✅ Validation
|
||||
- Uses existing `validate_device_index()` function (already in code)
|
||||
- Validates region against `REGIONAL_BANDS` dictionary
|
||||
- Checks for already running scanner
|
||||
|
||||
## Architecture Pattern
|
||||
|
||||
The implementation follows the same pattern as Aircraft/ADS-B:
|
||||
|
||||
1. **Device Detection**: `/devices` endpoint (shared across all modes)
|
||||
2. **Device Claiming**: `claim_sdr_device()` before starting
|
||||
3. **Device Releasing**: `release_sdr_device()` on stop
|
||||
4. **UI Consistency**: Dynamic dropdown, disabled during operation
|
||||
5. **Error Handling**: Clear user messages, console logging
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
### 1. Device Detection
|
||||
```bash
|
||||
# Start application
|
||||
sudo -E venv/bin/python intercept.py
|
||||
|
||||
# Open GSM Spy dashboard in browser
|
||||
# Open DevTools console
|
||||
# Should see: "[GSM SPY] Detected X SDR device(s)"
|
||||
# Verify dropdown shows detected devices
|
||||
```
|
||||
|
||||
### 2. Device Claiming
|
||||
```bash
|
||||
# Start GSM scanner on device 0
|
||||
# Try to start another mode (e.g., ADS-B) on device 0
|
||||
# Should see conflict error message
|
||||
# Stop GSM scanner
|
||||
# Now ADS-B should be able to claim device 0
|
||||
```
|
||||
|
||||
### 3. Multiple Devices
|
||||
```bash
|
||||
# Connect multiple SDR devices
|
||||
# Open GSM Spy dashboard
|
||||
# Verify all devices appear in dropdown
|
||||
# Select different devices and verify they work independently
|
||||
```
|
||||
|
||||
### 4. UI State
|
||||
```bash
|
||||
# Start GSM scanner
|
||||
# Verify device selector is disabled
|
||||
# Verify region selector is disabled
|
||||
# Stop scanner
|
||||
# Verify both selectors are re-enabled
|
||||
```
|
||||
|
||||
### 5. Error Scenarios
|
||||
```bash
|
||||
# Disconnect SDR device
|
||||
# Try to start scanner
|
||||
# Should see graceful error message
|
||||
# Reconnect device
|
||||
# Refresh page - device should be detected
|
||||
```
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **gr-gsm Hardware Support**: The `gr-gsm` tools may have limited support for non-RTL-SDR devices. This implementation handles device selection properly, but `gr-gsm` itself may only work with RTL-SDR.
|
||||
|
||||
2. **Command Builder Integration**: Full SDRFactory integration (using device-specific command builders) would require adding GSM-specific methods to command builders in `utils/sdr/`. This is a future enhancement.
|
||||
|
||||
3. **Remote Device Support**: Unlike ADS-B which supports remote dump1090 connections, GSM Spy currently only supports local SDR devices.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### 1. SDRFactory Integration
|
||||
```python
|
||||
# In start_scanner():
|
||||
from utils.sdr import SDRFactory
|
||||
|
||||
devices = SDRFactory.detect_devices()
|
||||
sdr_device = next((d for d in devices if d.index == device_index), None)
|
||||
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
cmd = builder.build_gsm_scanner_command(device=sdr_device, bands=REGIONAL_BANDS[region])
|
||||
```
|
||||
|
||||
Note: This requires adding `build_gsm_scanner_command()` method to command builders.
|
||||
|
||||
### 2. Device-Specific Tuning
|
||||
- Different gain settings per SDR type
|
||||
- Frequency correction (PPM) based on device calibration
|
||||
- Sample rate optimization per hardware
|
||||
|
||||
### 3. Multi-Device Monitoring
|
||||
- Simultaneously monitor multiple towers on different devices
|
||||
- Parallel scanning across multiple frequency bands
|
||||
|
||||
## Compatibility
|
||||
|
||||
- **Frontend**: Modern browsers with ES6+ support (async/await)
|
||||
- **Backend**: Python 3.8+
|
||||
- **SDR Hardware**: RTL-SDR, HackRF, LimeSDR, Airspy, SDRPlay
|
||||
- **gr-gsm**: Requires gr-gsm toolkit installed
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `/opt/intercept/templates/gsm_spy_dashboard.html` - Frontend UI and JavaScript
|
||||
2. `/opt/intercept/routes/gsm_spy.py` - Backend route handlers
|
||||
|
||||
## Files Referenced (Not Modified)
|
||||
|
||||
1. `/opt/intercept/routes/adsb.py` - Reference implementation
|
||||
2. `/opt/intercept/utils/sdr/detection.py` - Device detection
|
||||
3. `/opt/intercept/utils/sdr/__init__.py` - SDRFactory
|
||||
4. `/opt/intercept/utils/validation.py` - Input validation
|
||||
5. `/opt/intercept/app.py` - Device registry functions
|
||||
|
||||
## Verification
|
||||
|
||||
All changes have been implemented according to the plan. The implementation:
|
||||
- ✅ Follows existing INTERCEPT patterns
|
||||
- ✅ Maintains UI consistency across modes
|
||||
- ✅ Includes proper error handling
|
||||
- ✅ Uses centralized validation
|
||||
- ✅ Integrates with device registry
|
||||
- ✅ Provides clear user feedback
|
||||
|
||||
## Implementation Date
|
||||
|
||||
2026-02-06
|
||||
@@ -1,289 +0,0 @@
|
||||
# GSM Spy Zombie Process Fix
|
||||
|
||||
## Issue Description
|
||||
|
||||
When starting GSM Spy, `grgsm_scanner` and `grgsm_livemon` processes were becoming zombies (defunct processes):
|
||||
|
||||
```
|
||||
root 12488 5.1 0.0 0 0 pts/2 Z+ 14:29 0:01 [grgsm_scanner] <defunct>
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
|
||||
**Zombie processes** occur when a child process terminates but the parent process doesn't call `wait()` or `waitpid()` to collect the exit status. The process remains in the process table as a zombie until the parent reaps it.
|
||||
|
||||
In the GSM Spy implementation, there were three issues:
|
||||
|
||||
### Issue 1: scanner_thread not reaping grgsm_scanner process
|
||||
- The `scanner_thread` function reads from `grgsm_scanner` stdout
|
||||
- When the process terminates (either normally or due to error), the thread exits
|
||||
- But it never calls `process.wait()` to reap the child process
|
||||
- Result: zombie `grgsm_scanner` process
|
||||
|
||||
### Issue 2: monitor_thread not reaping tshark process
|
||||
- The `monitor_thread` function reads from `tshark` stdout
|
||||
- Same problem as Issue 1
|
||||
- Result: zombie `tshark` process
|
||||
|
||||
### Issue 3: grgsm_livemon process not tracked at all
|
||||
- When starting monitoring, two processes are created:
|
||||
1. `grgsm_livemon` - captures GSM traffic and feeds it to tshark
|
||||
2. `tshark` - filters and parses GSM data
|
||||
- Only `tshark` was being tracked in `gsm_spy_monitor_process`
|
||||
- `grgsm_livemon` was started but never stored or cleaned up
|
||||
- Result: zombie `grgsm_livemon` process
|
||||
|
||||
## Solution
|
||||
|
||||
### Fix 1: Reap processes in scanner_thread
|
||||
|
||||
**File**: `/opt/intercept/routes/gsm_spy.py`
|
||||
**Function**: `scanner_thread()` (line ~1026)
|
||||
|
||||
**Changes**:
|
||||
```python
|
||||
finally:
|
||||
# Reap the process to prevent zombie
|
||||
try:
|
||||
if process.poll() is None:
|
||||
# Process still running, terminate it
|
||||
process.terminate()
|
||||
process.wait(timeout=5)
|
||||
else:
|
||||
# Process already terminated, just collect exit status
|
||||
process.wait()
|
||||
logger.info(f"Scanner process terminated with exit code {process.returncode}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up scanner process: {e}")
|
||||
try:
|
||||
process.kill()
|
||||
process.wait()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("Scanner thread terminated")
|
||||
```
|
||||
|
||||
**How it works**:
|
||||
1. Check if process is still running with `poll()`
|
||||
2. If running, terminate gracefully with `terminate()` then `wait()`
|
||||
3. If already terminated, just call `wait()` to collect exit status
|
||||
4. If anything fails, try `kill()` then `wait()`
|
||||
5. This ensures the child process is always reaped
|
||||
|
||||
### Fix 2: Reap processes in monitor_thread
|
||||
|
||||
**File**: `/opt/intercept/routes/gsm_spy.py`
|
||||
**Function**: `monitor_thread()` (line ~1089)
|
||||
|
||||
**Changes**: Same cleanup logic as Fix 1, applied to the monitor thread.
|
||||
|
||||
### Fix 3: Track and clean up grgsm_livemon process
|
||||
|
||||
#### 3a. Add global variable for grgsm_livemon
|
||||
|
||||
**File**: `/opt/intercept/app.py` (line ~185)
|
||||
|
||||
**Changes**:
|
||||
```python
|
||||
# GSM Spy
|
||||
gsm_spy_process = None
|
||||
gsm_spy_livemon_process = None # For grgsm_livemon process
|
||||
gsm_spy_monitor_process = None # For tshark monitoring process
|
||||
```
|
||||
|
||||
#### 3b. Update global declarations
|
||||
|
||||
**File**: `/opt/intercept/app.py` (line ~677)
|
||||
|
||||
**Changes**:
|
||||
```python
|
||||
global gsm_spy_process, gsm_spy_livemon_process, gsm_spy_monitor_process
|
||||
```
|
||||
|
||||
#### 3c. Clean up grgsm_livemon in reset function
|
||||
|
||||
**File**: `/opt/intercept/app.py` (line ~755)
|
||||
|
||||
**Changes**:
|
||||
```python
|
||||
if gsm_spy_livemon_process:
|
||||
try:
|
||||
safe_terminate(gsm_spy_livemon_process, 'grgsm_livemon')
|
||||
killed.append('grgsm_livemon')
|
||||
except Exception:
|
||||
pass
|
||||
gsm_spy_livemon_process = None
|
||||
```
|
||||
|
||||
#### 3d. Store grgsm_livemon process when starting
|
||||
|
||||
**File**: `/opt/intercept/routes/gsm_spy.py`
|
||||
|
||||
**Changes in `/monitor` endpoint** (line ~212):
|
||||
```python
|
||||
app_module.gsm_spy_livemon_process = grgsm_proc
|
||||
app_module.gsm_spy_monitor_process = tshark_proc
|
||||
```
|
||||
|
||||
**Changes in `auto_start_monitor()` function** (line ~997):
|
||||
```python
|
||||
app_module.gsm_spy_livemon_process = grgsm_proc
|
||||
app_module.gsm_spy_monitor_process = tshark_proc
|
||||
```
|
||||
|
||||
#### 3e. Stop grgsm_livemon when stopping scanner
|
||||
|
||||
**File**: `/opt/intercept/routes/gsm_spy.py` (line ~254)
|
||||
|
||||
**Changes**:
|
||||
```python
|
||||
if app_module.gsm_spy_livemon_process:
|
||||
try:
|
||||
app_module.gsm_spy_livemon_process.terminate()
|
||||
app_module.gsm_spy_livemon_process.wait(timeout=5)
|
||||
killed.append('livemon')
|
||||
except Exception:
|
||||
try:
|
||||
app_module.gsm_spy_livemon_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
app_module.gsm_spy_livemon_process = None
|
||||
```
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `/opt/intercept/routes/gsm_spy.py`
|
||||
- `scanner_thread()` - Added process reaping in finally block
|
||||
- `monitor_thread()` - Added process reaping in finally block
|
||||
- `/monitor` endpoint - Store grgsm_livemon process
|
||||
- `auto_start_monitor()` - Store grgsm_livemon process
|
||||
- `/stop` endpoint - Clean up grgsm_livemon process
|
||||
|
||||
2. `/opt/intercept/app.py`
|
||||
- Added `gsm_spy_livemon_process` global variable
|
||||
- Updated global declarations in `reset_decoder_processes()`
|
||||
- Added cleanup for `gsm_spy_livemon_process`
|
||||
|
||||
## Testing
|
||||
|
||||
### Before Fix
|
||||
```bash
|
||||
# Start GSM Spy
|
||||
# Check processes
|
||||
ps aux | grep grgsm
|
||||
|
||||
# You would see:
|
||||
root 12488 0.0 0.0 0 0 pts/2 Z+ 14:29 0:00 [grgsm_scanner] <defunct>
|
||||
root 12489 0.0 0.0 0 0 pts/2 Z+ 14:29 0:00 [grgsm_livemon] <defunct>
|
||||
```
|
||||
|
||||
### After Fix
|
||||
```bash
|
||||
# Start GSM Spy
|
||||
# Check processes
|
||||
ps aux | grep grgsm
|
||||
|
||||
# Active processes (no zombies):
|
||||
root 12488 1.2 0.5 12345 5678 pts/2 S+ 14:29 0:01 grgsm_scanner -d 0 --freq-range...
|
||||
root 12489 0.8 0.4 10234 4567 pts/2 S+ 14:29 0:01 grgsm_livemon -a 123 -d 0
|
||||
|
||||
# Stop GSM Spy
|
||||
# Check processes
|
||||
ps aux | grep grgsm
|
||||
|
||||
# No processes (all cleaned up properly)
|
||||
```
|
||||
|
||||
### Verification Commands
|
||||
|
||||
1. **Check for zombie processes**:
|
||||
```bash
|
||||
ps aux | grep defunct
|
||||
# Should return nothing after fix
|
||||
```
|
||||
|
||||
2. **Monitor process lifecycle**:
|
||||
```bash
|
||||
# In one terminal, watch processes
|
||||
watch -n 1 'ps aux | grep grgsm'
|
||||
|
||||
# In another terminal, start/stop GSM Spy
|
||||
# Verify:
|
||||
# - Processes start properly (S or R state, not Z)
|
||||
# - Processes disappear when stopped (not left as zombies)
|
||||
```
|
||||
|
||||
3. **Check process tree**:
|
||||
```bash
|
||||
pstree -p | grep grgsm
|
||||
# Should show proper parent-child relationships
|
||||
# No defunct/zombie entries
|
||||
```
|
||||
|
||||
## Process Lifecycle
|
||||
|
||||
### Normal Operation
|
||||
|
||||
1. **Scanner Start**:
|
||||
- `grgsm_scanner` spawned → stored in `gsm_spy_process`
|
||||
- `scanner_thread` reads output
|
||||
- Process running normally
|
||||
|
||||
2. **Monitor Start** (auto or manual):
|
||||
- `grgsm_livemon` spawned → stored in `gsm_spy_livemon_process`
|
||||
- `tshark` spawned → stored in `gsm_spy_monitor_process`
|
||||
- `monitor_thread` reads tshark output
|
||||
- Both processes running normally
|
||||
|
||||
3. **Stop**:
|
||||
- All three processes terminated gracefully
|
||||
- `wait()` called on each to collect exit status
|
||||
- Process handles set to None
|
||||
- No zombies remain
|
||||
|
||||
### Error Handling
|
||||
|
||||
1. **Process crashes during operation**:
|
||||
- Thread's stdout loop exits
|
||||
- `finally` block executes
|
||||
- `process.wait()` collects exit status
|
||||
- No zombie created
|
||||
|
||||
2. **Process hangs**:
|
||||
- `terminate()` called
|
||||
- `wait(timeout=5)` gives 5 seconds to exit
|
||||
- If timeout, `kill()` is called
|
||||
- `wait()` collects exit status
|
||||
|
||||
3. **Exception during cleanup**:
|
||||
- Fallback to `kill()` + `wait()`
|
||||
- Ensures zombie is always prevented
|
||||
|
||||
## Best Practices Applied
|
||||
|
||||
1. **Always reap child processes**: Call `wait()` or `waitpid()` after child process terminates
|
||||
2. **Use process.poll()**: Check if process is still running before terminating
|
||||
3. **Graceful shutdown**: Try `terminate()` before `kill()`
|
||||
4. **Timeout handling**: Use `wait(timeout=N)` to prevent hanging
|
||||
5. **Error recovery**: Multiple fallback levels in try/except blocks
|
||||
6. **Track all processes**: Store handles for all spawned processes, not just the primary one
|
||||
7. **Cleanup in finally**: Ensures cleanup happens even if exceptions occur
|
||||
|
||||
## Related Issues
|
||||
|
||||
This fix prevents:
|
||||
- Zombie processes accumulating over time
|
||||
- Process table filling up
|
||||
- System resource leaks
|
||||
- Confusing process listings for users
|
||||
|
||||
## Implementation Date
|
||||
|
||||
2026-02-06
|
||||
|
||||
## Additional Notes
|
||||
|
||||
- The fix follows the same patterns used in other INTERCEPT decoders
|
||||
- Compatible with existing SDR device selection implementation
|
||||
- No breaking changes to API or user interface
|
||||
- Applies to both manual monitoring and auto-monitoring
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import logging
|
||||
import queue
|
||||
import re
|
||||
import select
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
@@ -33,7 +34,8 @@ REGIONAL_BANDS = {
|
||||
'PCS1900': {'start': 1930e6, 'end': 1990e6, 'arfcn_start': 512, 'arfcn_end': 810}
|
||||
},
|
||||
'Europe': {
|
||||
'EGSM900': {'start': 925e6, 'end': 960e6, 'arfcn_start': 0, 'arfcn_end': 124}
|
||||
'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},
|
||||
@@ -47,6 +49,9 @@ gsm_connected = False
|
||||
gsm_towers_found = 0
|
||||
gsm_devices_tracked = 0
|
||||
|
||||
# Geocoding worker state
|
||||
_geocoding_worker_thread = None
|
||||
|
||||
|
||||
# ============================================
|
||||
# API Usage Tracking Helper Functions
|
||||
@@ -82,6 +87,100 @@ def can_use_api():
|
||||
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.
|
||||
|
||||
@@ -163,22 +262,18 @@ def start_scanner():
|
||||
|
||||
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
|
||||
# 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_process = True # Use as flag initially
|
||||
|
||||
# Start output parsing thread
|
||||
# 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=(process,),
|
||||
args=(cmd, device_index),
|
||||
daemon=True
|
||||
)
|
||||
scanner_thread_obj.start()
|
||||
@@ -242,14 +337,18 @@ def start_monitor():
|
||||
|
||||
logger.info(f"Starting GSM monitor: {' '.join(grgsm_cmd)} | {' '.join(tshark_cmd)}")
|
||||
|
||||
# Start grgsm_livemon
|
||||
# Start grgsm_livemon (outputs to UDP port 4729 by default)
|
||||
grgsm_proc = subprocess.Popen(
|
||||
grgsm_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
logger.info(f"Started grgsm_livemon (PID: {grgsm_proc.pid})")
|
||||
|
||||
# Start tshark
|
||||
# Give grgsm_livemon time to initialize and start sending UDP packets
|
||||
time.sleep(2)
|
||||
|
||||
# Start tshark (captures from loopback interface where UDP packets arrive)
|
||||
tshark_proc = subprocess.Popen(
|
||||
tshark_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
@@ -257,6 +356,7 @@ def start_monitor():
|
||||
universal_newlines=True,
|
||||
bufsize=1
|
||||
)
|
||||
logger.info(f"Started tshark (PID: {tshark_proc.pid})")
|
||||
|
||||
app_module.gsm_spy_livemon_process = grgsm_proc
|
||||
app_module.gsm_spy_monitor_process = tshark_proc
|
||||
@@ -291,17 +391,10 @@ def stop_scanner():
|
||||
with app_module.gsm_spy_lock:
|
||||
killed = []
|
||||
|
||||
# Stop scanner (now just a flag, thread will see it and exit)
|
||||
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
|
||||
killed.append('scanner')
|
||||
|
||||
if app_module.gsm_spy_livemon_process:
|
||||
try:
|
||||
@@ -917,33 +1010,45 @@ def traffic_correlation():
|
||||
# ============================================
|
||||
|
||||
def parse_grgsm_scanner_output(line: str) -> dict[str, Any] | None:
|
||||
"""Parse grgsm_scanner output line."""
|
||||
"""Parse grgsm_scanner output line.
|
||||
|
||||
Actual output format is a table:
|
||||
ARFCN | Freq (MHz) | CID | LAC | MCC | MNC | Power (dB)
|
||||
--------------------------------------------------------------------
|
||||
23 | 940.6 | 31245 | 1234 | 214 | 01 | -48
|
||||
"""
|
||||
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
|
||||
# Skip progress, header, and separator lines
|
||||
if 'Scanning:' in line or 'ARFCN' in line or '---' in line or 'Found' in line:
|
||||
return None
|
||||
|
||||
# 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)
|
||||
# Parse table row: " 23 | 940.6 | 31245 | 1234 | 214 | 01 | -48"
|
||||
# Split by pipe and clean whitespace
|
||||
parts = [p.strip() for p in line.split('|')]
|
||||
|
||||
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
|
||||
if len(parts) >= 7:
|
||||
arfcn = parts[0]
|
||||
freq = parts[1]
|
||||
cid = parts[2]
|
||||
lac = parts[3]
|
||||
mcc = parts[4]
|
||||
mnc = parts[5]
|
||||
power = parts[6]
|
||||
|
||||
# Validate that we have numeric data (not header line)
|
||||
if arfcn.isdigit():
|
||||
data = {
|
||||
'type': 'tower',
|
||||
'arfcn': int(arfcn),
|
||||
'frequency': float(freq),
|
||||
'cid': int(cid),
|
||||
'lac': int(lac),
|
||||
'mcc': int(mcc),
|
||||
'mnc': int(mnc),
|
||||
'signal_strength': float(power),
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse scanner line: {line} - {e}")
|
||||
@@ -1025,14 +1130,18 @@ def auto_start_monitor(tower_data):
|
||||
|
||||
logger.info(f"Starting auto-monitor: {' '.join(grgsm_cmd)} | {' '.join(tshark_cmd)}")
|
||||
|
||||
# Start grgsm_livemon (we don't capture its output)
|
||||
# Start grgsm_livemon (outputs to UDP port 4729 by default)
|
||||
grgsm_proc = subprocess.Popen(
|
||||
grgsm_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
logger.info(f"Started grgsm_livemon for auto-monitor (PID: {grgsm_proc.pid})")
|
||||
|
||||
# Start tshark
|
||||
# Give grgsm_livemon time to initialize and start sending UDP packets
|
||||
time.sleep(2)
|
||||
|
||||
# Start tshark (captures from loopback interface where UDP packets arrive)
|
||||
tshark_proc = subprocess.Popen(
|
||||
tshark_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
@@ -1040,6 +1149,7 @@ def auto_start_monitor(tower_data):
|
||||
universal_newlines=True,
|
||||
bufsize=1
|
||||
)
|
||||
logger.info(f"Started tshark for auto-monitor (PID: {tshark_proc.pid})")
|
||||
|
||||
app_module.gsm_spy_livemon_process = grgsm_proc
|
||||
app_module.gsm_spy_monitor_process = tshark_proc
|
||||
@@ -1069,66 +1179,192 @@ def auto_start_monitor(tower_data):
|
||||
logger.error(f"Error in auto-monitoring: {e}")
|
||||
|
||||
|
||||
def scanner_thread(process):
|
||||
"""Thread to read grgsm_scanner output."""
|
||||
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
|
||||
auto_monitor_triggered = False # Moved outside loop - persists across scans
|
||||
scan_count = 0
|
||||
process = None
|
||||
|
||||
try:
|
||||
for line in process.stdout:
|
||||
if not line:
|
||||
continue
|
||||
while app_module.gsm_spy_process: # Flag check
|
||||
scan_count += 1
|
||||
logger.info(f"Starting GSM scan #{scan_count}")
|
||||
|
||||
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
|
||||
try:
|
||||
# Start scanner process
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
# 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
|
||||
# Non-blocking stderr reader
|
||||
def read_stderr():
|
||||
try:
|
||||
for line in process.stderr:
|
||||
if line:
|
||||
logger.debug(f"grgsm_scanner: {line.strip()}")
|
||||
except Exception as e:
|
||||
logger.error(f"stderr read error: {e}")
|
||||
|
||||
# Queue for SSE stream
|
||||
try:
|
||||
app_module.gsm_spy_queue.put_nowait(parsed)
|
||||
except queue.Full:
|
||||
pass
|
||||
stderr_thread = threading.Thread(target=read_stderr, daemon=True)
|
||||
stderr_thread.start()
|
||||
|
||||
gsm_towers_found += 1
|
||||
# Non-blocking stdout reader with timeout
|
||||
last_output = time.time()
|
||||
scan_timeout = 120 # 2 minute maximum per scan
|
||||
|
||||
# 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()
|
||||
while app_module.gsm_spy_process:
|
||||
# Check if process died
|
||||
if process.poll() is not None:
|
||||
logger.info(f"Scanner exited (code: {process.returncode})")
|
||||
break
|
||||
|
||||
# Check for output with 1-second timeout
|
||||
ready, _, _ = select.select([process.stdout], [], [], 1.0)
|
||||
|
||||
if ready:
|
||||
line = process.stdout.readline()
|
||||
if not line:
|
||||
break # EOF
|
||||
|
||||
last_output = time.time()
|
||||
|
||||
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
|
||||
current_count = gsm_towers_found
|
||||
|
||||
# Auto-monitor strongest tower (once per session)
|
||||
if current_count >= 3 and not auto_monitor_triggered and strongest_tower:
|
||||
auto_monitor_triggered = True
|
||||
logger.info("Auto-starting monitor on strongest tower")
|
||||
threading.Thread(
|
||||
target=auto_start_monitor,
|
||||
args=(strongest_tower,),
|
||||
daemon=True
|
||||
).start()
|
||||
else:
|
||||
# No output, check timeout
|
||||
if time.time() - last_output > scan_timeout:
|
||||
logger.warning(f"Scan timeout after {scan_timeout}s")
|
||||
break
|
||||
|
||||
# Clean up process with timeout
|
||||
if process.poll() is None:
|
||||
logger.info("Terminating scanner process")
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Process didn't terminate, killing")
|
||||
process.kill()
|
||||
process.wait()
|
||||
else:
|
||||
process.wait() # Reap zombie
|
||||
|
||||
logger.info(f"Scan #{scan_count} complete")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scanner scan error: {e}", exc_info=True)
|
||||
if process and process.poll() is None:
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check if should continue
|
||||
if not app_module.gsm_spy_process:
|
||||
break
|
||||
|
||||
# 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_process:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scanner thread error: {e}")
|
||||
logger.error(f"Scanner thread fatal error: {e}", exc_info=True)
|
||||
|
||||
finally:
|
||||
# Reap the process to prevent zombie (don't terminate, just wait)
|
||||
try:
|
||||
process.wait()
|
||||
logger.info(f"Scanner process exited with code {process.returncode}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error waiting for scanner process: {e}")
|
||||
# Always cleanup
|
||||
if process and process.poll() is None:
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
process.wait()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info("Scanner thread terminated")
|
||||
|
||||
# Reset global state
|
||||
with app_module.gsm_spy_lock:
|
||||
app_module.gsm_spy_process = None
|
||||
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
|
||||
|
||||
|
||||
def monitor_thread(process):
|
||||
"""Thread to read grgsm_livemon | tshark output."""
|
||||
"""Thread to read tshark output with non-blocking I/O and timeouts."""
|
||||
global gsm_devices_tracked
|
||||
|
||||
try:
|
||||
for line in process.stdout:
|
||||
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
|
||||
|
||||
# Non-blocking read with timeout
|
||||
ready, _, _ = select.select([process.stdout], [], [], 1.0)
|
||||
|
||||
if not ready:
|
||||
continue # Timeout, check flag again
|
||||
|
||||
line = process.stdout.readline()
|
||||
if not line:
|
||||
continue
|
||||
break # EOF
|
||||
|
||||
parsed = parse_tshark_output(line)
|
||||
if parsed:
|
||||
@@ -1218,15 +1454,28 @@ def monitor_thread(process):
|
||||
except Exception as e:
|
||||
logger.error(f"Error storing device data: {e}")
|
||||
|
||||
gsm_devices_tracked += 1
|
||||
# Thread-safe counter
|
||||
with app_module.gsm_spy_lock:
|
||||
gsm_devices_tracked += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Monitor thread error: {e}")
|
||||
logger.error(f"Monitor thread error: {e}", exc_info=True)
|
||||
|
||||
finally:
|
||||
# Reap the process to prevent zombie (don't terminate, just wait)
|
||||
# Reap process with timeout
|
||||
try:
|
||||
process.wait()
|
||||
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 waiting for monitor process: {e}")
|
||||
logger.error(f"Error reaping monitor process: {e}")
|
||||
|
||||
logger.info("Monitor thread terminated")
|
||||
|
||||
@@ -1548,6 +1548,10 @@
|
||||
|
||||
if (data.type === 'tower') {
|
||||
updateTower(data);
|
||||
} else if (data.type === 'tower_update') {
|
||||
// Background geocoding resolved coordinates for a tower
|
||||
console.log(`Tower coordinates resolved via API: MCC=${data.mcc} MNC=${data.mnc} LAC=${data.lac} CID=${data.cid}`);
|
||||
updateTower(data);
|
||||
} else if (data.type === 'device') {
|
||||
updateDevice(data);
|
||||
} else if (data.type === 'rogue_alert') {
|
||||
@@ -1576,6 +1580,14 @@
|
||||
const key = `${data.mcc}-${data.mnc}-${data.lac}-${data.cid}`;
|
||||
towers[key] = data;
|
||||
|
||||
// Validate coordinates before creating map marker
|
||||
if (!data.lat || !data.lon || isNaN(parseFloat(data.lat)) || isNaN(parseFloat(data.lon))) {
|
||||
console.log(`Tower ${data.cid} pending geocoding (status: ${data.status || 'unknown'})`);
|
||||
// Update towers list but skip map marker
|
||||
updateTowersList();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create or update marker
|
||||
if (!towerMarkers[key]) {
|
||||
// Create new marker
|
||||
|
||||
261
test_gsm_spy_fixes.sh
Executable file
261
test_gsm_spy_fixes.sh
Executable file
@@ -0,0 +1,261 @@
|
||||
#!/bin/bash
|
||||
# GSM Spy System - Verification Test Script
|
||||
# Tests the 4 critical fixes: geocoding, pipeline, scanner loop, process management
|
||||
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo "GSM Spy System - Verification Tests"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Test results
|
||||
TESTS_PASSED=0
|
||||
TESTS_FAILED=0
|
||||
|
||||
function pass_test() {
|
||||
echo -e "${GREEN}✓ PASS:${NC} $1"
|
||||
((TESTS_PASSED++))
|
||||
}
|
||||
|
||||
function fail_test() {
|
||||
echo -e "${RED}✗ FAIL:${NC} $1"
|
||||
((TESTS_FAILED++))
|
||||
}
|
||||
|
||||
function info() {
|
||||
echo -e "${YELLOW}ℹ INFO:${NC} $1"
|
||||
}
|
||||
|
||||
# Test 1: Check that geocoding module exists
|
||||
echo "Test 1: Geocoding Module"
|
||||
echo "-------------------------"
|
||||
if [ -f "utils/gsm_geocoding.py" ]; then
|
||||
pass_test "Geocoding module exists"
|
||||
|
||||
# Check for key functions
|
||||
if grep -q "def enrich_tower_data" utils/gsm_geocoding.py; then
|
||||
pass_test "enrich_tower_data() function present"
|
||||
else
|
||||
fail_test "enrich_tower_data() function missing"
|
||||
fi
|
||||
|
||||
if grep -q "def lookup_cell_coordinates" utils/gsm_geocoding.py; then
|
||||
pass_test "lookup_cell_coordinates() function present"
|
||||
else
|
||||
fail_test "lookup_cell_coordinates() function missing"
|
||||
fi
|
||||
else
|
||||
fail_test "Geocoding module missing"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 2: Check scanner thread improvements
|
||||
echo "Test 2: Scanner Thread Non-Blocking I/O"
|
||||
echo "---------------------------------------"
|
||||
if grep -q "import select" routes/gsm_spy.py; then
|
||||
pass_test "select module imported"
|
||||
else
|
||||
fail_test "select module not imported"
|
||||
fi
|
||||
|
||||
if grep -q "select.select.*process.stdout" routes/gsm_spy.py; then
|
||||
pass_test "Non-blocking I/O with select.select() implemented"
|
||||
else
|
||||
fail_test "select.select() not found in scanner thread"
|
||||
fi
|
||||
|
||||
if grep -q "scan_timeout = 120" routes/gsm_spy.py; then
|
||||
pass_test "Scan timeout configured"
|
||||
else
|
||||
fail_test "Scan timeout not configured"
|
||||
fi
|
||||
|
||||
if grep -q "with app_module.gsm_spy_lock:" routes/gsm_spy.py; then
|
||||
pass_test "Thread-safe counter updates implemented"
|
||||
else
|
||||
fail_test "Thread-safe counter updates missing"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 3: Check geocoding worker
|
||||
echo "Test 3: Background Geocoding Worker"
|
||||
echo "-----------------------------------"
|
||||
if grep -q "def start_geocoding_worker" routes/gsm_spy.py; then
|
||||
pass_test "start_geocoding_worker() function exists"
|
||||
else
|
||||
fail_test "start_geocoding_worker() function missing"
|
||||
fi
|
||||
|
||||
if grep -q "def geocoding_worker" routes/gsm_spy.py; then
|
||||
pass_test "geocoding_worker() function exists"
|
||||
else
|
||||
fail_test "geocoding_worker() function missing"
|
||||
fi
|
||||
|
||||
if grep -q "start_geocoding_worker()" routes/gsm_spy.py; then
|
||||
pass_test "Geocoding worker is started in start_scanner()"
|
||||
else
|
||||
fail_test "Geocoding worker not started in start_scanner()"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 4: Check enrichment integration
|
||||
echo "Test 4: Tower Data Enrichment"
|
||||
echo "-----------------------------"
|
||||
if grep -q "from utils.gsm_geocoding import enrich_tower_data" routes/gsm_spy.py; then
|
||||
pass_test "enrich_tower_data imported in scanner thread"
|
||||
else
|
||||
fail_test "enrich_tower_data not imported"
|
||||
fi
|
||||
|
||||
if grep -q "enriched = enrich_tower_data(parsed)" routes/gsm_spy.py; then
|
||||
pass_test "Tower data enrichment called in scanner"
|
||||
else
|
||||
fail_test "Tower data enrichment not called"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 5: Check monitor pipeline fixes
|
||||
echo "Test 5: Monitor Pipeline Connection"
|
||||
echo "-----------------------------------"
|
||||
if grep -q "Give grgsm_livemon time to initialize" routes/gsm_spy.py; then
|
||||
pass_test "Pipeline initialization delay comment present"
|
||||
else
|
||||
fail_test "Pipeline initialization delay comment missing"
|
||||
fi
|
||||
|
||||
if grep -A 5 "Start grgsm_livemon" routes/gsm_spy.py | grep -q "time.sleep(2)"; then
|
||||
pass_test "2-second delay between grgsm_livemon and tshark"
|
||||
else
|
||||
fail_test "Initialization delay not implemented"
|
||||
fi
|
||||
|
||||
if grep -q "Started grgsm_livemon (PID:" routes/gsm_spy.py; then
|
||||
pass_test "Process verification logging added"
|
||||
else
|
||||
fail_test "Process verification logging missing"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 6: Check monitor thread improvements
|
||||
echo "Test 6: Monitor Thread Non-Blocking I/O"
|
||||
echo "---------------------------------------"
|
||||
if grep -q "def monitor_thread(process):" routes/gsm_spy.py; then
|
||||
pass_test "monitor_thread() function exists"
|
||||
|
||||
if grep -A 20 "def monitor_thread(process):" routes/gsm_spy.py | grep -q "select.select.*process.stdout"; then
|
||||
pass_test "Monitor thread uses non-blocking I/O"
|
||||
else
|
||||
fail_test "Monitor thread doesn't use select.select()"
|
||||
fi
|
||||
else
|
||||
fail_test "monitor_thread() function missing"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 7: Check frontend coordinate validation
|
||||
echo "Test 7: Frontend Coordinate Validation"
|
||||
echo "--------------------------------------"
|
||||
if grep -q "Validate coordinates before creating map marker" templates/gsm_spy_dashboard.html; then
|
||||
pass_test "Coordinate validation comment present"
|
||||
else
|
||||
fail_test "Coordinate validation comment missing"
|
||||
fi
|
||||
|
||||
if grep -q "isNaN(parseFloat(data.lat))" templates/gsm_spy_dashboard.html; then
|
||||
pass_test "Coordinate validation checks implemented"
|
||||
else
|
||||
fail_test "Coordinate validation checks missing"
|
||||
fi
|
||||
|
||||
if grep -q "tower_update" templates/gsm_spy_dashboard.html; then
|
||||
pass_test "tower_update message handler added"
|
||||
else
|
||||
fail_test "tower_update message handler missing"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 8: Check process cleanup improvements
|
||||
echo "Test 8: Process Cleanup & Zombie Prevention"
|
||||
echo "-------------------------------------------"
|
||||
if grep -q "process.terminate()" routes/gsm_spy.py; then
|
||||
pass_test "Process termination implemented"
|
||||
else
|
||||
fail_test "Process termination missing"
|
||||
fi
|
||||
|
||||
if grep -q "subprocess.TimeoutExpired" routes/gsm_spy.py; then
|
||||
pass_test "Timeout handling for process termination"
|
||||
else
|
||||
fail_test "Timeout handling missing"
|
||||
fi
|
||||
|
||||
if grep -q "process.kill()" routes/gsm_spy.py; then
|
||||
pass_test "Force kill fallback implemented"
|
||||
else
|
||||
fail_test "Force kill fallback missing"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 9: Python syntax check
|
||||
echo "Test 9: Python Syntax Validation"
|
||||
echo "--------------------------------"
|
||||
if python3 -m py_compile routes/gsm_spy.py 2>/dev/null; then
|
||||
pass_test "routes/gsm_spy.py has valid syntax"
|
||||
else
|
||||
fail_test "routes/gsm_spy.py has syntax errors"
|
||||
fi
|
||||
|
||||
if python3 -m py_compile utils/gsm_geocoding.py 2>/dev/null; then
|
||||
pass_test "utils/gsm_geocoding.py has valid syntax"
|
||||
else
|
||||
fail_test "utils/gsm_geocoding.py has syntax errors"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 10: Check auto-monitor persistence
|
||||
echo "Test 10: Auto-Monitor Flag Persistence"
|
||||
echo "--------------------------------------"
|
||||
if grep -q "auto_monitor_triggered = False.*# Moved outside loop" routes/gsm_spy.py; then
|
||||
pass_test "auto_monitor_triggered flag moved outside loop"
|
||||
else
|
||||
fail_test "auto_monitor_triggered flag not properly placed"
|
||||
fi
|
||||
|
||||
if grep -q "if current_count >= 3 and not auto_monitor_triggered" routes/gsm_spy.py; then
|
||||
pass_test "Auto-monitor only triggers once per session"
|
||||
else
|
||||
fail_test "Auto-monitor trigger condition incorrect"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Summary
|
||||
echo "=========================================="
|
||||
echo "Test Summary"
|
||||
echo "=========================================="
|
||||
echo -e "Tests passed: ${GREEN}${TESTS_PASSED}${NC}"
|
||||
echo -e "Tests failed: ${RED}${TESTS_FAILED}${NC}"
|
||||
echo ""
|
||||
|
||||
if [ $TESTS_FAILED -eq 0 ]; then
|
||||
echo -e "${GREEN}All tests passed! ✓${NC}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Start INTERCEPT: sudo -E venv/bin/python intercept.py"
|
||||
echo "2. Navigate to GSM Spy dashboard in browser"
|
||||
echo "3. Click 'Start Scanner' to test tower detection with geocoding"
|
||||
echo "4. Verify towers appear on map with coordinates"
|
||||
echo "5. Check that auto-monitor starts after 3+ towers found"
|
||||
echo "6. Test Stop button for responsive shutdown (< 2 seconds)"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}Some tests failed. Please review the output above.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
200
utils/gsm_geocoding.py
Normal file
200
utils/gsm_geocoding.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""GSM Cell Tower Geocoding Service.
|
||||
|
||||
Provides hybrid cache-first geocoding with async API fallback for cell towers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
import config
|
||||
from utils.database import get_db
|
||||
|
||||
logger = logging.getLogger('intercept.gsm_geocoding')
|
||||
|
||||
# Queue for pending geocoding requests
|
||||
_geocoding_queue = queue.Queue(maxsize=100)
|
||||
|
||||
|
||||
def lookup_cell_coordinates(mcc: int, mnc: int, lac: int, cid: int) -> dict[str, Any] | None:
|
||||
"""
|
||||
Lookup cell tower coordinates with cache-first strategy.
|
||||
|
||||
Strategy:
|
||||
1. Check gsm_cells table (cache) - fast synchronous lookup
|
||||
2. If not found, return None (caller decides whether to use API)
|
||||
|
||||
Args:
|
||||
mcc: Mobile Country Code
|
||||
mnc: Mobile Network Code
|
||||
lac: Location Area Code
|
||||
cid: Cell ID
|
||||
|
||||
Returns:
|
||||
dict with keys: lat, lon, source='cache', azimuth (optional),
|
||||
range_meters (optional), operator (optional), radio (optional)
|
||||
Returns None if not found in cache.
|
||||
"""
|
||||
try:
|
||||
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 {
|
||||
'lat': result['lat'],
|
||||
'lon': result['lon'],
|
||||
'source': 'cache',
|
||||
'azimuth': result['azimuth'],
|
||||
'range_meters': result['range_meters'],
|
||||
'operator': result['operator'],
|
||||
'radio': result['radio']
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error looking up coordinates from cache: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def lookup_cell_from_api(mcc: int, mnc: int, lac: int, cid: int) -> dict[str, Any] | None:
|
||||
"""
|
||||
Lookup cell tower from OpenCellID API and cache result.
|
||||
|
||||
Args:
|
||||
mcc: Mobile Country Code
|
||||
mnc: Mobile Network Code
|
||||
lac: Location Area Code
|
||||
cid: Cell ID
|
||||
|
||||
Returns:
|
||||
dict with keys: lat, lon, source='api', azimuth (optional),
|
||||
range_meters (optional), operator (optional), radio (optional)
|
||||
Returns None if API call fails or cell not found.
|
||||
"""
|
||||
try:
|
||||
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()
|
||||
|
||||
# Cache the result
|
||||
with get_db() as conn:
|
||||
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()
|
||||
|
||||
logger.info(f"Cached cell tower from API: MCC={mcc} MNC={mnc} LAC={lac} CID={cid}")
|
||||
|
||||
return {
|
||||
'lat': cell_data.get('lat'),
|
||||
'lon': cell_data.get('lon'),
|
||||
'source': 'api',
|
||||
'azimuth': cell_data.get('azimuth'),
|
||||
'range_meters': cell_data.get('range'),
|
||||
'operator': cell_data.get('operator'),
|
||||
'radio': cell_data.get('radio')
|
||||
}
|
||||
else:
|
||||
logger.warning(f"OpenCellID API returned {response.status_code} for MCC={mcc} MNC={mnc} LAC={lac} CID={cid}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calling OpenCellID API: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def enrich_tower_data(tower_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Enrich tower data with coordinates using cache-first strategy.
|
||||
|
||||
If coordinates found in cache, adds them immediately.
|
||||
If not found, marks as 'pending' and queues for background API lookup.
|
||||
|
||||
Args:
|
||||
tower_data: Dictionary with keys mcc, mnc, lac, cid (and other tower data)
|
||||
|
||||
Returns:
|
||||
Enriched tower_data dict with added fields:
|
||||
- lat, lon (if found in cache)
|
||||
- status='pending' (if needs API lookup)
|
||||
- source='cache' (if from cache)
|
||||
"""
|
||||
mcc = tower_data.get('mcc')
|
||||
mnc = tower_data.get('mnc')
|
||||
lac = tower_data.get('lac')
|
||||
cid = tower_data.get('cid')
|
||||
|
||||
# Validate required fields
|
||||
if not all([mcc is not None, mnc is not None, lac is not None, cid is not None]):
|
||||
logger.warning(f"Tower data missing required fields: {tower_data}")
|
||||
return tower_data
|
||||
|
||||
# Try cache lookup
|
||||
coords = lookup_cell_coordinates(mcc, mnc, lac, cid)
|
||||
|
||||
if coords:
|
||||
# Found in cache - add coordinates immediately
|
||||
tower_data['lat'] = coords['lat']
|
||||
tower_data['lon'] = coords['lon']
|
||||
tower_data['source'] = 'cache'
|
||||
|
||||
# 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']
|
||||
|
||||
logger.debug(f"Cache hit for tower: MCC={mcc} MNC={mnc} LAC={lac} CID={cid}")
|
||||
else:
|
||||
# Not in cache - mark as pending and queue for API lookup
|
||||
tower_data['status'] = 'pending'
|
||||
tower_data['source'] = 'unknown'
|
||||
|
||||
# Queue for background geocoding (non-blocking)
|
||||
try:
|
||||
_geocoding_queue.put_nowait(tower_data.copy())
|
||||
logger.debug(f"Queued tower for geocoding: MCC={mcc} MNC={mnc} LAC={lac} CID={cid}")
|
||||
except queue.Full:
|
||||
logger.warning("Geocoding queue full, dropping tower")
|
||||
|
||||
return tower_data
|
||||
|
||||
|
||||
def get_geocoding_queue() -> queue.Queue:
|
||||
"""Get the geocoding queue for the background worker."""
|
||||
return _geocoding_queue
|
||||
Reference in New Issue
Block a user