mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Blueprint registration and database init run synchronously (essential for routing). Process cleanup, database cleanup scheduling, and TLE satellite updates are deferred to a background thread with a 1-second delay so the gevent worker can start serving HTTP requests right away. Previously all init ran synchronously during module import, blocking the single gevent worker for minutes while TLE data was fetched from CelesTrak. Also removes duplicate TLE update — init_tle_auto_refresh() already schedules its own background fetch. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1136 lines
40 KiB
Python
1136 lines
40 KiB
Python
"""
|
|
INTERCEPT - Signal Intelligence Platform
|
|
|
|
Flask application and shared state.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import site
|
|
|
|
from utils.database import get_db
|
|
|
|
# Ensure user site-packages is available (may be disabled when running as root/sudo)
|
|
if not site.ENABLE_USER_SITE:
|
|
user_site = site.getusersitepackages()
|
|
if user_site and user_site not in sys.path:
|
|
sys.path.insert(0, user_site)
|
|
|
|
import os
|
|
import queue
|
|
import threading
|
|
import platform
|
|
import subprocess
|
|
|
|
from typing import Any
|
|
|
|
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session, send_from_directory
|
|
from werkzeug.security import check_password_hash
|
|
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
|
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
|
from utils.process import cleanup_stale_processes, cleanup_stale_dump1090
|
|
from utils.sdr import SDRFactory
|
|
from utils.cleanup import DataStore, cleanup_manager
|
|
from utils.constants import (
|
|
MAX_AIRCRAFT_AGE_SECONDS,
|
|
MAX_WIFI_NETWORK_AGE_SECONDS,
|
|
MAX_BT_DEVICE_AGE_SECONDS,
|
|
MAX_VESSEL_AGE_SECONDS,
|
|
MAX_DSC_MESSAGE_AGE_SECONDS,
|
|
MAX_DEAUTH_ALERTS_AGE_SECONDS,
|
|
QUEUE_MAX_SIZE,
|
|
)
|
|
import logging
|
|
from flask_limiter import Limiter
|
|
from flask_limiter.util import get_remote_address
|
|
# Track application start time for uptime calculation
|
|
import time as _time
|
|
_app_start_time = _time.time()
|
|
logger = logging.getLogger('intercept.database')
|
|
|
|
# Create Flask app
|
|
app = Flask(__name__)
|
|
app.secret_key = "signals_intelligence_secret" # Required for flash messages
|
|
|
|
# Set up rate limiting
|
|
limiter = Limiter(
|
|
key_func=get_remote_address,
|
|
app=app,
|
|
storage_uri="memory://",
|
|
)
|
|
|
|
# Disable Werkzeug debugger PIN (not needed for local development tool)
|
|
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
|
|
|
|
# ============================================
|
|
# ERROR HANDLERS
|
|
# ============================================
|
|
@app.errorhandler(429)
|
|
def ratelimit_handler(e):
|
|
logger.warning(f"Rate limit exceeded for IP: {request.remote_addr}")
|
|
flash("Too many login attempts. Please wait one minute before trying again.", "error")
|
|
return render_template('login.html', version=VERSION), 429
|
|
|
|
# ============================================
|
|
# SECURITY HEADERS
|
|
# ============================================
|
|
|
|
@app.after_request
|
|
def add_security_headers(response):
|
|
"""Add security headers to all responses."""
|
|
# Prevent MIME type sniffing
|
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
|
# Prevent clickjacking
|
|
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
|
# Enable XSS filter
|
|
response.headers['X-XSS-Protection'] = '1; mode=block'
|
|
# Referrer policy
|
|
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
|
# Permissions policy (disable unnecessary features)
|
|
response.headers['Permissions-Policy'] = 'geolocation=(self), microphone=()'
|
|
return response
|
|
|
|
|
|
# ============================================
|
|
# CONTEXT PROCESSORS
|
|
# ============================================
|
|
|
|
@app.context_processor
|
|
def inject_offline_settings():
|
|
"""Inject offline settings into all templates."""
|
|
from utils.database import get_setting
|
|
|
|
# Privacy-first defaults: keep dashboard assets/fonts local to avoid
|
|
# third-party tracker/storage defenses in strict browsers.
|
|
assets_source = str(get_setting('offline.assets_source', 'local') or 'local').lower()
|
|
fonts_source = str(get_setting('offline.fonts_source', 'local') or 'local').lower()
|
|
if assets_source not in ('local', 'cdn'):
|
|
assets_source = 'local'
|
|
if fonts_source not in ('local', 'cdn'):
|
|
fonts_source = 'local'
|
|
# Force local delivery for core dashboard pages.
|
|
assets_source = 'local'
|
|
fonts_source = 'local'
|
|
|
|
return {
|
|
'offline_settings': {
|
|
'enabled': get_setting('offline.enabled', False),
|
|
'assets_source': assets_source,
|
|
'fonts_source': fonts_source,
|
|
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
|
|
'tile_server_url': get_setting('offline.tile_server_url', '')
|
|
}
|
|
}
|
|
|
|
|
|
# ============================================
|
|
# GLOBAL PROCESS MANAGEMENT
|
|
# ============================================
|
|
|
|
# Pager decoder
|
|
current_process = None
|
|
output_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
process_lock = threading.Lock()
|
|
|
|
# RTL_433 sensor
|
|
sensor_process = None
|
|
sensor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
sensor_lock = threading.Lock()
|
|
|
|
# WiFi
|
|
wifi_process = None
|
|
wifi_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
wifi_lock = threading.Lock()
|
|
|
|
# Bluetooth
|
|
bt_process = None
|
|
bt_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
bt_lock = threading.Lock()
|
|
|
|
# ADS-B aircraft
|
|
adsb_process = None
|
|
adsb_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
adsb_lock = threading.Lock()
|
|
|
|
# Satellite/Iridium
|
|
satellite_process = None
|
|
satellite_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
satellite_lock = threading.Lock()
|
|
|
|
# ACARS aircraft messaging
|
|
acars_process = None
|
|
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
acars_lock = threading.Lock()
|
|
|
|
# VDL2 aircraft datalink
|
|
vdl2_process = None
|
|
vdl2_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
vdl2_lock = threading.Lock()
|
|
|
|
# APRS amateur radio tracking
|
|
aprs_process = None
|
|
aprs_rtl_process = None
|
|
aprs_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
aprs_lock = threading.Lock()
|
|
|
|
# RTLAMR utility meter reading
|
|
rtlamr_process = None
|
|
rtlamr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
rtlamr_lock = threading.Lock()
|
|
|
|
# AIS vessel tracking
|
|
ais_process = None
|
|
ais_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
ais_lock = threading.Lock()
|
|
|
|
# DSC (Digital Selective Calling)
|
|
dsc_process = None
|
|
dsc_rtl_process = None
|
|
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
dsc_lock = threading.Lock()
|
|
|
|
# TSCM (Technical Surveillance Countermeasures)
|
|
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
tscm_lock = threading.Lock()
|
|
|
|
# SubGHz Transceiver (HackRF)
|
|
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
subghz_lock = threading.Lock()
|
|
|
|
# Radiosonde weather balloon tracking
|
|
radiosonde_process = None
|
|
radiosonde_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
radiosonde_lock = threading.Lock()
|
|
|
|
# CW/Morse code decoder
|
|
morse_process = None
|
|
morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
morse_lock = threading.Lock()
|
|
|
|
# Deauth Attack Detection
|
|
deauth_detector = None
|
|
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
|
deauth_detector_lock = threading.Lock()
|
|
|
|
# ============================================
|
|
# GLOBAL STATE DICTIONARIES
|
|
# ============================================
|
|
|
|
# Logging settings
|
|
logging_enabled = False
|
|
log_file_path = 'pager_messages.log'
|
|
|
|
# WiFi state - using DataStore for automatic cleanup
|
|
wifi_monitor_interface = None
|
|
wifi_networks = DataStore(max_age_seconds=MAX_WIFI_NETWORK_AGE_SECONDS, name='wifi_networks')
|
|
wifi_clients = DataStore(max_age_seconds=MAX_WIFI_NETWORK_AGE_SECONDS, name='wifi_clients')
|
|
wifi_handshakes = [] # Captured handshakes (list, not auto-cleaned)
|
|
|
|
# Bluetooth state - using DataStore for automatic cleanup
|
|
bt_interface = None
|
|
bt_devices = DataStore(max_age_seconds=MAX_BT_DEVICE_AGE_SECONDS, name='bt_devices')
|
|
bt_beacons = DataStore(max_age_seconds=MAX_BT_DEVICE_AGE_SECONDS, name='bt_beacons')
|
|
bt_services = {} # MAC -> list of services (not auto-cleaned, user-requested)
|
|
|
|
# Aircraft (ADS-B) state - using DataStore for automatic cleanup
|
|
adsb_aircraft = DataStore(max_age_seconds=MAX_AIRCRAFT_AGE_SECONDS, name='adsb_aircraft')
|
|
|
|
# Vessel (AIS) state - using DataStore for automatic cleanup
|
|
ais_vessels = DataStore(max_age_seconds=MAX_VESSEL_AGE_SECONDS, name='ais_vessels')
|
|
|
|
# DSC (Digital Selective Calling) state - using DataStore for automatic cleanup
|
|
dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_messages')
|
|
|
|
# Deauth alerts - using DataStore for automatic cleanup
|
|
deauth_alerts = DataStore(max_age_seconds=MAX_DEAUTH_ALERTS_AGE_SECONDS, name='deauth_alerts')
|
|
|
|
# Satellite state
|
|
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
|
|
|
|
# Register data stores with cleanup manager
|
|
cleanup_manager.register(wifi_networks)
|
|
cleanup_manager.register(wifi_clients)
|
|
cleanup_manager.register(bt_devices)
|
|
cleanup_manager.register(bt_beacons)
|
|
cleanup_manager.register(adsb_aircraft)
|
|
cleanup_manager.register(ais_vessels)
|
|
cleanup_manager.register(dsc_messages)
|
|
cleanup_manager.register(deauth_alerts)
|
|
|
|
# ============================================
|
|
# SDR DEVICE REGISTRY
|
|
# ============================================
|
|
# Tracks which mode is using which SDR device to prevent conflicts
|
|
# Key: "sdr_type:device_index" (str), Value: mode_name (str)
|
|
sdr_device_registry: dict[str, str] = {}
|
|
sdr_device_registry_lock = threading.Lock()
|
|
|
|
|
|
def claim_sdr_device(device_index: int, mode_name: str, sdr_type: str = 'rtlsdr') -> str | None:
|
|
"""Claim an SDR device for a mode.
|
|
|
|
Checks the in-app registry first, then probes the USB device to
|
|
catch stale handles held by external processes (e.g. a leftover
|
|
rtl_fm from a previous crash).
|
|
|
|
Args:
|
|
device_index: The SDR device index to claim
|
|
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
|
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
|
|
|
|
Returns:
|
|
Error message if device is in use, None if successfully claimed
|
|
"""
|
|
key = f"{sdr_type}:{device_index}"
|
|
with sdr_device_registry_lock:
|
|
if key in sdr_device_registry:
|
|
in_use_by = sdr_device_registry[key]
|
|
return f'SDR device {sdr_type}:{device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
|
|
|
# Probe the USB device to catch external processes holding the handle
|
|
if sdr_type == 'rtlsdr':
|
|
try:
|
|
from utils.sdr.detection import probe_rtlsdr_device
|
|
usb_error = probe_rtlsdr_device(device_index)
|
|
if usb_error:
|
|
return usb_error
|
|
except Exception:
|
|
pass # If probe fails, let the caller proceed normally
|
|
|
|
sdr_device_registry[key] = mode_name
|
|
return None
|
|
|
|
|
|
def release_sdr_device(device_index: int, sdr_type: str = 'rtlsdr') -> None:
|
|
"""Release an SDR device from the registry.
|
|
|
|
Args:
|
|
device_index: The SDR device index to release
|
|
sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr')
|
|
"""
|
|
key = f"{sdr_type}:{device_index}"
|
|
with sdr_device_registry_lock:
|
|
sdr_device_registry.pop(key, None)
|
|
|
|
|
|
def get_sdr_device_status() -> dict[str, str]:
|
|
"""Get current SDR device allocations.
|
|
|
|
Returns:
|
|
Dictionary mapping 'sdr_type:device_index' keys to mode names
|
|
"""
|
|
with sdr_device_registry_lock:
|
|
return dict(sdr_device_registry)
|
|
|
|
|
|
# ============================================
|
|
# MAIN ROUTES
|
|
# ============================================
|
|
|
|
@app.before_request
|
|
def require_login():
|
|
# Routes that don't require login (to avoid infinite redirect loop)
|
|
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
|
|
|
|
# Allow audio streaming endpoints without session auth
|
|
if request.path.startswith('/listening/audio/'):
|
|
return None
|
|
|
|
# Allow WebSocket upgrade requests (page load already required auth)
|
|
if request.path.startswith('/ws/'):
|
|
return None
|
|
|
|
# Controller API endpoints use API key auth, not session auth
|
|
# Allow agent push/pull endpoints without session login
|
|
if request.path.startswith('/controller/'):
|
|
return None # Skip session check, controller routes handle their own auth
|
|
|
|
# If user is not logged in and the current route is not allowed...
|
|
if 'logged_in' not in session and request.endpoint not in allowed_routes:
|
|
return redirect(url_for('login'))
|
|
|
|
@app.route('/logout')
|
|
def logout():
|
|
session.pop('logged_in', None)
|
|
return redirect(url_for('login'))
|
|
|
|
@app.route('/login', methods=['GET', 'POST'])
|
|
@limiter.limit("5 per minute") # Limit to 5 login attempts per minute per IP
|
|
def login():
|
|
if request.method == 'POST':
|
|
username = request.form.get('username')
|
|
password = request.form.get('password')
|
|
|
|
# Connect to DB and find user
|
|
with get_db() as conn:
|
|
cursor = conn.execute(
|
|
'SELECT password_hash, role FROM users WHERE username = ?',
|
|
(username,)
|
|
)
|
|
user = cursor.fetchone()
|
|
|
|
# Verify user exists and password is correct
|
|
if user and check_password_hash(user['password_hash'], password):
|
|
# Store data in session
|
|
session['logged_in'] = True
|
|
session['username'] = username
|
|
session['role'] = user['role']
|
|
|
|
logger.info(f"User '{username}' logged in successfully.")
|
|
return redirect(url_for('index'))
|
|
else:
|
|
logger.warning(f"Failed login attempt for username: {username}")
|
|
flash("ACCESS DENIED: INVALID CREDENTIALS", "error")
|
|
|
|
return render_template('login.html', version=VERSION)
|
|
|
|
@app.route('/')
|
|
def index() -> str:
|
|
tools = {
|
|
'rtl_fm': check_tool('rtl_fm'),
|
|
'multimon': check_tool('multimon-ng'),
|
|
'rtl_433': check_tool('rtl_433'),
|
|
'rtlamr': check_tool('rtlamr')
|
|
}
|
|
devices = [d.to_dict() for d in SDRFactory.detect_devices()]
|
|
return render_template(
|
|
'index.html',
|
|
tools=tools,
|
|
devices=devices,
|
|
version=VERSION,
|
|
changelog=CHANGELOG,
|
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
|
default_latitude=DEFAULT_LATITUDE,
|
|
default_longitude=DEFAULT_LONGITUDE,
|
|
)
|
|
|
|
|
|
@app.route('/favicon.svg')
|
|
def favicon() -> Response:
|
|
return send_file('favicon.svg', mimetype='image/svg+xml')
|
|
|
|
|
|
@app.route('/sw.js')
|
|
def service_worker() -> Response:
|
|
resp = send_from_directory('static', 'sw.js', mimetype='application/javascript')
|
|
resp.headers['Service-Worker-Allowed'] = '/'
|
|
return resp
|
|
|
|
|
|
@app.route('/manifest.json')
|
|
def pwa_manifest() -> Response:
|
|
return send_from_directory('static', 'manifest.json', mimetype='application/manifest+json')
|
|
|
|
|
|
@app.route('/devices')
|
|
def get_devices() -> Response:
|
|
"""Get all detected SDR devices with hardware type info."""
|
|
devices = SDRFactory.detect_devices()
|
|
return jsonify([d.to_dict() for d in devices])
|
|
|
|
|
|
@app.route('/devices/status')
|
|
def get_devices_status() -> Response:
|
|
"""Get all SDR devices with usage status."""
|
|
devices = SDRFactory.detect_devices()
|
|
registry = get_sdr_device_status()
|
|
|
|
result = []
|
|
for device in devices:
|
|
d = device.to_dict()
|
|
key = f"{device.sdr_type.value}:{device.index}"
|
|
d['in_use'] = key in registry
|
|
d['used_by'] = registry.get(key)
|
|
result.append(d)
|
|
|
|
return jsonify(result)
|
|
|
|
|
|
@app.route('/devices/debug')
|
|
def get_devices_debug() -> Response:
|
|
"""Get detailed SDR device detection diagnostics."""
|
|
import shutil
|
|
|
|
diagnostics = {
|
|
'tools': {},
|
|
'rtl_test': {},
|
|
'soapy': {},
|
|
'usb': {},
|
|
'kernel_modules': {},
|
|
'detected_devices': [],
|
|
'suggestions': []
|
|
}
|
|
|
|
# Check for required tools
|
|
diagnostics['tools']['rtl_test'] = shutil.which('rtl_test') is not None
|
|
diagnostics['tools']['SoapySDRUtil'] = shutil.which('SoapySDRUtil') is not None
|
|
diagnostics['tools']['lsusb'] = shutil.which('lsusb') is not None
|
|
|
|
# Run rtl_test and capture full output
|
|
if diagnostics['tools']['rtl_test']:
|
|
try:
|
|
result = subprocess.run(
|
|
['rtl_test', '-t'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5
|
|
)
|
|
diagnostics['rtl_test'] = {
|
|
'returncode': result.returncode,
|
|
'stdout': result.stdout[:2000] if result.stdout else '',
|
|
'stderr': result.stderr[:2000] if result.stderr else ''
|
|
}
|
|
|
|
# Check for common errors
|
|
combined = (result.stdout or '') + (result.stderr or '')
|
|
if 'No supported devices found' in combined:
|
|
diagnostics['suggestions'].append('No RTL-SDR device detected. Check USB connection.')
|
|
if 'usb_claim_interface error' in combined:
|
|
diagnostics['suggestions'].append('Device busy - kernel DVB driver may have claimed it. Run: sudo modprobe -r dvb_usb_rtl28xxu')
|
|
if 'Permission denied' in combined.lower():
|
|
diagnostics['suggestions'].append('USB permission denied. Add udev rules or run as root.')
|
|
|
|
except subprocess.TimeoutExpired:
|
|
diagnostics['rtl_test'] = {'error': 'Timeout after 5 seconds'}
|
|
except Exception as e:
|
|
diagnostics['rtl_test'] = {'error': str(e)}
|
|
else:
|
|
diagnostics['suggestions'].append('rtl_test not found. Install rtl-sdr package.')
|
|
|
|
# Run SoapySDRUtil
|
|
if diagnostics['tools']['SoapySDRUtil']:
|
|
try:
|
|
result = subprocess.run(
|
|
['SoapySDRUtil', '--find'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10
|
|
)
|
|
diagnostics['soapy'] = {
|
|
'returncode': result.returncode,
|
|
'stdout': result.stdout[:2000] if result.stdout else '',
|
|
'stderr': result.stderr[:2000] if result.stderr else ''
|
|
}
|
|
except subprocess.TimeoutExpired:
|
|
diagnostics['soapy'] = {'error': 'Timeout after 10 seconds'}
|
|
except Exception as e:
|
|
diagnostics['soapy'] = {'error': str(e)}
|
|
|
|
# Check USB devices (Linux)
|
|
if diagnostics['tools']['lsusb']:
|
|
try:
|
|
result = subprocess.run(
|
|
['lsusb'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5
|
|
)
|
|
# Filter for common SDR vendor IDs
|
|
sdr_vendors = ['0bda', '1d50', '1df7', '0403'] # Realtek, OpenMoko/HackRF, SDRplay, FTDI
|
|
usb_lines = [l for l in result.stdout.split('\n')
|
|
if any(v in l.lower() for v in sdr_vendors) or 'rtl' in l.lower() or 'sdr' in l.lower()]
|
|
diagnostics['usb']['devices'] = usb_lines if usb_lines else ['No SDR-related USB devices found']
|
|
except Exception as e:
|
|
diagnostics['usb'] = {'error': str(e)}
|
|
|
|
# Check for loaded kernel modules that conflict (Linux)
|
|
if platform.system() == 'Linux':
|
|
try:
|
|
result = subprocess.run(
|
|
['lsmod'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5
|
|
)
|
|
conflicting = ['dvb_usb_rtl28xxu', 'rtl2832', 'rtl2830']
|
|
loaded = [m for m in conflicting if m in result.stdout]
|
|
diagnostics['kernel_modules']['conflicting_loaded'] = loaded
|
|
if loaded:
|
|
diagnostics['suggestions'].append(f"Conflicting kernel modules loaded: {', '.join(loaded)}. Run: sudo modprobe -r {' '.join(loaded)}")
|
|
except Exception as e:
|
|
diagnostics['kernel_modules'] = {'error': str(e)}
|
|
|
|
# Get detected devices
|
|
devices = SDRFactory.detect_devices()
|
|
diagnostics['detected_devices'] = [d.to_dict() for d in devices]
|
|
|
|
if not devices and not diagnostics['suggestions']:
|
|
diagnostics['suggestions'].append('No devices detected. Check USB connection and driver installation.')
|
|
|
|
return jsonify(diagnostics)
|
|
|
|
|
|
@app.route('/dependencies')
|
|
def get_dependencies() -> Response:
|
|
"""Get status of all tool dependencies."""
|
|
results = check_all_dependencies()
|
|
|
|
# Determine OS for install instructions
|
|
system = platform.system().lower()
|
|
if system == 'darwin':
|
|
pkg_manager = 'brew'
|
|
elif system == 'linux':
|
|
pkg_manager = 'apt'
|
|
else:
|
|
pkg_manager = 'manual'
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'os': system,
|
|
'pkg_manager': pkg_manager,
|
|
'modes': results
|
|
})
|
|
|
|
|
|
@app.route('/export/aircraft', methods=['GET'])
|
|
def export_aircraft() -> Response:
|
|
"""Export aircraft data as JSON or CSV."""
|
|
import csv
|
|
import io
|
|
|
|
format_type = request.args.get('format', 'json').lower()
|
|
|
|
if format_type == 'csv':
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow(['icao', 'callsign', 'altitude', 'speed', 'heading', 'lat', 'lon', 'squawk', 'last_seen'])
|
|
|
|
for icao, ac in adsb_aircraft.items():
|
|
writer.writerow([
|
|
icao,
|
|
ac.get('callsign', '') if isinstance(ac, dict) else '',
|
|
ac.get('altitude', '') if isinstance(ac, dict) else '',
|
|
ac.get('speed', '') if isinstance(ac, dict) else '',
|
|
ac.get('heading', '') if isinstance(ac, dict) else '',
|
|
ac.get('lat', '') if isinstance(ac, dict) else '',
|
|
ac.get('lon', '') if isinstance(ac, dict) else '',
|
|
ac.get('squawk', '') if isinstance(ac, dict) else '',
|
|
ac.get('lastSeen', '') if isinstance(ac, dict) else ''
|
|
])
|
|
|
|
response = Response(output.getvalue(), mimetype='text/csv')
|
|
response.headers['Content-Disposition'] = 'attachment; filename=aircraft.csv'
|
|
return response
|
|
else:
|
|
return jsonify({
|
|
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
|
|
'aircraft': adsb_aircraft.values()
|
|
})
|
|
|
|
|
|
@app.route('/export/wifi', methods=['GET'])
|
|
def export_wifi() -> Response:
|
|
"""Export WiFi networks as JSON or CSV."""
|
|
import csv
|
|
import io
|
|
|
|
format_type = request.args.get('format', 'json').lower()
|
|
|
|
if format_type == 'csv':
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow(['bssid', 'ssid', 'channel', 'signal', 'encryption', 'clients'])
|
|
|
|
for bssid, net in wifi_networks.items():
|
|
writer.writerow([
|
|
bssid,
|
|
net.get('ssid', '') if isinstance(net, dict) else '',
|
|
net.get('channel', '') if isinstance(net, dict) else '',
|
|
net.get('signal', '') if isinstance(net, dict) else '',
|
|
net.get('encryption', '') if isinstance(net, dict) else '',
|
|
net.get('clients', 0) if isinstance(net, dict) else 0
|
|
])
|
|
|
|
response = Response(output.getvalue(), mimetype='text/csv')
|
|
response.headers['Content-Disposition'] = 'attachment; filename=wifi_networks.csv'
|
|
return response
|
|
else:
|
|
return jsonify({
|
|
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
|
|
'networks': wifi_networks.values(),
|
|
'clients': wifi_clients.values()
|
|
})
|
|
|
|
|
|
@app.route('/export/bluetooth', methods=['GET'])
|
|
def export_bluetooth() -> Response:
|
|
"""Export Bluetooth devices as JSON or CSV."""
|
|
import csv
|
|
import io
|
|
|
|
format_type = request.args.get('format', 'json').lower()
|
|
|
|
if format_type == 'csv':
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow(['mac', 'name', 'rssi', 'type', 'manufacturer', 'last_seen'])
|
|
|
|
for mac, dev in bt_devices.items():
|
|
writer.writerow([
|
|
mac,
|
|
dev.get('name', '') if isinstance(dev, dict) else '',
|
|
dev.get('rssi', '') if isinstance(dev, dict) else '',
|
|
dev.get('type', '') if isinstance(dev, dict) else '',
|
|
dev.get('manufacturer', '') if isinstance(dev, dict) else '',
|
|
dev.get('lastSeen', '') if isinstance(dev, dict) else ''
|
|
])
|
|
|
|
response = Response(output.getvalue(), mimetype='text/csv')
|
|
response.headers['Content-Disposition'] = 'attachment; filename=bluetooth_devices.csv'
|
|
return response
|
|
else:
|
|
return jsonify({
|
|
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
|
|
'devices': bt_devices.values(),
|
|
'beacons': bt_beacons.values()
|
|
})
|
|
|
|
|
|
def _get_subghz_active() -> bool:
|
|
"""Check if SubGHz manager has an active process."""
|
|
try:
|
|
from utils.subghz import get_subghz_manager
|
|
return get_subghz_manager().active_mode != 'idle'
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _get_bluetooth_health() -> tuple[bool, int]:
|
|
"""Return Bluetooth active state and best-effort device count."""
|
|
legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False)
|
|
scanner_running = False
|
|
scanner_count = 0
|
|
|
|
try:
|
|
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
|
if bt_scanner is not None:
|
|
scanner_running = bool(bt_scanner.is_scanning)
|
|
scanner_count = int(bt_scanner.device_count)
|
|
except Exception:
|
|
scanner_running = False
|
|
scanner_count = 0
|
|
|
|
locate_running = False
|
|
try:
|
|
from utils.bt_locate import get_locate_session
|
|
session = get_locate_session()
|
|
if session and getattr(session, 'active', False):
|
|
scanner = getattr(session, '_scanner', None)
|
|
locate_running = bool(scanner and scanner.is_scanning)
|
|
except Exception:
|
|
locate_running = False
|
|
|
|
return (legacy_running or scanner_running or locate_running), max(len(bt_devices), scanner_count)
|
|
|
|
|
|
def _get_wifi_health() -> tuple[bool, int, int]:
|
|
"""Return WiFi active state and best-effort network/client counts."""
|
|
legacy_running = wifi_process is not None and (wifi_process.poll() is None if wifi_process else False)
|
|
scanner_running = False
|
|
scanner_networks = 0
|
|
scanner_clients = 0
|
|
|
|
try:
|
|
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
|
if wifi_scanner is not None:
|
|
status = wifi_scanner.get_status()
|
|
scanner_running = bool(status.is_scanning)
|
|
scanner_networks = int(status.networks_found or 0)
|
|
scanner_clients = int(status.clients_found or 0)
|
|
except Exception:
|
|
scanner_running = False
|
|
scanner_networks = 0
|
|
scanner_clients = 0
|
|
|
|
return (
|
|
legacy_running or scanner_running,
|
|
max(len(wifi_networks), scanner_networks),
|
|
max(len(wifi_clients), scanner_clients),
|
|
)
|
|
|
|
|
|
@app.route('/health')
|
|
def health_check() -> Response:
|
|
"""Health check endpoint for monitoring."""
|
|
import time
|
|
bt_active, bt_device_count = _get_bluetooth_health()
|
|
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
|
|
return jsonify({
|
|
'status': 'healthy',
|
|
'version': VERSION,
|
|
'uptime_seconds': round(time.time() - _app_start_time, 2),
|
|
'processes': {
|
|
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
|
|
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
|
|
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
|
|
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
|
|
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
|
|
'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_process else False),
|
|
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
|
'wifi': wifi_active,
|
|
'bluetooth': bt_active,
|
|
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
|
'radiosonde': radiosonde_process is not None and (radiosonde_process.poll() is None if radiosonde_process else False),
|
|
'morse': morse_process is not None and (morse_process.poll() is None if morse_process else False),
|
|
'subghz': _get_subghz_active(),
|
|
},
|
|
'data': {
|
|
'aircraft_count': len(adsb_aircraft),
|
|
'vessel_count': len(ais_vessels),
|
|
'wifi_networks_count': wifi_network_count,
|
|
'wifi_clients_count': wifi_client_count,
|
|
'bt_devices_count': bt_device_count,
|
|
'dsc_messages_count': len(dsc_messages),
|
|
}
|
|
})
|
|
|
|
|
|
@app.route('/killall', methods=['POST'])
|
|
def kill_all() -> Response:
|
|
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
|
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
|
global vdl2_process, morse_process, radiosonde_process
|
|
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
|
|
|
# Import modules to reset their state
|
|
from routes import adsb as adsb_module
|
|
from routes import ais as ais_module
|
|
from routes import radiosonde as radiosonde_module
|
|
from utils.bluetooth import reset_bluetooth_scanner
|
|
|
|
killed = []
|
|
processes_to_kill = [
|
|
'rtl_fm', 'multimon-ng', 'rtl_433',
|
|
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
|
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
|
|
'hcitool', 'bluetoothctl', 'satdump',
|
|
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
|
'hackrf_transfer', 'hackrf_sweep',
|
|
'auto_rx'
|
|
]
|
|
|
|
for proc in processes_to_kill:
|
|
try:
|
|
result = subprocess.run(['pkill', '-f', proc], capture_output=True)
|
|
if result.returncode == 0:
|
|
killed.append(proc)
|
|
except (subprocess.SubprocessError, OSError):
|
|
pass
|
|
|
|
with process_lock:
|
|
current_process = None
|
|
|
|
with sensor_lock:
|
|
sensor_process = None
|
|
|
|
with wifi_lock:
|
|
wifi_process = None
|
|
|
|
# Reset ADS-B state
|
|
with adsb_lock:
|
|
adsb_process = None
|
|
adsb_module.adsb_using_service = False
|
|
|
|
# Reset AIS state
|
|
with ais_lock:
|
|
ais_process = None
|
|
ais_module.ais_running = False
|
|
|
|
# Reset Radiosonde state
|
|
with radiosonde_lock:
|
|
radiosonde_process = None
|
|
radiosonde_module.radiosonde_running = False
|
|
|
|
# Reset ACARS state
|
|
with acars_lock:
|
|
acars_process = None
|
|
|
|
# Reset VDL2 state
|
|
with vdl2_lock:
|
|
vdl2_process = None
|
|
|
|
# Reset Morse state
|
|
with morse_lock:
|
|
morse_process = None
|
|
|
|
# Reset APRS state
|
|
with aprs_lock:
|
|
aprs_process = None
|
|
aprs_rtl_process = None
|
|
|
|
# Reset DSC state
|
|
with dsc_lock:
|
|
dsc_process = None
|
|
dsc_rtl_process = None
|
|
|
|
# Reset Bluetooth state (legacy)
|
|
with bt_lock:
|
|
if bt_process:
|
|
try:
|
|
bt_process.terminate()
|
|
bt_process.wait(timeout=2)
|
|
except Exception:
|
|
try:
|
|
bt_process.kill()
|
|
except Exception:
|
|
pass
|
|
bt_process = None
|
|
|
|
# Reset Bluetooth v2 scanner
|
|
try:
|
|
reset_bluetooth_scanner()
|
|
killed.append('bluetooth')
|
|
except Exception:
|
|
pass
|
|
|
|
# Reset SubGHz state
|
|
try:
|
|
from utils.subghz import get_subghz_manager
|
|
get_subghz_manager().stop_all()
|
|
except Exception:
|
|
pass
|
|
|
|
# Clear SDR device registry
|
|
with sdr_device_registry_lock:
|
|
sdr_device_registry.clear()
|
|
|
|
return jsonify({'status': 'killed', 'processes': killed})
|
|
|
|
|
|
def _ensure_self_signed_cert(cert_dir: str) -> tuple:
|
|
"""Generate a self-signed certificate if one doesn't already exist.
|
|
|
|
Returns (cert_path, key_path) tuple.
|
|
"""
|
|
cert_path = os.path.join(cert_dir, 'intercept.crt')
|
|
key_path = os.path.join(cert_dir, 'intercept.key')
|
|
|
|
if os.path.exists(cert_path) and os.path.exists(key_path):
|
|
print(f"Using existing SSL certificate: {cert_path}")
|
|
return cert_path, key_path
|
|
|
|
os.makedirs(cert_dir, exist_ok=True)
|
|
print("Generating self-signed SSL certificate...")
|
|
|
|
import subprocess
|
|
result = subprocess.run([
|
|
'openssl', 'req', '-x509', '-newkey', 'rsa:2048',
|
|
'-keyout', key_path, '-out', cert_path,
|
|
'-days', '365', '-nodes',
|
|
'-subj', '/CN=intercept/O=INTERCEPT/C=US',
|
|
], capture_output=True, text=True)
|
|
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"Failed to generate SSL certificate: {result.stderr}")
|
|
|
|
print(f"SSL certificate generated: {cert_path}")
|
|
return cert_path, key_path
|
|
|
|
|
|
_app_initialized = False
|
|
|
|
|
|
def _init_app() -> None:
|
|
"""Initialize blueprints, database, and websockets.
|
|
|
|
Safe to call multiple times — subsequent calls are no-ops.
|
|
Called automatically at module level for gunicorn, and also
|
|
from main() for the Flask dev server path.
|
|
|
|
Heavy/network operations (TLE updates, process cleanup) are
|
|
deferred to a background thread so the worker can serve
|
|
requests immediately.
|
|
"""
|
|
global _app_initialized
|
|
if _app_initialized:
|
|
return
|
|
_app_initialized = True
|
|
|
|
import os
|
|
|
|
# Initialize database for settings storage
|
|
from utils.database import init_db
|
|
init_db()
|
|
|
|
# Register blueprints (essential — without these, all routes 404)
|
|
from routes import register_blueprints
|
|
register_blueprints(app)
|
|
|
|
# Initialize WebSocket for audio streaming
|
|
try:
|
|
from routes.audio_websocket import init_audio_websocket
|
|
init_audio_websocket(app)
|
|
except ImportError:
|
|
pass
|
|
|
|
# Initialize KiwiSDR WebSocket audio proxy
|
|
try:
|
|
from routes.websdr import init_websdr_audio
|
|
init_websdr_audio(app)
|
|
except ImportError:
|
|
pass
|
|
|
|
# Initialize WebSocket for waterfall streaming
|
|
try:
|
|
from routes.waterfall_websocket import init_waterfall_websocket
|
|
init_waterfall_websocket(app)
|
|
except ImportError:
|
|
pass
|
|
|
|
# Defer heavy/network operations so the worker can serve requests immediately
|
|
import threading
|
|
|
|
def _deferred_init():
|
|
"""Run heavy initialization after a short delay."""
|
|
import time
|
|
time.sleep(1) # Let the worker start serving first
|
|
|
|
# Clean up stale processes from previous runs
|
|
try:
|
|
cleanup_stale_processes()
|
|
cleanup_stale_dump1090()
|
|
except Exception as e:
|
|
logger.warning(f"Stale process cleanup failed: {e}")
|
|
|
|
# Register and start database cleanup
|
|
try:
|
|
from utils.database import (
|
|
cleanup_old_signal_history,
|
|
cleanup_old_timeline_entries,
|
|
cleanup_old_dsc_alerts,
|
|
cleanup_old_payloads
|
|
)
|
|
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440)
|
|
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440)
|
|
cleanup_manager.register_db_cleanup(cleanup_old_dsc_alerts, interval_multiplier=1440)
|
|
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440)
|
|
cleanup_manager.start()
|
|
except Exception as e:
|
|
logger.warning(f"Cleanup manager init failed: {e}")
|
|
|
|
# Initialize TLE auto-refresh (must be after blueprint registration)
|
|
try:
|
|
from routes.satellite import init_tle_auto_refresh
|
|
if not os.environ.get('TESTING'):
|
|
init_tle_auto_refresh()
|
|
except Exception as e:
|
|
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
|
|
|
|
threading.Thread(target=_deferred_init, daemon=True).start()
|
|
|
|
|
|
# Auto-initialize when imported (e.g. by gunicorn)
|
|
_init_app()
|
|
|
|
|
|
def main() -> None:
|
|
"""Main entry point."""
|
|
import argparse
|
|
import config
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description='INTERCEPT - Signal Intelligence Platform',
|
|
epilog='Environment variables: INTERCEPT_HOST, INTERCEPT_PORT, INTERCEPT_DEBUG, INTERCEPT_LOG_LEVEL'
|
|
)
|
|
parser.add_argument(
|
|
'-p', '--port',
|
|
type=int,
|
|
default=config.PORT,
|
|
help=f'Port to run server on (default: {config.PORT})'
|
|
)
|
|
parser.add_argument(
|
|
'-H', '--host',
|
|
default=config.HOST,
|
|
help=f'Host to bind to (default: {config.HOST})'
|
|
)
|
|
parser.add_argument(
|
|
'-d', '--debug',
|
|
action='store_true',
|
|
default=config.DEBUG,
|
|
help='Enable debug mode'
|
|
)
|
|
parser.add_argument(
|
|
'--https',
|
|
action='store_true',
|
|
default=config.HTTPS,
|
|
help='Enable HTTPS with self-signed certificate'
|
|
)
|
|
parser.add_argument(
|
|
'--check-deps',
|
|
action='store_true',
|
|
help='Check dependencies and exit'
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
# Check dependencies only
|
|
if args.check_deps:
|
|
results = check_all_dependencies()
|
|
print("Dependency Status:")
|
|
print("-" * 40)
|
|
for mode, info in results.items():
|
|
status = "✓" if info['ready'] else "✗"
|
|
print(f"\n{status} {info['name']}:")
|
|
for tool, tool_info in info['tools'].items():
|
|
tool_status = "✓" if tool_info['installed'] else "✗"
|
|
req = " (required)" if tool_info['required'] else ""
|
|
print(f" {tool_status} {tool}{req}")
|
|
sys.exit(0)
|
|
|
|
print("=" * 50)
|
|
print(" INTERCEPT // Signal Intelligence")
|
|
print(" Pager / 433MHz / Aircraft / ACARS / Satellite / WiFi / BT")
|
|
print("=" * 50)
|
|
print()
|
|
|
|
# Check if running as root (required for WiFi monitor mode, some BT operations)
|
|
import os
|
|
if os.geteuid() != 0:
|
|
print("\033[93m" + "=" * 50)
|
|
print(" ⚠️ WARNING: Not running as root/sudo")
|
|
print("=" * 50)
|
|
print(" Some features require root privileges:")
|
|
print(" - WiFi monitor mode and scanning")
|
|
print(" - Bluetooth low-level operations")
|
|
print(" - RTL-SDR access (on some systems)")
|
|
print()
|
|
print(" To run with full capabilities:")
|
|
print(" sudo -E venv/bin/python intercept.py")
|
|
print("=" * 50 + "\033[0m")
|
|
print()
|
|
# Store for API access
|
|
app.config['RUNNING_AS_ROOT'] = False
|
|
else:
|
|
app.config['RUNNING_AS_ROOT'] = True
|
|
print("Running as root - full capabilities enabled")
|
|
print()
|
|
|
|
# Ensure app is initialized (no-op if already done by module-level call)
|
|
_init_app()
|
|
|
|
# Configure SSL if HTTPS is enabled
|
|
ssl_context = None
|
|
if args.https:
|
|
cert_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'certs')
|
|
if config.SSL_CERT and config.SSL_KEY:
|
|
ssl_context = (config.SSL_CERT, config.SSL_KEY)
|
|
print(f"Using provided SSL certificate: {config.SSL_CERT}")
|
|
else:
|
|
ssl_context = _ensure_self_signed_cert(cert_dir)
|
|
|
|
protocol = 'https' if ssl_context else 'http'
|
|
print(f"Open {protocol}://localhost:{args.port} in your browser")
|
|
print()
|
|
print("Press Ctrl+C to stop")
|
|
print()
|
|
|
|
# Avoid loading a global ~/.env when running the script directly.
|
|
app.run(
|
|
host=args.host,
|
|
port=args.port,
|
|
debug=args.debug,
|
|
threaded=True,
|
|
load_dotenv=False,
|
|
ssl_context=ssl_context,
|
|
)
|