mirror of
https://github.com/smittix/intercept.git
synced 2026-07-07 17:18:11 -07:00
Add ISMS Listening Station with GSM cell detection
- Add spectrum monitoring via rtl_power with configurable presets - Add OpenCelliD tower integration with Leaflet map display - Add grgsm_scanner integration for passive GSM cell detection (alpha) - Add rules engine for anomaly detection and findings - Add baseline recording and comparison system - Add setup.sh support for gr-gsm installation on Debian/Ubuntu Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ def register_blueprints(app):
|
||||
from .correlation import correlation_bp
|
||||
from .listening_post import listening_post_bp
|
||||
from .tscm import tscm_bp, init_tscm_state
|
||||
from .isms import isms_bp
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
@@ -29,6 +30,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(correlation_bp)
|
||||
app.register_blueprint(listening_post_bp)
|
||||
app.register_blueprint(tscm_bp)
|
||||
app.register_blueprint(isms_bp)
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
|
||||
+981
@@ -0,0 +1,981 @@
|
||||
"""ISMS Listening Station routes for spectrum monitoring and tower mapping."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.constants import SSE_QUEUE_TIMEOUT, SSE_KEEPALIVE_INTERVAL
|
||||
from utils.process import safe_terminate, register_process
|
||||
from utils.gps import get_current_position
|
||||
from utils.database import (
|
||||
create_isms_baseline,
|
||||
get_isms_baseline,
|
||||
get_all_isms_baselines,
|
||||
get_active_isms_baseline,
|
||||
set_active_isms_baseline,
|
||||
delete_isms_baseline,
|
||||
update_isms_baseline,
|
||||
create_isms_scan,
|
||||
update_isms_scan,
|
||||
get_isms_scan,
|
||||
get_recent_isms_scans,
|
||||
add_isms_finding,
|
||||
get_isms_findings,
|
||||
get_isms_findings_summary,
|
||||
acknowledge_isms_finding,
|
||||
)
|
||||
from utils.isms.spectrum import (
|
||||
run_rtl_power_scan,
|
||||
compute_band_metrics,
|
||||
detect_bursts,
|
||||
get_rtl_power_path,
|
||||
SpectrumBin,
|
||||
BandMetrics,
|
||||
)
|
||||
from utils.isms.towers import (
|
||||
query_nearby_towers,
|
||||
format_tower_info,
|
||||
build_ofcom_coverage_url,
|
||||
build_ofcom_emf_url,
|
||||
get_opencellid_token,
|
||||
)
|
||||
from utils.isms.rules import RulesEngine, create_default_engine
|
||||
from utils.isms.baseline import (
|
||||
BaselineRecorder,
|
||||
compare_spectrum_baseline,
|
||||
compare_tower_baseline,
|
||||
save_baseline_to_db,
|
||||
)
|
||||
from utils.isms.gsm import (
|
||||
GsmCell,
|
||||
run_grgsm_scan,
|
||||
run_gsm_scan_blocking,
|
||||
get_grgsm_scanner_path,
|
||||
format_gsm_cell,
|
||||
deduplicate_cells,
|
||||
identify_gsm_anomalies,
|
||||
)
|
||||
from data.isms_presets import (
|
||||
ISMS_SCAN_PRESETS,
|
||||
get_preset,
|
||||
get_all_presets,
|
||||
identify_band,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.isms')
|
||||
|
||||
isms_bp = Blueprint('isms', __name__, url_prefix='/isms')
|
||||
|
||||
# ============================================
|
||||
# GLOBAL STATE
|
||||
# ============================================
|
||||
|
||||
# Scanner state
|
||||
isms_thread: threading.Thread | None = None
|
||||
isms_running = False
|
||||
isms_lock = threading.Lock()
|
||||
isms_process: subprocess.Popen | None = None
|
||||
isms_current_scan_id: int | None = None
|
||||
|
||||
# Scanner configuration
|
||||
isms_config = {
|
||||
'preset': 'ism_433',
|
||||
'freq_start': 433.0,
|
||||
'freq_end': 434.8,
|
||||
'bin_size': 10000,
|
||||
'integration': 0.5,
|
||||
'device': 0,
|
||||
'gain': 40,
|
||||
'ppm': 0,
|
||||
'threshold': 50, # Activity threshold (0-100)
|
||||
'lat': None,
|
||||
'lon': None,
|
||||
'baseline_id': None,
|
||||
}
|
||||
|
||||
# SSE queue for real-time events
|
||||
isms_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
# Rules engine
|
||||
rules_engine: RulesEngine = create_default_engine()
|
||||
|
||||
# Baseline recorder
|
||||
baseline_recorder: BaselineRecorder = BaselineRecorder()
|
||||
|
||||
# Recent band metrics for display
|
||||
recent_metrics: dict[str, BandMetrics] = {}
|
||||
metrics_lock = threading.Lock()
|
||||
|
||||
# Findings count for current scan
|
||||
current_findings_count = 0
|
||||
|
||||
# GSM scanner state
|
||||
gsm_thread: threading.Thread | None = None
|
||||
gsm_running = False
|
||||
gsm_lock = threading.Lock()
|
||||
gsm_detected_cells: list[GsmCell] = []
|
||||
gsm_baseline_cells: list[GsmCell] = []
|
||||
|
||||
|
||||
# ============================================
|
||||
# HELPER FUNCTIONS
|
||||
# ============================================
|
||||
|
||||
def emit_event(event_type: str, data: dict) -> None:
|
||||
"""Emit an event to SSE queue."""
|
||||
try:
|
||||
isms_queue.put_nowait({
|
||||
'type': event_type,
|
||||
**data
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
|
||||
def emit_finding(severity: str, text: str, **details) -> None:
|
||||
"""Emit a finding event and store in database."""
|
||||
global current_findings_count
|
||||
|
||||
emit_event('finding', {
|
||||
'severity': severity,
|
||||
'text': text,
|
||||
'details': details,
|
||||
'timestamp': datetime.utcnow().isoformat() + 'Z',
|
||||
})
|
||||
|
||||
# Store in database if we have an active scan
|
||||
if isms_current_scan_id:
|
||||
add_isms_finding(
|
||||
scan_id=isms_current_scan_id,
|
||||
finding_type=details.get('finding_type', 'general'),
|
||||
severity=severity,
|
||||
description=text,
|
||||
band=details.get('band'),
|
||||
frequency=details.get('frequency'),
|
||||
details=details,
|
||||
)
|
||||
current_findings_count += 1
|
||||
|
||||
|
||||
def emit_meter(band: str, level: float, noise_floor: float) -> None:
|
||||
"""Emit a band meter update."""
|
||||
emit_event('meter', {
|
||||
'band': band,
|
||||
'level': min(100, max(0, level)),
|
||||
'noise_floor': noise_floor,
|
||||
})
|
||||
|
||||
|
||||
def emit_peak(freq_mhz: float, power_db: float, band: str) -> None:
|
||||
"""Emit a spectrum peak event."""
|
||||
emit_event('spectrum_peak', {
|
||||
'freq_mhz': round(freq_mhz, 3),
|
||||
'power_db': round(power_db, 1),
|
||||
'band': band,
|
||||
})
|
||||
|
||||
|
||||
def emit_status(state: str, **data) -> None:
|
||||
"""Emit a status update."""
|
||||
emit_event('status', {
|
||||
'state': state,
|
||||
**data
|
||||
})
|
||||
|
||||
|
||||
# ============================================
|
||||
# SCANNER LOOP
|
||||
# ============================================
|
||||
|
||||
def isms_scan_loop() -> None:
|
||||
"""Main ISMS scanning loop."""
|
||||
global isms_running, isms_process, current_findings_count
|
||||
|
||||
logger.info("ISMS scan thread started")
|
||||
emit_status('starting')
|
||||
|
||||
# Get preset configuration
|
||||
preset_name = isms_config.get('preset', 'ism_433')
|
||||
preset = get_preset(preset_name)
|
||||
|
||||
if preset:
|
||||
freq_start = preset['freq_start']
|
||||
freq_end = preset['freq_end']
|
||||
bin_size = preset.get('bin_size', 10000)
|
||||
integration = preset.get('integration', 1.0)
|
||||
band_name = preset['name']
|
||||
else:
|
||||
freq_start = isms_config['freq_start']
|
||||
freq_end = isms_config['freq_end']
|
||||
bin_size = isms_config['bin_size']
|
||||
integration = isms_config['integration']
|
||||
band_name = f'{freq_start}-{freq_end} MHz'
|
||||
|
||||
device = isms_config['device']
|
||||
gain = isms_config['gain']
|
||||
ppm = isms_config['ppm']
|
||||
threshold = isms_config['threshold']
|
||||
|
||||
# Get active baseline for comparison
|
||||
active_baseline = None
|
||||
baseline_spectrum = None
|
||||
if isms_config.get('baseline_id'):
|
||||
active_baseline = get_isms_baseline(isms_config['baseline_id'])
|
||||
if active_baseline:
|
||||
baseline_spectrum = active_baseline.get('spectrum_profile', {}).get(band_name)
|
||||
|
||||
emit_status('scanning', band=band_name, preset=preset_name)
|
||||
|
||||
current_bins: list[SpectrumBin] = []
|
||||
sweep_count = 0
|
||||
|
||||
try:
|
||||
# Run continuous spectrum scanning
|
||||
for spectrum_bin in run_rtl_power_scan(
|
||||
freq_start_mhz=freq_start,
|
||||
freq_end_mhz=freq_end,
|
||||
bin_size_hz=bin_size,
|
||||
integration_time=integration,
|
||||
device_index=device,
|
||||
gain=gain,
|
||||
ppm=ppm,
|
||||
single_shot=False,
|
||||
):
|
||||
if not isms_running:
|
||||
break
|
||||
|
||||
current_bins.append(spectrum_bin)
|
||||
|
||||
# Process a sweep's worth of data
|
||||
if spectrum_bin.freq_hz >= (freq_end * 1_000_000 - bin_size):
|
||||
sweep_count += 1
|
||||
|
||||
# Compute band metrics
|
||||
metrics = compute_band_metrics(current_bins, band_name)
|
||||
|
||||
# Store in recent metrics
|
||||
with metrics_lock:
|
||||
recent_metrics[band_name] = metrics
|
||||
|
||||
# Add to baseline recorder if recording
|
||||
if baseline_recorder.is_recording:
|
||||
baseline_recorder.add_spectrum_sample(band_name, metrics)
|
||||
|
||||
# Emit meter update
|
||||
emit_meter(band_name, metrics.activity_score, metrics.noise_floor_db)
|
||||
|
||||
# Emit peak if significant
|
||||
if metrics.peak_power_db > metrics.noise_floor_db + 6:
|
||||
emit_peak(metrics.peak_frequency_mhz, metrics.peak_power_db, band_name)
|
||||
|
||||
# Detect bursts
|
||||
bursts = detect_bursts(current_bins)
|
||||
if bursts:
|
||||
emit_event('bursts_detected', {
|
||||
'band': band_name,
|
||||
'count': len(bursts),
|
||||
})
|
||||
|
||||
# Run rules engine
|
||||
findings = rules_engine.evaluate_spectrum(
|
||||
band_name=band_name,
|
||||
noise_floor=metrics.noise_floor_db,
|
||||
peak_freq=metrics.peak_frequency_mhz,
|
||||
peak_power=metrics.peak_power_db,
|
||||
activity_score=metrics.activity_score,
|
||||
baseline_noise=baseline_spectrum.get('noise_floor_db') if baseline_spectrum else None,
|
||||
baseline_activity=baseline_spectrum.get('activity_score') if baseline_spectrum else None,
|
||||
baseline_peaks=baseline_spectrum.get('peak_frequencies') if baseline_spectrum else None,
|
||||
burst_count=len(bursts),
|
||||
)
|
||||
|
||||
for finding in findings:
|
||||
emit_finding(
|
||||
finding.severity,
|
||||
finding.description,
|
||||
finding_type=finding.finding_type,
|
||||
band=finding.band,
|
||||
frequency=finding.frequency,
|
||||
)
|
||||
|
||||
# Emit progress
|
||||
if sweep_count % 5 == 0:
|
||||
emit_status('scanning', band=band_name, sweeps=sweep_count)
|
||||
|
||||
# Clear for next sweep
|
||||
current_bins.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ISMS scan error: {e}")
|
||||
emit_status('error', message=str(e))
|
||||
|
||||
finally:
|
||||
isms_running = False
|
||||
emit_status('stopped', sweeps=sweep_count)
|
||||
|
||||
# Update scan record
|
||||
if isms_current_scan_id:
|
||||
update_isms_scan(
|
||||
isms_current_scan_id,
|
||||
status='completed',
|
||||
findings_count=current_findings_count,
|
||||
completed=True,
|
||||
)
|
||||
|
||||
logger.info(f"ISMS scan stopped after {sweep_count} sweeps")
|
||||
|
||||
|
||||
# ============================================
|
||||
# ROUTES: SCAN CONTROL
|
||||
# ============================================
|
||||
|
||||
@isms_bp.route('/start_scan', methods=['POST'])
|
||||
def start_scan():
|
||||
"""Start ISMS spectrum scanning."""
|
||||
global isms_thread, isms_running, isms_current_scan_id, current_findings_count
|
||||
|
||||
with isms_lock:
|
||||
if isms_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Scan already running'
|
||||
}), 409
|
||||
|
||||
# Get configuration from request
|
||||
data = request.get_json() or {}
|
||||
|
||||
isms_config['preset'] = data.get('preset', isms_config['preset'])
|
||||
isms_config['device'] = data.get('device', isms_config['device'])
|
||||
isms_config['gain'] = data.get('gain', isms_config['gain'])
|
||||
isms_config['threshold'] = data.get('threshold', isms_config['threshold'])
|
||||
isms_config['baseline_id'] = data.get('baseline_id')
|
||||
|
||||
# Custom frequency range
|
||||
if data.get('freq_start'):
|
||||
isms_config['freq_start'] = float(data['freq_start'])
|
||||
if data.get('freq_end'):
|
||||
isms_config['freq_end'] = float(data['freq_end'])
|
||||
|
||||
# Location
|
||||
if data.get('lat') and data.get('lon'):
|
||||
isms_config['lat'] = float(data['lat'])
|
||||
isms_config['lon'] = float(data['lon'])
|
||||
|
||||
# Check for rtl_power
|
||||
if not get_rtl_power_path():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_power not found. Install rtl-sdr tools.'
|
||||
}), 500
|
||||
|
||||
# Clear queue
|
||||
while not isms_queue.empty():
|
||||
try:
|
||||
isms_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Create scan record
|
||||
gps_coords = None
|
||||
if isms_config.get('lat') and isms_config.get('lon'):
|
||||
gps_coords = {'lat': isms_config['lat'], 'lon': isms_config['lon']}
|
||||
|
||||
isms_current_scan_id = create_isms_scan(
|
||||
scan_preset=isms_config['preset'],
|
||||
baseline_id=isms_config.get('baseline_id'),
|
||||
gps_coords=gps_coords,
|
||||
)
|
||||
current_findings_count = 0
|
||||
|
||||
# Start scanning thread
|
||||
isms_running = True
|
||||
isms_thread = threading.Thread(target=isms_scan_loop, daemon=True)
|
||||
isms_thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'scan_id': isms_current_scan_id,
|
||||
'config': {
|
||||
'preset': isms_config['preset'],
|
||||
'device': isms_config['device'],
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@isms_bp.route('/stop_scan', methods=['POST'])
|
||||
def stop_scan():
|
||||
"""Stop ISMS spectrum scanning."""
|
||||
global isms_running, isms_process
|
||||
|
||||
with isms_lock:
|
||||
if not isms_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No scan running'
|
||||
}), 400
|
||||
|
||||
isms_running = False
|
||||
|
||||
# Terminate any subprocess
|
||||
if isms_process:
|
||||
safe_terminate(isms_process)
|
||||
isms_process = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@isms_bp.route('/status', methods=['GET'])
|
||||
def get_status():
|
||||
"""Get current scanner status."""
|
||||
with isms_lock:
|
||||
status = {
|
||||
'running': isms_running,
|
||||
'config': {
|
||||
'preset': isms_config['preset'],
|
||||
'device': isms_config['device'],
|
||||
'baseline_id': isms_config.get('baseline_id'),
|
||||
},
|
||||
'current_scan_id': isms_current_scan_id,
|
||||
'findings_count': current_findings_count,
|
||||
}
|
||||
|
||||
# Add recent metrics
|
||||
with metrics_lock:
|
||||
status['metrics'] = {
|
||||
band: {
|
||||
'activity_score': m.activity_score,
|
||||
'noise_floor': m.noise_floor_db,
|
||||
'peak_freq': m.peak_frequency_mhz,
|
||||
'peak_power': m.peak_power_db,
|
||||
}
|
||||
for band, m in recent_metrics.items()
|
||||
}
|
||||
|
||||
return jsonify(status)
|
||||
|
||||
|
||||
# ============================================
|
||||
# ROUTES: SSE STREAM
|
||||
# ============================================
|
||||
|
||||
@isms_bp.route('/stream', methods=['GET'])
|
||||
def stream():
|
||||
"""SSE stream for real-time ISMS events."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = isms_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ============================================
|
||||
# ROUTES: PRESETS
|
||||
# ============================================
|
||||
|
||||
@isms_bp.route('/presets', methods=['GET'])
|
||||
def list_presets():
|
||||
"""List available scan presets."""
|
||||
return jsonify({
|
||||
'presets': get_all_presets()
|
||||
})
|
||||
|
||||
|
||||
# ============================================
|
||||
# ROUTES: TOWERS
|
||||
# ============================================
|
||||
|
||||
@isms_bp.route('/towers', methods=['GET'])
|
||||
def get_towers():
|
||||
"""Query nearby cell towers from OpenCelliD."""
|
||||
lat = request.args.get('lat', type=float)
|
||||
lon = request.args.get('lon', type=float)
|
||||
radius = request.args.get('radius', default=5.0, type=float)
|
||||
radio = request.args.get('radio') # GSM, UMTS, LTE, NR
|
||||
|
||||
if lat is None or lon is None:
|
||||
# Try to get from GPS
|
||||
pos = get_current_position()
|
||||
if pos:
|
||||
lat = pos.latitude
|
||||
lon = pos.longitude
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Location required (lat/lon parameters or GPS)'
|
||||
}), 400
|
||||
|
||||
# Check for token
|
||||
token = get_opencellid_token()
|
||||
if not token:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'OpenCelliD token not configured. Set OPENCELLID_TOKEN environment variable.',
|
||||
'config_required': True,
|
||||
}), 400
|
||||
|
||||
# Query towers
|
||||
towers = query_nearby_towers(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
radius_km=radius,
|
||||
radio=radio,
|
||||
)
|
||||
|
||||
# Format for response
|
||||
formatted_towers = [format_tower_info(t) for t in towers]
|
||||
|
||||
# Add to baseline recorder if recording
|
||||
if baseline_recorder.is_recording:
|
||||
for tower in towers:
|
||||
baseline_recorder.add_tower_sample(tower)
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'query': {
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'radius_km': radius,
|
||||
},
|
||||
'count': len(formatted_towers),
|
||||
'towers': formatted_towers,
|
||||
'links': {
|
||||
'ofcom_coverage': build_ofcom_coverage_url(),
|
||||
'ofcom_emf': build_ofcom_emf_url(),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# ============================================
|
||||
# ROUTES: BASELINES
|
||||
# ============================================
|
||||
|
||||
@isms_bp.route('/baselines', methods=['GET'])
|
||||
def list_baselines():
|
||||
"""List all ISMS baselines."""
|
||||
baselines = get_all_isms_baselines()
|
||||
return jsonify({
|
||||
'baselines': baselines
|
||||
})
|
||||
|
||||
|
||||
@isms_bp.route('/baselines', methods=['POST'])
|
||||
def create_baseline():
|
||||
"""Create a new baseline manually."""
|
||||
data = request.get_json() or {}
|
||||
|
||||
name = data.get('name')
|
||||
if not name:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Name required'
|
||||
}), 400
|
||||
|
||||
baseline_id = create_isms_baseline(
|
||||
name=name,
|
||||
location_name=data.get('location_name'),
|
||||
latitude=data.get('latitude'),
|
||||
longitude=data.get('longitude'),
|
||||
spectrum_profile=data.get('spectrum_profile'),
|
||||
cellular_environment=data.get('cellular_environment'),
|
||||
known_towers=data.get('known_towers'),
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'status': 'created',
|
||||
'baseline_id': baseline_id
|
||||
})
|
||||
|
||||
|
||||
@isms_bp.route('/baseline/<int:baseline_id>', methods=['GET'])
|
||||
def get_baseline(baseline_id: int):
|
||||
"""Get a specific baseline."""
|
||||
baseline = get_isms_baseline(baseline_id)
|
||||
if not baseline:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Baseline not found'
|
||||
}), 404
|
||||
|
||||
return jsonify(baseline)
|
||||
|
||||
|
||||
@isms_bp.route('/baseline/<int:baseline_id>', methods=['DELETE'])
|
||||
def remove_baseline(baseline_id: int):
|
||||
"""Delete a baseline."""
|
||||
if delete_isms_baseline(baseline_id):
|
||||
return jsonify({'status': 'deleted'})
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Baseline not found'
|
||||
}), 404
|
||||
|
||||
|
||||
@isms_bp.route('/baseline/<int:baseline_id>/activate', methods=['POST'])
|
||||
def activate_baseline(baseline_id: int):
|
||||
"""Set a baseline as active."""
|
||||
if set_active_isms_baseline(baseline_id):
|
||||
return jsonify({'status': 'activated'})
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Baseline not found'
|
||||
}), 404
|
||||
|
||||
|
||||
@isms_bp.route('/baseline/active', methods=['GET'])
|
||||
def get_active_baseline():
|
||||
"""Get the currently active baseline."""
|
||||
baseline = get_active_isms_baseline()
|
||||
if baseline:
|
||||
return jsonify(baseline)
|
||||
return jsonify({'status': 'none'})
|
||||
|
||||
|
||||
@isms_bp.route('/baseline/record/start', methods=['POST'])
|
||||
def start_baseline_recording():
|
||||
"""Start recording a new baseline."""
|
||||
if baseline_recorder.is_recording:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Already recording'
|
||||
}), 409
|
||||
|
||||
baseline_recorder.start_recording()
|
||||
return jsonify({'status': 'recording_started'})
|
||||
|
||||
|
||||
@isms_bp.route('/baseline/record/stop', methods=['POST'])
|
||||
def stop_baseline_recording():
|
||||
"""Stop recording and save baseline."""
|
||||
if not baseline_recorder.is_recording:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Not recording'
|
||||
}), 400
|
||||
|
||||
data = request.get_json() or {}
|
||||
name = data.get('name', f'Baseline {datetime.now().strftime("%Y-%m-%d %H:%M")}')
|
||||
location_name = data.get('location_name')
|
||||
|
||||
# Get current location
|
||||
lat = data.get('latitude') or isms_config.get('lat')
|
||||
lon = data.get('longitude') or isms_config.get('lon')
|
||||
|
||||
if not lat or not lon:
|
||||
pos = get_current_position()
|
||||
if pos:
|
||||
lat = pos.latitude
|
||||
lon = pos.longitude
|
||||
|
||||
# Stop recording and compile data
|
||||
baseline_data = baseline_recorder.stop_recording()
|
||||
|
||||
# Save to database
|
||||
baseline_id = save_baseline_to_db(
|
||||
name=name,
|
||||
location_name=location_name,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
baseline_data=baseline_data,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'status': 'saved',
|
||||
'baseline_id': baseline_id,
|
||||
'summary': {
|
||||
'bands': len(baseline_data.get('spectrum_profile', {})),
|
||||
'cells': len(baseline_data.get('cellular_environment', [])),
|
||||
'towers': len(baseline_data.get('known_towers', [])),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# ============================================
|
||||
# ROUTES: FINDINGS
|
||||
# ============================================
|
||||
|
||||
@isms_bp.route('/findings', methods=['GET'])
|
||||
def list_findings():
|
||||
"""Get ISMS findings."""
|
||||
scan_id = request.args.get('scan_id', type=int)
|
||||
severity = request.args.get('severity')
|
||||
limit = request.args.get('limit', default=100, type=int)
|
||||
|
||||
findings = get_isms_findings(
|
||||
scan_id=scan_id,
|
||||
severity=severity,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'findings': findings,
|
||||
'count': len(findings),
|
||||
})
|
||||
|
||||
|
||||
@isms_bp.route('/findings/summary', methods=['GET'])
|
||||
def findings_summary():
|
||||
"""Get findings count summary."""
|
||||
summary = get_isms_findings_summary()
|
||||
return jsonify(summary)
|
||||
|
||||
|
||||
@isms_bp.route('/finding/<int:finding_id>/acknowledge', methods=['POST'])
|
||||
def acknowledge_finding(finding_id: int):
|
||||
"""Acknowledge a finding."""
|
||||
if acknowledge_isms_finding(finding_id):
|
||||
return jsonify({'status': 'acknowledged'})
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Finding not found'
|
||||
}), 404
|
||||
|
||||
|
||||
# ============================================
|
||||
# ROUTES: SCANS
|
||||
# ============================================
|
||||
|
||||
@isms_bp.route('/scans', methods=['GET'])
|
||||
def list_scans():
|
||||
"""List recent scans."""
|
||||
limit = request.args.get('limit', default=20, type=int)
|
||||
scans = get_recent_isms_scans(limit=limit)
|
||||
return jsonify({
|
||||
'scans': scans
|
||||
})
|
||||
|
||||
|
||||
@isms_bp.route('/scan/<int:scan_id>', methods=['GET'])
|
||||
def get_scan_details(scan_id: int):
|
||||
"""Get details of a specific scan."""
|
||||
scan = get_isms_scan(scan_id)
|
||||
if not scan:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Scan not found'
|
||||
}), 404
|
||||
|
||||
# Include findings
|
||||
findings = get_isms_findings(scan_id=scan_id)
|
||||
scan['findings'] = findings
|
||||
|
||||
return jsonify(scan)
|
||||
|
||||
|
||||
# ============================================
|
||||
# ROUTES: GSM SCANNING
|
||||
# ============================================
|
||||
|
||||
def gsm_scan_loop(band: str, gain: int, ppm: int, timeout: float) -> None:
|
||||
"""GSM scanning background thread."""
|
||||
global gsm_running, gsm_detected_cells
|
||||
|
||||
logger.info(f"GSM scan thread started for band {band}")
|
||||
emit_status('gsm_scanning', band=band)
|
||||
|
||||
cells: list[GsmCell] = []
|
||||
|
||||
try:
|
||||
for cell in run_grgsm_scan(
|
||||
band=band,
|
||||
gain=gain,
|
||||
ppm=ppm,
|
||||
speed=4,
|
||||
timeout=timeout,
|
||||
):
|
||||
if not gsm_running:
|
||||
break
|
||||
|
||||
cells.append(cell)
|
||||
|
||||
# Emit cell detection event
|
||||
emit_event('gsm_cell', {
|
||||
'cell': format_gsm_cell(cell),
|
||||
})
|
||||
|
||||
# Deduplicate and store results
|
||||
gsm_detected_cells = deduplicate_cells(cells)
|
||||
|
||||
# Run anomaly detection if baseline available
|
||||
if gsm_baseline_cells:
|
||||
anomalies = identify_gsm_anomalies(gsm_detected_cells, gsm_baseline_cells)
|
||||
for anomaly in anomalies:
|
||||
emit_finding(
|
||||
anomaly['severity'],
|
||||
anomaly['description'],
|
||||
finding_type=anomaly['type'],
|
||||
band='GSM',
|
||||
frequency=anomaly.get('cell', {}).get('freq_mhz'),
|
||||
)
|
||||
|
||||
# Emit summary
|
||||
emit_event('gsm_scan_complete', {
|
||||
'cell_count': len(gsm_detected_cells),
|
||||
'cells': [format_gsm_cell(c) for c in gsm_detected_cells[:10]], # Top 10 by signal
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"GSM scan error: {e}")
|
||||
emit_status('gsm_error', message=str(e))
|
||||
|
||||
finally:
|
||||
gsm_running = False
|
||||
emit_status('gsm_stopped', cell_count=len(gsm_detected_cells))
|
||||
logger.info(f"GSM scan stopped, found {len(gsm_detected_cells)} cells")
|
||||
|
||||
|
||||
@isms_bp.route('/gsm/scan', methods=['POST'])
|
||||
def start_gsm_scan():
|
||||
"""Start GSM cell scanning with grgsm_scanner."""
|
||||
global gsm_thread, gsm_running
|
||||
|
||||
with gsm_lock:
|
||||
if gsm_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'GSM scan already running'
|
||||
}), 409
|
||||
|
||||
# Check for grgsm_scanner
|
||||
if not get_grgsm_scanner_path():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'grgsm_scanner not found. Install gr-gsm package.',
|
||||
'install_hint': 'See setup.sh or install gr-gsm from https://github.com/ptrkrysik/gr-gsm'
|
||||
}), 500
|
||||
|
||||
# Get configuration from request
|
||||
data = request.get_json() or {}
|
||||
band = data.get('band', 'GSM900')
|
||||
gain = data.get('gain', isms_config['gain'])
|
||||
ppm = data.get('ppm', isms_config.get('ppm', 0))
|
||||
timeout = data.get('timeout', 60)
|
||||
|
||||
# Validate band
|
||||
valid_bands = ['GSM900', 'EGSM900', 'GSM1800', 'GSM850', 'GSM1900']
|
||||
if band.upper() not in valid_bands:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid band. Must be one of: {", ".join(valid_bands)}'
|
||||
}), 400
|
||||
|
||||
gsm_running = True
|
||||
gsm_thread = threading.Thread(
|
||||
target=gsm_scan_loop,
|
||||
args=(band.upper(), gain, ppm, timeout),
|
||||
daemon=True
|
||||
)
|
||||
gsm_thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'config': {
|
||||
'band': band.upper(),
|
||||
'gain': gain,
|
||||
'timeout': timeout,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@isms_bp.route('/gsm/scan', methods=['DELETE'])
|
||||
def stop_gsm_scan():
|
||||
"""Stop GSM scanning."""
|
||||
global gsm_running
|
||||
|
||||
with gsm_lock:
|
||||
if not gsm_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No GSM scan running'
|
||||
}), 400
|
||||
|
||||
gsm_running = False
|
||||
|
||||
return jsonify({'status': 'stopping'})
|
||||
|
||||
|
||||
@isms_bp.route('/gsm/status', methods=['GET'])
|
||||
def get_gsm_status():
|
||||
"""Get GSM scanner status and detected cells."""
|
||||
with gsm_lock:
|
||||
return jsonify({
|
||||
'running': gsm_running,
|
||||
'cell_count': len(gsm_detected_cells),
|
||||
'cells': [format_gsm_cell(c) for c in gsm_detected_cells],
|
||||
'grgsm_available': get_grgsm_scanner_path() is not None,
|
||||
})
|
||||
|
||||
|
||||
@isms_bp.route('/gsm/cells', methods=['GET'])
|
||||
def get_gsm_cells():
|
||||
"""Get all detected GSM cells from last scan."""
|
||||
return jsonify({
|
||||
'cells': [format_gsm_cell(c) for c in gsm_detected_cells],
|
||||
'count': len(gsm_detected_cells),
|
||||
})
|
||||
|
||||
|
||||
@isms_bp.route('/gsm/baseline', methods=['POST'])
|
||||
def set_gsm_baseline():
|
||||
"""Save current GSM cells as baseline for comparison."""
|
||||
global gsm_baseline_cells
|
||||
|
||||
if not gsm_detected_cells:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No GSM cells detected. Run a scan first.'
|
||||
}), 400
|
||||
|
||||
gsm_baseline_cells = gsm_detected_cells.copy()
|
||||
|
||||
# Also add to baseline recorder if recording
|
||||
if baseline_recorder.is_recording:
|
||||
for cell in gsm_baseline_cells:
|
||||
baseline_recorder.add_gsm_cell(cell)
|
||||
|
||||
return jsonify({
|
||||
'status': 'saved',
|
||||
'cell_count': len(gsm_baseline_cells),
|
||||
'cells': [format_gsm_cell(c) for c in gsm_baseline_cells],
|
||||
})
|
||||
|
||||
|
||||
@isms_bp.route('/gsm/baseline', methods=['GET'])
|
||||
def get_gsm_baseline():
|
||||
"""Get current GSM baseline."""
|
||||
return jsonify({
|
||||
'cells': [format_gsm_cell(c) for c in gsm_baseline_cells],
|
||||
'count': len(gsm_baseline_cells),
|
||||
})
|
||||
|
||||
|
||||
@isms_bp.route('/gsm/baseline', methods=['DELETE'])
|
||||
def clear_gsm_baseline():
|
||||
"""Clear GSM baseline."""
|
||||
global gsm_baseline_cells
|
||||
gsm_baseline_cells = []
|
||||
return jsonify({'status': 'cleared'})
|
||||
Reference in New Issue
Block a user