Files
intercept/intercept_agent.py
T
cemaxecuter 8a581d46fb Add distributed agent architecture for multi-node signal intelligence
Features:
- Standalone agent server (intercept_agent.py) for remote sensor nodes
- Controller API blueprint for agent management and data aggregation
- Push mechanism for agents to send data to controller
- Pull mechanism for controller to proxy requests to agents
- Multi-agent SSE stream for combined data view
- Agent management page at /controller/manage
- Agent selector dropdown in main UI
- GPS integration for location tagging
- API key authentication for secure agent communication
- Integration with Intercept's dependency checking system

New files:
- intercept_agent.py: Remote agent HTTP server
- intercept_agent.cfg: Agent configuration template
- routes/controller.py: Controller API endpoints
- utils/agent_client.py: HTTP client for agents
- utils/trilateration.py: Multi-agent position calculation
- static/js/core/agents.js: Frontend agent management
- templates/agents.html: Agent management page
- docs/DISTRIBUTED_AGENTS.md: System documentation

Modified:
- app.py: Register controller blueprint
- utils/database.py: Add agents and push_payloads tables
- templates/index.html: Add agent selector section
2026-01-26 06:14:42 -05:00

1783 lines
64 KiB
Python

#!/usr/bin/env python3
"""
INTERCEPT Agent - Remote node for distributed signal intelligence.
This agent runs on remote nodes and exposes Intercept's capabilities via REST API.
It can push data to a central controller or respond to pull requests.
Usage:
python intercept_agent.py [--port 8020] [--config intercept_agent.cfg]
"""
from __future__ import annotations
import argparse
import configparser
import json
import logging
import os
import queue
import re
import shutil
import signal
import socket
import subprocess
import sys
import threading
import time
from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from typing import Any
from urllib.parse import urlparse, parse_qs
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# Import dependency checking from Intercept utils
try:
from utils.dependencies import check_all_dependencies, check_tool, TOOL_DEPENDENCIES
HAS_DEPENDENCIES_MODULE = True
except ImportError:
HAS_DEPENDENCIES_MODULE = False
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s'
)
logger = logging.getLogger('intercept.agent')
# Version
AGENT_VERSION = '1.0.0'
# =============================================================================
# Configuration
# =============================================================================
class AgentConfig:
"""Agent configuration loaded from INI file or defaults."""
def __init__(self):
# Agent settings
self.name: str = socket.gethostname()
self.port: int = 8020
self.allowed_ips: list[str] = []
self.allow_cors: bool = False
# Controller settings
self.controller_url: str = ''
self.controller_api_key: str = ''
self.push_enabled: bool = False
self.push_interval: int = 5
# Mode settings (all enabled by default)
self.modes_enabled: dict[str, bool] = {
'pager': True,
'sensor': True,
'adsb': True,
'ais': True,
'acars': True,
'aprs': True,
'wifi': True,
'bluetooth': True,
'dsc': True,
'rtlamr': True,
'tscm': True,
'satellite': True,
'listening_post': True,
}
def load_from_file(self, filepath: str) -> bool:
"""Load configuration from INI file."""
if not os.path.isfile(filepath):
logger.warning(f"Config file not found: {filepath}")
return False
parser = configparser.ConfigParser()
try:
parser.read(filepath)
# Agent section
if parser.has_section('agent'):
if parser.has_option('agent', 'name'):
self.name = parser.get('agent', 'name')
if parser.has_option('agent', 'port'):
self.port = parser.getint('agent', 'port')
if parser.has_option('agent', 'allowed_ips'):
ips = parser.get('agent', 'allowed_ips')
if ips.strip():
self.allowed_ips = [ip.strip() for ip in ips.split(',')]
if parser.has_option('agent', 'allow_cors'):
self.allow_cors = parser.getboolean('agent', 'allow_cors')
# Controller section
if parser.has_section('controller'):
if parser.has_option('controller', 'url'):
self.controller_url = parser.get('controller', 'url').rstrip('/')
if parser.has_option('controller', 'api_key'):
self.controller_api_key = parser.get('controller', 'api_key')
if parser.has_option('controller', 'push_enabled'):
self.push_enabled = parser.getboolean('controller', 'push_enabled')
if parser.has_option('controller', 'push_interval'):
self.push_interval = parser.getint('controller', 'push_interval')
# Modes section
if parser.has_section('modes'):
for mode in self.modes_enabled.keys():
if parser.has_option('modes', mode):
self.modes_enabled[mode] = parser.getboolean('modes', mode)
logger.info(f"Loaded configuration from {filepath}")
return True
except Exception as e:
logger.error(f"Error loading config: {e}")
return False
def to_dict(self) -> dict:
"""Convert config to dictionary."""
return {
'name': self.name,
'port': self.port,
'allowed_ips': self.allowed_ips,
'allow_cors': self.allow_cors,
'controller_url': self.controller_url,
'push_enabled': self.push_enabled,
'push_interval': self.push_interval,
'modes_enabled': self.modes_enabled,
}
# Global config
config = AgentConfig()
# =============================================================================
# GPS Integration
# =============================================================================
class GPSManager:
"""Manages GPS position via gpsd."""
def __init__(self):
self._client = None
self._position = None
self._lock = threading.Lock()
self._running = False
@property
def position(self) -> dict | None:
"""Get current GPS position."""
with self._lock:
if self._position:
return {
'lat': self._position.latitude,
'lon': self._position.longitude,
'altitude': self._position.altitude,
'speed': self._position.speed,
'heading': self._position.heading,
'fix_quality': self._position.fix_quality,
}
return None
def start(self, host: str = 'localhost', port: int = 2947) -> bool:
"""Start GPS client connection to gpsd."""
try:
from utils.gps import GPSDClient
self._client = GPSDClient(host, port)
self._client.add_callback(self._on_position_update)
success = self._client.start()
if success:
self._running = True
logger.info(f"GPS connected to gpsd at {host}:{port}")
return success
except ImportError:
logger.warning("GPS module not available")
return False
except Exception as e:
logger.error(f"Failed to start GPS: {e}")
return False
def stop(self):
"""Stop GPS client."""
if self._client:
self._client.stop()
self._client = None
self._running = False
def _on_position_update(self, position):
"""Callback for GPS position updates."""
with self._lock:
self._position = position
@property
def is_running(self) -> bool:
return self._running
# Global GPS manager
gps_manager = GPSManager()
# =============================================================================
# Controller Push Client
# =============================================================================
class ControllerPushClient(threading.Thread):
"""Daemon thread that pushes scan data to the controller."""
def __init__(self, cfg: AgentConfig):
super().__init__()
self.daemon = True
self.cfg = cfg
self.queue: queue.Queue = queue.Queue(maxsize=200)
self.running = False
self.stop_event = threading.Event()
def enqueue(self, scan_type: str, payload: dict, interface: str = None):
"""Add data to push queue."""
if not self.cfg.push_enabled or not self.cfg.controller_url:
return
item = {
'agent_name': self.cfg.name,
'scan_type': scan_type,
'interface': interface,
'payload': payload,
'received_at': datetime.now(timezone.utc).isoformat(),
'attempts': 0,
}
try:
self.queue.put_nowait(item)
except queue.Full:
logger.warning("Push queue full, dropping payload")
def run(self):
"""Main push loop."""
import requests
self.running = True
logger.info(f"Push client started, target: {self.cfg.controller_url}")
while not self.stop_event.is_set():
try:
item = self.queue.get(timeout=1.0)
except queue.Empty:
continue
if item is None:
continue
endpoint = f"{self.cfg.controller_url}/controller/api/ingest"
headers = {'Content-Type': 'application/json'}
if self.cfg.controller_api_key:
headers['X-API-Key'] = self.cfg.controller_api_key
body = {
'agent_name': item['agent_name'],
'scan_type': item['scan_type'],
'interface': item['interface'],
'payload': item['payload'],
'received_at': item['received_at'],
}
try:
response = requests.post(endpoint, json=body, headers=headers, timeout=5)
if response.status_code >= 400:
raise RuntimeError(f"HTTP {response.status_code}")
logger.debug(f"Pushed {item['scan_type']} data to controller")
except Exception as e:
item['attempts'] += 1
if item['attempts'] < 3 and not self.stop_event.is_set():
try:
self.queue.put_nowait(item)
except queue.Full:
pass
else:
logger.warning(f"Failed to push after {item['attempts']} attempts: {e}")
finally:
self.queue.task_done()
self.running = False
logger.info("Push client stopped")
def stop(self):
"""Stop the push client."""
self.stop_event.set()
# Global push client
push_client: ControllerPushClient | None = None
# =============================================================================
# Mode Manager - Uses Intercept's existing utilities and tools
# =============================================================================
class ModeManager:
"""
Manages mode state using Intercept's existing infrastructure.
This assumes Intercept (or its utilities) is installed on the agent host.
The agent imports and uses the existing modules rather than reimplementing
tool execution logic.
"""
def __init__(self):
self.running_modes: dict[str, dict] = {}
self.data_snapshots: dict[str, list] = {}
self.locks: dict[str, threading.Lock] = {}
self._capabilities: dict | None = None
# Process tracking per mode
self.processes: dict[str, subprocess.Popen] = {}
self.output_threads: dict[str, threading.Thread] = {}
self.stop_events: dict[str, threading.Event] = {}
# Data queues for each mode (for real-time collection)
self.data_queues: dict[str, queue.Queue] = {}
# WiFi-specific state
self.wifi_networks: dict[str, dict] = {}
self.wifi_clients: dict[str, dict] = {}
# ADS-B specific state
self.adsb_aircraft: dict[str, dict] = {}
# Bluetooth specific state
self.bluetooth_devices: dict[str, dict] = {}
# Lazy-loaded Intercept utilities
self._sdr_factory = None
self._dependencies = None
def _get_sdr_factory(self):
"""Lazy-load SDRFactory from Intercept's utils."""
if self._sdr_factory is None:
try:
from utils.sdr import SDRFactory
self._sdr_factory = SDRFactory
except ImportError:
logger.warning("SDRFactory not available - SDR features disabled")
return self._sdr_factory
def _get_dependencies(self):
"""Lazy-load dependencies module from Intercept's utils."""
if self._dependencies is None:
try:
from utils import dependencies
self._dependencies = dependencies
except ImportError:
logger.warning("Dependencies module not available")
return self._dependencies
def _check_tool(self, tool_name: str) -> bool:
"""Check if a tool is available using Intercept's dependency checker."""
deps = self._get_dependencies()
if deps and hasattr(deps, 'check_tool'):
return deps.check_tool(tool_name)
# Fallback to simple which check
return shutil.which(tool_name) is not None
def _get_tool_path(self, tool_name: str) -> str | None:
"""Get tool path using Intercept's dependency module."""
deps = self._get_dependencies()
if deps and hasattr(deps, 'get_tool_path'):
return deps.get_tool_path(tool_name)
return shutil.which(tool_name)
def detect_capabilities(self) -> dict:
"""Detect available tools and hardware using Intercept's utilities."""
if self._capabilities is not None:
return self._capabilities
capabilities = {
'modes': {},
'devices': [],
'agent_version': AGENT_VERSION,
'gps': gps_manager.is_running,
'gps_position': gps_manager.position,
'tool_details': {}, # Detailed tool status
}
# Use Intercept's comprehensive dependency checking if available
if HAS_DEPENDENCIES_MODULE:
try:
dep_status = check_all_dependencies()
# Map dependency status to mode availability
mode_mapping = {
'pager': 'pager',
'sensor': 'sensor',
'aircraft': 'adsb',
'ais': 'ais',
'acars': 'acars',
'aprs': 'aprs',
'wifi': 'wifi',
'bluetooth': 'bluetooth',
'tscm': 'tscm',
'satellite': 'satellite',
}
for dep_mode, cap_mode in mode_mapping.items():
if dep_mode in dep_status:
mode_info = dep_status[dep_mode]
# Check if mode is enabled in config
if not config.modes_enabled.get(cap_mode, True):
capabilities['modes'][cap_mode] = False
else:
capabilities['modes'][cap_mode] = mode_info['ready']
# Store detailed tool info
capabilities['tool_details'][cap_mode] = {
'name': mode_info['name'],
'ready': mode_info['ready'],
'missing_required': mode_info['missing_required'],
'tools': mode_info['tools'],
}
# Handle modes not in dependencies.py
extra_modes = ['dsc', 'rtlamr', 'listening_post']
extra_tools = {
'dsc': ['rtl_fm'],
'rtlamr': ['rtlamr'],
'listening_post': ['rtl_fm'],
}
for mode in extra_modes:
if not config.modes_enabled.get(mode, True):
capabilities['modes'][mode] = False
else:
tools = extra_tools.get(mode, [])
capabilities['modes'][mode] = all(
check_tool(tool) for tool in tools
) if tools else True
except Exception as e:
logger.warning(f"Dependency check failed, using fallback: {e}")
self._detect_capabilities_fallback(capabilities)
else:
self._detect_capabilities_fallback(capabilities)
# Use Intercept's SDR detection
sdr_factory = self._get_sdr_factory()
if sdr_factory:
try:
devices = sdr_factory.detect_devices()
capabilities['devices'] = [d.to_dict() for d in devices]
except Exception as e:
logger.warning(f"SDR device detection failed: {e}")
self._capabilities = capabilities
return capabilities
def _detect_capabilities_fallback(self, capabilities: dict):
"""Fallback capability detection when dependencies module unavailable."""
tool_checks = {
'pager': ['rtl_fm', 'multimon-ng'],
'sensor': ['rtl_433'],
'adsb': ['dump1090'],
'ais': ['AIS-catcher'],
'acars': ['acarsdec'],
'aprs': ['rtl_fm', 'direwolf'],
'wifi': ['airmon-ng', 'airodump-ng'],
'bluetooth': ['bluetoothctl'],
'dsc': ['rtl_fm'],
'rtlamr': ['rtlamr'],
'satellite': [],
'listening_post': ['rtl_fm'],
'tscm': ['rtl_fm'],
}
for mode, tools in tool_checks.items():
if not config.modes_enabled.get(mode, True):
capabilities['modes'][mode] = False
continue
if not tools:
capabilities['modes'][mode] = True
continue
if mode == 'adsb':
capabilities['modes'][mode] = (
self._check_tool('dump1090') or
self._check_tool('dump1090-fa') or
self._check_tool('readsb')
)
else:
capabilities['modes'][mode] = all(
self._check_tool(tool) for tool in tools
)
def get_status(self) -> dict:
"""Get overall agent status."""
status = {
'running_modes': list(self.running_modes.keys()),
'uptime': time.time() - _start_time,
'push_enabled': config.push_enabled,
'push_connected': push_client is not None and push_client.running,
'gps': gps_manager.is_running,
}
# Include GPS position if available
gps_pos = gps_manager.position
if gps_pos:
status['gps_position'] = gps_pos
return status
def start_mode(self, mode: str, params: dict) -> dict:
"""Start a mode with given parameters."""
if mode in self.running_modes:
return {'status': 'error', 'message': f'{mode} already running'}
caps = self.detect_capabilities()
if not caps['modes'].get(mode, False):
return {'status': 'error', 'message': f'{mode} not available (missing tools)'}
# Initialize lock if needed
if mode not in self.locks:
self.locks[mode] = threading.Lock()
with self.locks[mode]:
try:
# Mode-specific start logic
result = self._start_mode_internal(mode, params)
if result.get('status') == 'started':
self.running_modes[mode] = {
'started_at': datetime.now(timezone.utc).isoformat(),
'params': params,
}
return result
except Exception as e:
logger.exception(f"Error starting {mode}")
return {'status': 'error', 'message': str(e)}
def stop_mode(self, mode: str) -> dict:
"""Stop a running mode."""
if mode not in self.running_modes:
return {'status': 'not_running'}
if mode not in self.locks:
self.locks[mode] = threading.Lock()
with self.locks[mode]:
try:
result = self._stop_mode_internal(mode)
if mode in self.running_modes:
del self.running_modes[mode]
return result
except Exception as e:
logger.exception(f"Error stopping {mode}")
return {'status': 'error', 'message': str(e)}
def get_mode_status(self, mode: str) -> dict:
"""Get status of a specific mode."""
if mode in self.running_modes:
info = {
'running': True,
**self.running_modes[mode]
}
# Add mode-specific stats
if mode == 'adsb':
info['aircraft_count'] = len(self.adsb_aircraft)
elif mode == 'wifi':
info['network_count'] = len(self.wifi_networks)
info['client_count'] = len(self.wifi_clients)
elif mode == 'bluetooth':
info['device_count'] = len(self.bluetooth_devices)
elif mode == 'sensor':
info['reading_count'] = len(self.data_snapshots.get(mode, []))
return info
return {'running': False}
def get_mode_data(self, mode: str) -> dict:
"""Get current data snapshot for a mode."""
data = {
'mode': mode,
'timestamp': datetime.now(timezone.utc).isoformat(),
}
# Add GPS position
gps_pos = gps_manager.position
if gps_pos:
data['agent_gps'] = gps_pos
# Mode-specific data
if mode == 'adsb':
data['data'] = list(self.adsb_aircraft.values())
elif mode == 'wifi':
data['data'] = {
'networks': list(self.wifi_networks.values()),
'clients': list(self.wifi_clients.values()),
}
elif mode == 'bluetooth':
data['data'] = list(self.bluetooth_devices.values())
else:
data['data'] = self.data_snapshots.get(mode, [])
return data
# =========================================================================
# Mode-specific implementations
# =========================================================================
def _start_mode_internal(self, mode: str, params: dict) -> dict:
"""Internal mode start - dispatches to mode-specific handlers."""
logger.info(f"Starting mode {mode} with params: {params}")
# Initialize data structures
self.data_snapshots[mode] = []
self.data_queues[mode] = queue.Queue(maxsize=500)
self.stop_events[mode] = threading.Event()
# Dispatch to mode-specific handler
handlers = {
'sensor': self._start_sensor,
'adsb': self._start_adsb,
'wifi': self._start_wifi,
'bluetooth': self._start_bluetooth,
}
handler = handlers.get(mode)
if handler:
return handler(params)
# Default stub for modes not yet implemented
logger.warning(f"Mode {mode} not yet implemented - running in stub mode")
return {'status': 'started', 'mode': mode, 'stub': True}
def _stop_mode_internal(self, mode: str) -> dict:
"""Internal mode stop - terminates processes and cleans up."""
logger.info(f"Stopping mode {mode}")
# Signal stop
if mode in self.stop_events:
self.stop_events[mode].set()
# Terminate process if running
if mode in self.processes:
proc = self.processes[mode]
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
del self.processes[mode]
# Wait for output thread
if mode in self.output_threads:
thread = self.output_threads[mode]
if thread and thread.is_alive():
thread.join(timeout=2)
del self.output_threads[mode]
# Clean up
if mode in self.stop_events:
del self.stop_events[mode]
if mode in self.data_queues:
del self.data_queues[mode]
if mode in self.data_snapshots:
del self.data_snapshots[mode]
# Mode-specific cleanup
if mode == 'adsb':
self.adsb_aircraft.clear()
elif mode == 'wifi':
self.wifi_networks.clear()
self.wifi_clients.clear()
elif mode == 'bluetooth':
self.bluetooth_devices.clear()
return {'status': 'stopped', 'mode': mode}
# -------------------------------------------------------------------------
# SENSOR MODE (rtl_433) - Uses Intercept's SDR abstraction
# -------------------------------------------------------------------------
def _start_sensor(self, params: dict) -> dict:
"""Start rtl_433 sensor mode using Intercept's SDR utilities."""
freq = params.get('frequency', '433.92')
gain = params.get('gain')
device = params.get('device', '0')
ppm = params.get('ppm')
bias_t = params.get('bias_t', False)
sdr_type_str = params.get('sdr_type', 'rtlsdr')
# Try to use Intercept's SDR abstraction layer
sdr_factory = self._get_sdr_factory()
if sdr_factory:
try:
from utils.sdr import SDRType
sdr_type = SDRType(sdr_type_str)
sdr_device = sdr_factory.create_default_device(sdr_type, index=int(device))
builder = sdr_factory.get_builder(sdr_type)
# Use the builder to construct the command properly
cmd = builder.build_ism_command(
device=sdr_device,
frequency_mhz=float(freq),
gain=float(gain) if gain else None,
ppm=int(ppm) if ppm else None,
bias_t=bias_t
)
logger.info(f"Starting sensor (via SDR abstraction): {' '.join(cmd)}")
except Exception as e:
logger.warning(f"SDR abstraction failed, falling back to direct command: {e}")
cmd = self._build_sensor_command_fallback(freq, gain, device, ppm)
else:
# Fallback: build command directly
cmd = self._build_sensor_command_fallback(freq, gain, device, ppm)
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self.processes['sensor'] = proc
# Start output reader thread
thread = threading.Thread(
target=self._sensor_output_reader,
args=(proc,),
daemon=True
)
thread.start()
self.output_threads['sensor'] = thread
return {
'status': 'started',
'mode': 'sensor',
'command': ' '.join(cmd),
'gps_enabled': gps_manager.is_running
}
except FileNotFoundError:
return {'status': 'error', 'message': 'rtl_433 not found. Install via: apt install rtl-433'}
except Exception as e:
return {'status': 'error', 'message': str(e)}
def _build_sensor_command_fallback(self, freq, gain, device, ppm) -> list:
"""Build rtl_433 command without SDR abstraction."""
cmd = ['rtl_433', '-F', 'json']
if freq:
cmd.extend(['-f', f'{freq}M'])
if gain and str(gain) != '0':
cmd.extend(['-g', str(gain)])
if device and str(device) != '0':
cmd.extend(['-d', str(device)])
if ppm and str(ppm) != '0':
cmd.extend(['-p', str(ppm)])
return cmd
def _sensor_output_reader(self, proc: subprocess.Popen):
"""Read rtl_433 JSON output and collect data."""
mode = 'sensor'
stop_event = self.stop_events.get(mode)
try:
for line in iter(proc.stdout.readline, b''):
if stop_event and stop_event.is_set():
break
line = line.decode('utf-8', errors='replace').strip()
if not line:
continue
try:
data = json.loads(line)
data['type'] = 'sensor'
data['received_at'] = datetime.now(timezone.utc).isoformat()
# Add GPS if available
gps_pos = gps_manager.position
if gps_pos:
data['agent_gps'] = gps_pos
# Store in snapshot (keep last 100)
snapshots = self.data_snapshots.get(mode, [])
snapshots.append(data)
if len(snapshots) > 100:
snapshots = snapshots[-100:]
self.data_snapshots[mode] = snapshots
logger.debug(f"Sensor data: {data.get('model', 'Unknown')}")
except json.JSONDecodeError:
pass # Not JSON, ignore
except Exception as e:
logger.error(f"Sensor output reader error: {e}")
finally:
proc.wait()
logger.info("Sensor output reader stopped")
# -------------------------------------------------------------------------
# ADS-B MODE (dump1090) - Uses Intercept's SDR abstraction
# -------------------------------------------------------------------------
def _start_adsb(self, params: dict) -> dict:
"""Start dump1090 ADS-B mode using Intercept's utilities."""
gain = params.get('gain', '40')
device = params.get('device', '0')
bias_t = params.get('bias_t', False)
sdr_type_str = params.get('sdr_type', 'rtlsdr')
remote_sbs_host = params.get('remote_sbs_host')
remote_sbs_port = params.get('remote_sbs_port', 30003)
# If remote SBS host provided, just connect to it
if remote_sbs_host:
return self._start_adsb_sbs_connection(remote_sbs_host, remote_sbs_port)
# Check if dump1090 already running on port 30003
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(1.0)
result = sock.connect_ex(('localhost', 30003))
sock.close()
if result == 0:
logger.info("dump1090 already running, connecting to SBS port")
return self._start_adsb_sbs_connection('localhost', 30003)
except Exception:
pass
# Try using Intercept's SDR abstraction for building the command
sdr_factory = self._get_sdr_factory()
cmd = None
if sdr_factory:
try:
from utils.sdr import SDRType
sdr_type = SDRType(sdr_type_str)
sdr_device = sdr_factory.create_default_device(sdr_type, index=int(device))
builder = sdr_factory.get_builder(sdr_type)
# Use the builder to construct dump1090 command
cmd = builder.build_adsb_command(
device=sdr_device,
gain=float(gain) if gain else None,
bias_t=bias_t
)
logger.info(f"Starting ADS-B (via SDR abstraction): {' '.join(cmd)}")
except Exception as e:
logger.warning(f"SDR abstraction failed for ADS-B: {e}")
if not cmd:
# Fallback: find dump1090 manually and build command
dump1090_path = self._find_dump1090()
if not dump1090_path:
return {'status': 'error', 'message': 'dump1090 not found. Install via: apt install dump1090-fa'}
cmd = [dump1090_path, '--net', '--quiet']
if gain:
cmd.extend(['--gain', str(gain)])
if device and str(device) != '0':
cmd.extend(['--device-index', str(device)])
logger.info(f"Starting dump1090: {' '.join(cmd)}")
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
start_new_session=True
)
self.processes['adsb'] = proc
# Wait for dump1090 to start
time.sleep(2)
if proc.poll() is not None:
stderr = proc.stderr.read().decode('utf-8', errors='ignore')
return {'status': 'error', 'message': f'dump1090 failed to start: {stderr[:200]}'}
# Connect to SBS port
return self._start_adsb_sbs_connection('localhost', 30003)
except FileNotFoundError:
return {'status': 'error', 'message': 'dump1090 not found'}
except Exception as e:
return {'status': 'error', 'message': str(e)}
def _find_dump1090(self) -> str | None:
"""Find dump1090 binary using Intercept's dependency module or fallback."""
# Try Intercept's tool path finder first
for name in ['dump1090', 'dump1090-fa', 'dump1090-mutability', 'readsb']:
path = self._get_tool_path(name)
if path:
return path
# Fallback: check common installation paths
common_paths = [
'/opt/homebrew/bin/dump1090',
'/opt/homebrew/bin/dump1090-fa',
'/usr/local/bin/dump1090',
'/usr/local/bin/dump1090-fa',
'/usr/bin/dump1090',
'/usr/bin/dump1090-fa',
]
for path in common_paths:
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
return None
def _start_adsb_sbs_connection(self, host: str, port: int) -> dict:
"""Connect to SBS port and start parsing."""
thread = threading.Thread(
target=self._adsb_sbs_reader,
args=(host, port),
daemon=True
)
thread.start()
self.output_threads['adsb'] = thread
return {
'status': 'started',
'mode': 'adsb',
'sbs_source': f'{host}:{port}',
'gps_enabled': gps_manager.is_running
}
def _adsb_sbs_reader(self, host: str, port: int):
"""Read and parse SBS data from dump1090."""
mode = 'adsb'
stop_event = self.stop_events.get(mode)
retry_count = 0
max_retries = 5
while not (stop_event and stop_event.is_set()):
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5.0)
sock.connect((host, port))
logger.info(f"Connected to SBS at {host}:{port}")
retry_count = 0
buffer = ""
sock.settimeout(1.0)
while not (stop_event and stop_event.is_set()):
try:
data = sock.recv(4096).decode('utf-8', errors='ignore')
if not data:
break
buffer += data
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
self._parse_sbs_line(line.strip())
except socket.timeout:
continue
sock.close()
except Exception as e:
logger.warning(f"SBS connection error: {e}")
retry_count += 1
if retry_count >= max_retries:
logger.error("Max SBS retries reached, stopping")
break
time.sleep(2)
logger.info("ADS-B SBS reader stopped")
def _parse_sbs_line(self, line: str):
"""Parse SBS format line and update aircraft dict."""
if not line:
return
parts = line.split(',')
if len(parts) < 11 or parts[0] != 'MSG':
return
msg_type = parts[1]
icao = parts[4].upper()
if not icao:
return
aircraft = self.adsb_aircraft.get(icao) or {'icao': icao}
aircraft['last_seen'] = datetime.now(timezone.utc).isoformat()
# Add GPS
gps_pos = gps_manager.position
if gps_pos:
aircraft['agent_gps'] = gps_pos
try:
if msg_type == '1' and len(parts) > 10:
callsign = parts[10].strip()
if callsign:
aircraft['callsign'] = callsign
elif msg_type == '3' and len(parts) > 15:
if parts[11]:
aircraft['altitude'] = int(float(parts[11]))
if parts[14] and parts[15]:
aircraft['lat'] = float(parts[14])
aircraft['lon'] = float(parts[15])
elif msg_type == '4' and len(parts) > 16:
if parts[12]:
aircraft['speed'] = int(float(parts[12]))
if parts[13]:
aircraft['heading'] = int(float(parts[13]))
if parts[16]:
aircraft['vertical_rate'] = int(float(parts[16]))
elif msg_type == '5' and len(parts) > 11:
if parts[10]:
callsign = parts[10].strip()
if callsign:
aircraft['callsign'] = callsign
if parts[11]:
aircraft['altitude'] = int(float(parts[11]))
elif msg_type == '6' and len(parts) > 17:
if parts[17]:
aircraft['squawk'] = parts[17]
except (ValueError, IndexError):
pass
self.adsb_aircraft[icao] = aircraft
# -------------------------------------------------------------------------
# WIFI MODE (airodump-ng) - Uses Intercept's utilities
# -------------------------------------------------------------------------
def _start_wifi(self, params: dict) -> dict:
"""Start WiFi scanning using Intercept's existing infrastructure."""
interface = params.get('interface')
channel = params.get('channel')
band = params.get('band', 'abg')
if not interface:
return {'status': 'error', 'message': 'WiFi interface required'}
# Use Intercept's validation if available
try:
from utils.validation import validate_network_interface
interface = validate_network_interface(interface)
except ImportError:
# Fallback: basic validation
if not os.path.exists(f'/sys/class/net/{interface}'):
return {'status': 'error', 'message': f'Interface {interface} not found'}
except ValueError as e:
return {'status': 'error', 'message': str(e)}
# Clean up old output files
csv_path = '/tmp/intercept_agent_wifi'
for f in [f'{csv_path}-01.csv', f'{csv_path}-01.cap', f'{csv_path}-01.gps']:
try:
os.remove(f)
except OSError:
pass
# Get airodump-ng path using Intercept's dependency module
airodump_path = self._get_tool_path('airodump-ng')
if not airodump_path:
return {'status': 'error', 'message': 'airodump-ng not found. Install aircrack-ng suite.'}
# Determine output formats - include gps if gpsd is running
output_formats = 'csv'
if gps_manager.is_running:
output_formats = 'csv,gps' # GPS file for accurate coordinates
cmd = [
airodump_path,
'-w', csv_path,
'--output-format', output_formats,
'--band', band,
]
# Add GPS support if gpsd is running
# This writes GPS coordinates to a separate .gps file
if gps_manager.is_running:
cmd.append('--gpsd')
logger.info("GPS enabled for airodump-ng captures (gps file output)")
if channel:
cmd.extend(['-c', str(channel)])
# Interface must be last argument
cmd.append(interface)
logger.info(f"Starting airodump-ng: {' '.join(cmd)}")
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self.processes['wifi'] = proc
time.sleep(0.5)
if proc.poll() is not None:
stderr = proc.stderr.read().decode('utf-8', errors='ignore')
return {'status': 'error', 'message': f'airodump-ng failed: {stderr[:200]}'}
# Start CSV parser thread
thread = threading.Thread(
target=self._wifi_csv_reader,
args=(csv_path,),
daemon=True
)
thread.start()
self.output_threads['wifi'] = thread
return {
'status': 'started',
'mode': 'wifi',
'interface': interface,
'gps_enabled': gps_manager.is_running
}
except FileNotFoundError:
return {'status': 'error', 'message': 'airodump-ng not found'}
except Exception as e:
return {'status': 'error', 'message': str(e)}
def _wifi_csv_reader(self, csv_path: str):
"""Periodically parse airodump-ng CSV and GPS output."""
mode = 'wifi'
stop_event = self.stop_events.get(mode)
csv_file = csv_path + '-01.csv'
gps_file = csv_path + '-01.gps'
while not (stop_event and stop_event.is_set()):
if os.path.exists(csv_file):
try:
# Parse GPS file for accurate coordinates (if available)
gps_data = self._parse_airodump_gps(gps_file) if os.path.exists(gps_file) else None
networks, clients = self._parse_airodump_csv(csv_file, gps_data)
self.wifi_networks = networks
self.wifi_clients = clients
except Exception as e:
logger.error(f"CSV parse error: {e}")
time.sleep(2)
logger.info("WiFi CSV reader stopped")
def _parse_airodump_gps(self, gps_path: str) -> dict | None:
"""
Parse airodump-ng GPS file for accurate coordinates.
Format:
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE gps-run SYSTEM "...">
<gps-run gps-version="1">
<gps-point lat="LAT" lon="LON" alt="ALT" spd="SPD" time="TIME"/>
...
</gps-run>
Returns the most recent GPS point.
"""
try:
import xml.etree.ElementTree as ET
tree = ET.parse(gps_path)
root = tree.getroot()
# Get the last (most recent) GPS point
gps_points = root.findall('.//gps-point')
if gps_points:
last_point = gps_points[-1]
lat = last_point.get('lat')
lon = last_point.get('lon')
alt = last_point.get('alt')
if lat and lon:
return {
'lat': float(lat),
'lon': float(lon),
'altitude': float(alt) if alt else None,
'source': 'airodump_gps'
}
except Exception as e:
logger.debug(f"GPS file parse error: {e}")
return None
def _parse_airodump_csv(self, csv_path: str, gps_data: dict | None = None) -> tuple[dict, dict]:
"""Parse airodump-ng CSV file using Intercept's existing parser."""
networks = {}
clients = {}
try:
# Use Intercept's robust airodump parser (handles edge cases, proper CSV parsing)
from utils.wifi.parsers.airodump import parse_airodump_csv
network_obs, client_list = parse_airodump_csv(csv_path)
# Convert WiFiObservation objects to dicts for agent format
for obs in network_obs:
networks[obs.bssid] = {
'bssid': obs.bssid,
'essid': obs.essid or 'Hidden',
'channel': obs.channel,
'frequency_mhz': obs.frequency_mhz,
'signal': obs.rssi,
'security': obs.security,
'cipher': obs.cipher,
'auth': obs.auth,
'vendor': obs.vendor,
'beacon_count': obs.beacon_count,
'data_count': obs.data_count,
'band': obs.band,
'last_seen': datetime.now(timezone.utc).isoformat(),
}
# Convert client dicts (already in dict format from parser)
for client in client_list:
mac = client.get('mac')
if mac:
clients[mac] = {
'mac': mac,
'signal': client.get('rssi'),
'bssid': client.get('bssid'),
'probes': ','.join(client.get('probed_essids', [])),
'packets': client.get('packets', 0),
'last_seen': datetime.now(timezone.utc).isoformat(),
}
logger.debug(f"Parsed {len(networks)} networks, {len(clients)} clients")
except ImportError:
logger.warning("Intercept WiFi parser not available, using fallback")
# Fallback: simple parsing if running standalone
try:
with open(csv_path, 'r', errors='replace') as f:
content = f.read()
for section in content.split('\n\n'):
lines = section.strip().split('\n')
if not lines:
continue
header = lines[0]
if 'BSSID' in header and 'ESSID' in header:
for line in lines[1:]:
parts = [p.strip() for p in line.split(',')]
if len(parts) >= 14 and ':' in parts[0]:
networks[parts[0]] = {
'bssid': parts[0],
'channel': int(parts[3]) if parts[3].lstrip('-').isdigit() else None,
'signal': int(parts[8]) if parts[8].lstrip('-').isdigit() else None,
'security': parts[5],
'essid': parts[13] or 'Hidden',
'last_seen': datetime.now(timezone.utc).isoformat(),
}
elif 'Station MAC' in header:
for line in lines[1:]:
parts = [p.strip() for p in line.split(',')]
if len(parts) >= 6 and ':' in parts[0]:
clients[parts[0]] = {
'mac': parts[0],
'signal': int(parts[3]) if parts[3].lstrip('-').isdigit() else None,
'bssid': parts[5] if ':' in parts[5] else None,
'probes': parts[6] if len(parts) > 6 else '',
'last_seen': datetime.now(timezone.utc).isoformat(),
}
except Exception as e:
logger.error(f"Fallback CSV parse error: {e}")
except Exception as e:
logger.error(f"Error parsing CSV: {e}")
# Add GPS to all entries
# Prefer GPS from airodump's .gps file (more accurate timestamp)
# Fall back to GPSManager if no .gps file data
if gps_data:
# Use GPS coordinates from airodump's GPS file
gps_pos = {
'lat': gps_data['lat'],
'lon': gps_data['lon'],
'altitude': gps_data.get('altitude'),
'source': 'airodump_gps', # Mark as from airodump GPS file
}
logger.debug(f"Using airodump GPS: {gps_data['lat']:.6f}, {gps_data['lon']:.6f}")
else:
# Fall back to GPSManager position
gps_pos = gps_manager.position
if gps_pos:
for net in networks.values():
net['agent_gps'] = gps_pos
for client in clients.values():
client['agent_gps'] = gps_pos
return networks, clients
# -------------------------------------------------------------------------
# BLUETOOTH MODE
# -------------------------------------------------------------------------
def _start_bluetooth(self, params: dict) -> dict:
"""Start Bluetooth scanning."""
adapter = params.get('adapter', 'hci0')
# Check for bluetoothctl
if not shutil.which('bluetoothctl'):
return {'status': 'error', 'message': 'bluetoothctl not found'}
# Start scan thread
thread = threading.Thread(
target=self._bluetooth_scanner,
args=(adapter,),
daemon=True
)
thread.start()
self.output_threads['bluetooth'] = thread
return {
'status': 'started',
'mode': 'bluetooth',
'adapter': adapter,
'gps_enabled': gps_manager.is_running
}
def _bluetooth_scanner(self, adapter: str):
"""Scan for Bluetooth devices using bluetoothctl."""
mode = 'bluetooth'
stop_event = self.stop_events.get(mode)
try:
# Start bluetoothctl scan
proc = subprocess.Popen(
['bluetoothctl'],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self.processes['bluetooth'] = proc
# Enable scanning
proc.stdin.write(b'scan on\n')
proc.stdin.flush()
while not (stop_event and stop_event.is_set()):
line = proc.stdout.readline()
if not line:
break
line = line.decode('utf-8', errors='replace').strip()
# Parse device discovery lines
# Format: [NEW] Device XX:XX:XX:XX:XX:XX DeviceName
# Format: [CHG] Device XX:XX:XX:XX:XX:XX RSSI: -XX
if 'Device' in line:
self._parse_bluetooth_line(line)
time.sleep(0.1)
# Stop scanning
proc.stdin.write(b'scan off\n')
proc.stdin.write(b'exit\n')
proc.stdin.flush()
proc.wait(timeout=2)
except Exception as e:
logger.error(f"Bluetooth scanner error: {e}")
finally:
logger.info("Bluetooth scanner stopped")
def _parse_bluetooth_line(self, line: str):
"""Parse bluetoothctl output line."""
import re
# Match device address (MAC)
mac_match = re.search(r'([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})', line)
if not mac_match:
return
mac = mac_match.group(1).upper()
device = self.bluetooth_devices.get(mac) or {'mac': mac}
device['last_seen'] = datetime.now(timezone.utc).isoformat()
# Extract name
if '[NEW]' in line or '[CHG]' in line and 'Name:' not in line:
# Try to get name after MAC
parts = line.split(mac)
if len(parts) > 1:
name = parts[1].strip()
if name and not name.startswith('RSSI') and not name.startswith('ManufacturerData'):
device['name'] = name
# Extract RSSI
rssi_match = re.search(r'RSSI:\s*(-?\d+)', line)
if rssi_match:
device['rssi'] = int(rssi_match.group(1))
# Add GPS
gps_pos = gps_manager.position
if gps_pos:
device['agent_gps'] = gps_pos
self.bluetooth_devices[mac] = device
# Global mode manager
mode_manager = ModeManager()
_start_time = time.time()
# =============================================================================
# Data Push Loop
# =============================================================================
class DataPushLoop(threading.Thread):
"""Background thread that periodically pushes mode data to controller."""
def __init__(self, interval_seconds: float = 5.0):
super().__init__()
self.daemon = True
self.interval = interval_seconds
self.stop_event = threading.Event()
def run(self):
"""Main push loop."""
logger.info(f"Data push loop started (interval: {self.interval}s)")
while not self.stop_event.is_set():
if push_client and push_client.running:
# Push data for all running modes
for mode in list(mode_manager.running_modes.keys()):
try:
data = mode_manager.get_mode_data(mode)
if data.get('data'): # Only push if there's data
push_client.enqueue(
scan_type=mode,
payload=data,
interface=None
)
except Exception as e:
logger.warning(f"Failed to push {mode} data: {e}")
# Wait for next interval
self.stop_event.wait(self.interval)
logger.info("Data push loop stopped")
def stop(self):
"""Stop the push loop."""
self.stop_event.set()
# Global push loop
data_push_loop: DataPushLoop | None = None
# =============================================================================
# HTTP Request Handler
# =============================================================================
class InterceptAgentHandler(BaseHTTPRequestHandler):
"""HTTP request handler for the agent API."""
# Disable default logging
def log_message(self, format, *args):
logger.debug(f"{self.client_address[0]} - {format % args}")
def _check_ip_allowed(self) -> bool:
"""Check if client IP is allowed."""
if not config.allowed_ips:
return True
client_ip = self.client_address[0]
return client_ip in config.allowed_ips
def _send_json(self, data: dict, status: int = 200):
"""Send JSON response."""
body = json.dumps(data).encode('utf-8')
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', len(body))
if config.allow_cors:
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(body)
def _send_error(self, message: str, status: int = 400):
"""Send error response."""
self._send_json({'error': message}, status)
def _read_body(self) -> dict:
"""Read and parse JSON body."""
content_length = int(self.headers.get('Content-Length', 0))
if content_length == 0:
return {}
body = self.rfile.read(content_length)
try:
return json.loads(body.decode('utf-8'))
except json.JSONDecodeError:
return {}
def _parse_path(self) -> tuple[str, dict]:
"""Parse URL path and query parameters."""
parsed = urlparse(self.path)
path = parsed.path.rstrip('/')
query = parse_qs(parsed.query)
# Flatten single-value query params
params = {k: v[0] if len(v) == 1 else v for k, v in query.items()}
return path, params
def do_OPTIONS(self):
"""Handle CORS preflight."""
self.send_response(204)
if config.allow_cors:
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type, X-API-Key')
self.end_headers()
def do_GET(self):
"""Handle GET requests."""
if not self._check_ip_allowed():
self._send_error('Forbidden', 403)
return
path, params = self._parse_path()
# Route handling
if path == '/capabilities':
self._send_json(mode_manager.detect_capabilities())
elif path == '/status':
self._send_json(mode_manager.get_status())
elif path == '/health':
self._send_json({'status': 'healthy', 'version': AGENT_VERSION})
elif path == '/gps':
gps_pos = gps_manager.position
self._send_json({
'available': gps_manager.is_running,
'position': gps_pos,
})
elif path == '/config':
# Return non-sensitive config
cfg = config.to_dict()
if 'controller_api_key' in cfg:
del cfg['controller_api_key']
self._send_json(cfg)
elif path.startswith('/') and path.count('/') == 2:
# /{mode}/status or /{mode}/data
parts = path.split('/')
mode = parts[1]
action = parts[2]
if action == 'status':
self._send_json(mode_manager.get_mode_status(mode))
elif action == 'data':
self._send_json(mode_manager.get_mode_data(mode))
else:
self._send_error('Not found', 404)
else:
self._send_error('Not found', 404)
def do_POST(self):
"""Handle POST requests."""
if not self._check_ip_allowed():
self._send_error('Forbidden', 403)
return
path, _ = self._parse_path()
body = self._read_body()
if path == '/config':
# Update running config (limited fields)
if 'push_enabled' in body:
config.push_enabled = bool(body['push_enabled'])
if 'push_interval' in body:
config.push_interval = int(body['push_interval'])
self._send_json({'status': 'updated', 'config': config.to_dict()})
elif path.startswith('/') and path.count('/') == 2:
# /{mode}/start or /{mode}/stop
parts = path.split('/')
mode = parts[1]
action = parts[2]
if action == 'start':
result = mode_manager.start_mode(mode, body)
status = 200 if result.get('status') == 'started' else 400
self._send_json(result, status)
elif action == 'stop':
result = mode_manager.stop_mode(mode)
self._send_json(result)
else:
self._send_error('Not found', 404)
else:
self._send_error('Not found', 404)
# =============================================================================
# Threaded HTTP Server
# =============================================================================
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
"""Multi-threaded HTTP server."""
allow_reuse_address = True
daemon_threads = True
# =============================================================================
# Main
# =============================================================================
def main():
global config, push_client, _start_time
parser = argparse.ArgumentParser(
description='INTERCEPT Agent - Remote signal intelligence node'
)
parser.add_argument(
'--port', '-p',
type=int,
default=8020,
help='Port to listen on (default: 8020)'
)
parser.add_argument(
'--config', '-c',
default='intercept_agent.cfg',
help='Configuration file (default: intercept_agent.cfg)'
)
parser.add_argument(
'--name', '-n',
help='Agent name (overrides config file)'
)
parser.add_argument(
'--controller',
help='Controller URL for push mode'
)
parser.add_argument(
'--api-key',
help='API key for controller authentication'
)
parser.add_argument(
'--allowed-ips',
help='Comma-separated list of allowed client IPs'
)
parser.add_argument(
'--cors',
action='store_true',
help='Enable CORS headers'
)
parser.add_argument(
'--debug',
action='store_true',
help='Enable debug logging'
)
args = parser.parse_args()
if args.debug:
logging.getLogger().setLevel(logging.DEBUG)
# Load config file
config_path = args.config
if not os.path.isabs(config_path):
config_path = os.path.join(os.path.dirname(__file__), config_path)
config.load_from_file(config_path)
# Override with command line args
if args.port:
config.port = args.port
if args.name:
config.name = args.name
if args.controller:
config.controller_url = args.controller.rstrip('/')
config.push_enabled = True
if args.api_key:
config.controller_api_key = args.api_key
if args.allowed_ips:
config.allowed_ips = [ip.strip() for ip in args.allowed_ips.split(',')]
if args.cors:
config.allow_cors = True
_start_time = time.time()
print("=" * 60)
print(" INTERCEPT AGENT")
print(" Remote Signal Intelligence Node")
print("=" * 60)
print()
print(f" Agent Name: {config.name}")
print(f" Port: {config.port}")
print(f" CORS: {'Enabled' if config.allow_cors else 'Disabled'}")
# Start GPS
print()
print(" Initializing GPS...")
if gps_manager.start():
print(" GPS: Connected to gpsd")
else:
print(" GPS: Not available (gpsd not running)")
if config.allowed_ips:
print(f" Allowed IPs: {', '.join(config.allowed_ips)}")
else:
print(" Allowed IPs: Any")
print()
# Detect capabilities
caps = mode_manager.detect_capabilities()
print(" Available Modes:")
for mode, available in caps['modes'].items():
status = "OK" if available else "N/A"
print(f" - {mode}: {status}")
print()
if caps['devices']:
print(" Detected SDR Devices:")
for dev in caps['devices']:
print(f" - [{dev.get('index', '?')}] {dev.get('name', 'Unknown')}")
print()
# Start push client if enabled
global data_push_loop
if config.push_enabled and config.controller_url:
print(f" Push Mode: Enabled -> {config.controller_url}")
push_client = ControllerPushClient(config)
push_client.start()
# Start data push loop
data_push_loop = DataPushLoop(interval_seconds=config.push_interval)
data_push_loop.start()
else:
print(" Push Mode: Disabled")
print()
# Start HTTP server
server_address = ('', config.port)
httpd = ThreadedHTTPServer(server_address, InterceptAgentHandler)
print(f" Listening on http://0.0.0.0:{config.port}")
print()
print(" Press Ctrl+C to stop")
print()
# Handle shutdown
def signal_handler(sig, frame):
print("\nShutting down...")
# Stop all running modes
for mode in list(mode_manager.running_modes.keys()):
mode_manager.stop_mode(mode)
if data_push_loop:
data_push_loop.stop()
if push_client:
push_client.stop()
gps_manager.stop()
httpd.shutdown()
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
finally:
if push_client:
push_client.stop()
if __name__ == '__main__':
main()