mirror of
https://github.com/smittix/intercept.git
synced 2026-06-09 14:41:55 -07:00
f9e8fa896d
Implements Task 5: creates routes/drone.py with /status, /contacts, /start, /stop, and /stream (SSE fanout) endpoints; registers the drone_bp blueprint in routes/__init__.py; adds drone_queue to app.py; adds opendroneid>=1.0 to requirements.txt. All 39 drone tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1390 lines
47 KiB
Python
1390 lines
47 KiB
Python
"""
|
||
INTERCEPT - Signal Intelligence Platform
|
||
|
||
Flask application and shared state.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import site
|
||
import sys
|
||
|
||
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 logging
|
||
import os
|
||
import platform
|
||
import queue
|
||
import subprocess
|
||
import threading
|
||
from pathlib import Path
|
||
|
||
from flask import (
|
||
Flask,
|
||
Response,
|
||
flash,
|
||
jsonify,
|
||
redirect,
|
||
render_template,
|
||
request,
|
||
send_file,
|
||
send_from_directory,
|
||
session,
|
||
url_for,
|
||
)
|
||
from werkzeug.security import check_password_hash
|
||
|
||
from config import CHANGELOG, DEFAULT_LATITUDE, DEFAULT_LONGITUDE, SHARED_OBSERVER_LOCATION_ENABLED, VERSION
|
||
from utils.cleanup import DataStore, cleanup_manager
|
||
from utils.constants import (
|
||
MAX_AIRCRAFT_AGE_SECONDS,
|
||
MAX_BT_DEVICE_AGE_SECONDS,
|
||
MAX_DEAUTH_ALERTS_AGE_SECONDS,
|
||
MAX_DSC_MESSAGE_AGE_SECONDS,
|
||
MAX_VESSEL_AGE_SECONDS,
|
||
MAX_WIFI_NETWORK_AGE_SECONDS,
|
||
QUEUE_MAX_SIZE,
|
||
)
|
||
from utils.dependencies import check_all_dependencies, check_tool
|
||
from utils.process import cleanup_stale_dump1090, cleanup_stale_processes
|
||
from utils.sdr import SDRFactory
|
||
|
||
try:
|
||
from flask_limiter import Limiter
|
||
from flask_limiter.util import get_remote_address
|
||
|
||
_has_limiter = True
|
||
except ImportError:
|
||
_has_limiter = False
|
||
try:
|
||
from flask_compress import Compress
|
||
|
||
_has_compress = True
|
||
except ImportError:
|
||
_has_compress = False
|
||
try:
|
||
from flask_wtf.csrf import CSRFProtect
|
||
|
||
_has_csrf = True
|
||
except ImportError:
|
||
_has_csrf = False
|
||
# Track application start time for uptime calculation
|
||
import contextlib
|
||
import time as _time
|
||
|
||
_app_start_time = _time.time()
|
||
logger = logging.getLogger("intercept.database")
|
||
|
||
# Create Flask app
|
||
app = Flask(__name__)
|
||
|
||
|
||
def _load_or_generate_secret_key():
|
||
"""Load secret key from env var or instance file, generating if needed."""
|
||
env_key = os.environ.get("INTERCEPT_SECRET_KEY")
|
||
if env_key:
|
||
return env_key
|
||
key_path = Path("instance/secret.key")
|
||
if key_path.exists():
|
||
return key_path.read_text().strip()
|
||
key_path.parent.mkdir(exist_ok=True)
|
||
key = os.urandom(32).hex()
|
||
key_path.write_text(key)
|
||
return key
|
||
|
||
|
||
app.secret_key = _load_or_generate_secret_key()
|
||
|
||
# Set up HTTP compression (gzip/brotli for HTML, CSS, JS, JSON)
|
||
if _has_compress:
|
||
Compress(app)
|
||
else:
|
||
logging.getLogger("intercept").warning(
|
||
"flask-compress not installed – HTTP compression disabled. Install with: pip install flask-compress"
|
||
)
|
||
|
||
# Set up rate limiting
|
||
if _has_limiter:
|
||
limiter = Limiter(
|
||
key_func=get_remote_address,
|
||
app=app,
|
||
storage_uri="memory://",
|
||
)
|
||
else:
|
||
logging.getLogger("intercept").warning(
|
||
"flask-limiter not installed – rate limiting disabled. Install with: pip install flask-limiter"
|
||
)
|
||
|
||
class _NoopLimiter:
|
||
"""Stub so @limiter.limit() decorators are silently ignored."""
|
||
|
||
def limit(self, *a, **kw):
|
||
def decorator(f):
|
||
return f
|
||
|
||
return decorator
|
||
|
||
limiter = _NoopLimiter()
|
||
|
||
# Set up CSRF protection
|
||
if _has_csrf:
|
||
csrf = CSRFProtect(app)
|
||
else:
|
||
logging.getLogger("intercept").warning(
|
||
"flask-wtf not installed – CSRF protection disabled. Install with: pip install flask-wtf"
|
||
)
|
||
csrf = None
|
||
|
||
# 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=()"
|
||
# Cache-Control for static assets
|
||
if request.path.startswith("/static/"):
|
||
if "/vendor/" in request.path:
|
||
response.headers["Cache-Control"] = "public, max-age=604800" # 7 days for vendored libs
|
||
else:
|
||
response.headers["Cache-Control"] = "public, max-age=86400" # 24h for app assets
|
||
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()
|
||
|
||
# Ground Station automation
|
||
ground_station_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||
|
||
# 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()
|
||
|
||
# Meteor scatter detection
|
||
meteor_process = None
|
||
meteor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||
meteor_lock = threading.Lock()
|
||
|
||
# Generic OOK signal decoder
|
||
ook_process = None
|
||
ook_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||
ook_lock = threading.Lock()
|
||
|
||
# Deauth Attack Detection
|
||
deauth_detector = None
|
||
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||
deauth_detector_lock = threading.Lock()
|
||
|
||
# Drone Intelligence
|
||
drone_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||
|
||
# ============================================
|
||
# 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:
|
||
if request.args.get("mode") == "satellite":
|
||
return redirect(url_for("satellite.satellite_dashboard"))
|
||
|
||
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("static/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_singleton_running(module_path: str, getter_name: str, attr: str) -> bool:
|
||
"""Safely check if a singleton-based mode is running without creating instances."""
|
||
try:
|
||
import importlib
|
||
|
||
mod = importlib.import_module(module_path)
|
||
getter = getattr(mod, getter_name)
|
||
instance = getter()
|
||
if instance is None:
|
||
return False
|
||
return bool(getattr(instance, attr, False))
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _get_tscm_active() -> bool:
|
||
"""Check if a TSCM sweep is running."""
|
||
try:
|
||
from routes.tscm import _sweep_running
|
||
|
||
return bool(_sweep_running)
|
||
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 platform
|
||
import time
|
||
|
||
bt_active, bt_device_count = _get_bluetooth_health()
|
||
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
|
||
|
||
# Database health check
|
||
db_ok = True
|
||
try:
|
||
from utils.database import get_connection
|
||
|
||
get_connection().execute("SELECT 1")
|
||
except Exception:
|
||
db_ok = False
|
||
|
||
# SDR device count (cached, non-blocking)
|
||
sdr_count = 0
|
||
try:
|
||
from utils.sdr.detection import get_cached_devices
|
||
|
||
cached = get_cached_devices()
|
||
if cached is not None:
|
||
sdr_count = len(cached)
|
||
except (ImportError, Exception):
|
||
pass
|
||
|
||
overall_status = "healthy" if db_ok else "degraded"
|
||
status_code = 200 if db_ok else 503
|
||
|
||
response = jsonify(
|
||
{
|
||
"status": overall_status,
|
||
"version": VERSION,
|
||
"uptime_seconds": round(time.time() - _app_start_time, 2),
|
||
"system": {
|
||
"python_version": platform.python_version(),
|
||
"platform": platform.platform(),
|
||
},
|
||
"database": db_ok,
|
||
"sdr_devices": sdr_count,
|
||
"sdr_claims": get_sdr_device_status(),
|
||
"rate_limiting": _has_limiter,
|
||
"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(),
|
||
"rtlamr": rtlamr_process is not None and (rtlamr_process.poll() is None if rtlamr_process else False),
|
||
"meshtastic": _get_singleton_running("utils.meshtastic", "get_meshtastic_client", "is_running"),
|
||
"sstv": _get_singleton_running("utils.sstv", "get_sstv_decoder", "is_running"),
|
||
"weathersat": _get_singleton_running("utils.weather_sat", "get_weather_sat_decoder", "is_running"),
|
||
"wefax": _get_singleton_running("utils.wefax", "get_wefax_decoder", "is_running"),
|
||
"sstv_general": _get_singleton_running("utils.sstv", "get_general_sstv_decoder", "is_running"),
|
||
"tscm": _get_tscm_active(),
|
||
"gps": _get_singleton_running("utils.gps", "get_gps_reader", "is_running"),
|
||
"bt_locate": _get_singleton_running("utils.bt_locate", "get_locate_session", "is_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),
|
||
},
|
||
}
|
||
)
|
||
response.status_code = status_code
|
||
return response
|
||
|
||
|
||
@app.route("/killall", methods=["POST"])
|
||
@(csrf.exempt if csrf else lambda f: f)
|
||
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, ook_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 OOK state (full cleanup: parser thread, pipes, SDR release)
|
||
with ook_lock:
|
||
try:
|
||
from routes.ook import cleanup_ook
|
||
|
||
cleanup_ook(emit_status=False)
|
||
except Exception:
|
||
if ook_process:
|
||
safe_terminate(ook_process)
|
||
unregister_process(ook_process)
|
||
ook_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:
|
||
with contextlib.suppress(Exception):
|
||
bt_process.kill()
|
||
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 Exception:
|
||
pass
|
||
|
||
# Initialize KiwiSDR WebSocket audio proxy
|
||
try:
|
||
from routes.websdr import init_websdr_audio
|
||
|
||
init_websdr_audio(app)
|
||
except Exception:
|
||
pass
|
||
|
||
# Initialize WebSocket for waterfall streaming
|
||
try:
|
||
from routes.waterfall_websocket import init_waterfall_websocket
|
||
|
||
init_waterfall_websocket(app)
|
||
except Exception:
|
||
pass
|
||
|
||
# Initialize WebSocket for meteor scatter monitoring
|
||
try:
|
||
from routes.meteor_websocket import init_meteor_websocket
|
||
|
||
init_meteor_websocket(app)
|
||
except Exception:
|
||
pass
|
||
|
||
# Initialize WebSocket for ground station live waterfall
|
||
try:
|
||
from routes.ground_station import init_ground_station_websocket
|
||
|
||
init_ground_station_websocket(app)
|
||
except Exception:
|
||
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_dsc_alerts,
|
||
cleanup_old_payloads,
|
||
cleanup_old_signal_history,
|
||
cleanup_old_timeline_entries,
|
||
)
|
||
|
||
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}")
|
||
|
||
# Pre-warm SatNOGS transmitter cache so first dashboard load is instant
|
||
try:
|
||
if not os.environ.get("TESTING"):
|
||
from utils.satnogs import prefetch_transmitters
|
||
|
||
prefetch_transmitters()
|
||
except Exception as e:
|
||
logger.warning(f"SatNOGS prefetch failed: {e}")
|
||
|
||
# Wire ground station scheduler event → SSE queue
|
||
try:
|
||
import app as _self
|
||
from utils.ground_station.scheduler import get_ground_station_scheduler
|
||
|
||
gs_scheduler = get_ground_station_scheduler()
|
||
|
||
def _gs_event_to_sse(event: dict) -> None:
|
||
try:
|
||
_self.ground_station_queue.put_nowait(event)
|
||
except Exception:
|
||
pass
|
||
|
||
gs_scheduler.set_event_callback(_gs_event_to_sse)
|
||
except Exception as e:
|
||
logger.warning(f"Ground station scheduler init failed: {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,
|
||
)
|