Compare commits

..

24 Commits

Author SHA1 Message Date
Smittix f795180c7d Release v2.12.1
Bug fixes and improvements.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:16:12 +00:00
Smittix d1f1ce1f4b Add SDR device status panel and ADS-B Bias-T toggle
- Add /devices/status endpoint showing which SDR is in use and by what mode
- Add real-time status panel on main dashboard with 5s auto-refresh
- Add Bias-T toggle to ADS-B dashboard with localStorage persistence
- Auto-detect correct dump1090 bias-t flag (--enable-biast vs unsupported)
- Standardize SDR device labels across all pages

Closes #102

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:36:27 +00:00
Smittix 334073089f Fix SDR device type not synced on page refresh
Initialize currentDeviceList from server-provided deviceList on page load
and auto-select the correct hardware type dropdown value. Previously the
device list was empty until "Refresh Devices" was clicked, causing the
hardware type dropdown to show incorrect values.

Fixes #99

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:26:23 +00:00
Smittix df634dc741 Fix Meshtastic connection type not restored on page refresh
Pass connection_type to updateConnectionUI() in checkStatus() so TCP
connections display correctly after browser refresh instead of defaulting
to Serial.

Fixes #98

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:23:56 +00:00
Smittix a76dfde02d Add SDR device registry to prevent decoder conflicts
Implements centralized tracking of SDR device allocation to prevent
multiple decoders from trying to use the same device simultaneously.

- Add sdr_device_registry with claim/release/status functions in app.py
- Update all SDR-based routes to claim devices on start and release on stop
- Return HTTP 409 with DEVICE_BUSY error when device is already in use
- Clear registry on /killall
- Skip device claims for remote connections (rtl_tcp, remote SBS)

Fixes #100
Fixes #101

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:05:21 +00:00
Smittix 36f8349bc7 Merge pull request #96 from alphafox02/fix-agent-bugs
Fix agent mode issues and WiFi deep scan polling
2026-02-01 12:57:02 +00:00
cemaxecuter 130a3a2d8e Don't stop agent scans when switching agents
When switching between agents in the UI, only stop the UI polling -
don't send a stop command to the agent. Agent scans should continue
running independently. When switching back, checkScanStatus() will
detect the running scan and resume polling.
2026-01-31 08:59:30 -05:00
cemaxecuter bd6fa27970 Detect existing monitor mode when loading agent interfaces
When refreshing agent WiFi interfaces, check if any interface has
type='monitor' and automatically set the monitor status to Active.
Previously the UI only showed Active when monitor was explicitly
enabled via the button.
2026-01-31 08:55:05 -05:00
cemaxecuter 630bc2971a Fix WiFi deep scan polling on agent - normalize scan_type value
Agent returns scan_type 'deepscan' but UI expected 'deep', causing the
polling to immediately stop when checking scan status on agent switch.
Now normalizes 'deepscan' to 'deep' in checkScanStatus.
2026-01-31 08:51:17 -05:00
cemaxecuter 7182f7803a Auto-refresh agent capabilities after monitor mode toggle
When monitor mode is toggled on a remote agent, the controller now
automatically refreshes the agent's capabilities and updates the
database. This keeps the UI interface list in sync without requiring
a manual refresh.
2026-01-31 08:48:32 -05:00
cemaxecuter a64a7c414c Invalidate capabilities cache after monitor mode toggle
After enabling/disabling monitor mode, clear the cached capabilities
so the next refresh shows the updated interface list (e.g., wlo1mon
instead of wlo1).
2026-01-31 08:17:07 -05:00
cemaxecuter f0cc396a6b Fix agent mode issues and WiFi deep scan polling
Agent fixes:
- Fix Ctrl+C hang by running cleanup in background thread
- Add force-exit on double Ctrl+C
- Improve exception handling in output reader threads to prevent
  bad file descriptor errors on shutdown
- Reduce cleanup timeouts for faster shutdown

Controller/UI fixes:
- Add URL validation for agent registration (check port, protocol)
- Show helpful message when agent is unreachable during registration
- Clarify API key field label (reserved for future use)
- Add client-side URL validation with user-friendly error messages

WiFi agent mode fixes:
- Add polling fallback for deep scan when push mode is disabled
- Polls /controller/agents/{id}/wifi/data every 2 seconds
- Detect running scans when switching to an agent
- Fix scan_mode detection (agent uses params.scan_type)
2026-01-31 08:10:32 -05:00
Smittix 5f588a5513 fix: Auto-detect RTL-SDR drivers and blacklist instead of prompting
- Skip RTL-SDR Blog driver prompt if rtl_test already exists
- Skip DVB blacklist prompt if blacklist file already exists
- Only prompt user when configuration is actually needed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:08:08 +00:00
Smittix 599df7734b fix: Use Makefile instead of CMake for slowrx build
slowrx uses a simple Makefile, not CMake. Remove unnecessary cmake
dependency and fix the build process.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:02:07 +00:00
Smittix 49fa02142d feat: Add TCP connection support for Meshtastic
Allow connecting to WiFi-enabled Meshtastic devices via TCP/IP in
addition to USB/Serial connections. This enables remote monitoring
of mesh nodes that have WiFi capability (T-Beam, Heltec WiFi LoRa, etc).

- Add connection_type parameter ('serial' or 'tcp') to /meshtastic/start
- Add hostname parameter for TCP connections
- Update UI with connection type dropdown and hostname input field
- Show connection type in status responses

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:01:46 +00:00
Smittix 333dc00ee2 fix: Show build errors and add pkg-config for slowrx source builds
- Add pkg-config dependency for cmake to locate libraries
- Display cmake/make error output (last 20 lines) on failure
- Helps users troubleshoot slowrx build failures on Debian/Ubuntu/macOS

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:55:28 +00:00
Smittix 2bc71e44ad Merge branch 'upstream-shared-observer-location' 2026-01-30 22:52:17 +00:00
Smittix 92265da5fb fix: Add slowrx source build fallback for Debian/Ubuntu
If slowrx is not available via apt, build from source with required
dependencies (libfftw3-dev, libsndfile1-dev, libgtk-3-dev, libasound2-dev,
libpulse-dev).

Matches the existing fallback pattern used for macOS.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:43:39 +00:00
Smittix 9c1516c086 feat: Add real-time Doppler tracking for ISS SSTV reception
- Add DopplerTracker class using skyfield for satellite tracking
- Calculate and apply Doppler shift correction (up to ±3.5 kHz at 145.800 MHz)
- Background thread monitors shift and retunes rtl_fm when >500 Hz drift
- New /sstv/doppler endpoint for real-time Doppler info
- Start endpoint accepts latitude/longitude for automatic tracking

Also:
- Add slowrx installation to setup.sh (source build for macOS, apt for Debian)
- Sync observer location to dashboard-specific localStorage keys

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:40:27 +00:00
Smittix cd7940bdc2 fix: Add TPMS pressure field mappings for 433MHz sensor display
The sensor field mapping only handled pressure_hPa (weather station
barometric pressure), causing TPMS tire pressure data to not display.

Added mappings for TPMS-specific rtl_433 field names:
- pressure_PSI (common in US TPMS sensors)
- pressure_kPa
- tire_pressure_kPa
- flags/state (tire state indicators)

