"""Settings management routes.""" from __future__ import annotations import contextlib import os import re import subprocess import sys import threading from pathlib import Path from flask import Blueprint, Response, jsonify, request from utils.database import ( delete_setting, get_all_settings, get_correlations, get_setting, set_setting, ) from utils.logging import get_logger from utils.responses import api_error, api_success from utils.validation import validate_latitude, validate_longitude logger = get_logger('intercept.settings') settings_bp = Blueprint('settings', __name__, url_prefix='/settings') _env_lock = threading.Lock() def _get_env_file_path() -> Path: """Return the project .env path.""" return Path(__file__).resolve().parent.parent / '.env' def _write_env_value(key: str, value: str, env_path: Path | None = None) -> None: """Create or update a single key in the project .env file.""" path = env_path or _get_env_file_path() path.parent.mkdir(parents=True, exist_ok=True) with _env_lock: lines = path.read_text().splitlines() if path.exists() else [ '# INTERCEPT environment configuration', '', ] pattern = re.compile(rf'^\s*{re.escape(key)}=') updated = False new_lines: list[str] = [] for line in lines: if pattern.match(line): if not updated: new_lines.append(f'{key}={value}') updated = True continue new_lines.append(line) if not updated: if new_lines and new_lines[-1] != '': new_lines.append('') new_lines.append(f'{key}={value}') path.write_text('\n'.join(new_lines).rstrip('\n') + '\n') sudo_uid = os.environ.get('INTERCEPT_SUDO_UID') sudo_gid = os.environ.get('INTERCEPT_SUDO_GID') if os.geteuid() == 0 and sudo_uid and sudo_gid: with contextlib.suppress(OSError, ValueError): os.chown(path, int(sudo_uid), int(sudo_gid)) def _apply_runtime_observer_defaults(lat: float, lon: float) -> None: """Update in-process defaults so refreshed pages use the saved location.""" lat_str = str(lat) lon_str = str(lon) os.environ['INTERCEPT_DEFAULT_LAT'] = lat_str os.environ['INTERCEPT_DEFAULT_LON'] = lon_str import config config.DEFAULT_LATITUDE = lat config.DEFAULT_LONGITUDE = lon with contextlib.suppress(Exception): import app as app_module app_module.DEFAULT_LATITUDE = lat app_module.DEFAULT_LONGITUDE = lon with contextlib.suppress(Exception): from routes import adsb as adsb_routes adsb_routes.DEFAULT_LATITUDE = lat adsb_routes.DEFAULT_LONGITUDE = lon with contextlib.suppress(Exception): from routes import ais as ais_routes ais_routes.DEFAULT_LATITUDE = lat ais_routes.DEFAULT_LONGITUDE = lon @settings_bp.route('', methods=['GET']) def get_settings() -> Response: """Get all settings.""" try: settings = get_all_settings() return api_success(data={'settings': settings}) except Exception as e: logger.error(f"Error getting settings: {e}") return api_error(str(e), 500) @settings_bp.route('', methods=['POST']) def save_settings() -> Response: """Save one or more settings.""" data = request.json or {} if not data: return api_error('No settings provided', 400) try: saved = [] for key, value in data.items(): # Validate key (alphanumeric, underscores, dots, hyphens) if not key or not all(c.isalnum() or c in '_.-' for c in key): continue set_setting(key, value) saved.append(key) return api_success(data={'saved': saved}) except Exception as e: logger.error(f"Error saving settings: {e}") return api_error(str(e), 500) @settings_bp.route('/', methods=['GET']) def get_single_setting(key: str) -> Response: """Get a single setting by key.""" try: value = get_setting(key) if value is None: return jsonify({ 'status': 'not_found', 'key': key }), 404 return api_success(data={'key': key, 'value': value}) except Exception as e: logger.error(f"Error getting setting {key}: {e}") return api_error(str(e), 500) @settings_bp.route('/', methods=['PUT']) def update_single_setting(key: str) -> Response: """Update a single setting.""" data = request.json or {} value = data.get('value') if value is None and 'value' not in data: return api_error('Value is required', 400) try: set_setting(key, value) return api_success(data={'key': key, 'value': value}) except Exception as e: logger.error(f"Error updating setting {key}: {e}") return api_error(str(e), 500) @settings_bp.route('/', methods=['DELETE']) def delete_single_setting(key: str) -> Response: """Delete a setting.""" try: deleted = delete_setting(key) if deleted: return api_success(data={'key': key, 'deleted': True}) else: return jsonify({ 'status': 'not_found', 'key': key }), 404 except Exception as e: logger.error(f"Error deleting setting {key}: {e}") return api_error(str(e), 500) @settings_bp.route('/observer-location', methods=['POST']) def save_observer_location() -> Response: """Persist observer location to .env and refresh in-process defaults.""" data = request.json or {} try: lat = validate_latitude(data.get('lat')) lon = validate_longitude(data.get('lon')) except ValueError as exc: return api_error(str(exc), 400) try: _write_env_value('INTERCEPT_DEFAULT_LAT', str(lat)) _write_env_value('INTERCEPT_DEFAULT_LON', str(lon)) _apply_runtime_observer_defaults(lat, lon) return api_success( data={ 'lat': lat, 'lon': lon, 'saved': ['INTERCEPT_DEFAULT_LAT', 'INTERCEPT_DEFAULT_LON'], }, message='Observer location saved to .env', ) except Exception as exc: logger.error(f'Error saving observer location to .env: {exc}') return api_error(str(exc), 500) # ============================================================================= # Device Correlation Endpoints # ============================================================================= @settings_bp.route('/correlations', methods=['GET']) def get_device_correlations() -> Response: """Get device correlations between WiFi and Bluetooth.""" min_confidence = request.args.get('min_confidence', 0.5, type=float) try: correlations = get_correlations(min_confidence) return api_success(data={'correlations': correlations}) except Exception as e: logger.error(f"Error getting correlations: {e}") return api_error(str(e), 500) # ============================================================================= # RTL-SDR DVB Driver Management # ============================================================================= DVB_MODULES = ['dvb_usb_rtl28xxu', 'rtl2832_sdr', 'rtl2832', 'rtl2830', 'r820t'] BLACKLIST_FILE = '/etc/modprobe.d/blacklist-rtlsdr.conf' @settings_bp.route('/rtlsdr/driver-status', methods=['GET']) def check_dvb_driver_status() -> Response: """Check if DVB kernel drivers are loaded and blocking RTL-SDR devices.""" if sys.platform != 'linux': return jsonify({ 'status': 'success', 'platform': sys.platform, 'issue_detected': False, 'message': 'DVB driver conflict only affects Linux systems' }) # Check which DVB modules are currently loaded loaded_modules = [] try: result = subprocess.run(['lsmod'], capture_output=True, text=True, timeout=5) lsmod_output = result.stdout for mod in DVB_MODULES: if mod in lsmod_output: loaded_modules.append(mod) except Exception as e: logger.warning(f"Could not check loaded modules: {e}") # Check if blacklist file exists blacklist_exists = os.path.exists(BLACKLIST_FILE) # Check blacklist file contents blacklist_contents = [] if blacklist_exists: try: with open(BLACKLIST_FILE) as f: blacklist_contents = [line.strip() for line in f if line.strip() and not line.startswith('#')] except Exception: pass issue_detected = len(loaded_modules) > 0 return jsonify({ 'status': 'success', 'platform': 'linux', 'issue_detected': issue_detected, 'loaded_modules': loaded_modules, 'blacklist_file_exists': blacklist_exists, 'blacklist_contents': blacklist_contents, 'message': 'DVB drivers are claiming RTL-SDR devices' if issue_detected else 'No DVB driver conflict detected' }) @settings_bp.route('/rtlsdr/blacklist-drivers', methods=['POST']) def blacklist_dvb_drivers() -> Response: """Blacklist DVB kernel drivers to prevent them from claiming RTL-SDR devices.""" if sys.platform != 'linux': return api_error('This feature is only available on Linux', 400) # Check if we have permission (need to be running as root or with sudo) if os.geteuid() != 0: return api_error('Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t', 403) errors = [] successes = [] # Create blacklist file if it doesn't exist if not os.path.exists(BLACKLIST_FILE): try: blacklist_content = """# RTL-SDR blacklist - prevents DVB drivers from claiming RTL-SDR devices # Created by INTERCEPT blacklist dvb_usb_rtl28xxu blacklist rtl2832 blacklist rtl2830 blacklist r820t """ with open(BLACKLIST_FILE, 'w') as f: f.write(blacklist_content) successes.append(f'Created {BLACKLIST_FILE}') except Exception as e: errors.append(f'Failed to create blacklist file: {e}') # Unload the modules for mod in DVB_MODULES: try: result = subprocess.run( ['modprobe', '-r', mod], capture_output=True, text=True, timeout=10 ) if result.returncode == 0: successes.append(f'Unloaded module: {mod}') # returncode != 0 is OK - module might not be loaded except Exception as e: logger.warning(f"Could not unload {mod}: {e}") if errors: return jsonify({ 'status': 'partial', 'message': 'Some operations failed. Please unplug and replug your RTL-SDR device.', 'successes': successes, 'errors': errors }) return jsonify({ 'status': 'success', 'message': 'DVB drivers blacklisted. Please unplug and replug your RTL-SDR device.', 'successes': successes })