mirror of
https://github.com/smittix/intercept.git
synced 2026-06-12 16:03:29 -07:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f795180c7d | |||
| d1f1ce1f4b | |||
| 334073089f | |||
| df634dc741 | |||
| a76dfde02d | |||
| 36f8349bc7 | |||
| 130a3a2d8e | |||
| bd6fa27970 | |||
| 630bc2971a | |||
| 7182f7803a | |||
| a64a7c414c | |||
| f0cc396a6b | |||
| 5f588a5513 | |||
| 599df7734b | |||
| 49fa02142d | |||
| 333dc00ee2 | |||
| 2bc71e44ad | |||
| 92265da5fb | |||
| 9c1516c086 | |||
| cd7940bdc2 | |||
| 4a5f3e1802 | |||
| 1b5bf4c061 | |||
| 384d02649a | |||
| d51da40a67 |
@@ -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
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 = [];
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
})();
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user