Fixes #95

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:39:01 +00:00
James Ward 4a5f3e1802 docs: document shared location and auto-start env vars 2026-01-30 10:55:01 -08:00
James Ward 1b5bf4c061 fix: make ADS-B auto-start opt-in 2026-01-30 10:51:35 -08:00
James Ward 384d02649a feat: add shared observer location with opt-out 2026-01-30 10:49:53 -08:00
Smittix d51da40a67 Refactor settings modal HTML structure 2026-01-30 17:12:28 +00:00
46 changed files with 3782 additions and 387 deletions
+53 -12
View File
@@ -63,18 +63,59 @@ cd intercept
docker compose up -d
```
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
### ADS-B History (Optional)
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
```bash
# Start with ADS-B history and Postgres
docker compose --profile history up -d
```
Then open **/adsb/history** for the reporting dashboard.
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
### ADS-B History (Optional)
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
```bash
# Start with ADS-B history and Postgres
docker compose --profile history up -d
```
Set the following environment variables (for example in a `.env` file):
```bash
INTERCEPT_ADSB_HISTORY_ENABLED=true
INTERCEPT_ADSB_DB_HOST=adsb_db
INTERCEPT_ADSB_DB_PORT=5432
INTERCEPT_ADSB_DB_NAME=intercept_adsb
INTERCEPT_ADSB_DB_USER=intercept
INTERCEPT_ADSB_DB_PASSWORD=intercept
```
### Other ADS-B Settings
Set these as environment variables for either local installs or Docker:
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
**Local install example**
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
python app.py
```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
```
Then open **/adsb/history** for the reporting dashboard.
### Open the Interface
+85 -2
View File
@@ -27,7 +27,7 @@ from typing import Any
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
from werkzeug.security import check_password_hash
from config import VERSION, CHANGELOG
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
from utils.process import cleanup_stale_processes
from utils.sdr import SDRFactory
@@ -38,6 +38,7 @@ from utils.constants import (
MAX_BT_DEVICE_AGE_SECONDS,
MAX_VESSEL_AGE_SECONDS,
MAX_DSC_MESSAGE_AGE_SECONDS,
MAX_DEAUTH_ALERTS_AGE_SECONDS,
QUEUE_MAX_SIZE,
)
import logging
@@ -175,6 +176,11 @@ dsc_lock = threading.Lock()
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock()
# Deauth Attack Detection
deauth_detector = None
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
deauth_detector_lock = threading.Lock()
# ============================================
# GLOBAL STATE DICTIONARIES
# ============================================
@@ -204,6 +210,9 @@ ais_vessels = DataStore(max_age_seconds=MAX_VESSEL_AGE_SECONDS, name='ais_vessel
# DSC (Digital Selective Calling) state - using DataStore for automatic cleanup
dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_messages')
# Deauth alerts - using DataStore for automatic cleanup
deauth_alerts = DataStore(max_age_seconds=MAX_DEAUTH_ALERTS_AGE_SECONDS, name='deauth_alerts')
# Satellite state
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
@@ -215,6 +224,53 @@ cleanup_manager.register(bt_beacons)
cleanup_manager.register(adsb_aircraft)
cleanup_manager.register(ais_vessels)
cleanup_manager.register(dsc_messages)
cleanup_manager.register(deauth_alerts)
# ============================================
# SDR DEVICE REGISTRY
# ============================================
# Tracks which mode is using which SDR device to prevent conflicts
# Key: device_index (int), Value: mode_name (str)
sdr_device_registry: dict[int, str] = {}
sdr_device_registry_lock = threading.Lock()
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
"""Claim an SDR device for a mode.
Args:
device_index: The SDR device index to claim
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
Returns:
Error message if device is in use, None if successfully claimed
"""
with sdr_device_registry_lock:
if device_index in sdr_device_registry:
in_use_by = sdr_device_registry[device_index]
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
sdr_device_registry[device_index] = mode_name
return None
def release_sdr_device(device_index: int) -> None:
"""Release an SDR device from the registry.
Args:
device_index: The SDR device index to release
"""
with sdr_device_registry_lock:
sdr_device_registry.pop(device_index, None)
def get_sdr_device_status() -> dict[int, str]:
"""Get current SDR device allocations.
Returns:
Dictionary mapping device indices to mode names
"""
with sdr_device_registry_lock:
return dict(sdr_device_registry)
# ============================================
@@ -279,7 +335,14 @@ def index() -> str:
'rtlamr': check_tool('rtlamr')
}
devices = [d.to_dict() for d in SDRFactory.detect_devices()]
return render_template('index.html', tools=tools, devices=devices, version=VERSION, changelog=CHANGELOG)
return render_template(
'index.html',
tools=tools,
devices=devices,
version=VERSION,
changelog=CHANGELOG,
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
)
@app.route('/favicon.svg')
@@ -294,6 +357,22 @@ def get_devices() -> Response:
return jsonify([d.to_dict() for d in devices])
@app.route('/devices/status')
def get_devices_status() -> Response:
"""Get all SDR devices with usage status."""
devices = SDRFactory.detect_devices()
registry = get_sdr_device_status()
result = []
for device in devices:
d = device.to_dict()
d['in_use'] = device.index in registry
d['used_by'] = registry.get(device.index)
result.append(d)
return jsonify(result)
@app.route('/devices/debug')
def get_devices_debug() -> Response:
"""Get detailed SDR device detection diagnostics."""
@@ -622,6 +701,10 @@ def kill_all() -> Response:
dsc_process = None
dsc_rtl_process = None
# Clear SDR device registry
with sdr_device_registry_lock:
sdr_device_registry.clear()
return jsonify({'status': 'killed', 'processes': killed})
+12 -1
View File
@@ -7,10 +7,17 @@ import os
import sys
# Application version
VERSION = "2.12.0"
VERSION = "2.12.1"
# Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [
{
"version": "2.12.1",
"date": "February 2026",
"highlights": [
"Bug fixes and improvements",
]
},
{
"version": "2.12.0",
"date": "January 2026",
@@ -139,6 +146,7 @@ BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
# ADS-B settings
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
ADSB_AUTO_START = _get_env_bool('ADSB_AUTO_START', False)
ADSB_HISTORY_ENABLED = _get_env_bool('ADSB_HISTORY_ENABLED', False)
ADSB_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost')
ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432)
@@ -149,6 +157,9 @@ ADSB_HISTORY_BATCH_SIZE = _get_env_int('ADSB_HISTORY_BATCH_SIZE', 500)
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float('ADSB_HISTORY_FLUSH_INTERVAL', 1.0)
ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
# Observer location settings
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool('SHARED_OBSERVER_LOCATION', True)
# Satellite settings
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
+8
View File
@@ -36,6 +36,10 @@ services:
# - INTERCEPT_ADSB_DB_NAME=intercept_adsb
# - INTERCEPT_ADSB_DB_USER=intercept
# - INTERCEPT_ADSB_DB_PASSWORD=intercept
# ADS-B auto-start on dashboard load (default false)
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
# Shared observer location across modules
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
# Network mode for WiFi scanning (requires host network)
# network_mode: host
restart: unless-stopped
@@ -68,6 +72,10 @@ services:
- INTERCEPT_ADSB_DB_NAME=intercept_adsb
- INTERCEPT_ADSB_DB_USER=intercept
- INTERCEPT_ADSB_DB_PASSWORD=intercept
# ADS-B auto-start on dashboard load (default false)
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
# Shared observer location across modules
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
+76 -43
View File
@@ -61,55 +61,88 @@ INTERCEPT automatically detects known trackers:
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
2. **Check Tools** - Ensure dump1090 or readsb is installed
3. **Set Location** - Choose location source:
- **Manual Entry** - Type coordinates directly
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
5. **View Map** - Aircraft appear on the interactive Leaflet map
3. **Set Location** - Choose location source:
- **Manual Entry** - Type coordinates directly
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
- **Shared Location** - By default, the observer location is shared across modules
(disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
5. **View Map** - Aircraft appear on the interactive Leaflet map
6. **Click Aircraft** - Click markers for detailed information
7. **Display Options** - Toggle callsigns, altitude, trails, range rings, clustering
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
> Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load,
> set `INTERCEPT_ADSB_AUTO_START=true`.
### Emergency Squawks
The system highlights aircraft transmitting emergency squawks:
- **7500** - Hijack
- **7600** - Radio failure
- **7700** - General emergency
## ADS-B History (Optional)
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
### Enable History
Set the following environment variables (Docker recommended):
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
### Docker Setup
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
```bash
docker compose up -d
```
### Using the History Dashboard
1. Open **/adsb/history**
2. Use **Start Tracking** to run ADS-B in headless mode
3. View aircraft history and timelines
4. Stop tracking when desired (session history is recorded)
The system highlights aircraft transmitting emergency squawks:
- **7500** - Hijack
- **7600** - Radio failure
- **7700** - General emergency
## ADS-B History (Optional)
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
### Enable History
Set the following environment variables (Docker recommended):
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
### Other ADS-B Settings
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
**Local install example**
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
python app.py
```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
### Docker Setup
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
```bash
docker compose --profile history up -d
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
```
### Using the History Dashboard
1. Open **/adsb/history**
2. Use **Start Tracking** to run ADS-B in headless mode
3. View aircraft history and timelines
4. Stop tracking when desired (session history is recorded)
## Satellite Mode
+279 -41
View File
@@ -872,6 +872,150 @@ class ModeManager:
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
# =========================================================================
@@ -914,26 +1058,34 @@ class ModeManager:
"""Internal mode stop - terminates processes and cleans up."""
logger.info(f"Stopping mode {mode}")
# Signal stop
# 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]
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
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
# 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=2)
thread.join(timeout=1)
del self.output_threads[mode]
# Clean up
@@ -1137,10 +1289,16 @@ class ModeManager:
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:
proc.wait()
try:
proc.wait(timeout=1)
except Exception:
pass
logger.info("Sensor output reader stopped")
# -------------------------------------------------------------------------
@@ -2102,15 +2260,24 @@ class ModeManager:
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:
proc.wait()
try:
proc.wait(timeout=1)
except Exception:
pass
if 'pager_rtl' in self.processes:
rtl_proc = self.processes['pager_rtl']
if rtl_proc.poll() is None:
rtl_proc.terminate()
del self.processes['pager_rtl']
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:
@@ -2492,10 +2659,15 @@ class ModeManager:
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:
proc.wait()
try:
proc.wait(timeout=1)
except Exception:
pass
logger.info("ACARS reader stopped")
# -------------------------------------------------------------------------
@@ -2632,15 +2804,23 @@ class ModeManager:
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:
proc.wait()
try:
proc.wait(timeout=1)
except Exception:
pass
if 'aprs_rtl' in self.processes:
rtl_proc = self.processes['aprs_rtl']
if rtl_proc.poll() is None:
rtl_proc.terminate()
del self.processes['aprs_rtl']
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:
@@ -2788,15 +2968,23 @@ class ModeManager:
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:
proc.wait()
try:
proc.wait(timeout=1)
except Exception:
pass
if 'rtlamr_tcp' in self.processes:
tcp_proc = self.processes['rtlamr_tcp']
if tcp_proc.poll() is None:
tcp_proc.terminate()
del self.processes['rtlamr_tcp']
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")
# -------------------------------------------------------------------------
@@ -2901,10 +3089,15 @@ class ModeManager:
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:
proc.wait()
try:
proc.wait(timeout=1)
except Exception:
pass
logger.info("DSC reader stopped")
# -------------------------------------------------------------------------
@@ -3629,6 +3822,12 @@ class InterceptAgentHandler(BaseHTTPRequestHandler):
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('/')
@@ -3794,19 +3993,53 @@ def main():
print(" Press Ctrl+C to stop")
print()
# Handle shutdown
# 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...")
# Stop all running modes
for mode in list(mode_manager.running_modes.keys()):
mode_manager.stop_mode(mode)
if data_push_loop:
data_push_loop.stop()
if push_client:
push_client.stop()
gps_manager.stop()
httpd.shutdown()
sys.exit(0)
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)
@@ -3815,9 +4048,14 @@ def main():
httpd.serve_forever()
except KeyboardInterrupt:
pass
finally:
if push_client:
push_client.stop()
except Exception:
pass
# Give cleanup thread time to finish
if shutdown_requested.is_set():
time.sleep(0.5)
print("Agent stopped.")
if __name__ == '__main__':
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "intercept"
version = "2.12.0"
version = "2.12.1"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md"
requires-python = ">=3.9"
+3
View File
@@ -23,6 +23,9 @@ pyserial>=3.5
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
meshtastic>=2.0.0
# Deauthentication attack detection (optional - for WiFi TSCM)
scapy>=2.4.5
# QR code generation for Meshtastic channels (optional)
qrcode[pil]>=7.4
+31 -2
View File
@@ -43,6 +43,9 @@ DEFAULT_ACARS_FREQUENCIES = [
acars_message_count = 0
acars_last_message_time = None
# Track which device is being used
acars_active_device: int | None = None
def find_acarsdec():
"""Find acarsdec binary."""
@@ -175,7 +178,7 @@ def acars_status() -> Response:
@acars_bp.route('/start', methods=['POST'])
def start_acars() -> Response:
"""Start ACARS decoder."""
global acars_message_count, acars_last_message_time
global acars_message_count, acars_last_message_time, acars_active_device
with app_module.acars_lock:
if app_module.acars_process and app_module.acars_process.poll() is None:
@@ -202,6 +205,18 @@ def start_acars() -> Response:
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'acars')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
acars_active_device = device_int
# Get frequencies - use provided or defaults
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
if isinstance(frequencies, str):
@@ -282,7 +297,10 @@ def start_acars() -> Response:
time.sleep(PROCESS_START_WAIT)
if process.poll() is not None:
# Process died
# Process died - release device
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
acars_active_device = None
stderr = ''
if process.stderr:
stderr = process.stderr.read().decode('utf-8', errors='replace')
@@ -310,6 +328,10 @@ def start_acars() -> Response:
})
except Exception as e:
# Release device on failure
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
acars_active_device = None
logger.error(f"Failed to start ACARS decoder: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -317,6 +339,8 @@ def start_acars() -> Response:
@acars_bp.route('/stop', methods=['POST'])
def stop_acars() -> Response:
"""Stop ACARS decoder."""
global acars_active_device
with app_module.acars_lock:
if not app_module.acars_process:
return jsonify({
@@ -334,6 +358,11 @@ def stop_acars() -> Response:
app_module.acars_process = None
# Release device from registry
if acars_active_device is not None:
app_module.release_sdr_device(acars_active_device)
acars_active_device = None
return jsonify({'status': 'stopped'})
+26 -2
View File
@@ -33,7 +33,9 @@ from config import (
ADSB_DB_PASSWORD,
ADSB_DB_PORT,
ADSB_DB_USER,
ADSB_AUTO_START,
ADSB_HISTORY_ENABLED,
SHARED_OBSERVER_LOCATION_ENABLED,
)
from utils.logging import adsb_logger as logger
from utils.validation import (
@@ -684,6 +686,16 @@ def start_adsb():
app_module.adsb_process = None
logger.info("Killed stale ADS-B process")
# Check if device is available before starting local dump1090
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'adsb')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
# Create device object and build command via abstraction layer
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
@@ -712,7 +724,8 @@ def start_adsb():
time.sleep(DUMP1090_START_WAIT)
if app_module.adsb_process.poll() is not None:
# Process exited - try to get error message
# Process exited - release device and get error message
app_module.release_sdr_device(device_int)
stderr_output = ''
if app_module.adsb_process.stderr:
try:
@@ -750,6 +763,8 @@ def start_adsb():
'session': session
})
except Exception as e:
# Release device on failure
app_module.release_sdr_device(device_int)
return jsonify({'status': 'error', 'message': str(e)})
@@ -777,6 +792,11 @@ def stop_adsb():
pass
app_module.adsb_process = None
logger.info("ADS-B process stopped")
# Release device from registry
if adsb_active_device is not None:
app_module.release_sdr_device(adsb_active_device)
adsb_using_service = False
adsb_active_device = None
@@ -812,7 +832,11 @@ def stream_adsb():
@adsb_bp.route('/dashboard')
def adsb_dashboard():
"""Popout ADS-B dashboard."""
return render_template('adsb_dashboard.html')
return render_template(
'adsb_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
adsb_auto_start=ADSB_AUTO_START,
)
@adsb_bp.route('/history')
+24 -1
View File
@@ -15,6 +15,7 @@ from typing import Generator
from flask import Blueprint, jsonify, request, Response, render_template
import app as app_module
from config import SHARED_OBSERVER_LOCATION_ENABLED
from utils.logging import get_logger
from utils.validation import validate_device_index, validate_gain
from utils.sse import format_sse
@@ -369,6 +370,16 @@ def start_ais():
app_module.ais_process = None
logger.info("Killed existing AIS process")
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'ais')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
# Build command using SDR abstraction
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
@@ -399,6 +410,8 @@ def start_ais():
time.sleep(2.0)
if app_module.ais_process.poll() is not None:
# Release device on failure
app_module.release_sdr_device(device_int)
stderr_output = ''
if app_module.ais_process.stderr:
try:
@@ -424,6 +437,8 @@ def start_ais():
'port': tcp_port
})
except Exception as e:
# Release device on failure
app_module.release_sdr_device(device_int)
logger.error(f"Failed to start AIS-catcher: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@@ -447,6 +462,11 @@ def stop_ais():
pass
app_module.ais_process = None
logger.info("AIS process stopped")
# Release device from registry
if ais_active_device is not None:
app_module.release_sdr_device(ais_active_device)
ais_running = False
ais_active_device = None
@@ -480,4 +500,7 @@ def stream_ais():
@ais_bp.route('/dashboard')
def ais_dashboard():
"""Popout AIS dashboard."""
return render_template('ais_dashboard.html')
return render_template(
'ais_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
)
+66 -1
View File
@@ -91,6 +91,17 @@ def register_agent():
if not base_url:
return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400
# Validate URL format
from urllib.parse import urlparse
try:
parsed = urlparse(base_url)
if parsed.scheme not in ('http', 'https'):
return jsonify({'status': 'error', 'message': 'URL must start with http:// or https://'}), 400
if not parsed.netloc:
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
except Exception:
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
# Check if agent already exists
existing = get_agent_by_name(name)
if existing:
@@ -128,9 +139,12 @@ def register_agent():
update_agent(agent_id, update_last_seen=True)
agent = get_agent(agent_id)
message = 'Agent registered successfully'
if capabilities is None:
message += ' (could not connect - agent may be offline)'
return jsonify({
'status': 'success',
'message': 'Agent registered successfully',
'message': message,
'agent': agent
}), 201
@@ -466,6 +480,57 @@ def proxy_mode_data(agent_id: int, mode: str):
}), 502
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
def proxy_wifi_monitor(agent_id: int):
"""Toggle monitor mode on a remote agent's WiFi interface."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
data = request.json or {}
try:
client = create_client_from_agent(agent)
result = client.post('/wifi/monitor', data)
# Refresh agent capabilities after monitor mode toggle so UI stays in sync
if result.get('status') == 'success':
try:
metadata = client.refresh_metadata()
if metadata.get('healthy'):
caps = metadata.get('capabilities') or {}
agent_interfaces = caps.get('interfaces', {})
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
agent_interfaces['sdr_devices'] = caps.get('devices', [])
update_agent(
agent_id,
capabilities=caps.get('modes'),
interfaces=agent_interfaces,
update_last_seen=True
)
except Exception:
pass # Non-fatal if refresh fails
return jsonify({
'status': result.get('status', 'error'),
'agent_id': agent_id,
'agent_name': agent['name'],
'monitor_interface': result.get('monitor_interface'),
'message': result.get('message')
})
except AgentConnectionError as e:
return jsonify({
'status': 'error',
'message': f'Cannot connect to agent: {e}'
}), 503
except AgentHTTPError as e:
return jsonify({
'status': 'error',
'message': f'Agent error: {e}'
}), 502
# =============================================================================
# Push Data Ingestion
# =============================================================================
+29 -16
View File
@@ -47,6 +47,9 @@ dsc_bp = Blueprint('dsc', __name__, url_prefix='/dsc')
# Module state (track if running independent of process state)
dsc_running = False
# Track which device is being used
dsc_active_device: int | None = None
def _get_dsc_decoder_path() -> str | None:
"""Get path to DSC decoder."""
@@ -309,21 +312,18 @@ def start_decoding() -> Response:
'message': str(e)
}), 400
# Check if device is in use by AIS
try:
from routes import ais as ais_module
if hasattr(ais_module, 'ais_running') and ais_module.ais_running:
# AIS is running - check if same device
if hasattr(ais_module, 'ais_device') and str(ais_module.ais_device) == str(device):
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': f'SDR device {device} is in use by AIS tracking',
'suggestion': 'Use a different SDR device or stop AIS tracking first',
'in_use_by': 'ais'
}), 409
except ImportError:
pass
# Check if device is available using centralized registry
global dsc_active_device
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'dsc')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
dsc_active_device = device_int
# Clear queue
while not app_module.dsc_queue.empty():
@@ -408,11 +408,19 @@ def start_decoding() -> Response:
})
except FileNotFoundError as e:
# Release device on failure
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
dsc_active_device = None
return jsonify({
'status': 'error',
'message': f'Tool not found: {e.filename}'
}), 400
except Exception as e:
# Release device on failure
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
dsc_active_device = None
logger.error(f"Failed to start DSC decoder: {e}")
return jsonify({
'status': 'error',
@@ -423,7 +431,7 @@ def start_decoding() -> Response:
@dsc_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response:
"""Stop DSC decoder."""
global dsc_running
global dsc_running, dsc_active_device
with app_module.dsc_lock:
if not app_module.dsc_process:
@@ -460,6 +468,11 @@ def stop_decoding() -> Response:
app_module.dsc_process = None
app_module.dsc_rtl_process = None
# Release device from registry
if dsc_active_device is not None:
app_module.release_sdr_device(dsc_active_device)
dsc_active_device = None
return jsonify({'status': 'stopped'})
+49 -9
View File
@@ -3,8 +3,9 @@
Provides endpoints for connecting to Meshtastic devices, configuring
channels with encryption keys, and streaming received messages.
Requires a physical Meshtastic device (Heltec, T-Beam, RAK, etc.)
connected via USB/Serial.
Supports multiple connection types:
- USB/Serial: Physical device connected via USB
- TCP: WiFi-enabled devices accessible via IP address
"""
from __future__ import annotations
@@ -95,7 +96,7 @@ def get_status():
Get Meshtastic connection status.
Returns:
JSON with connection status, device info, and node information.
JSON with connection status, device info, connection type, and node information.
"""
if not is_meshtastic_available():
return jsonify({
@@ -111,6 +112,7 @@ def get_status():
'available': True,
'running': False,
'device': None,
'connection_type': None,
'node_info': None,
})
@@ -120,6 +122,7 @@ def get_status():
'available': True,
'running': client.is_running,
'device': client.device_path,
'connection_type': client.connection_type,
'error': client.error,
'node_info': node_info.to_dict() if node_info else None,
})
@@ -131,13 +134,20 @@ def start_mesh():
Start Meshtastic listener.
Connects to a Meshtastic device and begins receiving messages.
The device must be connected via USB/Serial.
Supports both USB/Serial and TCP connections.
JSON body (optional):
{
"device": "/dev/ttyUSB0" // Serial port path. Auto-discovers if not provided.
"connection_type": "serial", // 'serial' (default) or 'tcp'
"device": "/dev/ttyUSB0", // Serial port path. Auto-discovers if not provided.
"hostname": "192.168.1.100" // IP address or hostname for TCP connections
}
Examples:
Serial (auto-discover): {}
Serial (specific port): {"device": "/dev/ttyUSB0"}
TCP: {"connection_type": "tcp", "hostname": "192.168.1.100"}
Returns:
JSON with connection status.
"""
@@ -151,7 +161,8 @@ def start_mesh():
if client and client.is_running:
return jsonify({
'status': 'already_running',
'device': client.device_path
'device': client.device_path,
'connection_type': client.connection_type
})
# Clear queue and history
@@ -162,18 +173,46 @@ def start_mesh():
break
_recent_messages.clear()
# Get optional device path
# Parse connection parameters
data = request.get_json(silent=True) or {}
connection_type = data.get('connection_type', 'serial').lower().strip()
device = data.get('device')
hostname = data.get('hostname')
# Validate device path if provided
# Validate connection type
if connection_type not in ('serial', 'tcp'):
return jsonify({
'status': 'error',
'message': f"Invalid connection_type: {connection_type}. Must be 'serial' or 'tcp'"
}), 400
# Validate TCP parameters
if connection_type == 'tcp':
if not hostname:
return jsonify({
'status': 'error',
'message': 'hostname is required for TCP connections'
}), 400
hostname = str(hostname).strip()
if not hostname:
return jsonify({
'status': 'error',
'message': 'hostname cannot be empty'
}), 400
# Validate serial device path if provided
if device:
device = str(device).strip()
if not device:
device = None
# Start client
success = start_meshtastic(device=device, callback=_message_callback)
success = start_meshtastic(
device=device,
callback=_message_callback,
connection_type=connection_type,
hostname=hostname
)
if success:
client = get_meshtastic_client()
@@ -181,6 +220,7 @@ def start_mesh():
return jsonify({
'status': 'started',
'device': client.device_path if client else None,
'connection_type': client.connection_type if client else None,
'node_info': node_info.to_dict() if node_info else None,
})
else:
+40 -4
View File
@@ -29,6 +29,9 @@ from utils.dependencies import get_tool_path
pager_bp = Blueprint('pager', __name__)
# Track which device is being used
pager_active_device: int | None = None
def parse_multimon_output(line: str) -> dict[str, str] | None:
"""Parse multimon-ng output line."""
@@ -155,6 +158,8 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
@pager_bp.route('/start', methods=['POST'])
def start_decoding() -> Response:
global pager_active_device
with app_module.process_lock:
if app_module.current_process:
return jsonify({'status': 'error', 'message': 'Already running'}), 409
@@ -178,10 +183,29 @@ def start_decoding() -> Response:
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Claim local device if not using remote rtl_tcp
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'pager')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
pager_active_device = device_int
# Validate protocols
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
protocols = data.get('protocols', valid_protocols)
if not isinstance(protocols, list):
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
protocols = [p for p in protocols if p in valid_protocols]
if not protocols:
@@ -213,10 +237,6 @@ def start_decoding() -> Response:
except ValueError:
sdr_type = SDRType.RTL_SDR
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
if rtl_tcp_host:
# Validate and create network device
try:
@@ -302,13 +322,23 @@ def start_decoding() -> Response:
return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError as e:
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
except Exception as e:
# Release device on failure
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
return jsonify({'status': 'error', 'message': str(e)})
@pager_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response:
global pager_active_device
with app_module.process_lock:
if app_module.current_process:
# Kill rtl_fm process first
@@ -337,6 +367,12 @@ def stop_decoding() -> Response:
app_module.current_process.kill()
app_module.current_process = None
# Release device from registry
if pager_active_device is not None:
app_module.release_sdr_device(pager_active_device)
pager_active_device = None
return jsonify({'status': 'stopped'})
return jsonify({'status': 'not_running'})
+33 -7
View File
@@ -26,6 +26,9 @@ rtlamr_bp = Blueprint('rtlamr', __name__)
rtl_tcp_process = None
rtl_tcp_lock = threading.Lock()
# Track which device is being used
rtlamr_active_device: int | None = None
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
"""Stream rtlamr JSON output to queue."""
@@ -66,7 +69,7 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
def start_rtlamr() -> Response:
global rtl_tcp_process
global rtl_tcp_process, rtlamr_active_device
with app_module.rtlamr_lock:
if app_module.rtlamr_process:
@@ -83,6 +86,18 @@ def start_rtlamr() -> Response:
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'rtlamr')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
rtlamr_active_device = device_int
# Clear queue
while not app_module.rtlamr_queue.empty():
try:
@@ -182,27 +197,33 @@ def start_rtlamr() -> Response:
return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError:
# If rtlamr fails, clean up rtl_tcp
# If rtlamr fails, clean up rtl_tcp and release device
with rtl_tcp_lock:
if rtl_tcp_process:
rtl_tcp_process.terminate()
rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
rtlamr_active_device = None
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
except Exception as e:
# If rtlamr fails, clean up rtl_tcp
# If rtlamr fails, clean up rtl_tcp and release device
with rtl_tcp_lock:
if rtl_tcp_process:
rtl_tcp_process.terminate()
rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
rtlamr_active_device = None
return jsonify({'status': 'error', 'message': str(e)})
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
def stop_rtlamr() -> Response:
global rtl_tcp_process
global rtl_tcp_process, rtlamr_active_device
with app_module.rtlamr_lock:
if app_module.rtlamr_process:
app_module.rtlamr_process.terminate()
@@ -211,7 +232,7 @@ def stop_rtlamr() -> Response:
except subprocess.TimeoutExpired:
app_module.rtlamr_process.kill()
app_module.rtlamr_process = None
# Also stop rtl_tcp
with rtl_tcp_lock:
if rtl_tcp_process:
@@ -222,7 +243,12 @@ def stop_rtlamr() -> Response:
rtl_tcp_process.kill()
rtl_tcp_process = None
logger.info("rtl_tcp stopped")
# Release device from registry
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
rtlamr_active_device = None
return jsonify({'status': 'stopped'})
+9 -4
View File
@@ -11,7 +11,9 @@ from urllib.parse import urlparse
import requests
from flask import Blueprint, jsonify, request, render_template, Response
from flask import Blueprint, jsonify, request, render_template, Response
from config import SHARED_OBSERVER_LOCATION_ENABLED
from data.satellites import TLE_SATELLITES
from utils.logging import satellite_logger as logger
@@ -118,9 +120,12 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
@satellite_bp.route('/dashboard')
def satellite_dashboard():
"""Popout satellite tracking dashboard."""
return render_template('satellite_dashboard.html')
def satellite_dashboard():
"""Popout satellite tracking dashboard."""
return render_template(
'satellite_dashboard.html',
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
)
@satellite_bp.route('/predict', methods=['POST'])
+37 -4
View File
@@ -24,6 +24,9 @@ from utils.sdr import SDRFactory, SDRType
sensor_bp = Blueprint('sensor', __name__)
# Track which device is being used
sensor_active_device: int | None = None
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
"""Stream rtl_433 JSON output to queue."""
@@ -64,6 +67,8 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
@sensor_bp.route('/start_sensor', methods=['POST'])
def start_sensor() -> Response:
global sensor_active_device
with app_module.sensor_lock:
if app_module.sensor_process:
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
@@ -79,6 +84,22 @@ def start_sensor() -> Response:
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
# Claim local device if not using remote rtl_tcp
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'sensor')
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
sensor_active_device = device_int
# Clear queue
while not app_module.sensor_queue.empty():
try:
@@ -93,10 +114,6 @@ def start_sensor() -> Response:
except ValueError:
sdr_type = SDRType.RTL_SDR
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
if rtl_tcp_host:
# Validate and create network device
try:
@@ -155,13 +172,23 @@ def start_sensor() -> Response:
return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
except Exception as e:
# Release device on failure
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'error', 'message': str(e)})
@sensor_bp.route('/stop_sensor', methods=['POST'])
def stop_sensor() -> Response:
global sensor_active_device
with app_module.sensor_lock:
if app_module.sensor_process:
app_module.sensor_process.terminate()
@@ -170,6 +197,12 @@ def stop_sensor() -> Response:
except subprocess.TimeoutExpired:
app_module.sensor_process.kill()
app_module.sensor_process = None
# Release device from registry
if sensor_active_device is not None:
app_module.release_sdr_device(sensor_active_device)
sensor_active_device = None
return jsonify({'status': 'stopped'})
return jsonify({'status': 'not_running'})
+95 -8
View File
@@ -20,6 +20,7 @@ from utils.sstv import (
is_sstv_available,
ISS_SSTV_FREQ,
DecodeProgress,
DopplerInfo,
)
logger = get_logger('intercept.sstv')
@@ -53,13 +54,21 @@ def get_status():
available = is_sstv_available()
decoder = get_sstv_decoder()
return jsonify({
result = {
'available': available,
'decoder': decoder.decoder_available,
'running': decoder.is_running,
'iss_frequency': ISS_SSTV_FREQ,
'image_count': len(decoder.get_images()),
})
'doppler_enabled': decoder.doppler_enabled,
}
# Include Doppler info if available
doppler_info = decoder.last_doppler_info
if doppler_info:
result['doppler'] = doppler_info.to_dict()
return jsonify(result)
@sstv_bp.route('/start', methods=['POST'])
@@ -70,9 +79,15 @@ def start_decoder():
JSON body (optional):
{
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
"device": 0 // RTL-SDR device index
"device": 0, // RTL-SDR device index
"latitude": 40.7128, // Observer latitude for Doppler correction
"longitude": -74.0060 // Observer longitude for Doppler correction
}
If latitude and longitude are provided, real-time Doppler shift compensation
will be enabled, which improves reception by tracking the ISS frequency shift
as it passes overhead (up to ±3.5 kHz at 145.800 MHz).
Returns:
JSON with start status.
"""
@@ -87,7 +102,8 @@ def start_decoder():
if decoder.is_running:
return jsonify({
'status': 'already_running',
'frequency': ISS_SSTV_FREQ
'frequency': ISS_SSTV_FREQ,
'doppler_enabled': decoder.doppler_enabled
})
# Clear queue
@@ -101,6 +117,8 @@ def start_decoder():
data = request.get_json(silent=True) or {}
frequency = data.get('frequency', ISS_SSTV_FREQ)
device_index = data.get('device', 0)
latitude = data.get('latitude')
longitude = data.get('longitude')
# Validate frequency
try:
@@ -116,16 +134,52 @@ def start_decoder():
'message': 'Invalid frequency'
}), 400
# Validate location if provided
if latitude is not None and longitude is not None:
try:
latitude = float(latitude)
longitude = float(longitude)
if not (-90 <= latitude <= 90):
return jsonify({
'status': 'error',
'message': 'Latitude must be between -90 and 90'
}), 400
if not (-180 <= longitude <= 180):
return jsonify({
'status': 'error',
'message': 'Longitude must be between -180 and 180'
}), 400
except (TypeError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid latitude or longitude'
}), 400
else:
latitude = None
longitude = None
# Set callback and start
decoder.set_callback(_progress_callback)
success = decoder.start(frequency=frequency, device_index=device_index)
success = decoder.start(
frequency=frequency,
device_index=device_index,
latitude=latitude,
longitude=longitude
)
if success:
return jsonify({
result = {
'status': 'started',
'frequency': frequency,
'device': device_index
})
'device': device_index,
'doppler_enabled': decoder.doppler_enabled
}
# Include initial Doppler info if available
if decoder.doppler_enabled and decoder.last_doppler_info:
result['doppler'] = decoder.last_doppler_info.to_dict()
return jsonify(result)
else:
return jsonify({
'status': 'error',
@@ -146,6 +200,39 @@ def stop_decoder():
return jsonify({'status': 'stopped'})
@sstv_bp.route('/doppler')
def get_doppler():
"""
Get current Doppler shift information.
Returns real-time Doppler shift data if tracking is enabled.
Returns:
JSON with Doppler shift information.
"""
decoder = get_sstv_decoder()
if not decoder.doppler_enabled:
return jsonify({
'status': 'disabled',
'message': 'Doppler tracking not enabled. Provide latitude/longitude when starting decoder.'
})
doppler_info = decoder.last_doppler_info
if not doppler_info:
return jsonify({
'status': 'unavailable',
'message': 'Doppler data not yet available'
})
return jsonify({
'status': 'ok',
'doppler': doppler_info.to_dict(),
'nominal_frequency_mhz': ISS_SSTV_FREQ,
'corrected_frequency_mhz': doppler_info.frequency_hz / 1_000_000
})
@sstv_bp.route('/images')
def list_images():
"""
+140
View File
@@ -1413,3 +1413,143 @@ def v2_clear_data():
except Exception as e:
logger.exception("Error clearing data")
return jsonify({'error': str(e)}), 500
# =============================================================================
# V2 Deauth Detection Endpoints
# =============================================================================
@wifi_bp.route('/v2/deauth/status')
def v2_deauth_status():
"""
Get deauth detection status and recent alerts.
Returns:
- is_running: Whether deauth detector is active
- interface: Monitor interface being used
- stats: Detection statistics
- recent_alerts: Recent deauth alerts
"""
try:
scanner = get_wifi_scanner()
detector = scanner.deauth_detector
if detector:
stats = detector.stats
alerts = detector.get_alerts(limit=50)
else:
stats = {
'is_running': False,
'interface': None,
'packets_captured': 0,
'alerts_generated': 0,
}
alerts = []
return jsonify({
'is_running': stats.get('is_running', False),
'interface': stats.get('interface'),
'started_at': stats.get('started_at'),
'stats': {
'packets_captured': stats.get('packets_captured', 0),
'alerts_generated': stats.get('alerts_generated', 0),
'active_trackers': stats.get('active_trackers', 0),
},
'recent_alerts': alerts,
})
except Exception as e:
logger.exception("Error getting deauth status")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/deauth/stream')
def v2_deauth_stream():
"""
SSE stream for real-time deauth alerts.
Events:
- deauth_alert: A deauth attack was detected
- deauth_detector_started: Detector started
- deauth_detector_stopped: Detector stopped
- deauth_error: An error occurred
- keepalive: Periodic keepalive
"""
def generate():
last_keepalive = time.time()
keepalive_interval = SSE_KEEPALIVE_INTERVAL
while True:
try:
# Try to get from the dedicated deauth queue
msg = app_module.deauth_detector_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@wifi_bp.route('/v2/deauth/alerts')
def v2_deauth_alerts():
"""
Get historical deauth alerts.
Query params:
- limit: Maximum number of alerts to return (default 100)
"""
try:
limit = request.args.get('limit', 100, type=int)
limit = max(1, min(limit, 1000)) # Clamp between 1 and 1000
scanner = get_wifi_scanner()
alerts = scanner.get_deauth_alerts(limit=limit)
# Also include alerts from DataStore that might have been persisted
try:
stored_alerts = list(app_module.deauth_alerts.values())
# Merge and deduplicate by ID
alert_ids = {a.get('id') for a in alerts}
for alert in stored_alerts:
if alert.get('id') not in alert_ids:
alerts.append(alert)
# Sort by timestamp descending
alerts.sort(key=lambda a: a.get('timestamp', 0), reverse=True)
alerts = alerts[:limit]
except Exception:
pass
return jsonify({
'alerts': alerts,
'count': len(alerts),
})
except Exception as e:
logger.exception("Error getting deauth alerts")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/deauth/clear', methods=['POST'])
def v2_deauth_clear():
"""Clear deauth alert history."""
try:
scanner = get_wifi_scanner()
scanner.clear_deauth_alerts()
# Clear the queue
while not app_module.deauth_detector_queue.empty():
try:
app_module.deauth_detector_queue.get_nowait()
except queue.Empty:
break
return jsonify({'status': 'cleared'})
except Exception as e:
logger.exception("Error clearing deauth alerts")
return jsonify({'error': str(e)}), 500
+92 -16
View File
@@ -204,6 +204,7 @@ check_tools() {
check_required "dump1090" "ADS-B decoder" dump1090
check_required "acarsdec" "ACARS decoder" acarsdec
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
check_optional "slowrx" "SSTV decoder (ISS images)" slowrx
echo
info "GPS:"
@@ -385,6 +386,49 @@ install_rtlamr_from_source() {
fi
}
install_slowrx_from_source_macos() {
info "slowrx not available via Homebrew. Building from source..."
# Ensure build dependencies are installed
brew_install cmake
brew_install fftw
brew_install libsndfile
brew_install gtk+3
brew_install pkg-config
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning slowrx..."
git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \
|| { warn "Failed to clone slowrx"; exit 1; }
cd "$tmp_dir/slowrx"
info "Compiling slowrx..."
mkdir -p build && cd build
local cmake_log make_log
cmake_log=$(cmake .. 2>&1) || {
warn "cmake failed for slowrx:"
echo "$cmake_log" | tail -20
exit 1
}
make_log=$(make 2>&1) || {
warn "make failed for slowrx:"
echo "$make_log" | tail -20
exit 1
}
# Install to /usr/local/bin
if [[ -w /usr/local/bin ]]; then
install -m 0755 slowrx /usr/local/bin/slowrx
else
sudo install -m 0755 slowrx /usr/local/bin/slowrx
fi
ok "slowrx installed successfully from source"
)
}
install_multimon_ng_from_source_macos() {
info "multimon-ng not available via Homebrew. Building from source..."
@@ -417,7 +461,7 @@ install_multimon_ng_from_source_macos() {
}
install_macos_packages() {
TOTAL_STEPS=15
TOTAL_STEPS=16
CURRENT_STEP=0
progress "Checking Homebrew"
@@ -437,6 +481,13 @@ install_macos_packages() {
progress "Installing direwolf (APRS decoder)"
(brew_install direwolf) || warn "direwolf not available via Homebrew"
progress "Installing slowrx (SSTV decoder)"
if ! cmd_exists slowrx; then
install_slowrx_from_source_macos || warn "slowrx build failed - ISS SSTV decoding will not be available"
else
ok "slowrx already installed"
fi
progress "Installing ffmpeg"
brew_install ffmpeg
@@ -632,6 +683,37 @@ install_aiscatcher_from_source_debian() {
)
}
install_slowrx_from_source_debian() {
info "slowrx not available via APT. Building from source..."
# slowrx uses a simple Makefile, not CMake
apt_install build-essential git pkg-config \
libfftw3-dev libsndfile1-dev libgtk-3-dev libasound2-dev libpulse-dev
# Run in subshell to isolate EXIT trap
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning slowrx..."
git clone --depth 1 https://github.com/windytan/slowrx.git "$tmp_dir/slowrx" >/dev/null 2>&1 \
|| { warn "Failed to clone slowrx"; exit 1; }
cd "$tmp_dir/slowrx"
info "Compiling slowrx..."
local make_log
make_log=$(make 2>&1) || {
warn "make failed for slowrx:"
echo "$make_log" | tail -20
warn "ISS SSTV decoding will not be available."
exit 1
}
$SUDO install -m 0755 slowrx /usr/local/bin/slowrx
ok "slowrx installed successfully."
)
}
install_ubertooth_from_source_debian() {
info "Building Ubertooth from source..."
@@ -767,7 +849,7 @@ install_debian_packages() {
export NEEDRESTART_MODE=a
fi
TOTAL_STEPS=20
TOTAL_STEPS=21
CURRENT_STEP=0
progress "Updating APT package lists"
@@ -811,19 +893,9 @@ install_debian_packages() {
progress "RTL-SDR Blog drivers"
if cmd_exists rtl_test; then
info "RTL-SDR tools already installed."
if $IS_DRAGONOS; then
info "Skipping RTL-SDR Blog driver installation (DragonOS has working drivers)."
else
echo "RTL-SDR Blog drivers provide improved support for V4 dongles."
echo "Installing these will REPLACE your current RTL-SDR drivers."
if ask_yes_no "Install RTL-SDR Blog drivers?"; then
install_rtlsdr_blog_drivers_debian
else
ok "Keeping existing RTL-SDR drivers."
fi
fi
ok "RTL-SDR drivers already installed"
else
info "RTL-SDR drivers not found, installing RTL-SDR Blog drivers..."
install_rtlsdr_blog_drivers_debian
fi
@@ -833,6 +905,9 @@ install_debian_packages() {
progress "Installing direwolf (APRS decoder)"
apt_install direwolf || true
progress "Installing slowrx (SSTV decoder)"
apt_install slowrx || cmd_exists slowrx || install_slowrx_from_source_debian
progress "Installing ffmpeg"
apt_install ffmpeg
@@ -922,11 +997,12 @@ install_debian_packages() {
setup_udev_rules_debian
progress "Kernel driver configuration"
echo
if $IS_DRAGONOS; then
info "DragonOS already has RTL-SDR drivers configured correctly."
info "Skipping kernel driver blacklist (not needed)."
elif [[ -f /etc/modprobe.d/blacklist-rtlsdr.conf ]]; then
ok "DVB kernel drivers already blacklisted"
else
echo
echo "The DVB-T kernel drivers conflict with RTL-SDR userspace access."
echo "Blacklisting them allows rtl_sdr tools to access the device."
if ask_yes_no "Blacklist conflicting kernel drivers?"; then
+18
View File
@@ -814,6 +814,24 @@ body {
color: var(--accent-green);
}
/* Bias-T toggle styling */
.bias-t-label {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: linear-gradient(90deg, rgba(255, 100, 0, 0.15), rgba(255, 100, 0, 0.05));
border: 1px solid var(--accent-orange, #ff6400);
border-radius: 4px;
color: var(--accent-orange, #ff6400);
font-weight: 500;
font-size: 10px;
}
.bias-t-label input[type="checkbox"] {
accent-color: var(--accent-orange, #ff6400);
}
.control-group.airband-group {
background: rgba(245, 158, 11, 0.05);
border-color: rgba(245, 158, 11, 0.2);
+12
View File
@@ -978,6 +978,18 @@ header h1 {
color: var(--accent-cyan);
}
/* Donate button - warm amber accent */
.nav-tool-btn--donate {
text-decoration: none;
color: var(--accent-amber);
}
.nav-tool-btn--donate:hover {
color: var(--accent-orange);
border-color: var(--accent-amber);
background: rgba(212, 168, 83, 0.1);
}
/* Theme toggle icon states in nav bar */
.nav-tool-btn .icon-sun,
.nav-tool-btn .icon-moon {
+21
View File
@@ -259,6 +259,27 @@
max-width: 120px;
}
.mesh-strip-input {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
padding: 4px 8px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
max-width: 140px;
}
.mesh-strip-input::placeholder {
color: var(--text-secondary);
opacity: 0.7;
}
.mesh-strip-input:focus {
outline: none;
border-color: var(--accent-cyan);
}
.mesh-strip-btn {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
+28
View File
@@ -351,6 +351,34 @@
color: var(--accent-cyan, #00d4ff);
}
/* Donate Button */
.donate-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 20px;
background: linear-gradient(135deg, var(--accent-amber, #d4a853) 0%, var(--accent-orange, #f59e0b) 100%);
border: none;
border-radius: 6px;
color: #000;
font-size: 13px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(212, 168, 83, 0.3);
}
.donate-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(212, 168, 83, 0.4);
filter: brightness(1.1);
}
.donate-btn:active {
transform: translateY(0);
}
/* Tile Provider Custom URL */
.custom-url-row {
margin-top: 8px;
+13 -10
View File
@@ -31,16 +31,19 @@ let autoScroll = localStorage.getItem('autoScroll') !== 'false';
let muted = localStorage.getItem('audioMuted') === 'true';
// Observer location (load from localStorage or default to London)
let observerLocation = (function() {
const saved = localStorage.getItem('observerLocation');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) return parsed;
} catch (e) {}
}
return { lat: 51.5074, lon: -0.1278 };
})();
let observerLocation = (function() {
if (window.ObserverLocation && ObserverLocation.getForModule) {
return ObserverLocation.getForModule('observerLocation');
}
const saved = localStorage.getItem('observerLocation');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) return parsed;
} catch (e) {}
}
return { lat: 51.5074, lon: -0.1278 };
})();
// Message storage for export
let allMessages = [];
+103
View File
@@ -0,0 +1,103 @@
// Shared observer location helper for map-based modules.
// Default: shared location enabled unless explicitly disabled via config.
window.ObserverLocation = (function() {
const DEFAULT_LOCATION = { lat: 51.5074, lon: -0.1278 };
const SHARED_KEY = 'observerLocation';
const AIS_KEY = 'ais_observerLocation';
const LEGACY_LAT_KEY = 'observerLat';
const LEGACY_LON_KEY = 'observerLon';
function isSharedEnabled() {
return window.INTERCEPT_SHARED_OBSERVER_LOCATION !== false;
}
function normalize(lat, lon) {
const latNum = parseFloat(lat);
const lonNum = parseFloat(lon);
if (Number.isNaN(latNum) || Number.isNaN(lonNum)) return null;
if (latNum < -90 || latNum > 90 || lonNum < -180 || lonNum > 180) return null;
return { lat: latNum, lon: lonNum };
}
function parseLocation(raw) {
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (parsed && parsed.lat !== undefined && parsed.lon !== undefined) {
return normalize(parsed.lat, parsed.lon);
}
} catch (e) {}
return null;
}
function readKey(key) {
return parseLocation(localStorage.getItem(key));
}
function readLegacyLatLon() {
const lat = localStorage.getItem(LEGACY_LAT_KEY);
const lon = localStorage.getItem(LEGACY_LON_KEY);
if (!lat || !lon) return null;
return normalize(lat, lon);
}
function getShared() {
const current = readKey(SHARED_KEY);
if (current) return current;
const legacy = readKey(AIS_KEY) || readLegacyLatLon();
if (legacy) {
setShared(legacy);
return legacy;
}
return { ...DEFAULT_LOCATION };
}
function setShared(location, options = {}) {
if (!location) return;
localStorage.setItem(SHARED_KEY, JSON.stringify(location));
if (options.updateLegacy !== false) {
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
}
}
function getForModule(moduleKey, options = {}) {
if (isSharedEnabled()) {
return getShared();
}
if (moduleKey) {
const moduleLocation = readKey(moduleKey);
if (moduleLocation) return moduleLocation;
}
if (options.fallbackToLatLon) {
const legacy = readLegacyLatLon();
if (legacy) return legacy;
}
return { ...DEFAULT_LOCATION };
}
function setForModule(moduleKey, location, options = {}) {
if (!location) return;
if (isSharedEnabled()) {
setShared(location, options);
return;
}
if (moduleKey) {
localStorage.setItem(moduleKey, JSON.stringify(location));
} else if (options.fallbackToLatLon) {
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
}
}
return {
isSharedEnabled,
getShared,
setShared,
getForModule,
setForModule,
normalize,
DEFAULT_LOCATION: { ...DEFAULT_LOCATION }
};
})();
+44 -14
View File
@@ -547,9 +547,14 @@ document.addEventListener('DOMContentLoaded', () => {
/**
* Load and display current observer location
*/
function loadObserverLocation() {
const lat = localStorage.getItem('observerLat');
const lon = localStorage.getItem('observerLon');
function loadObserverLocation() {
let lat = localStorage.getItem('observerLat');
let lon = localStorage.getItem('observerLon');
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
lat = shared.lat.toString();
lon = shared.lon.toString();
}
const latInput = document.getElementById('observerLatInput');
const lonInput = document.getElementById('observerLonInput');
@@ -565,6 +570,17 @@ function loadObserverLocation() {
if (currentLonDisplay) {
currentLonDisplay.textContent = lon ? parseFloat(lon).toFixed(4) + '°' : 'Not set';
}
// Sync dashboard-specific location keys for backward compatibility
if (lat && lon) {
const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) });
if (!localStorage.getItem('observerLocation')) {
localStorage.setItem('observerLocation', locationObj);
}
if (!localStorage.getItem('ais_observerLocation')) {
localStorage.setItem('ais_observerLocation', locationObj);
}
}
}
/**
@@ -622,9 +638,9 @@ function detectLocationGPS(btn) {
/**
* Save observer location to localStorage
*/
function saveObserverLocation() {
const latInput = document.getElementById('observerLatInput');
const lonInput = document.getElementById('observerLonInput');
function saveObserverLocation() {
const latInput = document.getElementById('observerLatInput');
const lonInput = document.getElementById('observerLonInput');
const lat = parseFloat(latInput?.value);
const lon = parseFloat(lonInput?.value);
@@ -647,18 +663,32 @@ function saveObserverLocation() {
return;
}
localStorage.setItem('observerLat', lat.toString());
localStorage.setItem('observerLon', lon.toString());
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat, lon });
} else {
localStorage.setItem('observerLat', lat.toString());
localStorage.setItem('observerLon', lon.toString());
}
// Also update dashboard-specific location keys for ADS-B and AIS
const locationObj = JSON.stringify({ lat: lat, lon: lon });
localStorage.setItem('observerLocation', locationObj); // ADS-B dashboard
localStorage.setItem('ais_observerLocation', locationObj); // AIS dashboard
// Update display
const currentLatDisplay = document.getElementById('currentLatDisplay');
const currentLonDisplay = document.getElementById('currentLonDisplay');
if (currentLatDisplay) currentLatDisplay.textContent = lat.toFixed(4) + '°';
if (currentLonDisplay) currentLonDisplay.textContent = lon.toFixed(4) + '°';
if (typeof showNotification === 'function') {
showNotification('Location', 'Observer location saved');
}
if (currentLatDisplay) currentLatDisplay.textContent = lat.toFixed(4) + '°';
if (currentLonDisplay) currentLonDisplay.textContent = lon.toFixed(4) + '°';
if (typeof showNotification === 'function') {
showNotification('Location', 'Observer location saved');
}
if (window.observerLocation) {
window.observerLocation.lat = lat;
window.observerLocation.lon = lon;
}
// Refresh SSTV ISS schedule if available
if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') {
+74 -17
View File
@@ -143,7 +143,7 @@ const Meshtastic = (function() {
if (data.running) {
isConnected = true;
updateConnectionUI(true, data.device);
updateConnectionUI(true, data.device, data.connection_type);
if (data.node_info) {
updateNodeInfo(data.node_info);
localNodeId = data.node_info.num;
@@ -158,21 +158,64 @@ const Meshtastic = (function() {
}
}
/**
* Handle connection type change (serial vs TCP)
*/
function onConnectionTypeChange() {
const connTypeSelect = document.getElementById('meshStripConnType');
const deviceSelect = document.getElementById('meshStripDevice');
const hostnameInput = document.getElementById('meshStripHostname');
if (!connTypeSelect) return;
const connType = connTypeSelect.value;
if (connType === 'tcp') {
// Show hostname input, hide device select
if (deviceSelect) deviceSelect.style.display = 'none';
if (hostnameInput) hostnameInput.style.display = 'block';
} else {
// Show device select, hide hostname input
if (deviceSelect) deviceSelect.style.display = 'block';
if (hostnameInput) hostnameInput.style.display = 'none';
}
}
/**
* Start Meshtastic connection
*/
async function start() {
// Try strip device select first, then sidebar
const stripDeviceSelect = document.getElementById('meshStripDevice');
const sidebarDeviceSelect = document.getElementById('meshDeviceSelect');
let device = stripDeviceSelect?.value || sidebarDeviceSelect?.value || null;
// Get connection type
const connTypeSelect = document.getElementById('meshStripConnType');
const connectionType = connTypeSelect?.value || 'serial';
// Check if auto-detect is selected but multiple ports exist
if (!device && stripDeviceSelect && stripDeviceSelect.options.length > 2) {
// Multiple ports available - prompt user to select one
showStatusMessage('Multiple ports detected. Please select a specific device from the dropdown.', 'warning');
updateStatusIndicator('disconnected', 'Select a device');
return;
// Get connection parameters based on type
let device = null;
let hostname = null;
if (connectionType === 'tcp') {
// TCP connection - get hostname
const hostnameInput = document.getElementById('meshStripHostname');
hostname = hostnameInput?.value?.trim() || null;
if (!hostname) {
showStatusMessage('Please enter a hostname or IP address for TCP connection', 'error');
updateStatusIndicator('disconnected', 'Enter hostname');
return;
}
} else {
// Serial connection - get device
const stripDeviceSelect = document.getElementById('meshStripDevice');
const sidebarDeviceSelect = document.getElementById('meshDeviceSelect');
device = stripDeviceSelect?.value || sidebarDeviceSelect?.value || null;
// Check if auto-detect is selected but multiple ports exist
if (!device && stripDeviceSelect && stripDeviceSelect.options.length > 2) {
// Multiple ports available - prompt user to select one
showStatusMessage('Multiple ports detected. Please select a specific device from the dropdown.', 'warning');
updateStatusIndicator('disconnected', 'Select a device');
return;
}
}
updateStatusIndicator('connecting', 'Connecting...');
@@ -184,17 +227,27 @@ const Meshtastic = (function() {
if (stripStatus) stripStatus.textContent = 'Connecting...';
try {
const requestBody = {
connection_type: connectionType
};
if (connectionType === 'tcp') {
requestBody.hostname = hostname;
} else if (device) {
requestBody.device = device;
}
const response = await fetch('/meshtastic/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device: device || undefined })
body: JSON.stringify(requestBody)
});
const data = await response.json();
if (data.status === 'started' || data.status === 'already_running') {
isConnected = true;
updateConnectionUI(true, data.device);
updateConnectionUI(true, data.device, data.connection_type);
if (data.node_info) {
updateNodeInfo(data.node_info);
localNodeId = data.node_info.num;
@@ -202,7 +255,8 @@ const Meshtastic = (function() {
loadChannels();
loadNodes();
startStream();
showNotification('Meshtastic', 'Connected to device');
const connLabel = data.connection_type === 'tcp' ? 'TCP' : 'Serial';
showNotification('Meshtastic', `Connected via ${connLabel}`);
} else {
updateStatusIndicator('disconnected', data.message || 'Connection failed');
showStatusMessage(data.message || 'Failed to connect', 'error');
@@ -232,7 +286,7 @@ const Meshtastic = (function() {
/**
* Update connection UI state
*/
function updateConnectionUI(connected, device) {
function updateConnectionUI(connected, device, connectionType) {
const connectBtn = document.getElementById('meshConnectBtn');
const disconnectBtn = document.getElementById('meshDisconnectBtn');
const nodeSection = document.getElementById('meshNodeSection');
@@ -248,7 +302,9 @@ const Meshtastic = (function() {
const stripStatus = document.getElementById('meshStripStatus');
if (connected) {
updateStatusIndicator('connected', device ? `Connected to ${device}` : 'Connected');
const connLabel = connectionType === 'tcp' ? 'TCP' : 'Serial';
const statusText = device ? `${device} (${connLabel})` : `Connected (${connLabel})`;
updateStatusIndicator('connected', statusText);
if (connectBtn) connectBtn.style.display = 'none';
if (disconnectBtn) disconnectBtn.style.display = 'block';
if (nodeSection) nodeSection.style.display = 'block';
@@ -263,7 +319,7 @@ const Meshtastic = (function() {
if (stripDot) {
stripDot.className = 'mesh-strip-dot connected';
}
if (stripStatus) stripStatus.textContent = device || 'Connected';
if (stripStatus) stripStatus.textContent = statusText;
} else {
updateStatusIndicator('disconnected', 'Disconnected');
if (connectBtn) connectBtn.style.display = 'block';
@@ -2200,6 +2256,7 @@ const Meshtastic = (function() {
init,
start,
stop,
onConnectionTypeChange,
loadPorts,
refreshChannels,
openChannelModal,
+33 -20
View File
@@ -37,15 +37,20 @@ const SSTV = (function() {
/**
* Load location into input fields
*/
function loadLocationInputs() {
const latInput = document.getElementById('sstvObsLat');
const lonInput = document.getElementById('sstvObsLon');
const storedLat = localStorage.getItem('observerLat');
const storedLon = localStorage.getItem('observerLon');
if (latInput && storedLat) latInput.value = storedLat;
if (lonInput && storedLon) lonInput.value = storedLon;
function loadLocationInputs() {
const latInput = document.getElementById('sstvObsLat');
const lonInput = document.getElementById('sstvObsLon');
let storedLat = localStorage.getItem('observerLat');
let storedLon = localStorage.getItem('observerLon');
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
storedLat = shared.lat.toString();
storedLon = shared.lon.toString();
}
if (latInput && storedLat) latInput.value = storedLat;
if (lonInput && storedLon) lonInput.value = storedLon;
// Add change handlers to save and refresh
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
@@ -55,19 +60,23 @@ const SSTV = (function() {
/**
* Save location from input fields
*/
function saveLocationFromInputs() {
const latInput = document.getElementById('sstvObsLat');
const lonInput = document.getElementById('sstvObsLon');
function saveLocationFromInputs() {
const latInput = document.getElementById('sstvObsLat');
const lonInput = document.getElementById('sstvObsLon');
const lat = parseFloat(latInput?.value);
const lon = parseFloat(lonInput?.value);
if (!isNaN(lat) && lat >= -90 && lat <= 90 &&
!isNaN(lon) && lon >= -180 && lon <= 180) {
localStorage.setItem('observerLat', lat.toString());
localStorage.setItem('observerLon', lon.toString());
loadIssSchedule(); // Refresh pass predictions
}
if (!isNaN(lat) && lat >= -90 && lat <= 90 &&
!isNaN(lon) && lon >= -180 && lon <= 180) {
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat, lon });
} else {
localStorage.setItem('observerLat', lat.toString());
localStorage.setItem('observerLon', lon.toString());
}
loadIssSchedule(); // Refresh pass predictions
}
}
/**
@@ -94,8 +103,12 @@ const SSTV = (function() {
if (latInput) latInput.value = lat;
if (lonInput) lonInput.value = lon;
localStorage.setItem('observerLat', lat);
localStorage.setItem('observerLon', lon);
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat: parseFloat(lat), lon: parseFloat(lon) });
} else {
localStorage.setItem('observerLat', lat);
localStorage.setItem('observerLon', lon);
}
btn.innerHTML = originalText;
btn.disabled = false;
+106 -5
View File
@@ -77,6 +77,7 @@ const WiFiMode = (function() {
let scanMode = 'quick'; // 'quick' or 'deep'
let eventSource = null;
let pollTimer = null;
let agentPollTimer = null;
// Data stores
let networks = new Map(); // bssid -> network
@@ -505,8 +506,13 @@ const WiFiMode = (function() {
console.log('[WiFiMode] Agent deep scan started:', scanResult);
}
// Start SSE stream for real-time updates
// Start SSE stream for real-time updates (works with push-enabled agents)
startEventStream();
// Also start polling for agent data (works without push enabled)
if (isAgentMode) {
startAgentDeepScanPolling();
}
} catch (error) {
console.error('[WiFiMode] Deep scan error:', error);
showError(error.message);
@@ -523,6 +529,9 @@ const WiFiMode = (function() {
pollTimer = null;
}
// Stop agent polling
stopAgentDeepScanPolling();
// Close event stream
if (eventSource) {
eventSource.close();
@@ -584,9 +593,18 @@ const WiFiMode = (function() {
const status = isAgentMode && data.result ? data.result : data;
if (status.is_scanning || status.running) {
setScanning(true, status.scan_mode);
if (status.scan_mode === 'deep') {
// Agent returns scan_type in params, local returns scan_mode
// Normalize: agent may return 'deepscan' or 'deep', UI expects 'deep' or 'quick'
let detectedMode = status.scan_mode || (status.params && status.params.scan_type) || 'deep';
if (detectedMode === 'deepscan') detectedMode = 'deep';
setScanning(true, detectedMode);
if (detectedMode === 'deep') {
startEventStream();
// Also start polling for agent mode (works without push enabled)
if (isAgentMode) {
startAgentDeepScanPolling();
}
} else {
startQuickScanPolling();
}
@@ -655,6 +673,76 @@ const WiFiMode = (function() {
});
}
// ==========================================================================
// Agent Deep Scan Polling (fallback when push is not enabled)
// ==========================================================================
function startAgentDeepScanPolling() {
if (agentPollTimer) return;
console.log('[WiFiMode] Starting agent deep scan polling...');
agentPollTimer = setInterval(async () => {
if (!isScanning || scanMode !== 'deep') {
clearInterval(agentPollTimer);
agentPollTimer = null;
return;
}
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
if (!isAgentMode) {
clearInterval(agentPollTimer);
agentPollTimer = null;
return;
}
try {
const response = await fetch(`/controller/agents/${currentAgent}/wifi/data`);
if (!response.ok) return;
const result = await response.json();
if (result.status !== 'success' || !result.data) return;
const data = result.data.data || result.data;
const agentName = result.agent_name || 'Remote';
// Process networks
if (data.networks && Array.isArray(data.networks)) {
data.networks.forEach(net => {
net._agent = agentName;
handleStreamEvent({
type: 'network_update',
network: net
});
});
}
// Process clients
if (data.clients && Array.isArray(data.clients)) {
data.clients.forEach(client => {
client._agent = agentName;
handleStreamEvent({
type: 'client_update',
client: client
});
});
}
console.debug(`[WiFiMode] Agent poll: ${data.networks?.length || 0} networks, ${data.clients?.length || 0} clients`);
} catch (error) {
console.debug('[WiFiMode] Agent poll error:', error);
}
}, 2000); // Poll every 2 seconds
}
function stopAgentDeepScanPolling() {
if (agentPollTimer) {
clearInterval(agentPollTimer);
agentPollTimer = null;
}
}
// ==========================================================================
// SSE Event Stream
// ==========================================================================
@@ -1292,9 +1380,19 @@ const WiFiMode = (function() {
console.log('[WiFiMode] Agent changed from', lastAgentId, 'to', currentAgentId);
// Stop any running scan
// Stop UI polling only - don't stop the actual scan on the agent
// The agent should continue running independently
if (isScanning) {
stopScan();
stopAgentDeepScanPolling();
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (eventSource) {
eventSource.close();
eventSource = null;
}
setScanning(false);
}
// Clear existing data when switching agents (unless "Show All" is enabled)
@@ -1306,6 +1404,9 @@ const WiFiMode = (function() {
// Refresh capabilities for new agent
checkCapabilities();
// Check if new agent already has a scan running
checkScanStatus();
lastAgentId = currentAgentId;
}
+61 -24
View File
@@ -20,6 +20,11 @@
{% endif %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}">
<script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
window.INTERCEPT_ADSB_AUTO_START = {{ adsb_auto_start | tojson }};
</script>
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head>
<body>
<div class="radar-bg"></div>
@@ -264,6 +269,7 @@
<select id="adsbDeviceSelect" title="SDR device for ADS-B (1090 MHz)">
<option value="0">SDR 0</option>
</select>
<label class="bias-t-label" title="Enable Bias-T power for external LNA/preamp"><input type="checkbox" id="adsbBiasT" onchange="saveAdsbBiasTSetting()"> Bias-T</label>
<button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button>
</div>
</div>
@@ -317,10 +323,23 @@
<script>
// ============================================
// BIAS-T HELPER (reads from main dashboard localStorage)
// BIAS-T HELPER
// ============================================
function getBiasTEnabled() {
return localStorage.getItem('biasTEnabled') === 'true';
return document.getElementById('adsbBiasT')?.checked || false;
}
function saveAdsbBiasTSetting() {
const enabled = document.getElementById('adsbBiasT')?.checked || false;
localStorage.setItem('adsbBiasTEnabled', enabled);
}
function loadAdsbBiasTSetting() {
const saved = localStorage.getItem('adsbBiasTEnabled');
if (saved === 'true') {
const checkbox = document.getElementById('adsbBiasT');
if (checkbox) checkbox.checked = true;
}
}
// ============================================
@@ -518,6 +537,9 @@
// Observer location and range rings (load from localStorage or default to London)
let observerLocation = (function() {
if (window.ObserverLocation && ObserverLocation.getForModule) {
return ObserverLocation.getForModule('observerLocation');
}
const saved = localStorage.getItem('observerLocation');
if (saved) {
try {
@@ -1803,7 +1825,11 @@ ACARS: ${r.statistics.acarsMessages} messages`;
observerLocation.lon = lon;
// Save to localStorage for persistence
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
if (window.ObserverLocation) {
ObserverLocation.setForModule('observerLocation', observerLocation);
} else {
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
}
if (radarMap) {
radarMap.setView([lat, lon], radarMap.getZoom());
@@ -1831,7 +1857,11 @@ ACARS: ${r.statistics.acarsMessages} messages`;
observerLocation.lon = position.coords.longitude;
// Save to localStorage for persistence
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
if (window.ObserverLocation) {
ObserverLocation.setForModule('observerLocation', observerLocation);
} else {
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
}
document.getElementById('obsLat').value = observerLocation.lat.toFixed(4);
document.getElementById('obsLon').value = observerLocation.lon.toFixed(4);
@@ -1925,6 +1955,9 @@ ACARS: ${r.statistics.acarsMessages} messages`;
observerLocation.lon = position.longitude;
document.getElementById('obsLat').value = position.latitude.toFixed(4);
document.getElementById('obsLon').value = position.longitude.toFixed(4);
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat: position.latitude, lon: position.longitude });
}
// Center map on GPS location (on first fix)
if (radarMap && !radarMap._gpsInitialized) {
@@ -1989,6 +2022,9 @@ ACARS: ${r.statistics.acarsMessages} messages`;
const detectionToggle = document.getElementById('detectionSoundToggle');
if (detectionToggle) detectionToggle.checked = detectionSoundEnabled;
// Load Bias-T setting from localStorage
loadAdsbBiasTSetting();
initMap();
initDeviceSelectors();
updateClock();
@@ -2027,27 +2063,18 @@ ACARS: ${r.statistics.acarsMessages} messages`;
} else {
devices.forEach((dev, i) => {
const idx = dev.index !== undefined ? dev.index : i;
// Build descriptive label
const type = dev.sdr_type || dev.driver || 'RTL-SDR';
const typeName = type.toUpperCase().replace('RTLSDR', 'RTL-SDR');
const shortSerial = dev.serial ? ` (${dev.serial.slice(-4)})` : '';
const displayName = `${typeName} #${idx}${shortSerial}`;
const fullName = dev.name || `${typeName} Device ${idx}`;
const tooltip = `${fullName}${dev.serial ? ' - Serial: ' + dev.serial : ''}`;
const displayName = `SDR ${idx}: ${dev.name}`;
// Add to ADS-B selector
const adsbOpt = document.createElement('option');
adsbOpt.value = idx;
adsbOpt.textContent = displayName;
adsbOpt.title = tooltip;
adsbSelect.appendChild(adsbOpt);
// Add to Airband selector
const airbandOpt = document.createElement('option');
airbandOpt.value = idx;
airbandOpt.textContent = displayName;
airbandOpt.title = tooltip;
airbandSelect.appendChild(airbandOpt);
});
@@ -2537,9 +2564,13 @@ sudo make install</code>
try {
const response = await fetch('/adsb/session');
if (!response.ok) {
// No session info - try to auto-start if SDR available
console.log('[ADS-B] No session found, attempting auto-start...');
await tryAutoStartLocal();
// No session info - only auto-start if enabled
if (window.INTERCEPT_ADSB_AUTO_START) {
console.log('[ADS-B] No session found, attempting auto-start...');
await tryAutoStartLocal();
} else {
console.log('[ADS-B] No session found; auto-start disabled');
}
return;
}
const data = await response.json();
@@ -2581,15 +2612,21 @@ sudo make install</code>
const statusEl = document.getElementById('trackingStatus');
statusEl.textContent = 'TRACKING';
} else {
// Session not active - try to auto-start
console.log('[ADS-B] No active session, attempting auto-start...');
await tryAutoStartLocal();
// Session not active - only auto-start if enabled
if (window.INTERCEPT_ADSB_AUTO_START) {
console.log('[ADS-B] No active session, attempting auto-start...');
await tryAutoStartLocal();
} else {
console.log('[ADS-B] No active session; auto-start disabled');
}
}
} catch (err) {
console.warn('[ADS-B] Failed to sync tracking status:', err);
// Try auto-start anyway
await tryAutoStartLocal();
// Try auto-start only if enabled
if (window.INTERCEPT_ADSB_AUTO_START) {
await tryAutoStartLocal();
}
}
}
@@ -3975,7 +4012,7 @@ sudo make install</code>
devices.forEach((d, i) => {
const opt = document.createElement('option');
opt.value = d.index || i;
opt.textContent = `Device ${d.index || i}: ${d.name || d.type || 'SDR'}`;
opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`;
select.appendChild(opt);
});
// Default to device 1 if available (device 0 likely used for ADS-B)
@@ -4745,7 +4782,7 @@ sudo make install</code>
devices.forEach(device => {
const opt = document.createElement('option');
opt.value = device.index;
opt.textContent = `Device ${device.index}: ${device.name || device.type || 'SDR'}`;
opt.textContent = `SDR ${device.index}: ${device.name || device.type || 'SDR'}`;
select.appendChild(opt);
});
}
+1 -3
View File
@@ -558,11 +558,9 @@
}
devices.forEach((dev, idx) => {
const index = dev.index !== undefined ? dev.index : idx;
const type = (dev.sdr_type || dev.driver || 'RTL-SDR').toUpperCase();
const serial = dev.serial ? ` (${dev.serial.slice(-4)})` : '';
const opt = document.createElement('option');
opt.value = index;
opt.textContent = `${type} #${index}${serial}`;
opt.textContent = `SDR ${index}: ${dev.name}`;
sessionDeviceSelect.appendChild(opt);
});
sessionDeviceSelect.disabled = false;
+17
View File
@@ -337,6 +337,7 @@
<div class="form-group">
<label for="agentApiKey">API Key (optional)</label>
<input type="text" id="agentApiKey" placeholder="shared-secret">
<small style="color: #888; font-size: 11px;">Required if agent has push mode enabled with API key</small>
</div>
</div>
<div class="form-row">
@@ -455,6 +456,22 @@
const apiKey = document.getElementById('agentApiKey').value.trim();
const description = document.getElementById('agentDescription').value.trim();
// Validate URL format
try {
const url = new URL(baseUrl);
if (!url.port && !baseUrl.includes(':80') && !baseUrl.includes(':443')) {
showToast('URL should include a port (e.g., http://192.168.1.50:8020)', 'error');
return;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
showToast('URL must start with http:// or https://', 'error');
return;
}
} catch (e) {
showToast('Invalid URL format. Use: http://IP_ADDRESS:PORT', 'error');
return;
}
try {
const response = await fetch('/controller/agents', {
method: 'POST',
+23 -14
View File
@@ -20,6 +20,10 @@
{% endif %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
</script>
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head>
<body>
<!-- Radar background effects -->
@@ -219,7 +223,12 @@
const MAX_TRAIL_POINTS = 50;
// Observer location
let observerLocation = { lat: 51.5074, lon: -0.1278 };
let observerLocation = (function() {
if (window.ObserverLocation && ObserverLocation.getForModule) {
return ObserverLocation.getForModule('ais_observerLocation');
}
return { lat: 51.5074, lon: -0.1278 };
})();
let rangeRingsLayer = null;
let observerMarker = null;
@@ -376,17 +385,9 @@
// Initialize map
function initMap() {
// Load saved observer location
const saved = localStorage.getItem('ais_observerLocation');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) {
observerLocation = parsed;
document.getElementById('obsLat').value = parsed.lat;
document.getElementById('obsLon').value = parsed.lon;
}
} catch (e) {}
if (observerLocation) {
document.getElementById('obsLat').value = observerLocation.lat;
document.getElementById('obsLon').value = observerLocation.lon;
}
vesselMap = L.map('vesselMap', {
@@ -470,7 +471,11 @@
const lon = parseFloat(document.getElementById('obsLon').value);
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
observerLocation = { lat, lon };
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation));
if (window.ObserverLocation) {
ObserverLocation.setForModule('ais_observerLocation', observerLocation);
} else {
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation));
}
if (observerMarker) {
observerMarker.setLatLng([lat, lon]);
}
@@ -1058,7 +1063,11 @@
drawRangeRings();
// Save to localStorage
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation));
if (window.ObserverLocation) {
ObserverLocation.setForModule('ais_observerLocation', observerLocation);
} else {
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation));
}
}
function showGpsIndicator(show) {
+193 -25
View File
@@ -14,6 +14,10 @@
window._showDisclaimerOnLoad = true;
}
</script>
<script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
</script>
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
<!-- Fonts - Conditional CDN/Local loading -->
{% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
@@ -419,6 +423,7 @@
<a href="/controller/monitor" class="nav-tool-btn" title="Network Monitor - Multi-Agent View" style="text-decoration: none;"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span></a>
<a href="/controller/manage" class="nav-tool-btn" title="Manage Remote Agents" style="text-decoration: none;"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span></a>
<button class="nav-tool-btn" onclick="showSettings()" title="Settings"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span></button>
<a href="https://buymeacoffee.com/smittix" target="_blank" rel="noopener noreferrer" class="nav-tool-btn nav-tool-btn--donate" title="Support the Project"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/></svg></span></a>
<button class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation">?</button>
<button class="nav-tool-btn" onclick="logout(event)" title="Logout">
<span class="power-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span>
@@ -533,6 +538,14 @@
Refresh Devices
</button>
<!-- SDR Device Status -->
<div id="sdrStatusPanel" style="margin-top: 10px; border: 1px solid var(--border-color); border-radius: 4px;">
<div id="sdrStatusList" style="max-height: 150px; overflow-y: auto;"></div>
<div style="padding: 6px 8px; background: var(--bg-tertiary); border-top: 1px solid var(--border-color); font-size: 10px; color: #666;">
Auto-refreshes every 5s
</div>
</div>
<!-- Remote SDR (rtl_tcp) -->
<div class="form-group" style="margin-top: 10px;">
<label class="inline-checkbox">
@@ -1625,9 +1638,14 @@
<span class="mesh-strip-dot disconnected" id="meshStripDot"></span>
<span class="mesh-strip-status-text" id="meshStripStatus">Disconnected</span>
</div>
<select id="meshStripConnType" class="mesh-strip-select" title="Connection Type" onchange="Meshtastic.onConnectionTypeChange()" style="width: 70px;">
<option value="serial">Serial</option>
<option value="tcp">TCP</option>
</select>
<select id="meshStripDevice" class="mesh-strip-select" title="Device">
<option value="">Auto-detect</option>
</select>
<input type="text" id="meshStripHostname" class="mesh-strip-input" placeholder="IP address" title="Hostname/IP for TCP" style="display: none; width: 120px;">
<button class="mesh-strip-btn connect" id="meshStripConnectBtn" onclick="Meshtastic.start()">Connect</button>
<button class="mesh-strip-btn disconnect" id="meshStripDisconnectBtn" onclick="Meshtastic.stop()" style="display: none;">Disconnect</button>
</div>
@@ -2252,6 +2270,9 @@
// Observer location for distance calculations (load from localStorage or default to London)
let observerLocation = (function () {
if (window.ObserverLocation && ObserverLocation.getForModule) {
return ObserverLocation.getForModule('observerLocation');
}
const saved = localStorage.getItem('observerLocation');
if (saved) {
try {
@@ -2406,6 +2427,19 @@
// Load bias-T setting from localStorage
loadBiasTSetting();
// Initialize device list from server-provided data
// This ensures currentDeviceList is populated on page load (fixes #99)
if (typeof deviceList !== 'undefined' && deviceList.length > 0) {
currentDeviceList = deviceList;
const firstType = deviceList[0].sdr_type || 'rtlsdr';
const sdrTypeSelect = document.getElementById('sdrTypeSelect');
if (sdrTypeSelect) {
sdrTypeSelect.value = firstType;
}
// Defer onSDRTypeChanged to ensure DOM is ready
setTimeout(onSDRTypeChanged, 0);
}
// Initialize observer location input fields from saved location
const obsLatInput = document.getElementById('obsLat');
const obsLonInput = document.getElementById('obsLon');
@@ -2420,6 +2454,9 @@
// Initialize dropdown nav active state
updateDropdownActiveState();
// Start SDR device status polling
startSdrStatusPolling();
});
// Toggle section collapse
@@ -3004,7 +3041,18 @@
if (data.pressure_hPa !== undefined) {
msg.pressure = data.pressure_hPa;
msg.pressure_unit = 'hPa';
} else if (data.pressure_PSI !== undefined) {
msg.pressure = data.pressure_PSI;
msg.pressure_unit = 'PSI';
} else if (data.pressure_kPa !== undefined) {
msg.pressure = data.pressure_kPa;
msg.pressure_unit = 'kPa';
} else if (data.tire_pressure_kPa !== undefined) {
msg.pressure = data.tire_pressure_kPa;
msg.pressure_unit = 'kPa';
}
if (data.flags !== undefined) msg.state = data.flags;
else if (data.state !== undefined) msg.state = data.state;
if (data.wind_avg_km_h !== undefined) {
msg.wind_speed = data.wind_avg_km_h;
msg.wind_unit = 'km/h';
@@ -3681,6 +3729,9 @@
// Trigger filter update
onSDRTypeChanged();
// Also refresh SDR status panel
fetchSdrStatus();
})
.catch(err => {
console.error('Failed to refresh devices:', err);
@@ -3689,6 +3740,71 @@
});
}
// SDR Device Status Panel
let sdrStatusPollingInterval = null;
function renderSdrStatus(devices) {
const container = document.getElementById('sdrStatusList');
if (!container) return;
if (!devices || devices.length === 0) {
container.innerHTML = '<div style="padding: 8px; color: #888; font-size: 11px; text-align: center;">No SDR devices detected</div>';
return;
}
const html = devices.map(d => {
const isActive = d.in_use;
const statusDot = isActive
? '<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #00ff88; box-shadow: 0 0 6px #00ff88; margin-right: 6px;"></span>'
: '<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #555; margin-right: 6px;"></span>';
const modeName = d.used_by ? d.used_by.toUpperCase() : 'IDLE';
const modeColor = isActive ? '#00ff88' : '#666';
const sdrType = (d.sdr_type || 'RTL').toUpperCase().replace('RTLSDR', 'RTL');
return `<div style="display: flex; align-items: center; justify-content: space-between; padding: 6px 8px; border-bottom: 1px solid var(--border-color);">
<div style="display: flex; align-items: center;">
${statusDot}
<span style="font-size: 11px;">#${d.index} ${d.name || 'Unknown'}</span>
</div>
<div style="display: flex; align-items: center; gap: 6px;">
<span style="font-size: 10px; color: ${modeColor}; font-weight: bold;">${modeName}</span>
<span style="font-size: 9px; padding: 1px 4px; background: var(--bg-tertiary); border-radius: 3px; color: #888;">${sdrType}</span>
</div>
</div>`;
}).join('');
container.innerHTML = html;
}
function fetchSdrStatus() {
fetch('/devices/status')
.then(r => r.json())
.then(devices => {
renderSdrStatus(devices);
})
.catch(err => {
console.error('Failed to fetch SDR status:', err);
const container = document.getElementById('sdrStatusList');
if (container) {
container.innerHTML = '<div style="padding: 8px; color: #ff6666; font-size: 11px; text-align: center;">Error loading status</div>';
}
});
}
function startSdrStatusPolling() {
// Initial fetch
fetchSdrStatus();
// Poll every 5 seconds
sdrStatusPollingInterval = setInterval(fetchSdrStatus, 5000);
}
function stopSdrStatusPolling() {
if (sdrStatusPollingInterval) {
clearInterval(sdrStatusPollingInterval);
sdrStatusPollingInterval = null;
}
}
function getSelectedDevice() {
return document.getElementById('deviceSelect').value;
}
@@ -5421,6 +5537,53 @@
const select = document.getElementById('wifiInterfaceSelect');
select.innerHTML = '<option value="">Loading interfaces...</option>';
// Check if we're in agent mode
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
if (isAgentMode) {
// Fetch from agent via controller
fetch(`/controller/agents/${currentAgent}?refresh=true`)
.then(r => {
if (!r.ok) throw new Error('Failed to fetch agent interfaces');
return r.json();
})
.then(data => {
const interfaces = data.agent?.interfaces?.wifi_interfaces || [];
if (interfaces.length === 0) {
select.innerHTML = '<option value="">No WiFi interfaces on agent</option>';
showNotification('WiFi', 'No WiFi interfaces found on remote agent.');
monitorInterface = null;
updateMonitorStatus(false);
} else {
select.innerHTML = interfaces.map(i => {
let label = i.name || i;
if (i.display_name) label = i.display_name;
else if (i.type) label += ` (${i.type})`;
if (i.monitor_capable) label += ' [Monitor OK]';
return `<option value="${i.name || i}" data-type="${i.type || 'managed'}">${label}</option>`;
}).join('');
showNotification('WiFi', `Found ${interfaces.length} interface(s) on agent`);
// Check if any interface is already in monitor mode
const monitorIface = interfaces.find(i => i.type === 'monitor');
if (monitorIface) {
monitorInterface = monitorIface.name;
updateMonitorStatus(true);
select.value = monitorIface.name;
} else {
monitorInterface = null;
updateMonitorStatus(false);
}
}
})
.catch(err => {
console.error('Failed to refresh agent interfaces:', err);
select.innerHTML = '<option value="">Error loading agent interfaces</option>';
showNotification('WiFi', 'Failed to load agent interfaces');
});
return;
}
fetch('/wifi/interfaces')
.then(r => {
if (!r.ok) throw new Error('Failed to fetch interfaces');
@@ -5477,6 +5640,7 @@
}
const killProcesses = document.getElementById('killProcesses').checked;
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
// Show loading state
const btn = document.getElementById('monitorStartBtn');
@@ -5484,7 +5648,12 @@
btn.textContent = 'Enabling...';
btn.disabled = true;
fetch('/wifi/monitor', {
// Use agent endpoint if in agent mode
const endpoint = isAgentMode
? `/controller/agents/${currentAgent}/wifi/monitor`
: '/wifi/monitor';
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interface: iface, action: 'start', kill_processes: killProcesses })
@@ -5496,29 +5665,13 @@
if (data.status === 'success') {
monitorInterface = data.monitor_interface;
updateMonitorStatus(true);
showInfo('Monitor mode enabled on ' + monitorInterface + ' - Ready to scan!');
const location = isAgentMode ? ' on remote agent' : '';
showInfo('Monitor mode enabled on ' + monitorInterface + location + ' - Ready to scan!');
// Refresh interface list and auto-select the monitor interface
fetch('/wifi/interfaces')
.then(r => r.json())
.then(ifaceData => {
const select = document.getElementById('wifiInterfaceSelect');
if (ifaceData.interfaces.length > 0) {
select.innerHTML = ifaceData.interfaces.map(i => {
let label = i.name;
let details = [];
if (i.chipset) details.push(i.chipset);
else if (i.driver) details.push(i.driver);
if (i.mac) details.push(i.mac.substring(0, 8) + '...');
if (details.length > 0) label += ' - ' + details.join(' | ');
label += ` (${i.type})`;
if (i.monitor_capable) label += ' [Monitor OK]';
return `<option value="${i.name}" ${i.name === monitorInterface ? 'selected' : ''}>${label}</option>`;
}).join('');
}
});
refreshWifiInterfaces();
} else {
alert('Error: ' + data.message);
alert('Error: ' + (data.message || 'Unknown error'));
}
})
.catch(err => {
@@ -5531,8 +5684,13 @@
// Disable monitor mode
function disableMonitorMode() {
const iface = monitorInterface || document.getElementById('wifiInterfaceSelect').value;
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
fetch('/wifi/monitor', {
const endpoint = isAgentMode
? `/controller/agents/${currentAgent}/wifi/monitor`
: '/wifi/monitor';
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interface: iface, action: 'stop' })
@@ -5543,7 +5701,7 @@
updateMonitorStatus(false);
showInfo('Monitor mode disabled');
} else {
alert('Error: ' + data.message);
alert('Error: ' + (data.message || 'Unknown error'));
}
});
}
@@ -8364,8 +8522,15 @@
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
position => {
document.getElementById('obsLat').value = position.coords.latitude.toFixed(4);
document.getElementById('obsLon').value = position.coords.longitude.toFixed(4);
const lat = position.coords.latitude;
const lon = position.coords.longitude;
document.getElementById('obsLat').value = lat.toFixed(4);
document.getElementById('obsLon').value = lon.toFixed(4);
observerLocation.lat = lat;
observerLocation.lon = lon;
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat, lon });
}
showInfo('Location updated!');
},
error => {
@@ -8465,6 +8630,9 @@
// Update observerLocation
observerLocation.lat = position.latitude;
observerLocation.lon = position.longitude;
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat: position.latitude, lon: position.longitude });
}
// Update APRS user location
updateAprsUserLocation(position);
+19 -1
View File
@@ -291,10 +291,28 @@
WiFi/Bluetooth scanning, and more.
</p>
<p>
<a href="https://github.com/intercept" target="_blank">GitHub Repository</a>
<a href="https://github.com/smittix/intercept" target="_blank">GitHub Repository</a>
</p>
</div>
</div>
<div class="settings-group">
<div class="settings-group-title">Support the Project</div>
<p style="color: var(--text-dim); margin-bottom: 15px; font-size: 12px;">
If you find iNTERCEPT useful, consider supporting its development.
</p>
<a href="https://buymeacoffee.com/smittix" target="_blank" rel="noopener noreferrer" class="donate-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 18px; height: 18px; vertical-align: -3px; margin-right: 8px;">
<path d="M17 8h1a4 4 0 1 1 0 8h-1"/>
<path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/>
<line x1="6" y1="2" x2="6" y2="4"/>
<line x1="10" y1="2" x2="10" y2="4"/>
<line x1="14" y1="2" x2="14" y2="4"/>
</svg>
Buy Me a Coffee
</a>
</div>
</div>
</div>
</div>
+82 -47
View File
@@ -17,10 +17,14 @@
{% else %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
{% endif %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
</head>
{% endif %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
<script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
</script>
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head>
<body>
<div class="grid-bg"></div>
<div class="scanline"></div>
@@ -313,16 +317,33 @@
}
}
document.addEventListener('DOMContentLoaded', () => {
setupEmbeddedMode();
initGroundMap();
updateClock();
setInterval(updateClock, 1000);
setInterval(updateCountdown, 1000);
setInterval(updateRealTimePositions, 5000);
loadAgents();
getLocation();
});
function applySharedObserverLocation() {
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
if (shared) {
const latInput = document.getElementById('obsLat');
const lonInput = document.getElementById('obsLon');
if (latInput) latInput.value = shared.lat.toFixed(4);
if (lonInput) lonInput.value = shared.lon.toFixed(4);
return true;
}
}
return false;
}
document.addEventListener('DOMContentLoaded', () => {
setupEmbeddedMode();
const usedShared = applySharedObserverLocation();
initGroundMap();
updateClock();
setInterval(updateClock, 1000);
setInterval(updateCountdown, 1000);
setInterval(updateRealTimePositions, 5000);
loadAgents();
if (!usedShared) {
getLocation();
}
});
async function loadAgents() {
try {
@@ -376,10 +397,13 @@
if (data.status === 'success' && data.result) {
const agentStatus = data.result;
if (agentStatus.gps_position) {
const gps = agentStatus.gps_position;
document.getElementById('obsLat').value = gps.lat.toFixed(4);
document.getElementById('obsLon').value = gps.lon.toFixed(4);
if (agentStatus.gps_position) {
const gps = agentStatus.gps_position;
document.getElementById('obsLat').value = gps.lat.toFixed(4);
document.getElementById('obsLon').value = gps.lon.toFixed(4);
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat: gps.lat, lon: gps.lon });
}
// Update observer marker label
const agent = agents.find(a => a.id == agentId);
@@ -407,39 +431,50 @@
now.toISOString().substring(11, 19) + ' UTC';
}
function initGroundMap() {
groundMap = L.map('groundMap', {
center: [20, 0],
zoom: 2,
minZoom: 1,
maxZoom: 10,
worldCopyJump: true
});
function initGroundMap() {
groundMap = L.map('groundMap', {
center: [20, 0],
zoom: 2,
minZoom: 1,
maxZoom: 10,
worldCopyJump: true
});
// Use settings manager for tile layer (allows runtime changes)
window.groundMap = groundMap;
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
Settings.createTileLayer().addTo(groundMap);
Settings.registerMap(groundMap);
} else {
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd'
}).addTo(groundMap);
}
}
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
Settings.createTileLayer().addTo(groundMap);
Settings.registerMap(groundMap);
} else {
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd'
}).addTo(groundMap);
}
const lat = parseFloat(document.getElementById('obsLat')?.value);
const lon = parseFloat(document.getElementById('obsLon')?.value);
if (!Number.isNaN(lat) && !Number.isNaN(lon)) {
groundMap.setView([lat, lon], 3);
}
}
function getLocation() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => {
document.getElementById('obsLat').value = pos.coords.latitude.toFixed(4);
document.getElementById('obsLon').value = pos.coords.longitude.toFixed(4);
calculatePasses();
}, () => {
calculatePasses();
});
} else {
function getLocation() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => {
const lat = pos.coords.latitude;
const lon = pos.coords.longitude;
document.getElementById('obsLat').value = lat.toFixed(4);
document.getElementById('obsLon').value = lon.toFixed(4);
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat, lon });
}
calculatePasses();
}, () => {
calculatePasses();
});
} else {
calculatePasses();
}
}
+587
View File
@@ -0,0 +1,587 @@
"""
Unit tests for deauthentication attack detector.
"""
import os
import sys
import time
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from utils.wifi.deauth_detector import (
DeauthDetector,
DeauthPacketInfo,
DeauthTracker,
DeauthAlert,
DEAUTH_REASON_CODES,
)
from utils.constants import (
DEAUTH_DETECTION_WINDOW,
DEAUTH_ALERT_THRESHOLD,
DEAUTH_CRITICAL_THRESHOLD,
)
class TestDeauthPacketInfo:
"""Tests for DeauthPacketInfo dataclass."""
def test_creation(self):
"""Test basic creation of packet info."""
pkt = DeauthPacketInfo(
timestamp=1234567890.0,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
signal_dbm=-45,
)
assert pkt.frame_type == 'deauth'
assert pkt.src_mac == 'AA:BB:CC:DD:EE:FF'
assert pkt.reason_code == 7
assert pkt.signal_dbm == -45
class TestDeauthTracker:
"""Tests for DeauthTracker."""
def test_add_packet(self):
"""Test adding packets to tracker."""
tracker = DeauthTracker()
pkt1 = DeauthPacketInfo(
timestamp=100.0,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
)
tracker.add_packet(pkt1)
assert len(tracker.packets) == 1
assert tracker.first_seen == 100.0
assert tracker.last_seen == 100.0
def test_multiple_packets(self):
"""Test adding multiple packets."""
tracker = DeauthTracker()
for i in range(5):
pkt = DeauthPacketInfo(
timestamp=100.0 + i,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
)
tracker.add_packet(pkt)
assert len(tracker.packets) == 5
assert tracker.first_seen == 100.0
assert tracker.last_seen == 104.0
def test_get_packets_in_window(self):
"""Test filtering packets by time window."""
tracker = DeauthTracker()
now = time.time()
# Add old packet
tracker.add_packet(DeauthPacketInfo(
timestamp=now - 10,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
))
# Add recent packets
for i in range(3):
tracker.add_packet(DeauthPacketInfo(
timestamp=now - i,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
))
# 5-second window should only include the 3 recent packets
in_window = tracker.get_packets_in_window(5.0)
assert len(in_window) == 3
def test_cleanup_old_packets(self):
"""Test removing old packets."""
tracker = DeauthTracker()
now = time.time()
# Add old packet
tracker.add_packet(DeauthPacketInfo(
timestamp=now - 20,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
))
# Add recent packet
tracker.add_packet(DeauthPacketInfo(
timestamp=now,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
))
tracker.alert_sent = True
# Cleanup with 10-second window
tracker.cleanup_old_packets(10.0)
assert len(tracker.packets) == 1
assert tracker.packets[0].timestamp == now
def test_cleanup_resets_alert_sent(self):
"""Test that cleanup resets alert_sent when all packets removed."""
tracker = DeauthTracker()
now = time.time()
tracker.add_packet(DeauthPacketInfo(
timestamp=now - 100, # Very old
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='AA:BB:CC:DD:EE:FF',
reason_code=7,
))
tracker.alert_sent = True
# Cleanup should remove all packets
tracker.cleanup_old_packets(10.0)
assert len(tracker.packets) == 0
assert tracker.alert_sent is False
class TestDeauthAlert:
"""Tests for DeauthAlert."""
def test_to_dict(self):
"""Test conversion to dictionary."""
alert = DeauthAlert(
id='deauth-123-1',
timestamp=1234567890.0,
severity='high',
attacker_mac='AA:BB:CC:DD:EE:FF',
attacker_vendor='Unknown',
attacker_signal_dbm=-45,
is_spoofed_ap=True,
target_mac='11:22:33:44:55:66',
target_vendor='Apple',
target_type='client',
target_known_from_scan=True,
ap_bssid='AA:BB:CC:DD:EE:FF',
ap_essid='TestNetwork',
ap_channel=6,
frame_type='deauth',
reason_code=7,
reason_text='Class 3 frame received from nonassociated STA',
packet_count=50,
window_seconds=5.0,
packets_per_second=10.0,
attack_type='targeted',
description='Targeted deauth flood against known client',
)
d = alert.to_dict()
assert d['id'] == 'deauth-123-1'
assert d['type'] == 'deauth_alert'
assert d['severity'] == 'high'
assert d['attacker']['mac'] == 'AA:BB:CC:DD:EE:FF'
assert d['attacker']['is_spoofed_ap'] is True
assert d['target']['type'] == 'client'
assert d['access_point']['essid'] == 'TestNetwork'
assert d['attack_info']['packet_count'] == 50
assert d['analysis']['attack_type'] == 'targeted'
class TestDeauthDetector:
"""Tests for DeauthDetector."""
def test_init(self):
"""Test detector initialization."""
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
assert detector.interface == 'wlan0mon'
assert detector.event_callback == callback
assert not detector.is_running
def test_stats(self):
"""Test stats property."""
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
stats = detector.stats
assert stats['is_running'] is False
assert stats['interface'] == 'wlan0mon'
assert stats['packets_captured'] == 0
assert stats['alerts_generated'] == 0
def test_get_alerts_empty(self):
"""Test getting alerts when none exist."""
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
alerts = detector.get_alerts()
assert alerts == []
def test_clear_alerts(self):
"""Test clearing alerts."""
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
# Add a mock alert
detector._alerts.append(MagicMock())
detector._trackers[('A', 'B', 'C')] = DeauthTracker()
detector._alert_counter = 5
detector.clear_alerts()
assert len(detector._alerts) == 0
assert len(detector._trackers) == 0
assert detector._alert_counter == 0
@patch('utils.wifi.deauth_detector.time.time')
def test_generate_alert_severity_low(self, mock_time):
"""Test alert generation with low severity."""
mock_time.return_value = 1000.0
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
# Create packets just at threshold
packets = []
for i in range(DEAUTH_ALERT_THRESHOLD):
packets.append(DeauthPacketInfo(
timestamp=1000.0 - (DEAUTH_ALERT_THRESHOLD - 1 - i) * 0.1,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='99:88:77:66:55:44',
reason_code=7,
signal_dbm=-50,
))
alert = detector._generate_alert(
tracker_key=('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44'),
packets=packets,
packet_count=DEAUTH_ALERT_THRESHOLD,
)
assert alert.severity == 'low'
assert alert.packet_count == DEAUTH_ALERT_THRESHOLD
@patch('utils.wifi.deauth_detector.time.time')
def test_generate_alert_severity_high(self, mock_time):
"""Test alert generation with high severity."""
mock_time.return_value = 1000.0
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
# Create packets above critical threshold
packets = []
for i in range(DEAUTH_CRITICAL_THRESHOLD):
packets.append(DeauthPacketInfo(
timestamp=1000.0 - (DEAUTH_CRITICAL_THRESHOLD - 1 - i) * 0.1,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='99:88:77:66:55:44',
reason_code=7,
))
alert = detector._generate_alert(
tracker_key=('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44'),
packets=packets,
packet_count=DEAUTH_CRITICAL_THRESHOLD,
)
assert alert.severity == 'high'
@patch('utils.wifi.deauth_detector.time.time')
def test_generate_alert_broadcast_attack(self, mock_time):
"""Test alert classification for broadcast attack."""
mock_time.return_value = 1000.0
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
packets = [DeauthPacketInfo(
timestamp=999.9,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='FF:FF:FF:FF:FF:FF', # Broadcast
bssid='99:88:77:66:55:44',
reason_code=7,
)]
alert = detector._generate_alert(
tracker_key=('AA:BB:CC:DD:EE:FF', 'FF:FF:FF:FF:FF:FF', '99:88:77:66:55:44'),
packets=packets,
packet_count=10,
)
assert alert.attack_type == 'broadcast'
assert alert.target_type == 'broadcast'
assert 'all clients' in alert.description.lower()
def test_lookup_ap_no_callback(self):
"""Test AP lookup when no callback is provided."""
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
get_networks=None,
)
result = detector._lookup_ap('AA:BB:CC:DD:EE:FF')
assert result['bssid'] == 'AA:BB:CC:DD:EE:FF'
assert result['essid'] is None
assert result['channel'] is None
def test_lookup_ap_with_callback(self):
"""Test AP lookup with callback."""
callback = MagicMock()
get_networks = MagicMock(return_value={
'AA:BB:CC:DD:EE:FF': {'essid': 'TestNet', 'channel': 6}
})
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
get_networks=get_networks,
)
result = detector._lookup_ap('AA:BB:CC:DD:EE:FF')
assert result['bssid'] == 'AA:BB:CC:DD:EE:FF'
assert result['essid'] == 'TestNet'
assert result['channel'] == 6
def test_check_spoofed_source(self):
"""Test detection of spoofed AP source."""
callback = MagicMock()
get_networks = MagicMock(return_value={
'AA:BB:CC:DD:EE:FF': {'essid': 'TestNet'}
})
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
get_networks=get_networks,
)
# Source matches known AP - spoofed
assert detector._check_spoofed_source('AA:BB:CC:DD:EE:FF') is True
# Source does not match any AP - not spoofed
assert detector._check_spoofed_source('11:22:33:44:55:66') is False
def test_cleanup_old_trackers(self):
"""Test cleanup of old trackers."""
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
now = time.time()
# Add an old tracker
old_tracker = DeauthTracker()
old_tracker.add_packet(DeauthPacketInfo(
timestamp=now - 100, # Very old
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='99:88:77:66:55:44',
reason_code=7,
))
detector._trackers[('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44')] = old_tracker
# Add a recent tracker
recent_tracker = DeauthTracker()
recent_tracker.add_packet(DeauthPacketInfo(
timestamp=now,
frame_type='deauth',
src_mac='BB:CC:DD:EE:FF:AA',
dst_mac='22:33:44:55:66:77',
bssid='88:77:66:55:44:33',
reason_code=7,
))
detector._trackers[('BB:CC:DD:EE:FF:AA', '22:33:44:55:66:77', '88:77:66:55:44:33')] = recent_tracker
detector._cleanup_old_trackers()
# Old tracker should be removed
assert ('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44') not in detector._trackers
# Recent tracker should remain
assert ('BB:CC:DD:EE:FF:AA', '22:33:44:55:66:77', '88:77:66:55:44:33') in detector._trackers
class TestReasonCodes:
"""Tests for reason code dictionary."""
def test_common_reason_codes(self):
"""Test that common reason codes are defined."""
assert 1 in DEAUTH_REASON_CODES # Unspecified
assert 7 in DEAUTH_REASON_CODES # Class 3 frame
assert 14 in DEAUTH_REASON_CODES # MIC failure
def test_reason_code_descriptions(self):
"""Test reason code descriptions are strings."""
for code, desc in DEAUTH_REASON_CODES.items():
assert isinstance(code, int)
assert isinstance(desc, str)
assert len(desc) > 0
class TestDeauthDetectorIntegration:
"""Integration tests for DeauthDetector with mocked scapy."""
@patch('utils.wifi.deauth_detector.time.time')
def test_process_deauth_packet_generates_alert(self, mock_time):
"""Test that processing packets generates alert when threshold exceeded."""
mock_time.return_value = 1000.0
callback = MagicMock()
detector = DeauthDetector(
interface='wlan0mon',
event_callback=callback,
)
# Create a mock scapy packet
mock_pkt = MagicMock()
# Mock Dot11Deauth layer
mock_deauth = MagicMock()
mock_deauth.reason = 7
# Mock Dot11 layer
mock_dot11 = MagicMock()
mock_dot11.addr1 = '11:22:33:44:55:66' # dst
mock_dot11.addr2 = 'AA:BB:CC:DD:EE:FF' # src
mock_dot11.addr3 = '99:88:77:66:55:44' # bssid
# Mock RadioTap layer
mock_radiotap = MagicMock()
mock_radiotap.dBm_AntSignal = -50
# Set up haslayer behavior
def haslayer_side_effect(layer):
if 'Dot11Deauth' in str(layer):
return True
if 'Dot11Disas' in str(layer):
return False
if 'RadioTap' in str(layer):
return True
return False
mock_pkt.haslayer = haslayer_side_effect
# Set up __getitem__ behavior
def getitem_side_effect(layer):
if 'Dot11Deauth' in str(layer):
return mock_deauth
if 'Dot11' in str(layer) and 'Deauth' not in str(layer):
return mock_dot11
if 'RadioTap' in str(layer):
return mock_radiotap
return MagicMock()
mock_pkt.__getitem__ = getitem_side_effect
# Patch the scapy imports inside _process_deauth_packet
with patch('utils.wifi.deauth_detector.DeauthDetector._process_deauth_packet.__globals__', {
'Dot11': MagicMock,
'Dot11Deauth': MagicMock,
'Dot11Disas': MagicMock,
'RadioTap': MagicMock,
}):
# Process enough packets to trigger alert
for i in range(DEAUTH_ALERT_THRESHOLD + 5):
mock_time.return_value = 1000.0 + i * 0.1
# Manually simulate what _process_deauth_packet does
pkt_info = DeauthPacketInfo(
timestamp=mock_time.return_value,
frame_type='deauth',
src_mac='AA:BB:CC:DD:EE:FF',
dst_mac='11:22:33:44:55:66',
bssid='99:88:77:66:55:44',
reason_code=7,
signal_dbm=-50,
)
detector._packets_captured += 1
tracker_key = ('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '99:88:77:66:55:44')
tracker = detector._trackers[tracker_key]
tracker.add_packet(pkt_info)
packets_in_window = tracker.get_packets_in_window(DEAUTH_DETECTION_WINDOW)
packet_count = len(packets_in_window)
if packet_count >= DEAUTH_ALERT_THRESHOLD and not tracker.alert_sent:
alert = detector._generate_alert(
tracker_key=tracker_key,
packets=packets_in_window,
packet_count=packet_count,
)
detector._alerts.append(alert)
detector._alerts_generated += 1
tracker.alert_sent = True
detector.event_callback(alert.to_dict())
# Verify alert was generated
assert detector._alerts_generated == 1
assert len(detector._alerts) == 1
assert callback.called
# Verify callback was called with alert data
call_args = callback.call_args[0][0]
assert call_args['type'] == 'deauth_alert'
assert call_args['attacker']['mac'] == 'AA:BB:CC:DD:EE:FF'
assert call_args['target']['mac'] == '11:22:33:44:55:66'
+4
View File
@@ -141,6 +141,10 @@ class AgentClient:
except requests.RequestException as e:
raise AgentHTTPError(f"Request failed: {e}")
def post(self, path: str, data: dict | None = None) -> dict:
"""Public POST method for arbitrary endpoints."""
return self._post(path, data)
# =========================================================================
# Capability & Status
# =========================================================================
+20
View File
@@ -254,3 +254,23 @@ MAX_DSC_MESSAGE_AGE_SECONDS = 3600 # 1 hour
# DSC process termination timeout
DSC_TERMINATE_TIMEOUT = 3
# =============================================================================
# DEAUTH ATTACK DETECTION
# =============================================================================
# Time window for grouping deauth packets (seconds)
DEAUTH_DETECTION_WINDOW = 5
# Number of deauth packets in window to trigger alert
DEAUTH_ALERT_THRESHOLD = 10
# Number of deauth packets in window for critical severity
DEAUTH_CRITICAL_THRESHOLD = 50
# Maximum age for deauth alerts in DataStore (seconds)
MAX_DEAUTH_ALERTS_AGE_SECONDS = 300 # 5 minutes
# Deauth detector sniff timeout (seconds)
DEAUTH_SNIFF_TIMEOUT = 0.5
+42 -13
View File
@@ -3,7 +3,10 @@
This module provides integration with Meshtastic mesh networking devices,
allowing INTERCEPT to receive and decode messages from LoRa mesh networks.
Requires a physical Meshtastic device connected via USB/Serial.
Supports multiple connection types:
- USB/Serial: Physical device connected via USB
- TCP: WiFi-enabled devices (T-Beam, Heltec WiFi LoRa, etc.)
Install SDK with: pip install meshtastic
"""
@@ -28,6 +31,7 @@ logger = get_logger('intercept.meshtastic')
try:
import meshtastic
import meshtastic.serial_interface
import meshtastic.tcp_interface
from meshtastic import BROADCAST_ADDR
from pubsub import pub
HAS_MESHTASTIC = True
@@ -278,6 +282,7 @@ class MeshtasticClient:
self._lock = threading.Lock()
self._nodes: dict[int, MeshNode] = {} # num -> MeshNode
self._device_path: str | None = None
self._connection_type: str | None = None # 'serial' or 'tcp'
self._error: str | None = None
self._traceroute_results: list[TracerouteResult] = []
self._max_traceroute_results = 50
@@ -309,6 +314,10 @@ class MeshtasticClient:
def device_path(self) -> str | None:
return self._device_path
@property
def connection_type(self) -> str | None:
return self._connection_type
@property
def error(self) -> str | None:
return self._error
@@ -317,13 +326,16 @@ class MeshtasticClient:
"""Set callback for received messages."""
self._callback = callback
def connect(self, device: str | None = None) -> bool:
def connect(self, device: str | None = None, connection_type: str = 'serial',
hostname: str | None = None) -> bool:
"""
Connect to a Meshtastic device.
Args:
device: Serial port path (e.g., /dev/ttyUSB0, /dev/ttyACM0).
If None, auto-discovers first available device.
Only used for serial connections. If None, auto-discovers.
connection_type: Connection type - 'serial' or 'tcp' (default: 'serial')
hostname: Hostname or IP address for TCP connections (e.g., '192.168.1.100')
Returns:
True if connected successfully.
@@ -342,18 +354,30 @@ class MeshtasticClient:
pub.subscribe(self._on_connection, "meshtastic.connection.established")
pub.subscribe(self._on_disconnect, "meshtastic.connection.lost")
# Connect to device
if device:
self._interface = meshtastic.serial_interface.SerialInterface(device)
self._device_path = device
# Connect based on connection type
if connection_type == 'tcp':
if not hostname:
self._error = "Hostname is required for TCP connections"
self._cleanup_subscriptions()
return False
self._interface = meshtastic.tcp_interface.TCPInterface(hostname=hostname)
self._device_path = hostname
self._connection_type = 'tcp'
logger.info(f"Connected to Meshtastic device via TCP: {hostname}")
else:
# Auto-discover
self._interface = meshtastic.serial_interface.SerialInterface()
self._device_path = "auto"
# Serial connection (default)
if device:
self._interface = meshtastic.serial_interface.SerialInterface(device)
self._device_path = device
else:
# Auto-discover
self._interface = meshtastic.serial_interface.SerialInterface()
self._device_path = "auto"
self._connection_type = 'serial'
logger.info(f"Connected to Meshtastic device via serial: {self._device_path}")
self._running = True
self._error = None
logger.info(f"Connected to Meshtastic device: {self._device_path}")
return True
except Exception as e:
@@ -375,6 +399,7 @@ class MeshtasticClient:
self._cleanup_subscriptions()
self._running = False
self._device_path = None
self._connection_type = None
logger.info("Disconnected from Meshtastic device")
def _cleanup_subscriptions(self) -> None:
@@ -1502,13 +1527,17 @@ def get_meshtastic_client() -> MeshtasticClient | None:
def start_meshtastic(device: str | None = None,
callback: Callable[[MeshtasticMessage], None] | None = None) -> bool:
callback: Callable[[MeshtasticMessage], None] | None = None,
connection_type: str = 'serial',
hostname: str | None = None) -> bool:
"""
Start the Meshtastic client.
Args:
device: Serial port path (optional, auto-discovers if not provided)
callback: Function to call when messages are received
connection_type: Connection type - 'serial' or 'tcp' (default: 'serial')
hostname: Hostname or IP address for TCP connections
Returns:
True if started successfully
@@ -1522,7 +1551,7 @@ def start_meshtastic(device: str | None = None,
if callback:
_client.set_callback(callback)
return _client.connect(device)
return _client.connect(device, connection_type=connection_type, hostname=hostname)
def stop_meshtastic() -> None:
+41 -1
View File
@@ -7,11 +7,44 @@ with existing RTL-SDR installations. No SoapySDR dependency required.
from __future__ import annotations
import logging
import subprocess
from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
from utils.dependencies import get_tool_path
logger = logging.getLogger('intercept.sdr.rtlsdr')
def _get_dump1090_bias_t_flag(dump1090_path: str) -> Optional[str]:
"""Detect the correct bias-t flag for the installed dump1090 variant.
Different dump1090 forks use different flags:
- dump1090-fa, readsb: --enable-biast (no hyphen before 't')
- dump1090-mutability, original dump1090: no bias-t support
Returns the correct flag string or None if bias-t is not supported.
"""
try:
result = subprocess.run(
[dump1090_path, '--help'],
capture_output=True,
text=True,
timeout=5
)
help_text = result.stdout + result.stderr
# Check for dump1090-fa/readsb style flag (no hyphen)
if '--enable-biast' in help_text:
return '--enable-biast'
# No bias-t support found
return None
except Exception as e:
logger.warning(f"Could not detect dump1090 bias-t support: {e}")
return None
class RTLSDRCommandBuilder(CommandBuilder):
"""RTL-SDR command builder using native rtl_* tools."""
@@ -113,7 +146,14 @@ class RTLSDRCommandBuilder(CommandBuilder):
cmd.extend(['--gain', str(int(gain))])
if bias_t:
cmd.extend(['--enable-bias-t'])
bias_t_flag = _get_dump1090_bias_t_flag(dump1090_path)
if bias_t_flag:
cmd.append(bias_t_flag)
else:
logger.warning(
f"Bias-t requested but {dump1090_path} does not support it. "
"Consider using dump1090-fa or readsb for bias-t support."
)
return cmd
+322 -18
View File
@@ -4,6 +4,8 @@ This module provides SSTV decoding capabilities for receiving images
from the International Space Station during special events.
ISS SSTV typically transmits on 145.800 MHz FM.
Includes real-time Doppler shift compensation for improved reception.
"""
from __future__ import annotations
@@ -14,7 +16,7 @@ import subprocess
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Callable
@@ -25,10 +27,151 @@ logger = get_logger('intercept.sstv')
# ISS SSTV frequency
ISS_SSTV_FREQ = 145.800 # MHz
# Speed of light in m/s
SPEED_OF_LIGHT = 299_792_458
# Common SSTV modes used by ISS
SSTV_MODES = ['PD120', 'PD180', 'Martin1', 'Martin2', 'Scottie1', 'Scottie2', 'Robot36']
@dataclass
class DopplerInfo:
"""Doppler shift information."""
frequency_hz: float # Doppler-corrected frequency in Hz
shift_hz: float # Doppler shift in Hz (positive = approaching)
range_rate_km_s: float # Range rate in km/s (negative = approaching)
elevation: float # Current elevation in degrees
azimuth: float # Current azimuth in degrees
timestamp: datetime
def to_dict(self) -> dict:
return {
'frequency_hz': self.frequency_hz,
'shift_hz': round(self.shift_hz, 1),
'range_rate_km_s': round(self.range_rate_km_s, 3),
'elevation': round(self.elevation, 1),
'azimuth': round(self.azimuth, 1),
'timestamp': self.timestamp.isoformat(),
}
class DopplerTracker:
"""
Real-time Doppler shift calculator for satellite tracking.
Uses skyfield to calculate the range rate between observer and satellite,
then computes the Doppler-shifted receive frequency.
"""
def __init__(self, satellite_name: str = 'ISS'):
self._satellite_name = satellite_name
self._observer_lat: float | None = None
self._observer_lon: float | None = None
self._satellite = None
self._observer = None
self._ts = None
self._enabled = False
def configure(self, latitude: float, longitude: float) -> bool:
"""
Configure the Doppler tracker with observer location.
Args:
latitude: Observer latitude in degrees
longitude: Observer longitude in degrees
Returns:
True if configured successfully
"""
try:
from skyfield.api import load, wgs84, EarthSatellite
from data.satellites import TLE_SATELLITES
# Get satellite TLE
tle_data = TLE_SATELLITES.get(self._satellite_name)
if not tle_data:
logger.error(f"No TLE data for satellite: {self._satellite_name}")
return False
self._ts = load.timescale()
self._satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], self._ts)
self._observer = wgs84.latlon(latitude, longitude)
self._observer_lat = latitude
self._observer_lon = longitude
self._enabled = True
logger.info(f"Doppler tracker configured for {self._satellite_name} at ({latitude}, {longitude})")
return True
except ImportError:
logger.warning("skyfield not available - Doppler tracking disabled")
return False
except Exception as e:
logger.error(f"Failed to configure Doppler tracker: {e}")
return False
@property
def is_enabled(self) -> bool:
return self._enabled
def calculate(self, nominal_freq_mhz: float) -> DopplerInfo | None:
"""
Calculate current Doppler-shifted frequency.
Args:
nominal_freq_mhz: Nominal transmit frequency in MHz
Returns:
DopplerInfo with corrected frequency, or None if unavailable
"""
if not self._enabled or not self._satellite or not self._observer:
return None
try:
# Get current time
t = self._ts.now()
# Calculate satellite position relative to observer
difference = self._satellite - self._observer
topocentric = difference.at(t)
# Get altitude/azimuth
alt, az, distance = topocentric.altaz()
# Get velocity (range rate) - negative means approaching
# We need the rate of change of distance
# Calculate positions slightly apart to get velocity
dt_seconds = 1.0
t_future = self._ts.utc(t.utc_datetime() + timedelta(seconds=dt_seconds))
topocentric_future = difference.at(t_future)
_, _, distance_future = topocentric_future.altaz()
# Range rate in km/s (negative = approaching = positive Doppler)
range_rate_km_s = (distance_future.km - distance.km) / dt_seconds
# Calculate Doppler shift
# f_received = f_transmitted * (1 - v_radial / c)
# When approaching (negative range_rate), frequency is higher
nominal_freq_hz = nominal_freq_mhz * 1_000_000
doppler_factor = 1 - (range_rate_km_s * 1000 / SPEED_OF_LIGHT)
corrected_freq_hz = nominal_freq_hz * doppler_factor
shift_hz = corrected_freq_hz - nominal_freq_hz
return DopplerInfo(
frequency_hz=corrected_freq_hz,
shift_hz=shift_hz,
range_rate_km_s=range_rate_km_s,
elevation=alt.degrees,
azimuth=az.degrees,
timestamp=datetime.now(timezone.utc)
)
except Exception as e:
logger.error(f"Doppler calculation failed: {e}")
return None
@dataclass
class SSTVImage:
"""Decoded SSTV image."""
@@ -76,19 +219,34 @@ class DecodeProgress:
class SSTVDecoder:
"""SSTV decoder using external tools (slowrx or qsstv)."""
"""SSTV decoder using external tools (slowrx) with Doppler compensation."""
# Minimum frequency change (Hz) before retuning rtl_fm
RETUNE_THRESHOLD_HZ = 500
# How often to check/update Doppler (seconds)
DOPPLER_UPDATE_INTERVAL = 5
def __init__(self, output_dir: str | Path | None = None):
self._process = None
self._rtl_process = None
self._running = False
self._lock = threading.Lock()
self._callback: Callable[[DecodeProgress], None] | None = None
self._output_dir = Path(output_dir) if output_dir else Path('instance/sstv_images')
self._images: list[SSTVImage] = []
self._reader_thread = None
self._watcher_thread = None
self._doppler_thread = None
self._frequency = ISS_SSTV_FREQ
self._current_tuned_freq_hz: int = 0
self._device_index = 0
# Doppler tracking
self._doppler_tracker = DopplerTracker('ISS')
self._doppler_enabled = False
self._last_doppler_info: DopplerInfo | None = None
# Ensure output directory exists
self._output_dir.mkdir(parents=True, exist_ok=True)
@@ -114,13 +272,7 @@ class SSTVDecoder:
except Exception:
pass
# Check for qsstv (if available as CLI)
try:
result = subprocess.run(['which', 'qsstv'], capture_output=True, timeout=5)
if result.returncode == 0:
return 'qsstv'
except Exception:
pass
# Note: qsstv is GUI-only and not suitable for headless/server operation
# Check for Python sstv package
try:
@@ -129,20 +281,28 @@ class SSTVDecoder:
except ImportError:
pass
logger.warning("No SSTV decoder found. Install slowrx or python sstv package.")
logger.warning("No SSTV decoder found. Install slowrx (apt install slowrx) or python sstv package. Note: qsstv is GUI-only and not supported for headless operation.")
return None
def set_callback(self, callback: Callable[[DecodeProgress], None]) -> None:
"""Set callback for decode progress updates."""
self._callback = callback
def start(self, frequency: float = ISS_SSTV_FREQ, device_index: int = 0) -> bool:
def start(
self,
frequency: float = ISS_SSTV_FREQ,
device_index: int = 0,
latitude: float | None = None,
longitude: float | None = None,
) -> bool:
"""
Start SSTV decoder listening on specified frequency.
Args:
frequency: Frequency in MHz (default: 145.800 for ISS)
device_index: RTL-SDR device index
latitude: Observer latitude for Doppler correction (optional)
longitude: Observer longitude for Doppler correction (optional)
Returns:
True if started successfully
@@ -162,6 +322,15 @@ class SSTVDecoder:
self._frequency = frequency
self._device_index = device_index
# Configure Doppler tracking if location provided
self._doppler_enabled = False
if latitude is not None and longitude is not None:
if self._doppler_tracker.configure(latitude, longitude):
self._doppler_enabled = True
logger.info(f"Doppler tracking enabled for location ({latitude}, {longitude})")
else:
logger.warning("Doppler tracking unavailable - using fixed frequency")
try:
if self._decoder == 'slowrx':
self._start_slowrx()
@@ -172,11 +341,23 @@ class SSTVDecoder:
return False
self._running = True
logger.info(f"SSTV decoder started on {frequency} MHz")
self._emit_progress(DecodeProgress(
status='detecting',
message=f'Listening on {frequency} MHz...'
))
# Start Doppler tracking thread if enabled
if self._doppler_enabled:
self._doppler_thread = threading.Thread(target=self._doppler_tracking_loop, daemon=True)
self._doppler_thread.start()
logger.info(f"SSTV decoder started on {frequency} MHz with Doppler tracking")
self._emit_progress(DecodeProgress(
status='detecting',
message=f'Listening on {frequency} MHz with Doppler tracking...'
))
else:
logger.info(f"SSTV decoder started on {frequency} MHz (no Doppler tracking)")
self._emit_progress(DecodeProgress(
status='detecting',
message=f'Listening on {frequency} MHz...'
))
return True
except Exception as e:
@@ -189,9 +370,32 @@ class SSTVDecoder:
def _start_slowrx(self) -> None:
"""Start slowrx decoder with rtl_fm piped input."""
# Convert frequency to Hz
freq_hz = int(self._frequency * 1_000_000)
# Calculate initial frequency (with Doppler correction if enabled)
freq_hz = self._get_doppler_corrected_freq_hz()
self._current_tuned_freq_hz = freq_hz
self._start_rtl_fm_pipeline(freq_hz)
def _get_doppler_corrected_freq_hz(self) -> int:
"""Get the Doppler-corrected frequency in Hz."""
nominal_freq_hz = int(self._frequency * 1_000_000)
if self._doppler_enabled:
doppler_info = self._doppler_tracker.calculate(self._frequency)
if doppler_info:
self._last_doppler_info = doppler_info
corrected_hz = int(doppler_info.frequency_hz)
logger.info(
f"Doppler correction: {doppler_info.shift_hz:+.1f} Hz "
f"(range rate: {doppler_info.range_rate_km_s:+.3f} km/s, "
f"el: {doppler_info.elevation:.1f}°)"
)
return corrected_hz
return nominal_freq_hz
def _start_rtl_fm_pipeline(self, freq_hz: int) -> None:
"""Start the rtl_fm -> slowrx pipeline at the specified frequency."""
# Build rtl_fm command for FM demodulation
rtl_cmd = [
'rtl_fm',
@@ -237,6 +441,106 @@ class SSTVDecoder:
self._watcher_thread = threading.Thread(target=self._watch_images, daemon=True)
self._watcher_thread.start()
def _doppler_tracking_loop(self) -> None:
"""Background thread that monitors Doppler shift and retunes when needed."""
logger.info("Doppler tracking thread started")
while self._running and self._doppler_enabled:
time.sleep(self.DOPPLER_UPDATE_INTERVAL)
if not self._running:
break
try:
doppler_info = self._doppler_tracker.calculate(self._frequency)
if not doppler_info:
continue
self._last_doppler_info = doppler_info
new_freq_hz = int(doppler_info.frequency_hz)
freq_diff = abs(new_freq_hz - self._current_tuned_freq_hz)
# Log current Doppler status
logger.debug(
f"Doppler: {doppler_info.shift_hz:+.1f} Hz, "
f"el: {doppler_info.elevation:.1f}°, "
f"diff from tuned: {freq_diff} Hz"
)
# Emit Doppler update to callback
self._emit_progress(DecodeProgress(
status='detecting',
message=f'Doppler: {doppler_info.shift_hz:+.0f} Hz, elevation: {doppler_info.elevation:.1f}°'
))
# Retune if frequency has drifted enough
if freq_diff >= self.RETUNE_THRESHOLD_HZ:
logger.info(
f"Retuning: {self._current_tuned_freq_hz} -> {new_freq_hz} Hz "
f"(Doppler shift: {doppler_info.shift_hz:+.1f} Hz)"
)
self._retune_rtl_fm(new_freq_hz)
except Exception as e:
logger.error(f"Doppler tracking error: {e}")
logger.info("Doppler tracking thread stopped")
def _retune_rtl_fm(self, new_freq_hz: int) -> None:
"""
Retune rtl_fm to a new frequency.
Since rtl_fm doesn't support dynamic frequency changes, we need to
restart the rtl_fm process. The slowrx process continues running
and will resume decoding when audio resumes.
"""
with self._lock:
if not self._running:
return
# Terminate old rtl_fm process
if self._rtl_process:
try:
self._rtl_process.terminate()
self._rtl_process.wait(timeout=2)
except Exception:
try:
self._rtl_process.kill()
except Exception:
pass
# Start new rtl_fm at new frequency
rtl_cmd = [
'rtl_fm',
'-d', str(self._device_index),
'-f', str(new_freq_hz),
'-M', 'fm',
'-s', '48000',
'-r', '48000',
'-l', '0',
'-'
]
logger.debug(f"Restarting rtl_fm: {' '.join(rtl_cmd)}")
self._rtl_process = subprocess.Popen(
rtl_cmd,
stdout=self._process.stdin if self._process else subprocess.PIPE,
stderr=subprocess.PIPE
)
self._current_tuned_freq_hz = new_freq_hz
@property
def last_doppler_info(self) -> DopplerInfo | None:
"""Get the most recent Doppler calculation."""
return self._last_doppler_info
@property
def doppler_enabled(self) -> bool:
"""Check if Doppler tracking is enabled."""
return self._doppler_enabled
def _start_python_sstv(self) -> None:
"""Start Python SSTV decoder (requires audio file input)."""
# Python sstv package typically works with audio files
+616
View File
@@ -0,0 +1,616 @@
"""
Deauthentication attack detector using scapy.
Monitors a WiFi interface in monitor mode for deauthentication and disassociation
frames, detecting potential deauth flood attacks.
"""
from __future__ import annotations
import logging
import threading
import time
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime
from typing import Callable, Optional, Any
from utils.constants import (
DEAUTH_DETECTION_WINDOW,
DEAUTH_ALERT_THRESHOLD,
DEAUTH_CRITICAL_THRESHOLD,
DEAUTH_SNIFF_TIMEOUT,
)
logger = logging.getLogger(__name__)
# Deauth reason code descriptions
DEAUTH_REASON_CODES = {
0: "Reserved",
1: "Unspecified reason",
2: "Previous authentication no longer valid",
3: "Station is leaving (or has left) IBSS or ESS",
4: "Disassociated due to inactivity",
5: "Disassociated because AP is unable to handle all currently associated STAs",
6: "Class 2 frame received from nonauthenticated STA",
7: "Class 3 frame received from nonassociated STA",
8: "Disassociated because sending STA is leaving (or has left) BSS",
9: "STA requesting (re)association is not authenticated with responding STA",
10: "Disassociated because the information in the Power Capability element is unacceptable",
11: "Disassociated because the information in the Supported Channels element is unacceptable",
12: "Disassociated due to BSS Transition Management",
13: "Invalid information element",
14: "MIC failure",
15: "4-Way Handshake timeout",
16: "Group Key Handshake timeout",
17: "Information element in 4-Way Handshake different from (Re)Association Request/Probe Response/Beacon frame",
18: "Invalid group cipher",
19: "Invalid pairwise cipher",
20: "Invalid AKMP",
21: "Unsupported RSNE version",
22: "Invalid RSNE capabilities",
23: "IEEE 802.1X authentication failed",
24: "Cipher suite rejected because of security policy",
}
@dataclass
class DeauthPacketInfo:
"""Information about a captured deauth/disassoc packet."""
timestamp: float
frame_type: str # 'deauth' or 'disassoc'
src_mac: str
dst_mac: str
bssid: str
reason_code: int
signal_dbm: Optional[int] = None
@dataclass
class DeauthTracker:
"""Tracks deauth packets for a specific source/dest/bssid combination."""
packets: list[DeauthPacketInfo] = field(default_factory=list)
first_seen: float = 0.0
last_seen: float = 0.0
alert_sent: bool = False
def add_packet(self, pkt: DeauthPacketInfo):
self.packets.append(pkt)
now = pkt.timestamp
if self.first_seen == 0.0:
self.first_seen = now
self.last_seen = now
def get_packets_in_window(self, window_seconds: float) -> list[DeauthPacketInfo]:
"""Get packets within the time window."""
cutoff = time.time() - window_seconds
return [p for p in self.packets if p.timestamp >= cutoff]
def cleanup_old_packets(self, window_seconds: float):
"""Remove packets older than the window."""
cutoff = time.time() - window_seconds
self.packets = [p for p in self.packets if p.timestamp >= cutoff]
if self.packets:
self.first_seen = self.packets[0].timestamp
else:
self.first_seen = 0.0
self.alert_sent = False
@dataclass
class DeauthAlert:
"""A deauthentication attack alert."""
id: str
timestamp: float
severity: str # 'low', 'medium', 'high'
# Attacker info
attacker_mac: str
attacker_vendor: Optional[str]
attacker_signal_dbm: Optional[int]
is_spoofed_ap: bool
# Target info
target_mac: str
target_vendor: Optional[str]
target_type: str # 'client', 'broadcast', 'ap'
target_known_from_scan: bool
# Access point info
ap_bssid: str
ap_essid: Optional[str]
ap_channel: Optional[int]
# Attack info
frame_type: str
reason_code: int
reason_text: str
packet_count: int
window_seconds: float
packets_per_second: float
# Analysis
attack_type: str # 'targeted', 'broadcast', 'ap_flood'
description: str
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
'id': self.id,
'type': 'deauth_alert',
'timestamp': self.timestamp,
'severity': self.severity,
'attacker': {
'mac': self.attacker_mac,
'vendor': self.attacker_vendor,
'signal_dbm': self.attacker_signal_dbm,
'is_spoofed_ap': self.is_spoofed_ap,
},
'target': {
'mac': self.target_mac,
'vendor': self.target_vendor,
'type': self.target_type,
'known_from_scan': self.target_known_from_scan,
},
'access_point': {
'bssid': self.ap_bssid,
'essid': self.ap_essid,
'channel': self.ap_channel,
},
'attack_info': {
'frame_type': self.frame_type,
'reason_code': self.reason_code,
'reason_text': self.reason_text,
'packet_count': self.packet_count,
'window_seconds': self.window_seconds,
'packets_per_second': self.packets_per_second,
},
'analysis': {
'attack_type': self.attack_type,
'description': self.description,
},
}
class DeauthDetector:
"""
Detects deauthentication attacks using scapy.
Monitors a WiFi interface in monitor mode for deauth/disassoc frames
and emits alerts when attack thresholds are exceeded.
"""
def __init__(
self,
interface: str,
event_callback: Callable[[dict], None],
get_networks: Optional[Callable[[], dict[str, Any]]] = None,
get_clients: Optional[Callable[[], dict[str, Any]]] = None,
):
"""
Initialize the deauth detector.
Args:
interface: Monitor mode interface to sniff on
event_callback: Callback function to receive alert events
get_networks: Optional function to get current WiFi networks (bssid -> network_info)
get_clients: Optional function to get current WiFi clients (mac -> client_info)
"""
self.interface = interface
self.event_callback = event_callback
self.get_networks = get_networks
self.get_clients = get_clients
self._stop_event = threading.Event()
self._thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
# Track deauth packets by (src, dst, bssid) tuple
self._trackers: dict[tuple[str, str, str], DeauthTracker] = defaultdict(DeauthTracker)
# Alert history
self._alerts: list[DeauthAlert] = []
self._alert_counter = 0
# Stats
self._packets_captured = 0
self._alerts_generated = 0
self._started_at: Optional[float] = None
@property
def is_running(self) -> bool:
"""Check if detector is running."""
return self._thread is not None and self._thread.is_alive()
@property
def stats(self) -> dict:
"""Get detector statistics."""
return {
'is_running': self.is_running,
'interface': self.interface,
'started_at': self._started_at,
'packets_captured': self._packets_captured,
'alerts_generated': self._alerts_generated,
'active_trackers': len(self._trackers),
}
def start(self) -> bool:
"""
Start detection in background thread.
Returns:
True if started successfully.
"""
if self.is_running:
logger.warning("Deauth detector already running")
return True
self._stop_event.clear()
self._started_at = time.time()
self._thread = threading.Thread(
target=self._sniff_loop,
name="DeauthDetector",
daemon=True,
)
self._thread.start()
logger.info(f"Deauth detector started on {self.interface}")
return True
def stop(self) -> bool:
"""
Stop detection.
Returns:
True if stopped successfully.
"""
if not self.is_running:
return True
logger.info("Stopping deauth detector...")
self._stop_event.set()
if self._thread:
self._thread.join(timeout=5)
if self._thread.is_alive():
logger.warning("Deauth detector thread did not stop cleanly")
self._thread = None
self._started_at = None
logger.info("Deauth detector stopped")
return True
def get_alerts(self, limit: int = 100) -> list[dict]:
"""Get recent alerts."""
with self._lock:
return [a.to_dict() for a in self._alerts[-limit:]]
def clear_alerts(self):
"""Clear alert history."""
with self._lock:
self._alerts.clear()
self._trackers.clear()
self._alert_counter = 0
def _sniff_loop(self):
"""Main sniffing loop using scapy."""
try:
from scapy.all import sniff, Dot11, Dot11Deauth, Dot11Disas
except ImportError:
logger.error("scapy not installed. Install with: pip install scapy")
self.event_callback({
'type': 'deauth_error',
'error': 'scapy not installed',
})
return
logger.info(f"Starting deauth sniff on {self.interface}")
def packet_handler(pkt):
"""Handle each captured packet."""
if self._stop_event.is_set():
return
# Check for deauth or disassoc frames
if pkt.haslayer(Dot11Deauth) or pkt.haslayer(Dot11Disas):
self._process_deauth_packet(pkt)
try:
# Use stop_filter to allow clean shutdown
sniff(
iface=self.interface,
prn=packet_handler,
store=False,
stop_filter=lambda _: self._stop_event.is_set(),
timeout=DEAUTH_SNIFF_TIMEOUT,
)
# Continue sniffing until stop is requested
while not self._stop_event.is_set():
sniff(
iface=self.interface,
prn=packet_handler,
store=False,
stop_filter=lambda _: self._stop_event.is_set(),
timeout=DEAUTH_SNIFF_TIMEOUT,
)
# Periodic cleanup
self._cleanup_old_trackers()
except OSError as e:
if "No such device" in str(e):
logger.error(f"Interface {self.interface} not found")
self.event_callback({
'type': 'deauth_error',
'error': f'Interface {self.interface} not found',
})
else:
logger.exception(f"Sniff error: {e}")
self.event_callback({
'type': 'deauth_error',
'error': str(e),
})
except Exception as e:
logger.exception(f"Sniff error: {e}")
self.event_callback({
'type': 'deauth_error',
'error': str(e),
})
def _process_deauth_packet(self, pkt):
"""Process a deauth/disassoc packet and emit alert if threshold exceeded."""
try:
from scapy.all import Dot11, Dot11Deauth, Dot11Disas, RadioTap
except ImportError:
return
# Determine frame type
if pkt.haslayer(Dot11Deauth):
frame_type = 'deauth'
reason_code = pkt[Dot11Deauth].reason
elif pkt.haslayer(Dot11Disas):
frame_type = 'disassoc'
reason_code = pkt[Dot11Disas].reason
else:
return
# Extract addresses from Dot11 layer
dot11 = pkt[Dot11]
dst_mac = (dot11.addr1 or '').upper()
src_mac = (dot11.addr2 or '').upper()
bssid = (dot11.addr3 or '').upper()
# Skip if addresses are missing
if not src_mac or not dst_mac:
return
# Extract signal strength from RadioTap if available
signal_dbm = None
if pkt.haslayer(RadioTap):
try:
signal_dbm = pkt[RadioTap].dBm_AntSignal
except AttributeError:
pass
# Create packet info
pkt_info = DeauthPacketInfo(
timestamp=time.time(),
frame_type=frame_type,
src_mac=src_mac,
dst_mac=dst_mac,
bssid=bssid,
reason_code=reason_code,
signal_dbm=signal_dbm,
)
self._packets_captured += 1
# Track packet
tracker_key = (src_mac, dst_mac, bssid)
with self._lock:
tracker = self._trackers[tracker_key]
tracker.add_packet(pkt_info)
# Check if threshold exceeded
packets_in_window = tracker.get_packets_in_window(DEAUTH_DETECTION_WINDOW)
packet_count = len(packets_in_window)
if packet_count >= DEAUTH_ALERT_THRESHOLD and not tracker.alert_sent:
# Generate alert
alert = self._generate_alert(
tracker_key=tracker_key,
packets=packets_in_window,
packet_count=packet_count,
)
self._alerts.append(alert)
self._alerts_generated += 1
tracker.alert_sent = True
# Emit event
self.event_callback(alert.to_dict())
logger.warning(
f"Deauth attack detected: {src_mac} -> {dst_mac} "
f"({packet_count} packets in {DEAUTH_DETECTION_WINDOW}s)"
)
def _generate_alert(
self,
tracker_key: tuple[str, str, str],
packets: list[DeauthPacketInfo],
packet_count: int,
) -> DeauthAlert:
"""Generate an alert from tracked packets."""
src_mac, dst_mac, bssid = tracker_key
# Get latest packet for details
latest_pkt = packets[-1] if packets else None
# Determine severity
if packet_count >= DEAUTH_CRITICAL_THRESHOLD:
severity = 'high'
elif packet_count >= DEAUTH_ALERT_THRESHOLD * 2.5:
severity = 'medium'
else:
severity = 'low'
# Lookup AP info
ap_info = self._lookup_ap(bssid)
# Lookup target info
target_info = self._lookup_device(dst_mac)
# Determine target type
if dst_mac == 'FF:FF:FF:FF:FF:FF':
target_type = 'broadcast'
elif dst_mac in self._get_known_aps():
target_type = 'ap'
else:
target_type = 'client'
# Check if source is spoofed (matches known AP)
is_spoofed = self._check_spoofed_source(src_mac)
# Get attacker vendor
attacker_vendor = self._get_vendor(src_mac)
# Calculate packets per second
if packets:
time_span = packets[-1].timestamp - packets[0].timestamp
pps = packet_count / time_span if time_span > 0 else float(packet_count)
else:
pps = 0.0
# Determine attack type and description
if dst_mac == 'FF:FF:FF:FF:FF:FF':
attack_type = 'broadcast'
description = "Broadcast deauth flood targeting all clients on the network"
elif target_type == 'ap':
attack_type = 'ap_flood'
description = "Deauth flood targeting access point"
else:
attack_type = 'targeted'
description = f"Targeted deauth flood against {'known' if target_info.get('known_from_scan') else 'unknown'} client"
# Get reason code info
reason_code = latest_pkt.reason_code if latest_pkt else 0
reason_text = DEAUTH_REASON_CODES.get(reason_code, f"Unknown ({reason_code})")
# Get signal
signal_dbm = None
for pkt in reversed(packets):
if pkt.signal_dbm is not None:
signal_dbm = pkt.signal_dbm
break
# Generate unique ID
self._alert_counter += 1
alert_id = f"deauth-{int(time.time())}-{self._alert_counter}"
return DeauthAlert(
id=alert_id,
timestamp=time.time(),
severity=severity,
attacker_mac=src_mac,
attacker_vendor=attacker_vendor,
attacker_signal_dbm=signal_dbm,
is_spoofed_ap=is_spoofed,
target_mac=dst_mac,
target_vendor=target_info.get('vendor'),
target_type=target_type,
target_known_from_scan=target_info.get('known_from_scan', False),
ap_bssid=bssid,
ap_essid=ap_info.get('essid'),
ap_channel=ap_info.get('channel'),
frame_type=latest_pkt.frame_type if latest_pkt else 'deauth',
reason_code=reason_code,
reason_text=reason_text,
packet_count=packet_count,
window_seconds=DEAUTH_DETECTION_WINDOW,
packets_per_second=round(pps, 1),
attack_type=attack_type,
description=description,
)
def _lookup_ap(self, bssid: str) -> dict:
"""Get AP info from current scan data."""
if not self.get_networks:
return {'bssid': bssid, 'essid': None, 'channel': None}
try:
networks = self.get_networks()
ap = networks.get(bssid.upper())
if ap:
return {
'bssid': bssid,
'essid': ap.get('essid') or ap.get('ssid'),
'channel': ap.get('channel'),
}
except Exception as e:
logger.debug(f"Error looking up AP {bssid}: {e}")
return {'bssid': bssid, 'essid': None, 'channel': None}
def _lookup_device(self, mac: str) -> dict:
"""Get device info and vendor from MAC."""
vendor = self._get_vendor(mac)
known_from_scan = False
if self.get_clients:
try:
clients = self.get_clients()
if mac.upper() in clients:
known_from_scan = True
except Exception:
pass
return {
'mac': mac,
'vendor': vendor,
'known_from_scan': known_from_scan,
}
def _get_known_aps(self) -> set[str]:
"""Get set of known AP BSSIDs."""
if not self.get_networks:
return set()
try:
networks = self.get_networks()
return {bssid.upper() for bssid in networks.keys()}
except Exception:
return set()
def _check_spoofed_source(self, src_mac: str) -> bool:
"""Check if source MAC matches a known AP (spoofing indicator)."""
return src_mac.upper() in self._get_known_aps()
def _get_vendor(self, mac: str) -> Optional[str]:
"""Get vendor from MAC OUI."""
try:
from data.oui import get_manufacturer
vendor = get_manufacturer(mac)
return vendor if vendor != 'Unknown' else None
except Exception:
pass
# Fallback to wifi constants
try:
from utils.wifi.constants import get_vendor_from_mac
return get_vendor_from_mac(mac)
except Exception:
return None
def _cleanup_old_trackers(self):
"""Remove old packets and empty trackers."""
with self._lock:
keys_to_remove = []
for key, tracker in self._trackers.items():
tracker.cleanup_old_packets(DEAUTH_DETECTION_WINDOW * 2)
if not tracker.packets:
keys_to_remove.append(key)
for key in keys_to_remove:
del self._trackers[key]
+114 -1
View File
@@ -19,7 +19,10 @@ import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Callable, Generator, Optional
from typing import Callable, Generator, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .deauth_detector import DeauthDetector
from .constants import (
DEFAULT_QUICK_SCAN_TIMEOUT,
@@ -87,6 +90,9 @@ class UnifiedWiFiScanner:
self._deep_scan_thread: Optional[threading.Thread] = None
self._deep_scan_stop_event = threading.Event()
# Deauth detector
self._deauth_detector: Optional['DeauthDetector'] = None
# Event queue for SSE streaming
self._event_queue: queue.Queue = queue.Queue(maxsize=1000)
@@ -623,6 +629,9 @@ class UnifiedWiFiScanner:
'interface': iface,
})
# Auto-start deauth detector
self._start_deauth_detector(iface)
return True
def stop_deep_scan(self) -> bool:
@@ -636,6 +645,9 @@ class UnifiedWiFiScanner:
if not self._status.is_scanning:
return True
# Stop deauth detector first
self._stop_deauth_detector()
self._deep_scan_stop_event.set()
if self._deep_scan_process:
@@ -1148,6 +1160,107 @@ class UnifiedWiFiScanner:
with self._lock:
return [ap.to_legacy_dict() for ap in self._access_points.values()]
# =========================================================================
# Deauth Detection Integration
# =========================================================================
def _start_deauth_detector(self, interface: str):
"""Start deauth detector on the given interface."""
try:
from .deauth_detector import DeauthDetector
except ImportError as e:
logger.warning(f"Could not import DeauthDetector (scapy not installed?): {e}")
return
if self._deauth_detector and self._deauth_detector.is_running:
logger.debug("Deauth detector already running")
return
def event_callback(event: dict):
"""Handle deauth events and forward to queue."""
self._queue_event(event)
# Also store in app-level DataStore if available
try:
import app as app_module
if hasattr(app_module, 'deauth_alerts') and event.get('type') == 'deauth_alert':
alert_id = event.get('id', str(time.time()))
app_module.deauth_alerts[alert_id] = event
if hasattr(app_module, 'deauth_detector_queue'):
try:
app_module.deauth_detector_queue.put_nowait(event)
except queue.Full:
pass
except Exception as e:
logger.debug(f"Error storing deauth alert: {e}")
def get_networks() -> dict:
"""Get current networks for cross-reference."""
with self._lock:
return {bssid: ap.to_summary_dict() for bssid, ap in self._access_points.items()}
def get_clients() -> dict:
"""Get current clients for cross-reference."""
with self._lock:
return {mac: client.to_dict() for mac, client in self._clients.items()}
try:
self._deauth_detector = DeauthDetector(
interface=interface,
event_callback=event_callback,
get_networks=get_networks,
get_clients=get_clients,
)
self._deauth_detector.start()
logger.info(f"Deauth detector started on {interface}")
self._queue_event({
'type': 'deauth_detector_started',
'interface': interface,
})
except Exception as e:
logger.error(f"Failed to start deauth detector: {e}")
self._queue_event({
'type': 'deauth_error',
'error': f"Failed to start deauth detector: {e}",
})
def _stop_deauth_detector(self):
"""Stop the deauth detector."""
if self._deauth_detector:
try:
self._deauth_detector.stop()
logger.info("Deauth detector stopped")
self._queue_event({
'type': 'deauth_detector_stopped',
})
except Exception as e:
logger.error(f"Error stopping deauth detector: {e}")
finally:
self._deauth_detector = None
@property
def deauth_detector(self) -> Optional['DeauthDetector']:
"""Get the deauth detector instance."""
return self._deauth_detector
def get_deauth_alerts(self, limit: int = 100) -> list[dict]:
"""Get recent deauth alerts."""
if self._deauth_detector:
return self._deauth_detector.get_alerts(limit)
return []
def clear_deauth_alerts(self):
"""Clear deauth alert history."""
if self._deauth_detector:
self._deauth_detector.clear_alerts()
# Also clear from app-level store
try:
import app as app_module
if hasattr(app_module, 'deauth_alerts'):
app_module.deauth_alerts.clear()
except Exception:
pass
# =============================================================================
# Module-level functions