mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 07:10:00 -07:00
After enabling/disabling monitor mode, clear the cached capabilities so the next refresh shows the updated interface list (e.g., wlo1mon instead of wlo1).
4063 lines
160 KiB
Python
4063 lines
160 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
|
|
|
|
# Import TSCM modules for consistent analysis (same as local mode)
|
|
try:
|
|
from utils.tscm.detector import ThreatDetector
|
|
from utils.tscm.correlation import CorrelationEngine
|
|
HAS_TSCM_MODULES = True
|
|
except ImportError:
|
|
HAS_TSCM_MODULES = False
|
|
ThreatDetector = None
|
|
CorrelationEngine = None
|
|
|
|
# Import database functions for baseline support (same as local mode)
|
|
try:
|
|
from utils.database import get_tscm_baseline, get_active_tscm_baseline
|
|
HAS_BASELINE_DB = True
|
|
except ImportError:
|
|
HAS_BASELINE_DB = False
|
|
get_tscm_baseline = None
|
|
get_active_tscm_baseline = None
|
|
|
|
# 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': [],
|
|
'interfaces': {
|
|
'wifi_interfaces': [],
|
|
'bt_adapters': [],
|
|
'sdr_devices': [],
|
|
},
|
|
'agent_version': AGENT_VERSION,
|
|
'gps': gps_manager.is_running,
|
|
'gps_position': gps_manager.position,
|
|
'tool_details': {}, # Detailed tool status
|
|
}
|
|
|
|
# Detect interfaces using Intercept's TSCM device detection
|
|
self._detect_interfaces(capabilities)
|
|
|
|
# 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()
|
|
sdr_list = []
|
|
for sdr in devices:
|
|
sdr_dict = sdr.to_dict()
|
|
# Create friendly display name
|
|
display_name = sdr.name
|
|
if sdr.serial and sdr.serial not in ('N/A', 'Unknown'):
|
|
display_name = f'{sdr.name} (SN: {sdr.serial[-8:]})'
|
|
sdr_dict['display_name'] = display_name
|
|
sdr_list.append(sdr_dict)
|
|
capabilities['devices'] = sdr_list
|
|
capabilities['interfaces']['sdr_devices'] = sdr_list
|
|
except Exception as e:
|
|
logger.warning(f"SDR device detection failed: {e}")
|
|
|
|
self._capabilities = capabilities
|
|
return capabilities
|
|
|
|
def _detect_interfaces(self, capabilities: dict):
|
|
"""Detect WiFi interfaces and Bluetooth adapters."""
|
|
import platform
|
|
|
|
interfaces = capabilities.get('interfaces', {})
|
|
|
|
# Detect WiFi interfaces
|
|
if platform.system() == 'Darwin': # macOS
|
|
try:
|
|
result = subprocess.run(
|
|
['networksetup', '-listallhardwareports'],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
lines = result.stdout.split('\n')
|
|
for i, line in enumerate(lines):
|
|
if 'Wi-Fi' in line or 'AirPort' in line:
|
|
port_name = line.replace('Hardware Port:', '').strip()
|
|
for j in range(i + 1, min(i + 3, len(lines))):
|
|
if 'Device:' in lines[j]:
|
|
device = lines[j].split('Device:')[1].strip()
|
|
interfaces['wifi_interfaces'].append({
|
|
'name': device,
|
|
'display_name': f'{port_name} ({device})',
|
|
'type': 'internal',
|
|
'monitor_capable': False
|
|
})
|
|
break
|
|
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
pass
|
|
else: # Linux
|
|
try:
|
|
result = subprocess.run(
|
|
['iw', 'dev'],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
current_iface = None
|
|
for line in result.stdout.split('\n'):
|
|
line = line.strip()
|
|
if line.startswith('Interface'):
|
|
current_iface = line.split()[1]
|
|
elif current_iface and 'type' in line:
|
|
iface_type = line.split()[-1]
|
|
interfaces['wifi_interfaces'].append({
|
|
'name': current_iface,
|
|
'display_name': f'Wireless ({current_iface}) - {iface_type}',
|
|
'type': iface_type,
|
|
'monitor_capable': True
|
|
})
|
|
current_iface = None
|
|
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
# Fall back to iwconfig
|
|
try:
|
|
result = subprocess.run(
|
|
['iwconfig'],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
for line in result.stdout.split('\n'):
|
|
if 'IEEE 802.11' in line:
|
|
iface = line.split()[0]
|
|
interfaces['wifi_interfaces'].append({
|
|
'name': iface,
|
|
'display_name': f'Wireless ({iface})',
|
|
'type': 'managed',
|
|
'monitor_capable': True
|
|
})
|
|
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
pass
|
|
|
|
# Detect Bluetooth adapters
|
|
if platform.system() == 'Linux':
|
|
try:
|
|
result = subprocess.run(
|
|
['hciconfig'],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
|
|
for block in blocks:
|
|
if block.strip():
|
|
first_line = block.split('\n')[0]
|
|
match = re.match(r'(hci\d+):', first_line)
|
|
if match:
|
|
iface_name = match.group(1)
|
|
is_up = 'UP RUNNING' in block or '\tUP ' in block
|
|
interfaces['bt_adapters'].append({
|
|
'name': iface_name,
|
|
'display_name': f'Bluetooth Adapter ({iface_name})',
|
|
'type': 'hci',
|
|
'status': 'up' if is_up else 'down'
|
|
})
|
|
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
# Try bluetoothctl as fallback
|
|
try:
|
|
result = subprocess.run(
|
|
['bluetoothctl', 'list'],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
for line in result.stdout.split('\n'):
|
|
if 'Controller' in line:
|
|
parts = line.split()
|
|
if len(parts) >= 3:
|
|
addr = parts[1]
|
|
name = ' '.join(parts[2:]) if len(parts) > 2 else 'Bluetooth'
|
|
interfaces['bt_adapters'].append({
|
|
'name': addr,
|
|
'display_name': f'{name} ({addr[-8:]})',
|
|
'type': 'controller',
|
|
'status': 'available'
|
|
})
|
|
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
pass
|
|
elif platform.system() == 'Darwin':
|
|
try:
|
|
result = subprocess.run(
|
|
['system_profiler', 'SPBluetoothDataType'],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
bt_name = 'Built-in Bluetooth'
|
|
bt_addr = ''
|
|
for line in result.stdout.split('\n'):
|
|
if 'Address:' in line:
|
|
bt_addr = line.split('Address:')[1].strip()
|
|
break
|
|
interfaces['bt_adapters'].append({
|
|
'name': 'default',
|
|
'display_name': f'{bt_name}' + (f' ({bt_addr[-8:]})' if bt_addr else ''),
|
|
'type': 'macos',
|
|
'status': 'available'
|
|
})
|
|
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
interfaces['bt_adapters'].append({
|
|
'name': 'default',
|
|
'display_name': 'Built-in Bluetooth',
|
|
'type': 'macos',
|
|
'status': 'available'
|
|
})
|
|
|
|
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."""
|
|
# Build running modes with device info for multi-SDR tracking
|
|
running_modes_detail = {}
|
|
for mode, info in self.running_modes.items():
|
|
params = info.get('params', {})
|
|
running_modes_detail[mode] = {
|
|
'started_at': info.get('started_at'),
|
|
'device': params.get('device', params.get('device_index', 0)),
|
|
}
|
|
|
|
status = {
|
|
'running_modes': list(self.running_modes.keys()),
|
|
'running_modes_detail': running_modes_detail, # Include device info per mode
|
|
'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
|
|
|
|
# Modes that use RTL-SDR devices
|
|
SDR_MODES = {'adsb', 'sensor', 'pager', 'ais', 'acars', 'dsc', 'rtlamr', 'listening_post'}
|
|
|
|
def get_sdr_in_use(self, device: int = 0) -> str | None:
|
|
"""Check if an SDR device is in use by another mode.
|
|
|
|
Returns the mode name using the device, or None if available.
|
|
"""
|
|
for mode, info in self.running_modes.items():
|
|
if mode in self.SDR_MODES:
|
|
mode_device = info.get('params', {}).get('device', 0)
|
|
# Normalize to int for comparison
|
|
try:
|
|
mode_device = int(mode_device)
|
|
except (ValueError, TypeError):
|
|
mode_device = 0
|
|
if mode_device == device:
|
|
return mode
|
|
return None
|
|
|
|
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)'}
|
|
|
|
# Check SDR device conflicts for SDR-based modes
|
|
if mode in self.SDR_MODES:
|
|
device = params.get('device', 0)
|
|
try:
|
|
device = int(device)
|
|
except (ValueError, TypeError):
|
|
device = 0
|
|
in_use_by = self.get_sdr_in_use(device)
|
|
if in_use_by:
|
|
return {
|
|
'status': 'error',
|
|
'message': f'SDR device {device} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
|
}
|
|
|
|
# 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, []))
|
|
elif mode == 'ais':
|
|
info['vessel_count'] = len(getattr(self, 'ais_vessels', {}))
|
|
elif mode == 'aprs':
|
|
info['station_count'] = len(getattr(self, 'aprs_stations', {}))
|
|
elif mode == 'pager':
|
|
info['message_count'] = len(self.data_snapshots.get(mode, []))
|
|
elif mode == 'acars':
|
|
info['message_count'] = len(self.data_snapshots.get(mode, []))
|
|
elif mode == 'rtlamr':
|
|
info['reading_count'] = len(self.data_snapshots.get(mode, []))
|
|
elif mode == 'tscm':
|
|
info['anomaly_count'] = len(getattr(self, 'tscm_anomalies', []))
|
|
elif mode == 'satellite':
|
|
info['pass_count'] = len(self.data_snapshots.get(mode, []))
|
|
elif mode == 'listening_post':
|
|
info['signal_count'] = len(getattr(self, 'listening_post_activity', []))
|
|
info['current_freq'] = getattr(self, 'listening_post_current_freq', 0)
|
|
info['freqs_scanned'] = getattr(self, 'listening_post_freqs_scanned', 0)
|
|
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())
|
|
elif mode == 'ais':
|
|
data['data'] = list(getattr(self, 'ais_vessels', {}).values())
|
|
elif mode == 'aprs':
|
|
data['data'] = list(getattr(self, 'aprs_stations', {}).values())
|
|
elif mode == 'tscm':
|
|
data['data'] = {
|
|
'anomalies': getattr(self, 'tscm_anomalies', []),
|
|
'baseline': getattr(self, 'tscm_baseline', {}),
|
|
'wifi_devices': list(self.wifi_networks.values()),
|
|
'bt_devices': list(self.bluetooth_devices.values()),
|
|
'rf_signals': getattr(self, 'tscm_rf_signals', []),
|
|
}
|
|
elif mode == 'listening_post':
|
|
data['data'] = {
|
|
'activity': getattr(self, 'listening_post_activity', []),
|
|
'current_freq': getattr(self, 'listening_post_current_freq', 0),
|
|
'freqs_scanned': getattr(self, 'listening_post_freqs_scanned', 0),
|
|
'signal_count': len(getattr(self, 'listening_post_activity', [])),
|
|
}
|
|
elif mode == 'pager':
|
|
# Return recent pager messages
|
|
messages = self.data_snapshots.get(mode, [])
|
|
data['data'] = {
|
|
'messages': messages[-50:] if len(messages) > 50 else messages,
|
|
'total_count': len(messages),
|
|
}
|
|
elif mode == 'dsc':
|
|
# Return DSC messages
|
|
messages = getattr(self, 'dsc_messages', [])
|
|
data['data'] = {
|
|
'messages': messages[-50:] if len(messages) > 50 else messages,
|
|
'total_count': len(messages),
|
|
}
|
|
else:
|
|
data['data'] = self.data_snapshots.get(mode, [])
|
|
|
|
return data
|
|
|
|
# =========================================================================
|
|
# WiFi Monitor Mode
|
|
# =========================================================================
|
|
|
|
def toggle_monitor_mode(self, params: dict) -> dict:
|
|
"""Enable or disable monitor mode on a WiFi interface."""
|
|
import re
|
|
|
|
action = params.get('action', 'start')
|
|
interface = params.get('interface', '')
|
|
kill_processes = params.get('kill_processes', False)
|
|
|
|
# Validate interface name (alphanumeric, underscore, dash only)
|
|
if not interface or not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', interface):
|
|
return {'status': 'error', 'message': 'Invalid interface name'}
|
|
|
|
airmon_path = self._get_tool_path('airmon-ng')
|
|
iw_path = self._get_tool_path('iw')
|
|
|
|
if action == 'start':
|
|
if airmon_path:
|
|
try:
|
|
# Get interfaces before
|
|
def get_wireless_interfaces():
|
|
interfaces = set()
|
|
try:
|
|
for iface in os.listdir('/sys/class/net'):
|
|
if os.path.exists(f'/sys/class/net/{iface}/wireless') or 'mon' in iface:
|
|
interfaces.add(iface)
|
|
except OSError:
|
|
pass
|
|
return interfaces
|
|
|
|
interfaces_before = get_wireless_interfaces()
|
|
|
|
# Kill interfering processes if requested
|
|
if kill_processes:
|
|
subprocess.run([airmon_path, 'check', 'kill'],
|
|
capture_output=True, timeout=10)
|
|
|
|
# Start monitor mode
|
|
result = subprocess.run([airmon_path, 'start', interface],
|
|
capture_output=True, text=True, timeout=15)
|
|
output = result.stdout + result.stderr
|
|
|
|
time.sleep(1)
|
|
interfaces_after = get_wireless_interfaces()
|
|
|
|
# Find the new monitor interface
|
|
new_interfaces = interfaces_after - interfaces_before
|
|
monitor_iface = None
|
|
|
|
if new_interfaces:
|
|
for iface in new_interfaces:
|
|
if 'mon' in iface:
|
|
monitor_iface = iface
|
|
break
|
|
if not monitor_iface:
|
|
monitor_iface = list(new_interfaces)[0]
|
|
|
|
# Try to parse from airmon-ng output
|
|
if not monitor_iface:
|
|
patterns = [
|
|
r'\b([a-zA-Z][a-zA-Z0-9_-]*mon)\b',
|
|
r'\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*mon)',
|
|
r'enabled.*?\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*)',
|
|
]
|
|
for pattern in patterns:
|
|
match = re.search(pattern, output, re.IGNORECASE)
|
|
if match:
|
|
candidate = match.group(1)
|
|
if candidate and not candidate[0].isdigit():
|
|
monitor_iface = candidate
|
|
break
|
|
|
|
# Fallback: check if original interface is in monitor mode
|
|
if not monitor_iface:
|
|
try:
|
|
result = subprocess.run(['iwconfig', interface],
|
|
capture_output=True, text=True, timeout=5)
|
|
if 'Mode:Monitor' in result.stdout:
|
|
monitor_iface = interface
|
|
except (subprocess.SubprocessError, OSError):
|
|
pass
|
|
|
|
# Last resort: try common naming
|
|
if not monitor_iface:
|
|
potential = interface + 'mon'
|
|
if os.path.exists(f'/sys/class/net/{potential}'):
|
|
monitor_iface = potential
|
|
|
|
if not monitor_iface or not os.path.exists(f'/sys/class/net/{monitor_iface}'):
|
|
all_wireless = list(get_wireless_interfaces())
|
|
return {
|
|
'status': 'error',
|
|
'message': f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}'
|
|
}
|
|
|
|
self.wifi_monitor_interface = monitor_iface
|
|
self._capabilities = None # Invalidate cache so interfaces refresh
|
|
logger.info(f"Monitor mode enabled on {monitor_iface}")
|
|
return {'status': 'success', 'monitor_interface': monitor_iface}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error enabling monitor mode: {e}")
|
|
return {'status': 'error', 'message': str(e)}
|
|
|
|
elif iw_path:
|
|
try:
|
|
subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
|
|
subprocess.run([iw_path, interface, 'set', 'monitor', 'control'], capture_output=True)
|
|
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
|
|
self.wifi_monitor_interface = interface
|
|
self._capabilities = None # Invalidate cache
|
|
return {'status': 'success', 'monitor_interface': interface}
|
|
except Exception as e:
|
|
return {'status': 'error', 'message': str(e)}
|
|
else:
|
|
return {'status': 'error', 'message': 'No monitor mode tools available (airmon-ng or iw)'}
|
|
|
|
else: # stop
|
|
current_iface = getattr(self, 'wifi_monitor_interface', None) or interface
|
|
if airmon_path:
|
|
try:
|
|
subprocess.run([airmon_path, 'stop', current_iface],
|
|
capture_output=True, text=True, timeout=15)
|
|
self.wifi_monitor_interface = None
|
|
self._capabilities = None # Invalidate cache
|
|
return {'status': 'success', 'message': 'Monitor mode disabled'}
|
|
except Exception as e:
|
|
return {'status': 'error', 'message': str(e)}
|
|
elif iw_path:
|
|
try:
|
|
subprocess.run(['ip', 'link', 'set', current_iface, 'down'], capture_output=True)
|
|
subprocess.run([iw_path, current_iface, 'set', 'type', 'managed'], capture_output=True)
|
|
subprocess.run(['ip', 'link', 'set', current_iface, 'up'], capture_output=True)
|
|
self.wifi_monitor_interface = None
|
|
self._capabilities = None # Invalidate cache
|
|
return {'status': 'success', 'message': 'Monitor mode disabled'}
|
|
except Exception as e:
|
|
return {'status': 'error', 'message': str(e)}
|
|
|
|
return {'status': 'error', 'message': 'Unknown action'}
|
|
|
|
# =========================================================================
|
|
# 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,
|
|
'pager': self._start_pager,
|
|
'ais': self._start_ais,
|
|
'acars': self._start_acars,
|
|
'aprs': self._start_aprs,
|
|
'rtlamr': self._start_rtlamr,
|
|
'dsc': self._start_dsc,
|
|
'tscm': self._start_tscm,
|
|
'satellite': self._start_satellite,
|
|
'listening_post': self._start_listening_post,
|
|
}
|
|
|
|
handler = handlers.get(mode)
|
|
if handler:
|
|
return handler(params)
|
|
|
|
# Unknown mode
|
|
logger.warning(f"Unknown mode: {mode}")
|
|
return {'status': 'error', 'message': f'Unknown mode: {mode}'}
|
|
|
|
def _stop_mode_internal(self, mode: str) -> dict:
|
|
"""Internal mode stop - terminates processes and cleans up."""
|
|
logger.info(f"Stopping mode {mode}")
|
|
|
|
# Signal stop first - this unblocks any waiting threads
|
|
if mode in self.stop_events:
|
|
self.stop_events[mode].set()
|
|
|
|
# Terminate process if running
|
|
if mode in self.processes:
|
|
proc = self.processes[mode]
|
|
try:
|
|
if proc and proc.poll() is None:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=2)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
try:
|
|
proc.wait(timeout=1)
|
|
except Exception:
|
|
pass
|
|
except (OSError, ProcessLookupError) as e:
|
|
# Process already dead or inaccessible
|
|
logger.debug(f"Process cleanup for {mode}: {e}")
|
|
del self.processes[mode]
|
|
|
|
# Wait for output thread (short timeout since stop event is set)
|
|
if mode in self.output_threads:
|
|
thread = self.output_threads[mode]
|
|
if thread and thread.is_alive():
|
|
thread.join(timeout=1)
|
|
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()
|
|
elif mode == 'tscm':
|
|
# Clean up TSCM sub-threads
|
|
for sub_thread_name in ['tscm_wifi', 'tscm_bt', 'tscm_rf']:
|
|
if sub_thread_name in self.output_threads:
|
|
thread = self.output_threads[sub_thread_name]
|
|
if thread and thread.is_alive():
|
|
thread.join(timeout=2)
|
|
del self.output_threads[sub_thread_name]
|
|
# Clear TSCM data
|
|
self.tscm_anomalies = []
|
|
self.tscm_baseline = {}
|
|
self.tscm_rf_signals = []
|
|
# Clear reported threat tracking sets
|
|
if hasattr(self, '_tscm_reported_wifi'):
|
|
self._tscm_reported_wifi.clear()
|
|
if hasattr(self, '_tscm_reported_bt'):
|
|
self._tscm_reported_bt.clear()
|
|
elif mode == 'dsc':
|
|
# Clear DSC data
|
|
if hasattr(self, 'dsc_messages'):
|
|
self.dsc_messages = []
|
|
elif mode == 'pager':
|
|
# Pager uses two processes: multimon-ng (pager) and rtl_fm (pager_rtl)
|
|
# Kill the rtl_fm process as well
|
|
if 'pager_rtl' in self.processes:
|
|
rtl_proc = self.processes['pager_rtl']
|
|
if rtl_proc and rtl_proc.poll() is None:
|
|
rtl_proc.terminate()
|
|
try:
|
|
rtl_proc.wait(timeout=3)
|
|
except subprocess.TimeoutExpired:
|
|
rtl_proc.kill()
|
|
del self.processes['pager_rtl']
|
|
# Clear pager data
|
|
if hasattr(self, 'pager_messages'):
|
|
self.pager_messages = []
|
|
elif mode == 'aprs':
|
|
# APRS uses two processes: decoder (aprs) and rtl_fm (aprs_rtl)
|
|
if 'aprs_rtl' in self.processes:
|
|
rtl_proc = self.processes['aprs_rtl']
|
|
if rtl_proc and rtl_proc.poll() is None:
|
|
rtl_proc.terminate()
|
|
try:
|
|
rtl_proc.wait(timeout=3)
|
|
except subprocess.TimeoutExpired:
|
|
rtl_proc.kill()
|
|
del self.processes['aprs_rtl']
|
|
elif mode == 'rtlamr':
|
|
# RTLAMR uses two processes: rtlamr and rtl_tcp (rtlamr_tcp)
|
|
if 'rtlamr_tcp' in self.processes:
|
|
tcp_proc = self.processes['rtlamr_tcp']
|
|
if tcp_proc and tcp_proc.poll() is None:
|
|
tcp_proc.terminate()
|
|
try:
|
|
tcp_proc.wait(timeout=3)
|
|
except subprocess.TimeoutExpired:
|
|
tcp_proc.kill()
|
|
del self.processes['rtlamr_tcp']
|
|
|
|
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
|
|
|
|
# Wait briefly to verify process started successfully
|
|
time.sleep(0.5)
|
|
if proc.poll() is not None:
|
|
stderr_output = proc.stderr.read().decode('utf-8', errors='replace')
|
|
del self.processes['sensor']
|
|
return {'status': 'error', 'message': f'rtl_433 failed to start: {stderr_output[:200]}'}
|
|
|
|
# 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 (OSError, ValueError) as e:
|
|
# Bad file descriptor or closed file - process was terminated
|
|
logger.debug(f"Sensor output reader stopped: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Sensor output reader error: {e}")
|
|
finally:
|
|
try:
|
|
proc.wait(timeout=1)
|
|
except Exception:
|
|
pass
|
|
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 UnifiedWiFiScanner."""
|
|
interface = params.get('interface')
|
|
channel = params.get('channel')
|
|
band = params.get('band', 'abg')
|
|
scan_type = params.get('scan_type', 'deep')
|
|
|
|
# Handle quick scan - returns results synchronously
|
|
if scan_type == 'quick':
|
|
return self._wifi_quick_scan(interface)
|
|
|
|
# Deep scan - use Intercept's UnifiedWiFiScanner
|
|
try:
|
|
from utils.wifi.scanner import get_wifi_scanner
|
|
scanner = get_wifi_scanner(interface)
|
|
|
|
# Store scanner reference
|
|
self._wifi_scanner_instance = scanner
|
|
|
|
# Check capabilities
|
|
caps = scanner.check_capabilities()
|
|
if not caps.can_deep_scan:
|
|
return {'status': 'error', 'message': f'Deep scan not available: {", ".join(caps.issues)}'}
|
|
|
|
# Convert band parameter
|
|
if band == 'abg':
|
|
scan_band = 'all'
|
|
elif band == 'bg':
|
|
scan_band = '2.4'
|
|
elif band == 'a':
|
|
scan_band = '5'
|
|
else:
|
|
scan_band = 'all'
|
|
|
|
# Start deep scan
|
|
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel):
|
|
# Start thread to sync data to agent's dictionaries
|
|
thread = threading.Thread(
|
|
target=self._wifi_data_sync,
|
|
args=(scanner,),
|
|
daemon=True
|
|
)
|
|
thread.start()
|
|
self.output_threads['wifi'] = thread
|
|
|
|
return {
|
|
'status': 'started',
|
|
'mode': 'wifi',
|
|
'interface': interface,
|
|
'gps_enabled': gps_manager.is_running
|
|
}
|
|
else:
|
|
return {'status': 'error', 'message': scanner.get_status().error or 'Failed to start deep scan'}
|
|
|
|
except ImportError:
|
|
# Fallback to direct airodump-ng
|
|
return self._start_wifi_fallback(interface, channel, band)
|
|
except Exception as e:
|
|
logger.error(f"WiFi scanner error: {e}")
|
|
return {'status': 'error', 'message': str(e)}
|
|
|
|
def _wifi_data_sync(self, scanner):
|
|
"""Sync WiFi scanner data to agent's data structures."""
|
|
mode = 'wifi'
|
|
stop_event = self.stop_events.get(mode)
|
|
|
|
while not (stop_event and stop_event.is_set()):
|
|
try:
|
|
gps_position = gps_manager.position
|
|
|
|
# Sync access points
|
|
for ap in scanner.access_points:
|
|
net = ap.to_dict()
|
|
if gps_position:
|
|
net['agent_gps'] = gps_position
|
|
self.wifi_networks[ap.bssid.upper()] = net
|
|
|
|
# Sync clients
|
|
for client in scanner.clients:
|
|
client_data = client.to_dict()
|
|
if gps_position:
|
|
client_data['agent_gps'] = gps_position
|
|
self.wifi_clients[client.mac.upper()] = client_data
|
|
|
|
time.sleep(2)
|
|
except Exception as e:
|
|
logger.debug(f"WiFi sync error: {e}")
|
|
time.sleep(2)
|
|
|
|
# Stop scanner when done
|
|
if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance:
|
|
self._wifi_scanner_instance.stop_deep_scan()
|
|
|
|
def _start_wifi_fallback(self, interface: str | None, channel: int | None, band: str) -> dict:
|
|
"""Fallback WiFi deep scan using airodump-ng directly."""
|
|
if not interface:
|
|
return {'status': 'error', 'message': 'WiFi interface required'}
|
|
|
|
# Validate interface
|
|
try:
|
|
from utils.validation import validate_network_interface
|
|
interface = validate_network_interface(interface)
|
|
except (ImportError, ValueError) as e:
|
|
if not os.path.exists(f'/sys/class/net/{interface}'):
|
|
return {'status': 'error', 'message': f'Interface {interface} not found'}
|
|
|
|
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
|
|
|
|
airodump_path = self._get_tool_path('airodump-ng')
|
|
if not airodump_path:
|
|
return {'status': 'error', 'message': 'airodump-ng not found'}
|
|
|
|
output_formats = 'csv,gps' if gps_manager.is_running else 'csv'
|
|
cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band]
|
|
if gps_manager.is_running:
|
|
cmd.append('--gpsd')
|
|
if channel:
|
|
cmd.extend(['-c', str(channel)])
|
|
cmd.append(interface)
|
|
|
|
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]}'}
|
|
|
|
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 Exception as e:
|
|
return {'status': 'error', 'message': str(e)}
|
|
|
|
def _wifi_quick_scan(self, interface: str | None) -> dict:
|
|
"""
|
|
Perform a quick one-shot WiFi scan using system tools.
|
|
|
|
Uses nmcli, iw, or iwlist (no monitor mode required).
|
|
Returns results synchronously.
|
|
"""
|
|
try:
|
|
from utils.wifi.scanner import get_wifi_scanner
|
|
scanner = get_wifi_scanner()
|
|
result = scanner.quick_scan(interface=interface, timeout=15.0)
|
|
|
|
if result.error:
|
|
return {
|
|
'status': 'error',
|
|
'message': result.error,
|
|
'warnings': result.warnings
|
|
}
|
|
|
|
# Convert access points to dict format
|
|
networks = []
|
|
gps_position = gps_manager.position
|
|
for ap in result.access_points:
|
|
net = ap.to_dict()
|
|
# Add agent GPS if available
|
|
if gps_position:
|
|
net['agent_gps'] = gps_position
|
|
networks.append(net)
|
|
|
|
return {
|
|
'status': 'success',
|
|
'scan_type': 'quick',
|
|
'access_points': networks,
|
|
'networks': networks, # Alias for compatibility
|
|
'network_count': len(networks),
|
|
'warnings': result.warnings,
|
|
'gps_enabled': gps_manager.is_running,
|
|
'agent_gps': gps_position
|
|
}
|
|
|
|
except ImportError:
|
|
# Fallback: simple nmcli scan
|
|
return self._wifi_quick_scan_fallback(interface)
|
|
except Exception as e:
|
|
logger.exception("Quick WiFi scan failed")
|
|
return {'status': 'error', 'message': str(e)}
|
|
|
|
def _wifi_quick_scan_fallback(self, interface: str | None) -> dict:
|
|
"""Fallback quick scan using nmcli directly."""
|
|
nmcli_path = shutil.which('nmcli')
|
|
if not nmcli_path:
|
|
return {'status': 'error', 'message': 'nmcli not found. Install NetworkManager.'}
|
|
|
|
try:
|
|
# Trigger rescan
|
|
subprocess.run(
|
|
[nmcli_path, 'device', 'wifi', 'rescan'],
|
|
capture_output=True,
|
|
timeout=5
|
|
)
|
|
|
|
# Get results
|
|
cmd = [nmcli_path, '-t', '-f', 'BSSID,SSID,CHAN,SIGNAL,SECURITY', 'device', 'wifi', 'list']
|
|
if interface:
|
|
cmd.extend(['ifname', interface])
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
|
|
|
if result.returncode != 0:
|
|
return {'status': 'error', 'message': f'nmcli failed: {result.stderr}'}
|
|
|
|
networks = []
|
|
gps_position = gps_manager.position
|
|
for line in result.stdout.strip().split('\n'):
|
|
if not line.strip():
|
|
continue
|
|
parts = line.split(':')
|
|
if len(parts) >= 5:
|
|
net = {
|
|
'bssid': parts[0],
|
|
'essid': parts[1],
|
|
'channel': int(parts[2]) if parts[2].isdigit() else 0,
|
|
'signal': int(parts[3]) if parts[3].isdigit() else 0,
|
|
'rssi_current': int(parts[3]) - 100 if parts[3].isdigit() else -100, # Convert % to dBm approx
|
|
'security': parts[4],
|
|
}
|
|
if gps_position:
|
|
net['agent_gps'] = gps_position
|
|
networks.append(net)
|
|
|
|
return {
|
|
'status': 'success',
|
|
'scan_type': 'quick',
|
|
'access_points': networks,
|
|
'networks': networks,
|
|
'network_count': len(networks),
|
|
'warnings': ['Using fallback nmcli scanner'],
|
|
'gps_enabled': gps_manager.is_running,
|
|
'agent_gps': gps_position
|
|
}
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return {'status': 'error', 'message': 'nmcli scan timed out'}
|
|
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 using Intercept's BluetoothScanner."""
|
|
adapter = params.get('adapter', 'hci0')
|
|
mode_param = params.get('mode', 'auto')
|
|
duration = params.get('duration')
|
|
|
|
try:
|
|
# Use Intercept's BluetoothScanner
|
|
from utils.bluetooth.scanner import BluetoothScanner
|
|
scanner = BluetoothScanner(adapter_id=adapter)
|
|
|
|
# Store scanner reference
|
|
self._bluetooth_scanner_instance = scanner
|
|
|
|
# Set callback for device updates
|
|
def on_device_updated(device):
|
|
# Convert to agent's format and store
|
|
self.bluetooth_devices[device.address.upper()] = {
|
|
'mac': device.address.upper(),
|
|
'name': device.name,
|
|
'rssi': device.rssi_current,
|
|
'protocol': device.protocol,
|
|
'last_seen': device.last_seen.isoformat() if device.last_seen else None,
|
|
'first_seen': device.first_seen.isoformat() if device.first_seen else None,
|
|
'agent_gps': gps_manager.position
|
|
}
|
|
|
|
scanner.set_on_device_updated(on_device_updated)
|
|
|
|
# Start scanning
|
|
if scanner.start_scan(mode=mode_param, duration_s=duration):
|
|
# Start thread to sync device data
|
|
thread = threading.Thread(
|
|
target=self._bluetooth_data_sync,
|
|
args=(scanner,),
|
|
daemon=True
|
|
)
|
|
thread.start()
|
|
self.output_threads['bluetooth'] = thread
|
|
|
|
return {
|
|
'status': 'started',
|
|
'mode': 'bluetooth',
|
|
'adapter': adapter,
|
|
'backend': scanner.get_status().backend,
|
|
'gps_enabled': gps_manager.is_running
|
|
}
|
|
else:
|
|
return {'status': 'error', 'message': scanner.get_status().error or 'Failed to start scan'}
|
|
|
|
except ImportError:
|
|
# Fallback to direct bluetoothctl if scanner not available
|
|
return self._start_bluetooth_fallback(adapter)
|
|
except Exception as e:
|
|
logger.error(f"Bluetooth scanner error: {e}")
|
|
return {'status': 'error', 'message': str(e)}
|
|
|
|
def _bluetooth_data_sync(self, scanner):
|
|
"""Sync Bluetooth scanner data to agent's data structures."""
|
|
mode = 'bluetooth'
|
|
stop_event = self.stop_events.get(mode)
|
|
|
|
while not (stop_event and stop_event.is_set()):
|
|
try:
|
|
# Get devices from scanner
|
|
devices = scanner.get_devices()
|
|
for device in devices:
|
|
self.bluetooth_devices[device.address.upper()] = {
|
|
'mac': device.address.upper(),
|
|
'name': device.name,
|
|
'rssi': device.rssi_current,
|
|
'protocol': device.protocol,
|
|
'last_seen': device.last_seen.isoformat() if device.last_seen else None,
|
|
'agent_gps': gps_manager.position
|
|
}
|
|
time.sleep(1)
|
|
except Exception as e:
|
|
logger.debug(f"Bluetooth sync error: {e}")
|
|
time.sleep(1)
|
|
|
|
# Stop scanner when done
|
|
if hasattr(self, '_bluetooth_scanner_instance') and self._bluetooth_scanner_instance:
|
|
self._bluetooth_scanner_instance.stop_scan()
|
|
|
|
def _start_bluetooth_fallback(self, adapter: str) -> dict:
|
|
"""Fallback Bluetooth scanning using bluetoothctl directly."""
|
|
if not shutil.which('bluetoothctl'):
|
|
return {'status': 'error', 'message': 'bluetoothctl not found'}
|
|
|
|
thread = threading.Thread(
|
|
target=self._bluetooth_scanner_fallback,
|
|
args=(adapter,),
|
|
daemon=True
|
|
)
|
|
thread.start()
|
|
self.output_threads['bluetooth'] = thread
|
|
|
|
return {
|
|
'status': 'started',
|
|
'mode': 'bluetooth',
|
|
'adapter': adapter,
|
|
'backend': 'bluetoothctl',
|
|
'gps_enabled': gps_manager.is_running
|
|
}
|
|
|
|
def _bluetooth_scanner_fallback(self, adapter: str):
|
|
"""Fallback scan using bluetoothctl directly."""
|
|
mode = 'bluetooth'
|
|
stop_event = self.stop_events.get(mode)
|
|
|
|
try:
|
|
proc = subprocess.Popen(
|
|
['bluetoothctl'],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
self.processes['bluetooth'] = proc
|
|
|
|
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()
|
|
if 'Device' in line:
|
|
self._parse_bluetooth_line(line)
|
|
|
|
time.sleep(0.1)
|
|
|
|
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
|
|
|
|
# -------------------------------------------------------------------------
|
|
# PAGER MODE (rtl_fm | multimon-ng)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _start_pager(self, params: dict) -> dict:
|
|
"""Start POCSAG/FLEX pager decoding using rtl_fm | multimon-ng."""
|
|
freq = params.get('frequency', '929.6125')
|
|
gain = params.get('gain', '0')
|
|
device = params.get('device', '0')
|
|
ppm = params.get('ppm', '0')
|
|
squelch = params.get('squelch', '0')
|
|
protocols = params.get('protocols', ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX'])
|
|
|
|
# Validate tools
|
|
rtl_fm_path = self._get_tool_path('rtl_fm')
|
|
multimon_path = self._get_tool_path('multimon-ng')
|
|
if not rtl_fm_path:
|
|
return {'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr.'}
|
|
if not multimon_path:
|
|
return {'status': 'error', 'message': 'multimon-ng not found. Install multimon-ng.'}
|
|
|
|
# Build rtl_fm command for FM demodulation at 22050 Hz
|
|
rtl_fm_cmd = [
|
|
rtl_fm_path,
|
|
'-f', f'{freq}M',
|
|
'-s', '22050',
|
|
'-g', str(gain),
|
|
'-d', str(device),
|
|
]
|
|
if ppm and str(ppm) != '0':
|
|
rtl_fm_cmd.extend(['-p', str(ppm)])
|
|
if squelch and str(squelch) != '0':
|
|
rtl_fm_cmd.extend(['-l', str(squelch)])
|
|
|
|
# Build multimon-ng command
|
|
multimon_cmd = [multimon_path, '-t', 'raw', '-a']
|
|
for proto in protocols:
|
|
if proto in ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']:
|
|
multimon_cmd.extend(['-a', proto])
|
|
multimon_cmd.append('-')
|
|
|
|
logger.info(f"Starting pager: {' '.join(rtl_fm_cmd)} | {' '.join(multimon_cmd)}")
|
|
|
|
try:
|
|
# Start rtl_fm process
|
|
rtl_fm_proc = subprocess.Popen(
|
|
rtl_fm_cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
|
|
# Pipe to multimon-ng
|
|
multimon_proc = subprocess.Popen(
|
|
multimon_cmd,
|
|
stdin=rtl_fm_proc.stdout,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
rtl_fm_proc.stdout.close() # Allow SIGPIPE
|
|
|
|
# Store both processes
|
|
self.processes['pager'] = multimon_proc
|
|
self.processes['pager_rtl'] = rtl_fm_proc
|
|
|
|
# Wait briefly to verify processes started successfully
|
|
time.sleep(0.5)
|
|
if rtl_fm_proc.poll() is not None:
|
|
stderr_output = rtl_fm_proc.stderr.read().decode('utf-8', errors='replace')
|
|
multimon_proc.terminate()
|
|
del self.processes['pager']
|
|
del self.processes['pager_rtl']
|
|
return {'status': 'error', 'message': f'rtl_fm failed to start: {stderr_output[:200]}'}
|
|
|
|
# Start output reader
|
|
thread = threading.Thread(
|
|
target=self._pager_output_reader,
|
|
args=(multimon_proc,),
|
|
daemon=True
|
|
)
|
|
thread.start()
|
|
self.output_threads['pager'] = thread
|
|
|
|
return {
|
|
'status': 'started',
|
|
'mode': 'pager',
|
|
'frequency': freq,
|
|
'protocols': protocols,
|
|
'gps_enabled': gps_manager.is_running
|
|
}
|
|
|
|
except FileNotFoundError as e:
|
|
return {'status': 'error', 'message': str(e)}
|
|
except Exception as e:
|
|
return {'status': 'error', 'message': str(e)}
|
|
|
|
def _pager_output_reader(self, proc: subprocess.Popen):
|
|
"""Read and parse multimon-ng output for pager messages."""
|
|
mode = 'pager'
|
|
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
|
|
|
|
parsed = self._parse_pager_message(line)
|
|
if parsed:
|
|
parsed['received_at'] = datetime.now(timezone.utc).isoformat()
|
|
|
|
gps_pos = gps_manager.position
|
|
if gps_pos:
|
|
parsed['agent_gps'] = gps_pos
|
|
|
|
snapshots = self.data_snapshots.get(mode, [])
|
|
snapshots.append(parsed)
|
|
if len(snapshots) > 200:
|
|
snapshots = snapshots[-200:]
|
|
self.data_snapshots[mode] = snapshots
|
|
|
|
logger.debug(f"Pager: {parsed.get('protocol')} addr={parsed.get('address')}")
|
|
|
|
except (OSError, ValueError) as e:
|
|
# Bad file descriptor or closed file - process was terminated
|
|
logger.debug(f"Pager reader stopped: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Pager reader error: {e}")
|
|
finally:
|
|
try:
|
|
proc.wait(timeout=1)
|
|
except Exception:
|
|
pass
|
|
if 'pager_rtl' in self.processes:
|
|
try:
|
|
rtl_proc = self.processes['pager_rtl']
|
|
if rtl_proc.poll() is None:
|
|
rtl_proc.terminate()
|
|
del self.processes['pager_rtl']
|
|
except Exception:
|
|
pass
|
|
logger.info("Pager reader stopped")
|
|
|
|
def _parse_pager_message(self, line: str) -> dict | None:
|
|
"""Parse multimon-ng output line for POCSAG/FLEX using Intercept's parser."""
|
|
try:
|
|
# Use Intercept's existing pager parser
|
|
from routes.pager import parse_multimon_output
|
|
parsed = parse_multimon_output(line)
|
|
if parsed:
|
|
parsed['type'] = 'pager'
|
|
return parsed
|
|
return None
|
|
except ImportError:
|
|
# Fallback to inline parsing if import fails
|
|
import re
|
|
# POCSAG with message
|
|
match = re.match(
|
|
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(Alpha|Numeric):\s*(.*)',
|
|
line
|
|
)
|
|
if match:
|
|
return {
|
|
'type': 'pager',
|
|
'protocol': match.group(1),
|
|
'address': match.group(2),
|
|
'function': match.group(3),
|
|
'msg_type': match.group(4),
|
|
'message': match.group(5).strip() or '[No Message]'
|
|
}
|
|
|
|
# POCSAG address only (tone)
|
|
match = re.match(
|
|
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$',
|
|
line
|
|
)
|
|
if match:
|
|
return {
|
|
'type': 'pager',
|
|
'protocol': match.group(1),
|
|
'address': match.group(2),
|
|
'function': match.group(3),
|
|
'msg_type': 'Tone',
|
|
'message': '[Tone Only]'
|
|
}
|
|
|
|
# FLEX format
|
|
match = re.match(r'FLEX[:\|]\s*(.+)', line)
|
|
if match:
|
|
return {
|
|
'type': 'pager',
|
|
'protocol': 'FLEX',
|
|
'address': 'Unknown',
|
|
'function': '',
|
|
'msg_type': 'Unknown',
|
|
'message': match.group(1).strip()
|
|
}
|
|
|
|
return None
|
|
|
|
# -------------------------------------------------------------------------
|
|
# AIS MODE (AIS-catcher)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _start_ais(self, params: dict) -> dict:
|
|
"""Start AIS vessel tracking using AIS-catcher."""
|
|
gain = params.get('gain', '33')
|
|
device = params.get('device', '0')
|
|
bias_t = params.get('bias_t', False)
|
|
|
|
# Find AIS-catcher
|
|
ais_catcher = self._find_ais_catcher()
|
|
if not ais_catcher:
|
|
return {'status': 'error', 'message': 'AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher'}
|
|
|
|
# Initialize vessel dict
|
|
if not hasattr(self, 'ais_vessels'):
|
|
self.ais_vessels = {}
|
|
self.ais_vessels.clear()
|
|
|
|
# Build command - output JSON on TCP port 1234
|
|
cmd = [
|
|
ais_catcher,
|
|
'-d', str(device),
|
|
'-gr', f'TUNER={gain}',
|
|
'-o', '4', # JSON format
|
|
'-N', '1234', # TCP output on port 1234
|
|
]
|
|
|
|
if bias_t:
|
|
cmd.extend(['-gr', 'BIASTEE=on'])
|
|
|
|
logger.info(f"Starting AIS-catcher: {' '.join(cmd)}")
|
|
|
|
try:
|
|
proc = subprocess.Popen(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
start_new_session=True
|
|
)
|
|
self.processes['ais'] = proc
|
|
|
|
time.sleep(2)
|
|
if proc.poll() is not None:
|
|
stderr = proc.stderr.read().decode('utf-8', errors='ignore')
|
|
return {'status': 'error', 'message': f'AIS-catcher failed: {stderr[:200]}'}
|
|
|
|
# Start TCP reader thread
|
|
thread = threading.Thread(
|
|
target=self._ais_tcp_reader,
|
|
args=(1234,),
|
|
daemon=True
|
|
)
|
|
thread.start()
|
|
self.output_threads['ais'] = thread
|
|
|
|
return {
|
|
'status': 'started',
|
|
'mode': 'ais',
|
|
'tcp_port': 1234,
|
|
'gps_enabled': gps_manager.is_running
|
|
}
|
|
|
|
except FileNotFoundError:
|
|
return {'status': 'error', 'message': 'AIS-catcher not found'}
|
|
except Exception as e:
|
|
return {'status': 'error', 'message': str(e)}
|
|
|
|
def _find_ais_catcher(self) -> str | None:
|
|
"""Find AIS-catcher binary."""
|
|
for name in ['AIS-catcher', 'aiscatcher']:
|
|
path = self._get_tool_path(name)
|
|
if path:
|
|
return path
|
|
for path in ['/usr/local/bin/AIS-catcher', '/usr/bin/AIS-catcher', '/opt/homebrew/bin/AIS-catcher']:
|
|
if os.path.isfile(path) and os.access(path, os.X_OK):
|
|
return path
|
|
return None
|
|
|
|
def _ais_tcp_reader(self, port: int):
|
|
"""Read JSON vessel data from AIS-catcher TCP port."""
|
|
mode = 'ais'
|
|
stop_event = self.stop_events.get(mode)
|
|
retry_count = 0
|
|
|
|
# Initialize vessel dict
|
|
if not hasattr(self, 'ais_vessels'):
|
|
self.ais_vessels = {}
|
|
|
|
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(('localhost', port))
|
|
logger.info(f"Connected to AIS-catcher on port {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_ais_json(line.strip())
|
|
|
|
except socket.timeout:
|
|
continue
|
|
|
|
sock.close()
|
|
|
|
except Exception as e:
|
|
retry_count += 1
|
|
if retry_count >= 10:
|
|
logger.error("Max AIS retries reached")
|
|
break
|
|
time.sleep(2)
|
|
|
|
logger.info("AIS TCP reader stopped")
|
|
|
|
def _parse_ais_json(self, line: str):
|
|
"""Parse AIS-catcher JSON output."""
|
|
if not line:
|
|
return
|
|
|
|
try:
|
|
msg = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
return
|
|
|
|
mmsi = msg.get('mmsi')
|
|
if not mmsi:
|
|
return
|
|
|
|
mmsi = str(mmsi)
|
|
vessel = self.ais_vessels.get(mmsi) or {'mmsi': mmsi}
|
|
vessel['last_seen'] = datetime.now(timezone.utc).isoformat()
|
|
|
|
# Position
|
|
lat = msg.get('latitude') or msg.get('lat')
|
|
lon = msg.get('longitude') or msg.get('lon')
|
|
if lat is not None and lon is not None:
|
|
try:
|
|
lat, lon = float(lat), float(lon)
|
|
if -90 <= lat <= 90 and -180 <= lon <= 180:
|
|
vessel['lat'] = lat
|
|
vessel['lon'] = lon
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Speed and course
|
|
for field, max_val in [('speed', 102.3), ('course', 360)]:
|
|
if field in msg:
|
|
try:
|
|
val = float(msg[field])
|
|
if val < max_val:
|
|
vessel[field] = round(val, 1)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
if 'heading' in msg:
|
|
try:
|
|
heading = int(msg['heading'])
|
|
if heading < 360:
|
|
vessel['heading'] = heading
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Static data
|
|
for field in ['name', 'callsign', 'destination', 'shiptype', 'ship_type']:
|
|
if field in msg and msg[field]:
|
|
key = 'ship_type' if field == 'shiptype' else field
|
|
vessel[key] = str(msg[field]).strip()
|
|
|
|
gps_pos = gps_manager.position
|
|
if gps_pos:
|
|
vessel['agent_gps'] = gps_pos
|
|
|
|
self.ais_vessels[mmsi] = vessel
|
|
|
|
# -------------------------------------------------------------------------
|
|
# ACARS MODE (acarsdec)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _detect_acarsdec_fork(self, acarsdec_path: str) -> str:
|
|
"""Detect which acarsdec fork is installed.
|
|
|
|
Returns:
|
|
'--output' for f00b4r0 fork (DragonOS)
|
|
'-j' for TLeconte v4+
|
|
'-o' for TLeconte v3.x
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
[acarsdec_path],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5
|
|
)
|
|
output = result.stdout + result.stderr
|
|
|
|
# f00b4r0 fork uses --output instead of -j/-o
|
|
if '--output' in output:
|
|
return '--output'
|
|
|
|
# Parse version for TLeconte
|
|
import re
|
|
version_match = re.search(r'acarsdec[^\d]*v?(\d+)\.(\d+)', output, re.IGNORECASE)
|
|
if version_match:
|
|
major = int(version_match.group(1))
|
|
return '-j' if major >= 4 else '-o'
|
|
except Exception:
|
|
pass
|
|
return '-j' # Default to TLeconte v4+
|
|
|
|
def _start_acars(self, params: dict) -> dict:
|
|
"""Start ACARS decoding using acarsdec."""
|
|
gain = params.get('gain', '40')
|
|
device = params.get('device', '0')
|
|
frequencies = params.get('frequencies', ['131.550', '130.025', '129.125', '131.525', '131.725'])
|
|
|
|
acarsdec_path = self._get_tool_path('acarsdec')
|
|
if not acarsdec_path:
|
|
return {'status': 'error', 'message': 'acarsdec not found. Install acarsdec.'}
|
|
|
|
# Detect fork and build appropriate command
|
|
fork_type = self._detect_acarsdec_fork(acarsdec_path)
|
|
cmd = [acarsdec_path]
|
|
|
|
if fork_type == '--output':
|
|
# f00b4r0 fork (DragonOS): different syntax
|
|
cmd.extend(['--output', 'json:file']) # stdout
|
|
cmd.extend(['-g', str(gain)])
|
|
cmd.extend(['-m', '256']) # 3.2 MS/s for wider bandwidth
|
|
cmd.extend(['--rtlsdr', str(device)])
|
|
elif fork_type == '-j':
|
|
# TLeconte v4+
|
|
cmd.extend(['-j', '-g', str(gain), '-r', str(device)])
|
|
else:
|
|
# TLeconte v3.x
|
|
cmd.extend(['-o', '4', '-g', str(gain), '-r', str(device)])
|
|
|
|
cmd.extend(frequencies)
|
|
|
|
logger.info(f"Starting acarsdec: {' '.join(cmd)}")
|
|
|
|
try:
|
|
proc = subprocess.Popen(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
self.processes['acars'] = proc
|
|
|
|
thread = threading.Thread(
|
|
target=self._acars_output_reader,
|
|
args=(proc,),
|
|
daemon=True
|
|
)
|
|
thread.start()
|
|
self.output_threads['acars'] = thread
|
|
|
|
# Wait briefly to verify process started successfully
|
|
time.sleep(0.5)
|
|
if proc.poll() is not None:
|
|
# Process already exited - likely SDR busy or other error
|
|
stderr_output = proc.stderr.read().decode('utf-8', errors='replace')
|
|
del self.processes['acars']
|
|
return {'status': 'error', 'message': f'acarsdec failed to start: {stderr_output[:200]}'}
|
|
|
|
return {
|
|
'status': 'started',
|
|
'mode': 'acars',
|
|
'frequencies': frequencies,
|
|
'gps_enabled': gps_manager.is_running
|
|
}
|
|
|
|
except FileNotFoundError:
|
|
return {'status': 'error', 'message': 'acarsdec not found'}
|
|
except Exception as e:
|
|
return {'status': 'error', 'message': str(e)}
|
|
|
|
def _acars_output_reader(self, proc: subprocess.Popen):
|
|
"""Read acarsdec JSON output."""
|
|
mode = 'acars'
|
|
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:
|
|
msg = json.loads(line)
|
|
msg['type'] = 'acars'
|
|
msg['received_at'] = datetime.now(timezone.utc).isoformat()
|
|
|
|
gps_pos = gps_manager.position
|
|
if gps_pos:
|
|
msg['agent_gps'] = gps_pos
|
|
|
|
snapshots = self.data_snapshots.get(mode, [])
|
|
snapshots.append(msg)
|
|
if len(snapshots) > 100:
|
|
snapshots = snapshots[-100:]
|
|
self.data_snapshots[mode] = snapshots
|
|
|
|
logger.debug(f"ACARS: {msg.get('tail', 'Unknown')}")
|
|
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
except (OSError, ValueError) as e:
|
|
logger.debug(f"ACARS reader stopped: {e}")
|
|
except Exception as e:
|
|
logger.error(f"ACARS reader error: {e}")
|
|
finally:
|
|
try:
|
|
proc.wait(timeout=1)
|
|
except Exception:
|
|
pass
|
|
logger.info("ACARS reader stopped")
|
|
|
|
# -------------------------------------------------------------------------
|
|
# APRS MODE (rtl_fm | direwolf)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _start_aprs(self, params: dict) -> dict:
|
|
"""Start APRS decoding using rtl_fm | direwolf."""
|
|
freq = params.get('frequency', '144.390') # North America APRS
|
|
gain = params.get('gain', '40')
|
|
device = params.get('device', '0')
|
|
ppm = params.get('ppm', '0')
|
|
|
|
rtl_fm_path = self._get_tool_path('rtl_fm')
|
|
if not rtl_fm_path:
|
|
return {'status': 'error', 'message': 'rtl_fm not found'}
|
|
|
|
direwolf_path = self._get_tool_path('direwolf')
|
|
multimon_path = self._get_tool_path('multimon-ng')
|
|
decoder_path = direwolf_path or multimon_path
|
|
|
|
if not decoder_path:
|
|
return {'status': 'error', 'message': 'direwolf or multimon-ng not found'}
|
|
|
|
# Initialize state
|
|
if not hasattr(self, 'aprs_stations'):
|
|
self.aprs_stations = {}
|
|
self.aprs_stations.clear()
|
|
|
|
# Build rtl_fm command for APRS (22050 Hz for AFSK 1200 baud)
|
|
rtl_fm_cmd = [
|
|
rtl_fm_path,
|
|
'-f', f'{freq}M',
|
|
'-s', '22050',
|
|
'-g', str(gain),
|
|
'-d', str(device),
|
|
'-E', 'dc',
|
|
'-A', 'fast',
|
|
]
|
|
if ppm and str(ppm) != '0':
|
|
rtl_fm_cmd.extend(['-p', str(ppm)])
|
|
|
|
# Build decoder command
|
|
if direwolf_path:
|
|
dw_config = '/tmp/intercept_direwolf.conf'
|
|
try:
|
|
with open(dw_config, 'w') as f:
|
|
f.write("ADEVICE stdin null\nARATE 22050\nMODEM 1200\n")
|
|
except Exception as e:
|
|
return {'status': 'error', 'message': f'Failed to create direwolf config: {e}'}
|
|
decoder_cmd = [direwolf_path, '-c', dw_config, '-r', '22050', '-t', '0', '-']
|
|
else:
|
|
decoder_cmd = [multimon_path, '-t', 'raw', '-a', 'AFSK1200', '-']
|
|
|
|
logger.info(f"Starting APRS: {' '.join(rtl_fm_cmd)} | {' '.join(decoder_cmd)}")
|
|
|
|
try:
|
|
rtl_fm_proc = subprocess.Popen(
|
|
rtl_fm_cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
|
|
decoder_proc = subprocess.Popen(
|
|
decoder_cmd,
|
|
stdin=rtl_fm_proc.stdout,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
rtl_fm_proc.stdout.close()
|
|
|
|
self.processes['aprs'] = decoder_proc
|
|
self.processes['aprs_rtl'] = rtl_fm_proc
|
|
|
|
# Wait briefly to verify processes started successfully
|
|
time.sleep(0.5)
|
|
if rtl_fm_proc.poll() is not None:
|
|
stderr_output = rtl_fm_proc.stderr.read().decode('utf-8', errors='replace')
|
|
decoder_proc.terminate()
|
|
del self.processes['aprs']
|
|
del self.processes['aprs_rtl']
|
|
return {'status': 'error', 'message': f'rtl_fm failed to start: {stderr_output[:200]}'}
|
|
|
|
thread = threading.Thread(
|
|
target=self._aprs_output_reader,
|
|
args=(decoder_proc, direwolf_path is not None),
|
|
daemon=True
|
|
)
|
|
thread.start()
|
|
self.output_threads['aprs'] = thread
|
|
|
|
return {
|
|
'status': 'started',
|
|
'mode': 'aprs',
|
|
'frequency': freq,
|
|
'decoder': 'direwolf' if direwolf_path else 'multimon-ng',
|
|
'gps_enabled': gps_manager.is_running
|
|
}
|
|
|
|
except Exception as e:
|
|
return {'status': 'error', 'message': str(e)}
|
|
|
|
def _aprs_output_reader(self, proc: subprocess.Popen, is_direwolf: bool):
|
|
"""Read and parse APRS packets."""
|
|
mode = 'aprs'
|
|
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
|
|
|
|
parsed = self._parse_aprs_packet(line)
|
|
if parsed:
|
|
parsed['received_at'] = datetime.now(timezone.utc).isoformat()
|
|
|
|
gps_pos = gps_manager.position
|
|
if gps_pos:
|
|
parsed['agent_gps'] = gps_pos
|
|
|
|
callsign = parsed.get('callsign')
|
|
if callsign:
|
|
self.aprs_stations[callsign] = parsed
|
|
|
|
snapshots = self.data_snapshots.get(mode, [])
|
|
snapshots.append(parsed)
|
|
if len(snapshots) > 100:
|
|
snapshots = snapshots[-100:]
|
|
self.data_snapshots[mode] = snapshots
|
|
|
|
logger.debug(f"APRS: {callsign}")
|
|
|
|
except (OSError, ValueError) as e:
|
|
logger.debug(f"APRS reader stopped: {e}")
|
|
except Exception as e:
|
|
logger.error(f"APRS reader error: {e}")
|
|
finally:
|
|
try:
|
|
proc.wait(timeout=1)
|
|
except Exception:
|
|
pass
|
|
if 'aprs_rtl' in self.processes:
|
|
try:
|
|
rtl_proc = self.processes['aprs_rtl']
|
|
if rtl_proc.poll() is None:
|
|
rtl_proc.terminate()
|
|
del self.processes['aprs_rtl']
|
|
except Exception:
|
|
pass
|
|
logger.info("APRS reader stopped")
|
|
|
|
def _parse_aprs_packet(self, line: str) -> dict | None:
|
|
"""Parse APRS packet from direwolf or multimon-ng."""
|
|
match = re.match(r'([A-Z0-9-]+)>([^:]+):(.+)', line)
|
|
if not match:
|
|
return None
|
|
|
|
callsign = match.group(1)
|
|
path = match.group(2)
|
|
data = match.group(3)
|
|
|
|
packet = {
|
|
'type': 'aprs',
|
|
'callsign': callsign,
|
|
'path': path,
|
|
'raw': data,
|
|
}
|
|
|
|
# Try to extract position
|
|
pos_match = re.search(r'[!=/@](\d{4}\.\d{2})([NS])[/\\](\d{5}\.\d{2})([EW])', data)
|
|
if pos_match:
|
|
lat = float(pos_match.group(1)[:2]) + float(pos_match.group(1)[2:]) / 60
|
|
if pos_match.group(2) == 'S':
|
|
lat = -lat
|
|
lon = float(pos_match.group(3)[:3]) + float(pos_match.group(3)[3:]) / 60
|
|
if pos_match.group(4) == 'W':
|
|
lon = -lon
|
|
packet['lat'] = round(lat, 6)
|
|
packet['lon'] = round(lon, 6)
|
|
|
|
return packet
|
|
|
|
# -------------------------------------------------------------------------
|
|
# RTLAMR MODE (rtl_tcp + rtlamr)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _start_rtlamr(self, params: dict) -> dict:
|
|
"""Start utility meter reading using rtl_tcp + rtlamr."""
|
|
freq = params.get('frequency', '912.0')
|
|
device = params.get('device', '0')
|
|
gain = params.get('gain', '40')
|
|
msg_type = params.get('msgtype', 'scm')
|
|
filter_id = params.get('filterid')
|
|
|
|
rtl_tcp_path = self._get_tool_path('rtl_tcp')
|
|
rtlamr_path = self._get_tool_path('rtlamr')
|
|
|
|
if not rtl_tcp_path:
|
|
return {'status': 'error', 'message': 'rtl_tcp not found. Install rtl-sdr.'}
|
|
if not rtlamr_path:
|
|
return {'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'}
|
|
|
|
# Start rtl_tcp server
|
|
rtl_tcp_cmd = [rtl_tcp_path, '-a', '127.0.0.1', '-p', '1234', '-d', str(device)]
|
|
if gain:
|
|
rtl_tcp_cmd.extend(['-g', str(gain)])
|
|
|
|
logger.info(f"Starting rtl_tcp: {' '.join(rtl_tcp_cmd)}")
|
|
|
|
try:
|
|
rtl_tcp_proc = subprocess.Popen(
|
|
rtl_tcp_cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
self.processes['rtlamr_tcp'] = rtl_tcp_proc
|
|
|
|
time.sleep(2)
|
|
if rtl_tcp_proc.poll() is not None:
|
|
stderr = rtl_tcp_proc.stderr.read().decode('utf-8', errors='ignore')
|
|
return {'status': 'error', 'message': f'rtl_tcp failed: {stderr[:200]}'}
|
|
|
|
# Build rtlamr command
|
|
rtlamr_cmd = [
|
|
rtlamr_path,
|
|
'-server=127.0.0.1:1234',
|
|
f'-msgtype={msg_type}',
|
|
'-format=json',
|
|
f'-centerfreq={int(float(freq) * 1e6)}',
|
|
'-unique=true',
|
|
]
|
|
if filter_id:
|
|
rtlamr_cmd.append(f'-filterid={filter_id}')
|
|
|
|
logger.info(f"Starting rtlamr: {' '.join(rtlamr_cmd)}")
|
|
|
|
rtlamr_proc = subprocess.Popen(
|
|
rtlamr_cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
self.processes['rtlamr'] = rtlamr_proc
|
|
|
|
thread = threading.Thread(
|
|
target=self._rtlamr_output_reader,
|
|
args=(rtlamr_proc,),
|
|
daemon=True
|
|
)
|
|
thread.start()
|
|
self.output_threads['rtlamr'] = thread
|
|
|
|
return {
|
|
'status': 'started',
|
|
'mode': 'rtlamr',
|
|
'frequency': freq,
|
|
'msgtype': msg_type,
|
|
'gps_enabled': gps_manager.is_running
|
|
}
|
|
|
|
except Exception as e:
|
|
return {'status': 'error', 'message': str(e)}
|
|
|
|
def _rtlamr_output_reader(self, proc: subprocess.Popen):
|
|
"""Read rtlamr JSON output."""
|
|
mode = 'rtlamr'
|
|
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:
|
|
msg = json.loads(line)
|
|
msg['type'] = 'rtlamr'
|
|
msg['received_at'] = datetime.now(timezone.utc).isoformat()
|
|
|
|
gps_pos = gps_manager.position
|
|
if gps_pos:
|
|
msg['agent_gps'] = gps_pos
|
|
|
|
snapshots = self.data_snapshots.get(mode, [])
|
|
snapshots.append(msg)
|
|
if len(snapshots) > 100:
|
|
snapshots = snapshots[-100:]
|
|
self.data_snapshots[mode] = snapshots
|
|
|
|
logger.debug(f"RTLAMR: meter {msg.get('Message', {}).get('ID', 'Unknown')}")
|
|
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
except (OSError, ValueError) as e:
|
|
logger.debug(f"RTLAMR reader stopped: {e}")
|
|
except Exception as e:
|
|
logger.error(f"RTLAMR reader error: {e}")
|
|
finally:
|
|
try:
|
|
proc.wait(timeout=1)
|
|
except Exception:
|
|
pass
|
|
if 'rtlamr_tcp' in self.processes:
|
|
try:
|
|
tcp_proc = self.processes['rtlamr_tcp']
|
|
if tcp_proc.poll() is None:
|
|
tcp_proc.terminate()
|
|
del self.processes['rtlamr_tcp']
|
|
except Exception:
|
|
pass
|
|
logger.info("RTLAMR reader stopped")
|
|
|
|
# -------------------------------------------------------------------------
|
|
# DSC MODE (rtl_fm | dsc-decoder) - Digital Selective Calling
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _start_dsc(self, params: dict) -> dict:
|
|
"""Start DSC (VHF Channel 70) decoding using Intercept's DSCDecoder."""
|
|
device = params.get('device', '0')
|
|
gain = params.get('gain', '40')
|
|
ppm = params.get('ppm', '0')
|
|
freq = '156.525' # DSC Channel 70
|
|
|
|
rtl_fm_path = self._get_tool_path('rtl_fm')
|
|
if not rtl_fm_path:
|
|
return {'status': 'error', 'message': 'rtl_fm not found'}
|
|
|
|
# Initialize DSC messages list
|
|
if not hasattr(self, 'dsc_messages'):
|
|
self.dsc_messages = []
|
|
|
|
# Build rtl_fm command for DSC (48kHz sample rate)
|
|
rtl_fm_cmd = [
|
|
rtl_fm_path,
|
|
'-f', f'{freq}M',
|
|
'-s', '48000',
|
|
'-g', str(gain),
|
|
'-d', str(device),
|
|
]
|
|
if ppm and str(ppm) != '0':
|
|
rtl_fm_cmd.extend(['-p', str(ppm)])
|
|
|
|
logger.info(f"Starting DSC: {' '.join(rtl_fm_cmd)}")
|
|
|
|
try:
|
|
rtl_fm_proc = subprocess.Popen(
|
|
rtl_fm_cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
self.processes['dsc'] = rtl_fm_proc
|
|
|
|
# Wait briefly to verify process started successfully
|
|
time.sleep(0.5)
|
|
if rtl_fm_proc.poll() is not None:
|
|
stderr_output = rtl_fm_proc.stderr.read().decode('utf-8', errors='replace')
|
|
del self.processes['dsc']
|
|
return {'status': 'error', 'message': f'rtl_fm failed to start: {stderr_output[:200]}'}
|
|
|
|
# Start output reader thread using Intercept's DSCDecoder
|
|
thread = threading.Thread(
|
|
target=self._dsc_output_reader,
|
|
args=(rtl_fm_proc,),
|
|
daemon=True
|
|
)
|
|
thread.start()
|
|
self.output_threads['dsc'] = thread
|
|
|
|
return {
|
|
'status': 'started',
|
|
'mode': 'dsc',
|
|
'frequency': freq,
|
|
'channel': 70,
|
|
'gps_enabled': gps_manager.is_running
|
|
}
|
|
|
|
except Exception as e:
|
|
return {'status': 'error', 'message': str(e)}
|
|
|
|
def _dsc_output_reader(self, proc: subprocess.Popen):
|
|
"""Read rtl_fm audio and decode DSC using Intercept's DSCDecoder."""
|
|
mode = 'dsc'
|
|
stop_event = self.stop_events.get(mode)
|
|
|
|
try:
|
|
# Use Intercept's DSC decoder
|
|
from utils.dsc.decoder import DSCDecoder
|
|
decoder = DSCDecoder(sample_rate=48000)
|
|
logger.info("Using Intercept's DSCDecoder")
|
|
|
|
chunk_size = 9600 # 0.1 seconds at 48kHz, 16-bit
|
|
|
|
while not (stop_event and stop_event.is_set()):
|
|
audio_data = proc.stdout.read(chunk_size)
|
|
if not audio_data:
|
|
break
|
|
|
|
for message in decoder.process_audio(audio_data):
|
|
message['received_at'] = datetime.now(timezone.utc).isoformat()
|
|
|
|
gps_pos = gps_manager.position
|
|
if gps_pos:
|
|
message['agent_gps'] = gps_pos
|
|
|
|
# Store message
|
|
self.dsc_messages.append(message)
|
|
if len(self.dsc_messages) > 100:
|
|
self.dsc_messages = self.dsc_messages[-100:]
|
|
|
|
self.data_snapshots[mode] = self.dsc_messages.copy()
|
|
logger.info(f"DSC message: {message.get('category')} from {message.get('source_mmsi')}")
|
|
|
|
except ImportError:
|
|
logger.warning("DSCDecoder not available (missing scipy/numpy)")
|
|
except (OSError, ValueError) as e:
|
|
logger.debug(f"DSC reader stopped: {e}")
|
|
except Exception as e:
|
|
logger.error(f"DSC reader error: {e}")
|
|
finally:
|
|
try:
|
|
proc.wait(timeout=1)
|
|
except Exception:
|
|
pass
|
|
logger.info("DSC reader stopped")
|
|
|
|
# -------------------------------------------------------------------------
|
|
# TSCM MODE (Technical Surveillance Countermeasures)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _start_tscm(self, params: dict) -> dict:
|
|
"""Start TSCM scanning - uses existing Intercept scanning functions."""
|
|
# Initialize state
|
|
if not hasattr(self, 'tscm_baseline'):
|
|
self.tscm_baseline = {}
|
|
if not hasattr(self, 'tscm_anomalies'):
|
|
self.tscm_anomalies = []
|
|
if not hasattr(self, 'tscm_rf_signals'):
|
|
self.tscm_rf_signals = []
|
|
self.tscm_anomalies.clear()
|
|
|
|
# Get params for what to scan
|
|
scan_wifi = params.get('wifi', True)
|
|
scan_bt = params.get('bluetooth', True)
|
|
scan_rf = params.get('rf', True)
|
|
wifi_interface = params.get('wifi_interface') or params.get('interface')
|
|
bt_adapter = params.get('bt_interface') or params.get('adapter', 'hci0')
|
|
sdr_device = params.get('sdr_device', params.get('device', 0))
|
|
|
|
# Get baseline_id for comparison (same as local mode)
|
|
baseline_id = params.get('baseline_id')
|
|
|
|
started_scans = []
|
|
|
|
# Start the combined TSCM scanner thread using existing Intercept functions
|
|
thread = threading.Thread(
|
|
target=self._tscm_scanner_thread,
|
|
args=(scan_wifi, scan_bt, scan_rf, wifi_interface, bt_adapter, sdr_device, baseline_id),
|
|
daemon=True
|
|
)
|
|
thread.start()
|
|
self.output_threads['tscm'] = thread
|
|
|
|
if scan_wifi:
|
|
started_scans.append('wifi')
|
|
if scan_bt:
|
|
started_scans.append('bluetooth')
|
|
if scan_rf:
|
|
started_scans.append('rf')
|
|
|
|
return {
|
|
'status': 'started',
|
|
'mode': 'tscm',
|
|
'note': f'TSCM scanning {", ".join(started_scans) if started_scans else "using existing data"}',
|
|
'gps_enabled': gps_manager.is_running,
|
|
'scanning': started_scans
|
|
}
|
|
|
|
def _tscm_scanner_thread(self, scan_wifi: bool, scan_bt: bool, scan_rf: bool,
|
|
wifi_interface: str | None, bt_adapter: str, sdr_device: int,
|
|
baseline_id: int | None = None):
|
|
"""Combined TSCM scanner using existing Intercept functions.
|
|
|
|
NOTE: This matches local mode behavior exactly:
|
|
- If baseline_id provided, loads baseline and detects 'new_device' threats
|
|
- If no baseline, only 'anomaly' and 'hidden_camera' threats are detected
|
|
- Each new device seen during sweep is analyzed once
|
|
"""
|
|
logger.info("TSCM thread starting...")
|
|
mode = 'tscm'
|
|
stop_event = self.stop_events.get(mode)
|
|
|
|
# Import existing Intercept TSCM functions
|
|
from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals
|
|
logger.info("TSCM imports successful")
|
|
|
|
# Load baseline if specified (same as local mode)
|
|
baseline = None
|
|
if baseline_id and HAS_BASELINE_DB and get_tscm_baseline:
|
|
baseline = get_tscm_baseline(baseline_id)
|
|
if baseline:
|
|
logger.info(f"TSCM loaded baseline '{baseline.get('name')}' (ID: {baseline_id})")
|
|
else:
|
|
logger.warning(f"TSCM baseline ID {baseline_id} not found")
|
|
|
|
# Initialize detector and correlation engine (same as local mode)
|
|
if HAS_TSCM_MODULES and ThreatDetector:
|
|
self._tscm_detector = ThreatDetector(baseline=baseline)
|
|
self._tscm_correlation = CorrelationEngine() if CorrelationEngine else None
|
|
if baseline:
|
|
logger.info("TSCM detector initialized with baseline - will detect 'new_device' threats")
|
|
else:
|
|
logger.info("TSCM detector initialized without baseline - only 'anomaly'/'hidden_camera' threats")
|
|
else:
|
|
self._tscm_detector = None
|
|
self._tscm_correlation = None
|
|
|
|
# Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts)
|
|
seen_wifi = {}
|
|
seen_bt = {}
|
|
|
|
last_rf_scan = 0
|
|
rf_scan_interval = 30
|
|
|
|
while not (stop_event and stop_event.is_set()):
|
|
try:
|
|
current_time = time.time()
|
|
|
|
# WiFi scan using Intercept's function (same as local mode)
|
|
if scan_wifi:
|
|
try:
|
|
wifi_networks = _scan_wifi_networks(wifi_interface or '')
|
|
for net in wifi_networks:
|
|
bssid = net.get('bssid', '').upper()
|
|
if bssid and bssid not in seen_wifi:
|
|
# First time seeing this device during sweep
|
|
seen_wifi[bssid] = net
|
|
|
|
# Enrich with classification/scoring
|
|
enriched = dict(net)
|
|
# Ensure power/signal is numeric (scanner may return string)
|
|
if 'power' in enriched:
|
|
try:
|
|
enriched['power'] = int(enriched['power'])
|
|
except (ValueError, TypeError):
|
|
enriched['power'] = -100
|
|
if 'signal' in enriched and enriched['signal'] is not None:
|
|
try:
|
|
enriched['signal'] = int(enriched['signal'])
|
|
except (ValueError, TypeError):
|
|
enriched['signal'] = -100
|
|
|
|
# Analyze for threats (same as local mode)
|
|
if self._tscm_detector:
|
|
threat = self._tscm_detector.analyze_wifi_device(enriched)
|
|
if threat:
|
|
self.tscm_anomalies.append(threat)
|
|
if len(self.tscm_anomalies) > 100:
|
|
self.tscm_anomalies = self.tscm_anomalies[-100:]
|
|
print(f"[TSCM] WiFi threat: {threat.get('threat_type')} - {threat.get('name')}", flush=True)
|
|
|
|
classification = self._tscm_detector.classify_wifi_device(enriched)
|
|
enriched['is_new'] = not classification.get('in_baseline', False)
|
|
enriched['reasons'] = classification.get('reasons', [])
|
|
|
|
if self._tscm_correlation:
|
|
profile = self._tscm_correlation.analyze_wifi_device(enriched)
|
|
enriched['classification'] = profile.risk_level.value
|
|
enriched['score'] = profile.total_score
|
|
enriched['indicators'] = [
|
|
{'type': i.type.value, 'desc': i.description}
|
|
for i in profile.indicators
|
|
]
|
|
enriched['recommended_action'] = profile.recommended_action
|
|
|
|
self.wifi_networks[bssid] = enriched
|
|
except Exception as e:
|
|
logger.debug(f"WiFi scan error: {e}")
|
|
|
|
# Bluetooth scan using Intercept's function (same as local mode)
|
|
if scan_bt:
|
|
try:
|
|
bt_devices = _scan_bluetooth_devices(bt_adapter, duration=5)
|
|
for dev in bt_devices:
|
|
mac = dev.get('mac', '').upper()
|
|
if mac and mac not in seen_bt:
|
|
# First time seeing this device during sweep
|
|
seen_bt[mac] = dev
|
|
|
|
# Enrich with classification/scoring
|
|
enriched = dict(dev)
|
|
# Ensure rssi/signal is numeric (scanner may return string)
|
|
if 'rssi' in enriched and enriched['rssi'] is not None:
|
|
try:
|
|
enriched['rssi'] = int(enriched['rssi'])
|
|
except (ValueError, TypeError):
|
|
enriched['rssi'] = -100
|
|
|
|
# Analyze for threats (same as local mode)
|
|
if self._tscm_detector:
|
|
threat = self._tscm_detector.analyze_bt_device(enriched)
|
|
if threat:
|
|
self.tscm_anomalies.append(threat)
|
|
if len(self.tscm_anomalies) > 100:
|
|
self.tscm_anomalies = self.tscm_anomalies[-100:]
|
|
logger.info(f"TSCM BT threat: {threat.get('threat_type')} - {threat.get('name')}")
|
|
|
|
classification = self._tscm_detector.classify_bt_device(enriched)
|
|
enriched['is_new'] = not classification.get('in_baseline', False)
|
|
enriched['reasons'] = classification.get('reasons', [])
|
|
|
|
if self._tscm_correlation:
|
|
profile = self._tscm_correlation.analyze_bluetooth_device(enriched)
|
|
enriched['classification'] = profile.risk_level.value
|
|
enriched['score'] = profile.total_score
|
|
enriched['indicators'] = [
|
|
{'type': i.type.value, 'desc': i.description}
|
|
for i in profile.indicators
|
|
]
|
|
enriched['recommended_action'] = profile.recommended_action
|
|
|
|
self.bluetooth_devices[mac] = enriched
|
|
except Exception as e:
|
|
logger.debug(f"Bluetooth scan error: {e}")
|
|
|
|
# RF scan using Intercept's function (less frequently)
|
|
if scan_rf and (current_time - last_rf_scan) >= rf_scan_interval:
|
|
try:
|
|
# Pass a stop check that uses our stop_event (not the module's _sweep_running)
|
|
agent_stop_check = lambda: stop_event and stop_event.is_set()
|
|
rf_signals = _scan_rf_signals(sdr_device, stop_check=agent_stop_check)
|
|
|
|
# Analyze each RF signal like local mode does
|
|
analyzed_signals = []
|
|
rf_threats = []
|
|
for signal in rf_signals:
|
|
analyzed = dict(signal)
|
|
is_threat = False
|
|
|
|
# Use detector to analyze for threats (same as local mode)
|
|
if hasattr(self, '_tscm_detector') and self._tscm_detector:
|
|
threat = self._tscm_detector.analyze_rf_signal(signal)
|
|
if threat:
|
|
rf_threats.append(threat)
|
|
is_threat = True
|
|
classification = self._tscm_detector.classify_rf_signal(signal)
|
|
analyzed['is_new'] = not classification.get('in_baseline', False)
|
|
analyzed['reasons'] = classification.get('reasons', [])
|
|
|
|
# Use correlation engine for scoring (same as local mode)
|
|
if hasattr(self, '_tscm_correlation') and self._tscm_correlation:
|
|
profile = self._tscm_correlation.analyze_rf_signal(signal)
|
|
analyzed['classification'] = profile.risk_level.value
|
|
analyzed['score'] = profile.total_score
|
|
analyzed['indicators'] = [
|
|
{'type': i.type.value, 'desc': i.description}
|
|
for i in profile.indicators
|
|
]
|
|
|
|
analyzed['is_threat'] = is_threat
|
|
analyzed_signals.append(analyzed)
|
|
|
|
# Add RF threats to anomalies list
|
|
if rf_threats:
|
|
self.tscm_anomalies.extend(rf_threats)
|
|
if len(self.tscm_anomalies) > 100:
|
|
self.tscm_anomalies = self.tscm_anomalies[-100:]
|
|
for threat in rf_threats:
|
|
logger.info(f"TSCM RF threat: {threat.get('threat_type')} - {threat.get('identifier')}")
|
|
|
|
self.tscm_rf_signals = analyzed_signals
|
|
logger.info(f"RF scan found {len(analyzed_signals)} signals")
|
|
last_rf_scan = current_time
|
|
except Exception as e:
|
|
logger.debug(f"RF scan error: {e}")
|
|
|
|
# Sleep between scan cycles (same interval as local mode)
|
|
time.sleep(5)
|
|
|
|
except Exception as e:
|
|
logger.error(f"TSCM scanner error: {e}")
|
|
time.sleep(5)
|
|
|
|
logger.info("TSCM scanner stopped")
|
|
|
|
# -------------------------------------------------------------------------
|
|
# SATELLITE MODE (TLE-based pass prediction)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _start_satellite(self, params: dict) -> dict:
|
|
"""Start satellite pass prediction - no SDR needed."""
|
|
lat = params.get('lat', params.get('latitude'))
|
|
lon = params.get('lon', params.get('longitude'))
|
|
min_elevation = params.get('min_elevation', 10)
|
|
|
|
if lat is None or lon is None:
|
|
gps_pos = gps_manager.position
|
|
if gps_pos:
|
|
lat = gps_pos.get('lat')
|
|
lon = gps_pos.get('lon')
|
|
|
|
if lat is None or lon is None:
|
|
return {'status': 'error', 'message': 'Observer location required (lat/lon)'}
|
|
|
|
thread = threading.Thread(
|
|
target=self._satellite_predictor,
|
|
args=(float(lat), float(lon), int(min_elevation)),
|
|
daemon=True
|
|
)
|
|
thread.start()
|
|
self.output_threads['satellite'] = thread
|
|
|
|
return {
|
|
'status': 'started',
|
|
'mode': 'satellite',
|
|
'observer': {'lat': lat, 'lon': lon},
|
|
'min_elevation': min_elevation,
|
|
'note': 'Satellite pass prediction - no SDR required'
|
|
}
|
|
|
|
def _satellite_predictor(self, lat: float, lon: float, min_elevation: int):
|
|
"""Calculate satellite passes using TLE data."""
|
|
mode = 'satellite'
|
|
stop_event = self.stop_events.get(mode)
|
|
|
|
try:
|
|
from skyfield.api import Topos, load
|
|
|
|
stations_url = 'https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=tle'
|
|
satellites = load.tle_file(stations_url)
|
|
|
|
ts = load.timescale()
|
|
observer = Topos(latitude_degrees=lat, longitude_degrees=lon)
|
|
|
|
logger.info(f"Satellite predictor: {len(satellites)} satellites loaded")
|
|
|
|
while not (stop_event and stop_event.is_set()):
|
|
passes = []
|
|
now = ts.now()
|
|
end = ts.utc(now.utc_datetime().year, now.utc_datetime().month,
|
|
now.utc_datetime().day + 1)
|
|
|
|
for sat in satellites[:20]:
|
|
try:
|
|
t, events = sat.find_events(observer, now, end, altitude_degrees=min_elevation)
|
|
|
|
for ti, event in zip(t, events):
|
|
if event == 0: # Rise
|
|
difference = sat - observer
|
|
topocentric = difference.at(ti)
|
|
alt, az, _ = topocentric.altaz()
|
|
passes.append({
|
|
'satellite': sat.name,
|
|
'rise_time': ti.utc_iso(),
|
|
'rise_azimuth': round(az.degrees, 1),
|
|
'max_elevation': min_elevation,
|
|
})
|
|
except Exception:
|
|
continue
|
|
|
|
self.data_snapshots[mode] = passes[:50]
|
|
time.sleep(300)
|
|
|
|
except ImportError:
|
|
logger.warning("skyfield not installed - satellite prediction unavailable")
|
|
self.data_snapshots[mode] = [{'error': 'skyfield not installed'}]
|
|
except Exception as e:
|
|
logger.error(f"Satellite predictor error: {e}")
|
|
|
|
logger.info("Satellite predictor stopped")
|
|
|
|
# -------------------------------------------------------------------------
|
|
# LISTENING POST MODE (Spectrum scanner - signal detection only)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _start_listening_post(self, params: dict) -> dict:
|
|
"""
|
|
Start listening post / spectrum scanner.
|
|
|
|
Note: Full FFT streaming isn't practical over HTTP agents.
|
|
Instead provides signal detection events and activity log.
|
|
"""
|
|
start_freq = params.get('start_freq', 88.0)
|
|
end_freq = params.get('end_freq', 108.0)
|
|
# Step is sent in kHz from frontend, convert to MHz
|
|
step_khz = params.get('step', 100)
|
|
step = step_khz / 1000.0 # Convert kHz to MHz
|
|
modulation = params.get('modulation', 'wfm')
|
|
squelch = params.get('squelch', 20)
|
|
device = params.get('device', '0')
|
|
gain = params.get('gain', '40')
|
|
dwell_time = params.get('dwell_time', 1.0)
|
|
|
|
rtl_fm_path = self._get_tool_path('rtl_fm')
|
|
if not rtl_fm_path:
|
|
return {'status': 'error', 'message': 'rtl_fm not found'}
|
|
|
|
# Quick SDR availability check - try to run rtl_fm briefly
|
|
test_proc = None
|
|
try:
|
|
test_proc = subprocess.Popen(
|
|
[rtl_fm_path, '-f', f'{start_freq}M', '-d', str(device), '-g', str(gain)],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
time.sleep(0.5)
|
|
if test_proc.poll() is not None:
|
|
stderr = test_proc.stderr.read().decode('utf-8', errors='ignore')
|
|
return {'status': 'error', 'message': f'SDR not available: {stderr[:200]}'}
|
|
# SDR is available - terminate test process
|
|
test_proc.terminate()
|
|
try:
|
|
test_proc.wait(timeout=2)
|
|
except subprocess.TimeoutExpired:
|
|
test_proc.kill()
|
|
test_proc.wait(timeout=1)
|
|
except Exception as e:
|
|
# Ensure test process is killed on any error
|
|
if test_proc and test_proc.poll() is None:
|
|
test_proc.kill()
|
|
try:
|
|
test_proc.wait(timeout=1)
|
|
except Exception:
|
|
pass
|
|
return {'status': 'error', 'message': f'SDR check failed: {str(e)}'}
|
|
|
|
# Initialize state
|
|
if not hasattr(self, 'listening_post_activity'):
|
|
self.listening_post_activity = []
|
|
self.listening_post_activity.clear()
|
|
self.listening_post_current_freq = float(start_freq)
|
|
|
|
thread = threading.Thread(
|
|
target=self._listening_post_scanner,
|
|
args=(float(start_freq), float(end_freq), float(step),
|
|
modulation, int(squelch), str(device), str(gain), float(dwell_time)),
|
|
daemon=True
|
|
)
|
|
thread.start()
|
|
self.output_threads['listening_post'] = thread
|
|
|
|
return {
|
|
'status': 'started',
|
|
'mode': 'listening_post',
|
|
'start_freq': start_freq,
|
|
'end_freq': end_freq,
|
|
'step': step,
|
|
'modulation': modulation,
|
|
'dwell_time': dwell_time,
|
|
'note': 'Provides signal detection events, not full FFT data',
|
|
'gps_enabled': gps_manager.is_running
|
|
}
|
|
|
|
def _listening_post_scanner(self, start_freq: float, end_freq: float,
|
|
step: float, modulation: str, squelch: int,
|
|
device: str, gain: str, dwell_time: float = 1.0):
|
|
"""Scan frequency range and report signal detections."""
|
|
import select
|
|
import os
|
|
import fcntl
|
|
|
|
mode = 'listening_post'
|
|
stop_event = self.stop_events.get(mode)
|
|
|
|
rtl_fm_path = self._get_tool_path('rtl_fm')
|
|
current_freq = start_freq
|
|
scan_direction = 1
|
|
self.listening_post_freqs_scanned = 0
|
|
|
|
logger.info(f"Listening post scanner starting: {start_freq}-{end_freq} MHz, step {step}, dwell {dwell_time}s")
|
|
|
|
while not (stop_event and stop_event.is_set()):
|
|
self.listening_post_current_freq = current_freq
|
|
|
|
cmd = [
|
|
rtl_fm_path,
|
|
'-f', f'{current_freq}M',
|
|
'-M', modulation,
|
|
'-s', '22050',
|
|
'-g', gain,
|
|
'-d', device,
|
|
'-l', str(squelch),
|
|
]
|
|
|
|
try:
|
|
proc = subprocess.Popen(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
|
|
# Set stdout to non-blocking
|
|
fd = proc.stdout.fileno()
|
|
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
|
|
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
|
|
signal_detected = False
|
|
start_time = time.time()
|
|
|
|
while time.time() - start_time < dwell_time:
|
|
if stop_event and stop_event.is_set():
|
|
break
|
|
|
|
# Use select for non-blocking read with timeout
|
|
ready, _, _ = select.select([proc.stdout], [], [], 0.1)
|
|
if ready:
|
|
try:
|
|
data = proc.stdout.read(2205)
|
|
if data and len(data) > 10:
|
|
# Simple signal detection via audio level
|
|
try:
|
|
samples = [int.from_bytes(data[i:i+2], 'little', signed=True)
|
|
for i in range(0, min(len(data)-1, 1000), 2)]
|
|
if samples:
|
|
rms = (sum(s*s for s in samples) / len(samples)) ** 0.5
|
|
if rms > 500:
|
|
signal_detected = True
|
|
except Exception:
|
|
pass
|
|
except (IOError, BlockingIOError):
|
|
pass
|
|
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=2)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
proc.wait(timeout=1)
|
|
|
|
self.listening_post_freqs_scanned += 1
|
|
|
|
if signal_detected:
|
|
event = {
|
|
'type': 'signal_found',
|
|
'frequency': current_freq,
|
|
'modulation': modulation,
|
|
'detected_at': datetime.now(timezone.utc).isoformat()
|
|
}
|
|
|
|
gps_pos = gps_manager.position
|
|
if gps_pos:
|
|
event['agent_gps'] = gps_pos
|
|
|
|
self.listening_post_activity.append(event)
|
|
if len(self.listening_post_activity) > 500:
|
|
self.listening_post_activity = self.listening_post_activity[-500:]
|
|
|
|
self.data_snapshots[mode] = self.listening_post_activity.copy()
|
|
logger.info(f"Listening post: signal at {current_freq} MHz")
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Scanner error at {current_freq}: {e}")
|
|
|
|
# Move to next frequency
|
|
current_freq += step * scan_direction
|
|
if current_freq >= end_freq:
|
|
current_freq = end_freq
|
|
scan_direction = -1
|
|
elif current_freq <= start_freq:
|
|
current_freq = start_freq
|
|
scan_direction = 1
|
|
|
|
time.sleep(0.1)
|
|
|
|
logger.info("Listening post scanner stopped")
|
|
|
|
|
|
# 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 == '/wifi/monitor':
|
|
# Enable/disable monitor mode on WiFi interface
|
|
result = mode_manager.toggle_monitor_mode(body)
|
|
status = 200 if result.get('status') == 'success' else 400
|
|
self._send_json(result, status)
|
|
|
|
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)
|
|
# Accept both 'started' and 'success' as valid (quick scans return 'success')
|
|
status = 200 if result.get('status') in ('started', 'success') 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()
|
|
|
|
# Shutdown flag
|
|
shutdown_requested = threading.Event()
|
|
|
|
# Handle shutdown - run cleanup in separate thread to avoid blocking
|
|
def signal_handler(sig, frame):
|
|
if shutdown_requested.is_set():
|
|
# Already shutting down, force exit
|
|
print("\nForce exit...")
|
|
os._exit(1)
|
|
shutdown_requested.set()
|
|
print("\nShutting down...")
|
|
|
|
def cleanup():
|
|
# Stop all running modes first (they have subprocesses)
|
|
for mode in list(mode_manager.running_modes.keys()):
|
|
try:
|
|
mode_manager.stop_mode(mode)
|
|
except Exception as e:
|
|
logger.debug(f"Error stopping {mode}: {e}")
|
|
|
|
# Stop push services
|
|
if data_push_loop:
|
|
try:
|
|
data_push_loop.stop()
|
|
except Exception:
|
|
pass
|
|
if push_client:
|
|
try:
|
|
push_client.stop()
|
|
except Exception:
|
|
pass
|
|
|
|
# Stop GPS
|
|
try:
|
|
gps_manager.stop()
|
|
except Exception:
|
|
pass
|
|
|
|
# Shutdown HTTP server
|
|
try:
|
|
httpd.shutdown()
|
|
except Exception:
|
|
pass
|
|
|
|
# Run cleanup in background thread so signal handler returns quickly
|
|
cleanup_thread = threading.Thread(target=cleanup, daemon=True)
|
|
cleanup_thread.start()
|
|
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
|
|
try:
|
|
httpd.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
# Give cleanup thread time to finish
|
|
if shutdown_requested.is_set():
|
|
time.sleep(0.5)
|
|
|
|
print("Agent stopped.")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|