mirror of
https://github.com/smittix/intercept.git
synced 2026-06-13 08:13:32 -07:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0eed4a2649 | |||
| 7b49c95967 | |||
| 30126b1709 | |||
| 66c7db73e2 | |||
| 07af3acb84 | |||
| b2feccdb90 | |||
| db2f46b46e | |||
| ff7c768287 | |||
| 236fbf061c | |||
| 21b0a153e8 | |||
| 35ca3f3a07 | |||
| 87f72db8ad | |||
| 93b763865b | |||
| b15b5ad9ba | |||
| 364600e545 | |||
| 23b2a2a0c0 | |||
| ef6eec3cf8 | |||
| 94f4682f2f | |||
| f407a3cb54 | |||
| c11c1200e2 | |||
| 0acbf87dde | |||
| 153336d757 | |||
| 570710c556 | |||
| de13d5ea74 | |||
| f36e528086 | |||
| 52ce930c31 | |||
| bb694c9926 | |||
| a8c77c8db3 | |||
| 3263638c57 | |||
| c30e5800df | |||
| 161e0d8ea8 | |||
| 93f68aa29d | |||
| c5ce35ff13 | |||
| 7069c8b636 | |||
| 6149427753 | |||
| 536b762f97 | |||
| b423dcedf7 | |||
| 16cd1fef2d | |||
| c94d0a642d | |||
| 135390788d | |||
| 98e4e38809 | |||
| 6d5a12a21f | |||
| fe3b3b536c | |||
| aa8a6baac4 | |||
| b0982249c3 | |||
| b3a8a69244 | |||
| 8cd1ecffc4 | |||
| 7967b71405 | |||
| cd0d5971e2 | |||
| b52b4db989 | |||
| ef5cfb4908 | |||
| ee7781ee67 | |||
| 8c5bb32ec6 |
+3
-1
@@ -30,5 +30,7 @@ dist/
|
|||||||
build/
|
build/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
|
||||||
# Package manager lock files
|
# Package manager lock files & DB files
|
||||||
uv.lock
|
uv.lock
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
|||||||
@@ -2,6 +2,30 @@
|
|||||||
|
|
||||||
All notable changes to iNTERCEPT will be documented in this file.
|
All notable changes to iNTERCEPT will be documented in this file.
|
||||||
|
|
||||||
|
## [2.9.5] - 2026-01-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **MAC-Randomization Resistant Detection** - TSCM now identifies devices using randomized MAC addresses
|
||||||
|
- **Clickable Score Cards** - Click on threat scores to see detailed findings
|
||||||
|
- **Device Detail Expansion** - Click-to-expand device details in TSCM results
|
||||||
|
- **Root Privilege Check** - Warning display when running without required privileges
|
||||||
|
- **Real-time Device Streaming** - Devices stream to dashboard during TSCM sweep
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **TSCM Correlation Engine** - Improved device correlation with comprehensive reporting
|
||||||
|
- **Device Classification System** - Enhanced threat classification and scoring
|
||||||
|
- **WiFi Scanning** - Improved scanning reliability and device naming
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **RF Scanning** - Fixed scanning issues with improved status feedback
|
||||||
|
- **TSCM Modal Readability** - Improved modal styling and close button visibility
|
||||||
|
- **Linux Device Detection** - Added more fallback methods for device detection
|
||||||
|
- **macOS Device Detection** - Fixed TSCM device detection on macOS
|
||||||
|
- **Bluetooth Event Type** - Fixed device type being overwritten
|
||||||
|
- **rtl_433 Bias-T Flag** - Corrected bias-t flag handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.9.0] - 2026-01-10
|
## [2.9.0] - 2026-01-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+34
-7
@@ -35,13 +35,40 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
procps \
|
procps \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install dump1090 for ADS-B (package name varies by distribution)
|
# Build dump1090-fa and acarsdec from source (packages not available in slim repos)
|
||||||
RUN apt-get update && \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
(apt-get install -y --no-install-recommends dump1090-mutability || \
|
build-essential \
|
||||||
apt-get install -y --no-install-recommends dump1090-fa || \
|
git \
|
||||||
apt-get install -y --no-install-recommends dump1090 || \
|
pkg-config \
|
||||||
echo "Note: dump1090 not available in repos, ADS-B features limited") && \
|
cmake \
|
||||||
rm -rf /var/lib/apt/lists/*
|
libncurses-dev \
|
||||||
|
libsndfile1-dev \
|
||||||
|
# Build dump1090
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
||||||
|
&& cd dump1090 \
|
||||||
|
&& make \
|
||||||
|
&& cp dump1090 /usr/bin/dump1090-fa \
|
||||||
|
&& ln -s /usr/bin/dump1090-fa /usr/bin/dump1090 \
|
||||||
|
&& rm -rf /tmp/dump1090 \
|
||||||
|
# Build acarsdec
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
|
||||||
|
&& cd acarsdec \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. -Drtl=ON \
|
||||||
|
&& make \
|
||||||
|
&& cp acarsdec /usr/bin/acarsdec \
|
||||||
|
&& rm -rf /tmp/acarsdec \
|
||||||
|
# Cleanup build tools to reduce image size
|
||||||
|
&& apt-get remove -y \
|
||||||
|
build-essential \
|
||||||
|
git \
|
||||||
|
pkg-config \
|
||||||
|
cmake \
|
||||||
|
libncurses-dev \
|
||||||
|
&& apt-get autoremove -y \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy requirements first for better caching
|
# Copy requirements first for better caching
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
||||||
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
|
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
|
||||||
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
||||||
|
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
||||||
- **Listening Post** - Frequency scanner with audio monitoring
|
- **Listening Post** - Frequency scanner with audio monitoring
|
||||||
- **Satellite Tracking** - Pass prediction using TLE data
|
- **Satellite Tracking** - Pass prediction using TLE data
|
||||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||||
@@ -38,7 +39,7 @@
|
|||||||
git clone https://github.com/smittix/intercept.git
|
git clone https://github.com/smittix/intercept.git
|
||||||
cd intercept
|
cd intercept
|
||||||
./setup.sh
|
./setup.sh
|
||||||
sudo python3 intercept.py
|
sudo -E venv/bin/python intercept.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker (Alternative)
|
### Docker (Alternative)
|
||||||
@@ -46,7 +47,7 @@ sudo python3 intercept.py
|
|||||||
```bash
|
```bash
|
||||||
git clone https://github.com/smittix/intercept.git
|
git clone https://github.com/smittix/intercept.git
|
||||||
cd intercept
|
cd intercept
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
|
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
|
||||||
@@ -121,6 +122,7 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
|||||||
[multimon-ng](https://github.com/EliasOenal/multimon-ng) |
|
[multimon-ng](https://github.com/EliasOenal/multimon-ng) |
|
||||||
[rtl_433](https://github.com/merbanan/rtl_433) |
|
[rtl_433](https://github.com/merbanan/rtl_433) |
|
||||||
[dump1090](https://github.com/flightaware/dump1090) |
|
[dump1090](https://github.com/flightaware/dump1090) |
|
||||||
|
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||||
[Leaflet.js](https://leafletjs.com/) |
|
[Leaflet.js](https://leafletjs.com/) |
|
||||||
[Celestrak](https://celestrak.org/)
|
[Celestrak](https://celestrak.org/)
|
||||||
@@ -128,3 +130,4 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"version": "2026-01-04_e27bf619",
|
"version": "2026-01-11_fae1348c",
|
||||||
"downloaded": "2026-01-07T14:55:20.680977Z"
|
"downloaded": "2026-01-12T15:55:42.769654Z"
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ from typing import Any
|
|||||||
|
|
||||||
from flask import Flask, render_template, jsonify, send_file, Response, request
|
from flask import Flask, render_template, jsonify, send_file, Response, request
|
||||||
|
|
||||||
from config import VERSION
|
from config import VERSION, CHANGELOG
|
||||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||||
from utils.process import cleanup_stale_processes
|
from utils.process import cleanup_stale_processes
|
||||||
from utils.sdr import SDRFactory
|
from utils.sdr import SDRFactory
|
||||||
@@ -103,6 +103,21 @@ satellite_process = None
|
|||||||
satellite_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
satellite_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
satellite_lock = threading.Lock()
|
satellite_lock = threading.Lock()
|
||||||
|
|
||||||
|
# ACARS aircraft messaging
|
||||||
|
acars_process = None
|
||||||
|
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
acars_lock = threading.Lock()
|
||||||
|
|
||||||
|
# APRS amateur radio tracking
|
||||||
|
aprs_process = None
|
||||||
|
aprs_rtl_process = None
|
||||||
|
aprs_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
aprs_lock = threading.Lock()
|
||||||
|
|
||||||
|
# TSCM (Technical Surveillance Countermeasures)
|
||||||
|
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
tscm_lock = threading.Lock()
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# GLOBAL STATE DICTIONARIES
|
# GLOBAL STATE DICTIONARIES
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -149,7 +164,7 @@ def index() -> str:
|
|||||||
'rtl_433': check_tool('rtl_433')
|
'rtl_433': check_tool('rtl_433')
|
||||||
}
|
}
|
||||||
devices = [d.to_dict() for d in SDRFactory.detect_devices()]
|
devices = [d.to_dict() for d in SDRFactory.detect_devices()]
|
||||||
return render_template('index.html', tools=tools, devices=devices, version=VERSION)
|
return render_template('index.html', tools=tools, devices=devices, version=VERSION, changelog=CHANGELOG)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/favicon.svg')
|
@app.route('/favicon.svg')
|
||||||
@@ -164,6 +179,120 @@ def get_devices() -> Response:
|
|||||||
return jsonify([d.to_dict() for d in devices])
|
return jsonify([d.to_dict() for d in devices])
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/devices/debug')
|
||||||
|
def get_devices_debug() -> Response:
|
||||||
|
"""Get detailed SDR device detection diagnostics."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
diagnostics = {
|
||||||
|
'tools': {},
|
||||||
|
'rtl_test': {},
|
||||||
|
'soapy': {},
|
||||||
|
'usb': {},
|
||||||
|
'kernel_modules': {},
|
||||||
|
'detected_devices': [],
|
||||||
|
'suggestions': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for required tools
|
||||||
|
diagnostics['tools']['rtl_test'] = shutil.which('rtl_test') is not None
|
||||||
|
diagnostics['tools']['SoapySDRUtil'] = shutil.which('SoapySDRUtil') is not None
|
||||||
|
diagnostics['tools']['lsusb'] = shutil.which('lsusb') is not None
|
||||||
|
|
||||||
|
# Run rtl_test and capture full output
|
||||||
|
if diagnostics['tools']['rtl_test']:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['rtl_test', '-t'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
diagnostics['rtl_test'] = {
|
||||||
|
'returncode': result.returncode,
|
||||||
|
'stdout': result.stdout[:2000] if result.stdout else '',
|
||||||
|
'stderr': result.stderr[:2000] if result.stderr else ''
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for common errors
|
||||||
|
combined = (result.stdout or '') + (result.stderr or '')
|
||||||
|
if 'No supported devices found' in combined:
|
||||||
|
diagnostics['suggestions'].append('No RTL-SDR device detected. Check USB connection.')
|
||||||
|
if 'usb_claim_interface error' in combined:
|
||||||
|
diagnostics['suggestions'].append('Device busy - kernel DVB driver may have claimed it. Run: sudo modprobe -r dvb_usb_rtl28xxu')
|
||||||
|
if 'Permission denied' in combined.lower():
|
||||||
|
diagnostics['suggestions'].append('USB permission denied. Add udev rules or run as root.')
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
diagnostics['rtl_test'] = {'error': 'Timeout after 5 seconds'}
|
||||||
|
except Exception as e:
|
||||||
|
diagnostics['rtl_test'] = {'error': str(e)}
|
||||||
|
else:
|
||||||
|
diagnostics['suggestions'].append('rtl_test not found. Install rtl-sdr package.')
|
||||||
|
|
||||||
|
# Run SoapySDRUtil
|
||||||
|
if diagnostics['tools']['SoapySDRUtil']:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['SoapySDRUtil', '--find'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
diagnostics['soapy'] = {
|
||||||
|
'returncode': result.returncode,
|
||||||
|
'stdout': result.stdout[:2000] if result.stdout else '',
|
||||||
|
'stderr': result.stderr[:2000] if result.stderr else ''
|
||||||
|
}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
diagnostics['soapy'] = {'error': 'Timeout after 10 seconds'}
|
||||||
|
except Exception as e:
|
||||||
|
diagnostics['soapy'] = {'error': str(e)}
|
||||||
|
|
||||||
|
# Check USB devices (Linux)
|
||||||
|
if diagnostics['tools']['lsusb']:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['lsusb'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
# Filter for common SDR vendor IDs
|
||||||
|
sdr_vendors = ['0bda', '1d50', '1df7', '0403'] # Realtek, OpenMoko/HackRF, SDRplay, FTDI
|
||||||
|
usb_lines = [l for l in result.stdout.split('\n')
|
||||||
|
if any(v in l.lower() for v in sdr_vendors) or 'rtl' in l.lower() or 'sdr' in l.lower()]
|
||||||
|
diagnostics['usb']['devices'] = usb_lines if usb_lines else ['No SDR-related USB devices found']
|
||||||
|
except Exception as e:
|
||||||
|
diagnostics['usb'] = {'error': str(e)}
|
||||||
|
|
||||||
|
# Check for loaded kernel modules that conflict (Linux)
|
||||||
|
if platform.system() == 'Linux':
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['lsmod'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
conflicting = ['dvb_usb_rtl28xxu', 'rtl2832', 'rtl2830']
|
||||||
|
loaded = [m for m in conflicting if m in result.stdout]
|
||||||
|
diagnostics['kernel_modules']['conflicting_loaded'] = loaded
|
||||||
|
if loaded:
|
||||||
|
diagnostics['suggestions'].append(f"Conflicting kernel modules loaded: {', '.join(loaded)}. Run: sudo modprobe -r {' '.join(loaded)}")
|
||||||
|
except Exception as e:
|
||||||
|
diagnostics['kernel_modules'] = {'error': str(e)}
|
||||||
|
|
||||||
|
# Get detected devices
|
||||||
|
devices = SDRFactory.detect_devices()
|
||||||
|
diagnostics['detected_devices'] = [d.to_dict() for d in devices]
|
||||||
|
|
||||||
|
if not devices and not diagnostics['suggestions']:
|
||||||
|
diagnostics['suggestions'].append('No devices detected. Check USB connection and driver installation.')
|
||||||
|
|
||||||
|
return jsonify(diagnostics)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/dependencies')
|
@app.route('/dependencies')
|
||||||
def get_dependencies() -> Response:
|
def get_dependencies() -> Response:
|
||||||
"""Get status of all tool dependencies."""
|
"""Get status of all tool dependencies."""
|
||||||
@@ -302,6 +431,8 @@ def health_check() -> Response:
|
|||||||
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
|
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
|
||||||
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
|
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
|
||||||
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
|
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
|
||||||
|
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
|
||||||
|
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
||||||
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
||||||
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
||||||
},
|
},
|
||||||
@@ -317,7 +448,8 @@ def health_check() -> Response:
|
|||||||
@app.route('/killall', methods=['POST'])
|
@app.route('/killall', methods=['POST'])
|
||||||
def kill_all() -> Response:
|
def kill_all() -> Response:
|
||||||
"""Kill all decoder and WiFi processes."""
|
"""Kill all decoder and WiFi processes."""
|
||||||
global current_process, sensor_process, wifi_process, adsb_process
|
global current_process, sensor_process, wifi_process, adsb_process, acars_process
|
||||||
|
global aprs_process, aprs_rtl_process
|
||||||
|
|
||||||
# Import adsb module to reset its state
|
# Import adsb module to reset its state
|
||||||
from routes import adsb as adsb_module
|
from routes import adsb as adsb_module
|
||||||
@@ -326,7 +458,7 @@ def kill_all() -> Response:
|
|||||||
processes_to_kill = [
|
processes_to_kill = [
|
||||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||||
'dump1090'
|
'dump1090', 'acarsdec', 'direwolf'
|
||||||
]
|
]
|
||||||
|
|
||||||
for proc in processes_to_kill:
|
for proc in processes_to_kill:
|
||||||
@@ -351,6 +483,15 @@ def kill_all() -> Response:
|
|||||||
adsb_process = None
|
adsb_process = None
|
||||||
adsb_module.adsb_using_service = False
|
adsb_module.adsb_using_service = False
|
||||||
|
|
||||||
|
# Reset ACARS state
|
||||||
|
with acars_lock:
|
||||||
|
acars_process = None
|
||||||
|
|
||||||
|
# Reset APRS state
|
||||||
|
with aprs_lock:
|
||||||
|
aprs_process = None
|
||||||
|
aprs_rtl_process = None
|
||||||
|
|
||||||
return jsonify({'status': 'killed', 'processes': killed})
|
return jsonify({'status': 'killed', 'processes': killed})
|
||||||
|
|
||||||
|
|
||||||
@@ -403,10 +544,32 @@ def main() -> None:
|
|||||||
|
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
print(" INTERCEPT // Signal Intelligence")
|
print(" INTERCEPT // Signal Intelligence")
|
||||||
print(" Pager / 433MHz / Aircraft / Satellite / WiFi / BT")
|
print(" Pager / 433MHz / Aircraft / ACARS / Satellite / WiFi / BT")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
# Check if running as root (required for WiFi monitor mode, some BT operations)
|
||||||
|
import os
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
print("\033[93m" + "=" * 50)
|
||||||
|
print(" ⚠️ WARNING: Not running as root/sudo")
|
||||||
|
print("=" * 50)
|
||||||
|
print(" Some features require root privileges:")
|
||||||
|
print(" - WiFi monitor mode and scanning")
|
||||||
|
print(" - Bluetooth low-level operations")
|
||||||
|
print(" - RTL-SDR access (on some systems)")
|
||||||
|
print()
|
||||||
|
print(" To run with full capabilities:")
|
||||||
|
print(" sudo -E venv/bin/python intercept.py")
|
||||||
|
print("=" * 50 + "\033[0m")
|
||||||
|
print()
|
||||||
|
# Store for API access
|
||||||
|
app.config['RUNNING_AS_ROOT'] = False
|
||||||
|
else:
|
||||||
|
app.config['RUNNING_AS_ROOT'] = True
|
||||||
|
print("Running as root - full capabilities enabled")
|
||||||
|
print()
|
||||||
|
|
||||||
# Clean up any stale processes from previous runs
|
# Clean up any stale processes from previous runs
|
||||||
cleanup_stale_processes()
|
cleanup_stale_processes()
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,51 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.9.0"
|
VERSION = "2.9.5"
|
||||||
|
|
||||||
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "2.9.5",
|
||||||
|
"date": "January 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Enhanced TSCM with MAC-randomization resistant detection",
|
||||||
|
"Clickable score cards and device detail expansion",
|
||||||
|
"RF scanning improvements with status feedback",
|
||||||
|
"Root privilege check and warning display",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.9.0",
|
||||||
|
"date": "January 2026",
|
||||||
|
"highlights": [
|
||||||
|
"New dropdown navigation menus for cleaner UI",
|
||||||
|
"TSCM baseline recording now captures device data",
|
||||||
|
"Device identity engine integration for threat detection",
|
||||||
|
"Welcome screen with mode selection",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.8.0",
|
||||||
|
"date": "December 2025",
|
||||||
|
"highlights": [
|
||||||
|
"Added TSCM counter-surveillance mode",
|
||||||
|
"WiFi/Bluetooth device correlation engine",
|
||||||
|
"Tracker detection (AirTag, Tile, SmartTag)",
|
||||||
|
"Risk scoring and threat classification",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.7.0",
|
||||||
|
"date": "November 2025",
|
||||||
|
"highlights": [
|
||||||
|
"Multi-SDR hardware support via SoapySDR",
|
||||||
|
"LimeSDR, HackRF, Airspy, SDRplay support",
|
||||||
|
"Improved aircraft database with photo lookup",
|
||||||
|
"GPS auto-detection and integration",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _get_env(key: str, default: str) -> str:
|
def _get_env(key: str, default: str) -> str:
|
||||||
|
|||||||
@@ -0,0 +1,436 @@
|
|||||||
|
"""
|
||||||
|
TSCM (Technical Surveillance Countermeasures) Frequency Database
|
||||||
|
|
||||||
|
Known surveillance device frequencies, sweep presets, and threat signatures
|
||||||
|
for counter-surveillance operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Known Surveillance Frequencies (MHz)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
SURVEILLANCE_FREQUENCIES = {
|
||||||
|
'wireless_mics': [
|
||||||
|
{'start': 49.0, 'end': 50.0, 'name': '49 MHz Wireless Mics', 'risk': 'medium'},
|
||||||
|
{'start': 72.0, 'end': 76.0, 'name': 'VHF Low Band Mics', 'risk': 'medium'},
|
||||||
|
{'start': 170.0, 'end': 216.0, 'name': 'VHF High Band Wireless', 'risk': 'medium'},
|
||||||
|
{'start': 470.0, 'end': 698.0, 'name': 'UHF TV Band Wireless', 'risk': 'medium'},
|
||||||
|
{'start': 902.0, 'end': 928.0, 'name': '900 MHz ISM Wireless', 'risk': 'high'},
|
||||||
|
{'start': 1880.0, 'end': 1920.0, 'name': 'DECT Wireless', 'risk': 'high'},
|
||||||
|
],
|
||||||
|
|
||||||
|
'wireless_cameras': [
|
||||||
|
{'start': 900.0, 'end': 930.0, 'name': '900 MHz Video TX', 'risk': 'high'},
|
||||||
|
{'start': 1200.0, 'end': 1300.0, 'name': '1.2 GHz Video', 'risk': 'high'},
|
||||||
|
{'start': 2400.0, 'end': 2483.5, 'name': '2.4 GHz WiFi Cameras', 'risk': 'high'},
|
||||||
|
{'start': 5150.0, 'end': 5850.0, 'name': '5.8 GHz Video', 'risk': 'high'},
|
||||||
|
],
|
||||||
|
|
||||||
|
'gps_trackers': [
|
||||||
|
{'start': 824.0, 'end': 849.0, 'name': 'Cellular 850 Uplink', 'risk': 'high'},
|
||||||
|
{'start': 869.0, 'end': 894.0, 'name': 'Cellular 850 Downlink', 'risk': 'high'},
|
||||||
|
{'start': 1710.0, 'end': 1755.0, 'name': 'AWS Uplink', 'risk': 'high'},
|
||||||
|
{'start': 1850.0, 'end': 1910.0, 'name': 'PCS Uplink', 'risk': 'high'},
|
||||||
|
{'start': 1930.0, 'end': 1990.0, 'name': 'PCS Downlink', 'risk': 'high'},
|
||||||
|
],
|
||||||
|
|
||||||
|
'body_worn': [
|
||||||
|
{'start': 49.0, 'end': 50.0, 'name': '49 MHz Body Wires', 'risk': 'critical'},
|
||||||
|
{'start': 72.0, 'end': 76.0, 'name': 'VHF Low Band Wires', 'risk': 'critical'},
|
||||||
|
{'start': 150.0, 'end': 174.0, 'name': 'VHF High Band', 'risk': 'critical'},
|
||||||
|
{'start': 380.0, 'end': 400.0, 'name': 'TETRA Band', 'risk': 'high'},
|
||||||
|
{'start': 406.0, 'end': 420.0, 'name': 'Federal/Government', 'risk': 'critical'},
|
||||||
|
{'start': 450.0, 'end': 470.0, 'name': 'UHF Business Band', 'risk': 'high'},
|
||||||
|
],
|
||||||
|
|
||||||
|
'common_bugs': [
|
||||||
|
{'start': 88.0, 'end': 108.0, 'name': 'FM Broadcast Band Bugs', 'risk': 'low'},
|
||||||
|
{'start': 140.0, 'end': 150.0, 'name': 'Low VHF Bugs', 'risk': 'high'},
|
||||||
|
{'start': 418.0, 'end': 419.0, 'name': '418 MHz ISM', 'risk': 'medium'},
|
||||||
|
{'start': 433.0, 'end': 434.8, 'name': '433 MHz ISM Band', 'risk': 'medium'},
|
||||||
|
{'start': 868.0, 'end': 870.0, 'name': '868 MHz ISM (Europe)', 'risk': 'medium'},
|
||||||
|
{'start': 315.0, 'end': 316.0, 'name': '315 MHz ISM (US)', 'risk': 'medium'},
|
||||||
|
],
|
||||||
|
|
||||||
|
'ism_bands': [
|
||||||
|
{'start': 26.96, 'end': 27.41, 'name': 'CB Radio / ISM 27 MHz', 'risk': 'low'},
|
||||||
|
{'start': 40.66, 'end': 40.70, 'name': 'ISM 40 MHz', 'risk': 'low'},
|
||||||
|
{'start': 315.0, 'end': 316.0, 'name': 'ISM 315 MHz (US)', 'risk': 'medium'},
|
||||||
|
{'start': 433.05, 'end': 434.79, 'name': 'ISM 433 MHz (EU)', 'risk': 'medium'},
|
||||||
|
{'start': 868.0, 'end': 868.6, 'name': 'ISM 868 MHz (EU)', 'risk': 'medium'},
|
||||||
|
{'start': 902.0, 'end': 928.0, 'name': 'ISM 915 MHz (US)', 'risk': 'medium'},
|
||||||
|
{'start': 2400.0, 'end': 2483.5, 'name': 'ISM 2.4 GHz', 'risk': 'medium'},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Sweep Presets
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
SWEEP_PRESETS = {
|
||||||
|
'quick': {
|
||||||
|
'name': 'Quick Scan',
|
||||||
|
'description': 'Fast 2-minute check of most common bug frequencies',
|
||||||
|
'duration_seconds': 120,
|
||||||
|
'ranges': [
|
||||||
|
{'start': 88.0, 'end': 108.0, 'step': 0.1, 'name': 'FM Band'},
|
||||||
|
{'start': 433.0, 'end': 435.0, 'step': 0.025, 'name': '433 MHz ISM'},
|
||||||
|
{'start': 868.0, 'end': 870.0, 'step': 0.025, 'name': '868 MHz ISM'},
|
||||||
|
],
|
||||||
|
'wifi': True,
|
||||||
|
'bluetooth': True,
|
||||||
|
'rf': True,
|
||||||
|
},
|
||||||
|
|
||||||
|
'standard': {
|
||||||
|
'name': 'Standard Sweep',
|
||||||
|
'description': 'Comprehensive 5-minute sweep of common surveillance bands',
|
||||||
|
'duration_seconds': 300,
|
||||||
|
'ranges': [
|
||||||
|
{'start': 25.0, 'end': 50.0, 'step': 0.1, 'name': 'HF/Low VHF'},
|
||||||
|
{'start': 88.0, 'end': 108.0, 'step': 0.1, 'name': 'FM Band'},
|
||||||
|
{'start': 140.0, 'end': 175.0, 'step': 0.025, 'name': 'VHF'},
|
||||||
|
{'start': 380.0, 'end': 450.0, 'step': 0.025, 'name': 'UHF Low'},
|
||||||
|
{'start': 868.0, 'end': 930.0, 'step': 0.05, 'name': 'ISM 868/915'},
|
||||||
|
],
|
||||||
|
'wifi': True,
|
||||||
|
'bluetooth': True,
|
||||||
|
'rf': True,
|
||||||
|
},
|
||||||
|
|
||||||
|
'full': {
|
||||||
|
'name': 'Full Spectrum',
|
||||||
|
'description': 'Complete 15-minute spectrum sweep (24 MHz - 1.7 GHz)',
|
||||||
|
'duration_seconds': 900,
|
||||||
|
'ranges': [
|
||||||
|
{'start': 24.0, 'end': 1700.0, 'step': 0.1, 'name': 'Full Spectrum'},
|
||||||
|
],
|
||||||
|
'wifi': True,
|
||||||
|
'bluetooth': True,
|
||||||
|
'rf': True,
|
||||||
|
},
|
||||||
|
|
||||||
|
'wireless_cameras': {
|
||||||
|
'name': 'Wireless Cameras',
|
||||||
|
'description': 'Focus on video transmission frequencies',
|
||||||
|
'duration_seconds': 180,
|
||||||
|
'ranges': [
|
||||||
|
{'start': 900.0, 'end': 930.0, 'step': 0.1, 'name': '900 MHz Video'},
|
||||||
|
{'start': 1200.0, 'end': 1300.0, 'step': 0.5, 'name': '1.2 GHz Video'},
|
||||||
|
],
|
||||||
|
'wifi': True, # WiFi cameras
|
||||||
|
'bluetooth': False,
|
||||||
|
'rf': True,
|
||||||
|
},
|
||||||
|
|
||||||
|
'body_worn': {
|
||||||
|
'name': 'Body-Worn Devices',
|
||||||
|
'description': 'Detect body wires and covert transmitters',
|
||||||
|
'duration_seconds': 240,
|
||||||
|
'ranges': [
|
||||||
|
{'start': 49.0, 'end': 50.0, 'step': 0.01, 'name': '49 MHz'},
|
||||||
|
{'start': 72.0, 'end': 76.0, 'step': 0.01, 'name': 'VHF Low'},
|
||||||
|
{'start': 150.0, 'end': 174.0, 'step': 0.0125, 'name': 'VHF High'},
|
||||||
|
{'start': 406.0, 'end': 420.0, 'step': 0.0125, 'name': 'Federal'},
|
||||||
|
{'start': 450.0, 'end': 470.0, 'step': 0.0125, 'name': 'UHF'},
|
||||||
|
],
|
||||||
|
'wifi': False,
|
||||||
|
'bluetooth': True, # BLE bugs
|
||||||
|
'rf': True,
|
||||||
|
},
|
||||||
|
|
||||||
|
'gps_trackers': {
|
||||||
|
'name': 'GPS Trackers',
|
||||||
|
'description': 'Detect cellular-based GPS tracking devices',
|
||||||
|
'duration_seconds': 180,
|
||||||
|
'ranges': [
|
||||||
|
{'start': 824.0, 'end': 894.0, 'step': 0.1, 'name': 'Cellular 850'},
|
||||||
|
{'start': 1850.0, 'end': 1990.0, 'step': 0.1, 'name': 'PCS Band'},
|
||||||
|
],
|
||||||
|
'wifi': False,
|
||||||
|
'bluetooth': True, # BLE trackers
|
||||||
|
'rf': True,
|
||||||
|
},
|
||||||
|
|
||||||
|
'bluetooth_only': {
|
||||||
|
'name': 'Bluetooth/BLE Trackers',
|
||||||
|
'description': 'Focus on BLE tracking devices (AirTag, Tile, etc.)',
|
||||||
|
'duration_seconds': 60,
|
||||||
|
'ranges': [],
|
||||||
|
'wifi': False,
|
||||||
|
'bluetooth': True,
|
||||||
|
'rf': False,
|
||||||
|
},
|
||||||
|
|
||||||
|
'wifi_only': {
|
||||||
|
'name': 'WiFi Devices',
|
||||||
|
'description': 'Scan for hidden WiFi cameras and access points',
|
||||||
|
'duration_seconds': 60,
|
||||||
|
'ranges': [],
|
||||||
|
'wifi': True,
|
||||||
|
'bluetooth': False,
|
||||||
|
'rf': False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Known Tracker Signatures
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
BLE_TRACKER_SIGNATURES = {
|
||||||
|
'apple_airtag': {
|
||||||
|
'name': 'Apple AirTag',
|
||||||
|
'company_id': 0x004C,
|
||||||
|
'patterns': ['findmy', 'airtag'],
|
||||||
|
'risk': 'high',
|
||||||
|
'description': 'Apple Find My network tracker',
|
||||||
|
},
|
||||||
|
'tile': {
|
||||||
|
'name': 'Tile Tracker',
|
||||||
|
'company_id': 0x00ED,
|
||||||
|
'patterns': ['tile'],
|
||||||
|
'oui_prefixes': ['C4:E7', 'DC:54', 'E6:43'],
|
||||||
|
'risk': 'high',
|
||||||
|
'description': 'Tile Bluetooth tracker',
|
||||||
|
},
|
||||||
|
'samsung_smarttag': {
|
||||||
|
'name': 'Samsung SmartTag',
|
||||||
|
'company_id': 0x0075,
|
||||||
|
'patterns': ['smarttag', 'smartthings'],
|
||||||
|
'risk': 'high',
|
||||||
|
'description': 'Samsung SmartThings tracker',
|
||||||
|
},
|
||||||
|
'chipolo': {
|
||||||
|
'name': 'Chipolo',
|
||||||
|
'company_id': 0x0A09,
|
||||||
|
'patterns': ['chipolo'],
|
||||||
|
'risk': 'high',
|
||||||
|
'description': 'Chipolo Bluetooth tracker',
|
||||||
|
},
|
||||||
|
'generic_beacon': {
|
||||||
|
'name': 'Unknown BLE Beacon',
|
||||||
|
'company_id': None,
|
||||||
|
'patterns': [],
|
||||||
|
'risk': 'medium',
|
||||||
|
'description': 'Unidentified BLE beacon device',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Threat Classification
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
THREAT_TYPES = {
|
||||||
|
'new_device': {
|
||||||
|
'name': 'New Device',
|
||||||
|
'description': 'Device not present in baseline',
|
||||||
|
'default_severity': 'medium',
|
||||||
|
},
|
||||||
|
'tracker': {
|
||||||
|
'name': 'Tracking Device',
|
||||||
|
'description': 'Known BLE tracker detected',
|
||||||
|
'default_severity': 'high',
|
||||||
|
},
|
||||||
|
'unknown_signal': {
|
||||||
|
'name': 'Unknown Signal',
|
||||||
|
'description': 'Unidentified RF transmission',
|
||||||
|
'default_severity': 'medium',
|
||||||
|
},
|
||||||
|
'burst_transmission': {
|
||||||
|
'name': 'Burst Transmission',
|
||||||
|
'description': 'Intermittent/store-and-forward signal detected',
|
||||||
|
'default_severity': 'high',
|
||||||
|
},
|
||||||
|
'hidden_camera': {
|
||||||
|
'name': 'Potential Hidden Camera',
|
||||||
|
'description': 'WiFi camera or video transmitter detected',
|
||||||
|
'default_severity': 'critical',
|
||||||
|
},
|
||||||
|
'gsm_bug': {
|
||||||
|
'name': 'GSM/Cellular Bug',
|
||||||
|
'description': 'Cellular transmission in non-phone device context',
|
||||||
|
'default_severity': 'critical',
|
||||||
|
},
|
||||||
|
'rogue_ap': {
|
||||||
|
'name': 'Rogue Access Point',
|
||||||
|
'description': 'Unauthorized WiFi access point',
|
||||||
|
'default_severity': 'high',
|
||||||
|
},
|
||||||
|
'anomaly': {
|
||||||
|
'name': 'Signal Anomaly',
|
||||||
|
'description': 'Unusual signal pattern or behavior',
|
||||||
|
'default_severity': 'low',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
SEVERITY_LEVELS = {
|
||||||
|
'critical': {
|
||||||
|
'level': 4,
|
||||||
|
'color': '#ff0000',
|
||||||
|
'description': 'Immediate action required - active surveillance likely',
|
||||||
|
},
|
||||||
|
'high': {
|
||||||
|
'level': 3,
|
||||||
|
'color': '#ff6600',
|
||||||
|
'description': 'Strong indicator of surveillance device',
|
||||||
|
},
|
||||||
|
'medium': {
|
||||||
|
'level': 2,
|
||||||
|
'color': '#ffcc00',
|
||||||
|
'description': 'Potential threat - requires investigation',
|
||||||
|
},
|
||||||
|
'low': {
|
||||||
|
'level': 1,
|
||||||
|
'color': '#00cc00',
|
||||||
|
'description': 'Minor anomaly - low probability of threat',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# WiFi Camera Detection Patterns
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
WIFI_CAMERA_PATTERNS = {
|
||||||
|
'ssid_patterns': [
|
||||||
|
'cam', 'camera', 'ipcam', 'webcam', 'dvr', 'nvr',
|
||||||
|
'hikvision', 'dahua', 'reolink', 'wyze', 'ring',
|
||||||
|
'arlo', 'nest', 'blink', 'eufy', 'yi',
|
||||||
|
],
|
||||||
|
'oui_manufacturers': [
|
||||||
|
'Hikvision',
|
||||||
|
'Dahua',
|
||||||
|
'Axis Communications',
|
||||||
|
'Hanwha Techwin',
|
||||||
|
'Vivotek',
|
||||||
|
'Ubiquiti',
|
||||||
|
'Wyze Labs',
|
||||||
|
'Amazon Technologies', # Ring
|
||||||
|
'Google', # Nest
|
||||||
|
],
|
||||||
|
'mac_prefixes': {
|
||||||
|
'C0:25:E9': 'TP-Link Camera',
|
||||||
|
'A4:DA:22': 'TP-Link Camera',
|
||||||
|
'78:8C:B5': 'TP-Link Camera',
|
||||||
|
'D4:6E:0E': 'TP-Link Camera',
|
||||||
|
'2C:AA:8E': 'Wyze Camera',
|
||||||
|
'AC:CF:85': 'Hikvision',
|
||||||
|
'54:C4:15': 'Hikvision',
|
||||||
|
'C0:56:E3': 'Hikvision',
|
||||||
|
'3C:EF:8C': 'Dahua',
|
||||||
|
'A0:BD:1D': 'Dahua',
|
||||||
|
'E4:24:6C': 'Dahua',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Utility Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_frequency_risk(frequency_mhz: float) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Determine the risk level for a given frequency.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (risk_level, category_name)
|
||||||
|
"""
|
||||||
|
for category, ranges in SURVEILLANCE_FREQUENCIES.items():
|
||||||
|
for freq_range in ranges:
|
||||||
|
if freq_range['start'] <= frequency_mhz <= freq_range['end']:
|
||||||
|
return freq_range['risk'], freq_range['name']
|
||||||
|
|
||||||
|
return 'low', 'Unknown Band'
|
||||||
|
|
||||||
|
|
||||||
|
def get_sweep_preset(preset_name: str) -> dict | None:
|
||||||
|
"""Get a sweep preset by name."""
|
||||||
|
return SWEEP_PRESETS.get(preset_name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_sweep_presets() -> dict:
|
||||||
|
"""Get all available sweep presets."""
|
||||||
|
return {
|
||||||
|
name: {
|
||||||
|
'name': preset['name'],
|
||||||
|
'description': preset['description'],
|
||||||
|
'duration_seconds': preset['duration_seconds'],
|
||||||
|
}
|
||||||
|
for name, preset in SWEEP_PRESETS.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_known_tracker(device_name: str | None, manufacturer_data: bytes | None = None) -> dict | None:
|
||||||
|
"""
|
||||||
|
Check if a BLE device matches known tracker signatures.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tracker info dict if match found, None otherwise
|
||||||
|
"""
|
||||||
|
if device_name:
|
||||||
|
name_lower = device_name.lower()
|
||||||
|
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
||||||
|
for pattern in tracker_info.get('patterns', []):
|
||||||
|
if pattern in name_lower:
|
||||||
|
return tracker_info
|
||||||
|
|
||||||
|
if manufacturer_data and len(manufacturer_data) >= 2:
|
||||||
|
company_id = int.from_bytes(manufacturer_data[:2], 'little')
|
||||||
|
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
||||||
|
if tracker_info.get('company_id') == company_id:
|
||||||
|
return tracker_info
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_potential_camera(ssid: str | None = None, mac: str | None = None, vendor: str | None = None) -> bool:
|
||||||
|
"""Check if a WiFi device might be a hidden camera."""
|
||||||
|
if ssid:
|
||||||
|
ssid_lower = ssid.lower()
|
||||||
|
for pattern in WIFI_CAMERA_PATTERNS['ssid_patterns']:
|
||||||
|
if pattern in ssid_lower:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if mac:
|
||||||
|
mac_prefix = mac[:8].upper()
|
||||||
|
if mac_prefix in WIFI_CAMERA_PATTERNS['mac_prefixes']:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if vendor:
|
||||||
|
vendor_lower = vendor.lower()
|
||||||
|
for manufacturer in WIFI_CAMERA_PATTERNS['oui_manufacturers']:
|
||||||
|
if manufacturer.lower() in vendor_lower:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_threat_severity(threat_type: str, context: dict | None = None) -> str:
|
||||||
|
"""
|
||||||
|
Determine threat severity based on type and context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
threat_type: Type of threat from THREAT_TYPES
|
||||||
|
context: Optional context dict with signal_strength, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Severity level string
|
||||||
|
"""
|
||||||
|
threat_info = THREAT_TYPES.get(threat_type, {})
|
||||||
|
base_severity = threat_info.get('default_severity', 'medium')
|
||||||
|
|
||||||
|
if context:
|
||||||
|
# Upgrade severity based on signal strength (closer = more concerning)
|
||||||
|
signal = context.get('signal_strength')
|
||||||
|
if signal and signal > -50: # Very strong signal
|
||||||
|
if base_severity == 'medium':
|
||||||
|
return 'high'
|
||||||
|
elif base_severity == 'high':
|
||||||
|
return 'critical'
|
||||||
|
|
||||||
|
return base_severity
|
||||||
+36
-2
@@ -75,13 +75,47 @@ Complete feature list for all modules.
|
|||||||
## Bluetooth Scanning
|
## Bluetooth Scanning
|
||||||
|
|
||||||
- **BLE and Classic** Bluetooth device scanning
|
- **BLE and Classic** Bluetooth device scanning
|
||||||
- **Multiple scan modes** - hcitool, bluetoothctl
|
- **Multiple scan modes** - hcitool, bluetoothctl, bleak
|
||||||
- **Tracker detection** - AirTag, Tile, Samsung SmartTag, Chipolo
|
- **Tracker detection** - AirTag, Tile, Samsung SmartTag, Chipolo
|
||||||
- **Device classification** - phones, audio, wearables, computers
|
- **Device classification** - phones, audio, wearables, computers
|
||||||
- **Manufacturer lookup** via OUI database
|
- **Manufacturer lookup** via OUI database and Bluetooth Company IDs
|
||||||
- **Proximity radar** visualization
|
- **Proximity radar** visualization
|
||||||
- **Device type breakdown** chart
|
- **Device type breakdown** chart
|
||||||
|
|
||||||
|
## TSCM Counter-Surveillance Mode
|
||||||
|
|
||||||
|
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
|
||||||
|
|
||||||
|
### Wireless Sweep Features
|
||||||
|
- **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32)
|
||||||
|
- **WiFi scanning** for rogue APs, hidden SSIDs, camera devices
|
||||||
|
- **RF spectrum analysis** (requires RTL-SDR) - FM bugs, ISM bands, video transmitters
|
||||||
|
- **Cross-protocol correlation** - links devices across BLE/WiFi/RF
|
||||||
|
- **Baseline comparison** - detect new/unknown devices vs known environment
|
||||||
|
|
||||||
|
### MAC-Randomization Resistant Detection
|
||||||
|
- **Device fingerprinting** based on advertisement payloads, not MAC addresses
|
||||||
|
- **Behavioral clustering** - groups observations into probable physical devices
|
||||||
|
- **Session tracking** - monitors device presence windows
|
||||||
|
- **Timing pattern analysis** - detects characteristic advertising intervals
|
||||||
|
- **RSSI trajectory correlation** - identifies co-located devices
|
||||||
|
|
||||||
|
### Risk Assessment
|
||||||
|
- **Three-tier scoring model**:
|
||||||
|
- Informational (0-2): Known or expected devices
|
||||||
|
- Needs Review (3-5): Unusual devices requiring assessment
|
||||||
|
- High Interest (6+): Multiple indicators warrant investigation
|
||||||
|
- **Risk indicators**: Stable RSSI, audio-capable, ESP32 chipsets, hidden identity, MAC rotation
|
||||||
|
- **Audit trail** - full evidence chain for each link/flag
|
||||||
|
- **Client-safe disclaimers** - findings are indicators, not confirmed surveillance
|
||||||
|
|
||||||
|
### Limitations (Documented)
|
||||||
|
- Cannot detect non-transmitting devices
|
||||||
|
- False positives/negatives expected
|
||||||
|
- Results require professional verification
|
||||||
|
- No cryptographic de-randomization
|
||||||
|
- Passive screening only (no active probing by default)
|
||||||
|
|
||||||
## User Interface
|
## User Interface
|
||||||
|
|
||||||
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
||||||
|
|||||||
+52
-7
@@ -139,14 +139,10 @@ pip install -r requirements.txt
|
|||||||
After installation:
|
After installation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Standard
|
sudo -E venv/bin/python intercept.py
|
||||||
sudo python3 intercept.py
|
|
||||||
|
|
||||||
# With virtual environment
|
|
||||||
sudo venv/bin/python intercept.py
|
|
||||||
|
|
||||||
# Custom port
|
# Custom port
|
||||||
INTERCEPT_PORT=8080 sudo python3 intercept.py
|
INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Open **http://localhost:5050** in your browser.
|
Open **http://localhost:5050** in your browser.
|
||||||
@@ -183,6 +179,7 @@ Open **http://localhost:5050** in your browser.
|
|||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `flask` | Web server |
|
| `flask` | Web server |
|
||||||
| `skyfield` | Satellite tracking |
|
| `skyfield` | Satellite tracking |
|
||||||
|
| `bleak` | BLE scanning with manufacturer data (TSCM) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -203,9 +200,57 @@ https://github.com/flightaware/dump1090
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## TSCM Mode Requirements
|
||||||
|
|
||||||
|
TSCM (Technical Surveillance Countermeasures) mode requires specific hardware for full functionality:
|
||||||
|
|
||||||
|
### BLE Scanning (Tracker Detection)
|
||||||
|
- Any Bluetooth adapter supported by your OS
|
||||||
|
- `bleak` Python library for manufacturer data detection
|
||||||
|
- Detects: AirTags, Tile, SmartTags, ESP32/ESP8266 devices
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install bleak
|
||||||
|
pip install bleak>=0.21.0
|
||||||
|
|
||||||
|
# Or via apt (Debian/Ubuntu)
|
||||||
|
sudo apt install python3-bleak
|
||||||
|
```
|
||||||
|
|
||||||
|
### RF Spectrum Analysis
|
||||||
|
- **RTL-SDR dongle** (required for RF sweeps)
|
||||||
|
- `rtl_power` command from `rtl-sdr` package
|
||||||
|
|
||||||
|
Frequency bands scanned:
|
||||||
|
| Band | Frequency | Purpose |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| FM Broadcast | 88-108 MHz | FM bugs |
|
||||||
|
| 315 MHz ISM | 315 MHz | US wireless devices |
|
||||||
|
| 433 MHz ISM | 433-434 MHz | EU wireless devices |
|
||||||
|
| 868 MHz ISM | 868-869 MHz | EU IoT devices |
|
||||||
|
| 915 MHz ISM | 902-928 MHz | US IoT devices |
|
||||||
|
| 1.2 GHz | 1200-1300 MHz | Video transmitters |
|
||||||
|
| 2.4 GHz ISM | 2400-2500 MHz | WiFi/BT/Video |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux
|
||||||
|
sudo apt install rtl-sdr
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
brew install librtlsdr
|
||||||
|
```
|
||||||
|
|
||||||
|
### WiFi Scanning
|
||||||
|
- Standard WiFi adapter (managed mode for basic scanning)
|
||||||
|
- Monitor mode capable adapter for advanced features
|
||||||
|
- `aircrack-ng` suite for monitor mode management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **Bluetooth on macOS**: Uses native CoreBluetooth, bluez tools not needed
|
- **Bluetooth on macOS**: Uses bleak library (CoreBluetooth backend), bluez tools not needed
|
||||||
- **WiFi on macOS**: Monitor mode has limited support, full functionality on Linux
|
- **WiFi on macOS**: Monitor mode has limited support, full functionality on Linux
|
||||||
- **System tools**: `iw`, `iwconfig`, `rfkill`, `ip` are pre-installed on most Linux systems
|
- **System tools**: `iw`, `iwconfig`, `rfkill`, `ip` are pre-installed on most Linux systems
|
||||||
|
- **TSCM on macOS**: BLE and WiFi scanning work; RF spectrum requires RTL-SDR
|
||||||
|
|
||||||
|
|||||||
@@ -336,9 +336,7 @@ rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
|
|||||||
|
|
||||||
Run INTERCEPT with sudo:
|
Run INTERCEPT with sudo:
|
||||||
```bash
|
```bash
|
||||||
sudo python3 intercept.py
|
sudo -E venv/bin/python intercept.py
|
||||||
# Or with venv:
|
|
||||||
sudo venv/bin/python intercept.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Interface not found after enabling monitor mode
|
### Interface not found after enabling monitor mode
|
||||||
|
|||||||
+1
-1
@@ -110,7 +110,7 @@ INTERCEPT can be configured via environment variables:
|
|||||||
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
|
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
|
||||||
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
|
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
|
||||||
|
|
||||||
Example: `INTERCEPT_PORT=8080 sudo python3 intercept.py`
|
Example: `INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py`
|
||||||
|
|
||||||
## Command-line Options
|
## Command-line Options
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.0.0"
|
version = "2.9.5"
|
||||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
+4
-1
@@ -2,6 +2,9 @@
|
|||||||
flask>=2.0.0
|
flask>=2.0.0
|
||||||
requests>=2.28.0
|
requests>=2.28.0
|
||||||
|
|
||||||
|
# BLE scanning with manufacturer data detection (optional - for TSCM)
|
||||||
|
bleak>=0.21.0
|
||||||
|
|
||||||
# Satellite tracking (optional - only needed for satellite features)
|
# Satellite tracking (optional - only needed for satellite features)
|
||||||
skyfield>=1.45
|
skyfield>=1.45
|
||||||
|
|
||||||
@@ -14,4 +17,4 @@ pyserial>=3.5
|
|||||||
# ruff>=0.1.0
|
# ruff>=0.1.0
|
||||||
# black>=23.0.0
|
# black>=23.0.0
|
||||||
# mypy>=1.0.0
|
# mypy>=1.0.0
|
||||||
flask-sock
|
flask-sock
|
||||||
|
|||||||
@@ -7,19 +7,30 @@ def register_blueprints(app):
|
|||||||
from .wifi import wifi_bp
|
from .wifi import wifi_bp
|
||||||
from .bluetooth import bluetooth_bp
|
from .bluetooth import bluetooth_bp
|
||||||
from .adsb import adsb_bp
|
from .adsb import adsb_bp
|
||||||
|
from .acars import acars_bp
|
||||||
|
from .aprs import aprs_bp
|
||||||
from .satellite import satellite_bp
|
from .satellite import satellite_bp
|
||||||
from .gps import gps_bp
|
from .gps import gps_bp
|
||||||
from .settings import settings_bp
|
from .settings import settings_bp
|
||||||
from .correlation import correlation_bp
|
from .correlation import correlation_bp
|
||||||
from .listening_post import listening_post_bp
|
from .listening_post import listening_post_bp
|
||||||
|
from .tscm import tscm_bp, init_tscm_state
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
app.register_blueprint(wifi_bp)
|
app.register_blueprint(wifi_bp)
|
||||||
app.register_blueprint(bluetooth_bp)
|
app.register_blueprint(bluetooth_bp)
|
||||||
app.register_blueprint(adsb_bp)
|
app.register_blueprint(adsb_bp)
|
||||||
|
app.register_blueprint(acars_bp)
|
||||||
|
app.register_blueprint(aprs_bp)
|
||||||
app.register_blueprint(satellite_bp)
|
app.register_blueprint(satellite_bp)
|
||||||
app.register_blueprint(gps_bp)
|
app.register_blueprint(gps_bp)
|
||||||
app.register_blueprint(settings_bp)
|
app.register_blueprint(settings_bp)
|
||||||
app.register_blueprint(correlation_bp)
|
app.register_blueprint(correlation_bp)
|
||||||
app.register_blueprint(listening_post_bp)
|
app.register_blueprint(listening_post_bp)
|
||||||
|
app.register_blueprint(tscm_bp)
|
||||||
|
|
||||||
|
# Initialize TSCM state with queue and lock from app
|
||||||
|
import app as app_module
|
||||||
|
if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'):
|
||||||
|
init_tscm_state(app_module.tscm_queue, app_module.tscm_lock)
|
||||||
|
|||||||
+316
@@ -0,0 +1,316 @@
|
|||||||
|
"""ACARS aircraft messaging routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import pty
|
||||||
|
import queue
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
|
from utils.logging import sensor_logger as logger
|
||||||
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.constants import (
|
||||||
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
PROCESS_START_WAIT,
|
||||||
|
)
|
||||||
|
|
||||||
|
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
||||||
|
|
||||||
|
# Default VHF ACARS frequencies (MHz) - common worldwide
|
||||||
|
DEFAULT_ACARS_FREQUENCIES = [
|
||||||
|
'131.550', # Primary worldwide
|
||||||
|
'130.025', # Secondary USA/Canada
|
||||||
|
'129.125', # USA
|
||||||
|
'131.525', # Europe
|
||||||
|
'131.725', # Europe secondary
|
||||||
|
]
|
||||||
|
|
||||||
|
# Message counter for statistics
|
||||||
|
acars_message_count = 0
|
||||||
|
acars_last_message_time = None
|
||||||
|
|
||||||
|
|
||||||
|
def find_acarsdec():
|
||||||
|
"""Find acarsdec binary."""
|
||||||
|
return shutil.which('acarsdec')
|
||||||
|
|
||||||
|
|
||||||
|
def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
|
||||||
|
"""Stream acarsdec JSON output to queue."""
|
||||||
|
global acars_message_count, acars_last_message_time
|
||||||
|
|
||||||
|
try:
|
||||||
|
app_module.acars_queue.put({'type': 'status', 'status': 'started'})
|
||||||
|
|
||||||
|
# Use appropriate sentinel based on mode (text mode for pty on macOS)
|
||||||
|
sentinel = '' if is_text_mode else b''
|
||||||
|
for line in iter(process.stdout.readline, sentinel):
|
||||||
|
if is_text_mode:
|
||||||
|
line = line.strip()
|
||||||
|
else:
|
||||||
|
line = line.decode('utf-8', errors='replace').strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# acarsdec -o 4 outputs JSON, one message per line
|
||||||
|
data = json.loads(line)
|
||||||
|
|
||||||
|
# Add our metadata
|
||||||
|
data['type'] = 'acars'
|
||||||
|
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
||||||
|
|
||||||
|
# Update stats
|
||||||
|
acars_message_count += 1
|
||||||
|
acars_last_message_time = time.time()
|
||||||
|
|
||||||
|
app_module.acars_queue.put(data)
|
||||||
|
|
||||||
|
# Log if enabled
|
||||||
|
if app_module.logging_enabled:
|
||||||
|
try:
|
||||||
|
with open(app_module.log_file_path, 'a') as f:
|
||||||
|
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
f.write(f"{ts} | ACARS | {json.dumps(data)}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Not JSON - could be status message
|
||||||
|
if line:
|
||||||
|
logger.debug(f"acarsdec non-JSON: {line[:100]}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"ACARS stream error: {e}")
|
||||||
|
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
||||||
|
finally:
|
||||||
|
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
|
with app_module.acars_lock:
|
||||||
|
app_module.acars_process = None
|
||||||
|
|
||||||
|
|
||||||
|
@acars_bp.route('/tools')
|
||||||
|
def check_acars_tools() -> Response:
|
||||||
|
"""Check for ACARS decoding tools."""
|
||||||
|
has_acarsdec = find_acarsdec() is not None
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'acarsdec': has_acarsdec,
|
||||||
|
'ready': has_acarsdec
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@acars_bp.route('/status')
|
||||||
|
def acars_status() -> Response:
|
||||||
|
"""Get ACARS decoder status."""
|
||||||
|
running = False
|
||||||
|
if app_module.acars_process:
|
||||||
|
running = app_module.acars_process.poll() is None
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'running': running,
|
||||||
|
'message_count': acars_message_count,
|
||||||
|
'last_message_time': acars_last_message_time,
|
||||||
|
'queue_size': app_module.acars_queue.qsize()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@acars_bp.route('/start', methods=['POST'])
|
||||||
|
def start_acars() -> Response:
|
||||||
|
"""Start ACARS decoder."""
|
||||||
|
global acars_message_count, acars_last_message_time
|
||||||
|
|
||||||
|
with app_module.acars_lock:
|
||||||
|
if app_module.acars_process and app_module.acars_process.poll() is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'ACARS decoder already running'
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
# Check for acarsdec
|
||||||
|
acarsdec_path = find_acarsdec()
|
||||||
|
if not acarsdec_path:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'acarsdec not found. Install with: sudo apt install acarsdec'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
try:
|
||||||
|
device = validate_device_index(data.get('device', '0'))
|
||||||
|
gain = validate_gain(data.get('gain', '40'))
|
||||||
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
# Get frequencies - use provided or defaults
|
||||||
|
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
||||||
|
if isinstance(frequencies, str):
|
||||||
|
frequencies = [f.strip() for f in frequencies.split(',')]
|
||||||
|
|
||||||
|
# Clear queue
|
||||||
|
while not app_module.acars_queue.empty():
|
||||||
|
try:
|
||||||
|
app_module.acars_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Reset stats
|
||||||
|
acars_message_count = 0
|
||||||
|
acars_last_message_time = None
|
||||||
|
|
||||||
|
# Build acarsdec command
|
||||||
|
# acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||||
|
# Note: -o 4 is JSON stdout, gain/ppm must come BEFORE -r
|
||||||
|
cmd = [
|
||||||
|
acarsdec_path,
|
||||||
|
'-o', '4', # JSON output to stdout
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add gain if not auto (must be before -r)
|
||||||
|
if gain and str(gain) != '0':
|
||||||
|
cmd.extend(['-g', str(gain)])
|
||||||
|
|
||||||
|
# Add PPM correction if specified (must be before -r)
|
||||||
|
if ppm and str(ppm) != '0':
|
||||||
|
cmd.extend(['-p', str(ppm)])
|
||||||
|
|
||||||
|
# Add device and frequencies (-r takes device, remaining args are frequencies)
|
||||||
|
cmd.extend(['-r', str(device)])
|
||||||
|
cmd.extend(frequencies)
|
||||||
|
|
||||||
|
logger.info(f"Starting ACARS decoder: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
is_text_mode = False
|
||||||
|
|
||||||
|
# On macOS, use pty to avoid stdout buffering issues
|
||||||
|
if platform.system() == 'Darwin':
|
||||||
|
master_fd, slave_fd = pty.openpty()
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=slave_fd,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
os.close(slave_fd)
|
||||||
|
# Wrap master_fd as a text file for line-buffered reading
|
||||||
|
process.stdout = io.open(master_fd, 'r', buffering=1)
|
||||||
|
is_text_mode = True
|
||||||
|
else:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait briefly to check if process started
|
||||||
|
time.sleep(PROCESS_START_WAIT)
|
||||||
|
|
||||||
|
if process.poll() is not None:
|
||||||
|
# Process died
|
||||||
|
stderr = ''
|
||||||
|
if process.stderr:
|
||||||
|
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||||
|
error_msg = f'acarsdec failed to start'
|
||||||
|
if stderr:
|
||||||
|
error_msg += f': {stderr[:200]}'
|
||||||
|
logger.error(error_msg)
|
||||||
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
|
|
||||||
|
app_module.acars_process = process
|
||||||
|
|
||||||
|
# Start output streaming thread
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=stream_acars_output,
|
||||||
|
args=(process, is_text_mode),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'frequencies': frequencies,
|
||||||
|
'device': device,
|
||||||
|
'gain': gain
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start ACARS decoder: {e}")
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@acars_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_acars() -> Response:
|
||||||
|
"""Stop ACARS decoder."""
|
||||||
|
with app_module.acars_lock:
|
||||||
|
if not app_module.acars_process:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'ACARS decoder not running'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
app_module.acars_process.terminate()
|
||||||
|
app_module.acars_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
app_module.acars_process.kill()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping ACARS: {e}")
|
||||||
|
|
||||||
|
app_module.acars_process = None
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@acars_bp.route('/stream')
|
||||||
|
def stream_acars() -> Response:
|
||||||
|
"""SSE stream for ACARS messages."""
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
last_keepalive = time.time()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
yield format_sse(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= SSE_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'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@acars_bp.route('/frequencies')
|
||||||
|
def get_frequencies() -> Response:
|
||||||
|
"""Get default ACARS frequencies."""
|
||||||
|
return jsonify({
|
||||||
|
'default': DEFAULT_ACARS_FREQUENCIES,
|
||||||
|
'regions': {
|
||||||
|
'north_america': ['129.125', '130.025', '130.450', '131.550'],
|
||||||
|
'europe': ['131.525', '131.725', '131.550'],
|
||||||
|
'asia_pacific': ['131.550', '131.450'],
|
||||||
|
}
|
||||||
|
})
|
||||||
+561
@@ -0,0 +1,561 @@
|
|||||||
|
"""APRS amateur radio position reporting routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Generator, Optional
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
|
from utils.logging import sensor_logger as logger
|
||||||
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.constants import (
|
||||||
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
PROCESS_START_WAIT,
|
||||||
|
)
|
||||||
|
|
||||||
|
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
||||||
|
|
||||||
|
# APRS frequencies by region (MHz)
|
||||||
|
APRS_FREQUENCIES = {
|
||||||
|
'north_america': '144.390',
|
||||||
|
'europe': '144.800',
|
||||||
|
'australia': '145.175',
|
||||||
|
'new_zealand': '144.575',
|
||||||
|
'argentina': '144.930',
|
||||||
|
'brazil': '145.570',
|
||||||
|
'japan': '144.640',
|
||||||
|
'china': '144.640',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Statistics
|
||||||
|
aprs_packet_count = 0
|
||||||
|
aprs_station_count = 0
|
||||||
|
aprs_last_packet_time = None
|
||||||
|
aprs_stations = {} # callsign -> station data
|
||||||
|
|
||||||
|
|
||||||
|
def find_direwolf() -> Optional[str]:
|
||||||
|
"""Find direwolf binary."""
|
||||||
|
return shutil.which('direwolf')
|
||||||
|
|
||||||
|
|
||||||
|
def find_multimon_ng() -> Optional[str]:
|
||||||
|
"""Find multimon-ng binary."""
|
||||||
|
return shutil.which('multimon-ng')
|
||||||
|
|
||||||
|
|
||||||
|
def find_rtl_fm() -> Optional[str]:
|
||||||
|
"""Find rtl_fm binary."""
|
||||||
|
return shutil.which('rtl_fm')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
|
||||||
|
"""Parse APRS packet into structured data."""
|
||||||
|
try:
|
||||||
|
# Basic APRS packet format: CALLSIGN>PATH:DATA
|
||||||
|
# Example: N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077
|
||||||
|
|
||||||
|
match = re.match(r'^([A-Z0-9-]+)>([^:]+):(.+)$', raw_packet, re.IGNORECASE)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
callsign = match.group(1).upper()
|
||||||
|
path = match.group(2)
|
||||||
|
data = match.group(3)
|
||||||
|
|
||||||
|
packet = {
|
||||||
|
'type': 'aprs',
|
||||||
|
'callsign': callsign,
|
||||||
|
'path': path,
|
||||||
|
'raw': raw_packet,
|
||||||
|
'timestamp': datetime.utcnow().isoformat() + 'Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine packet type and parse accordingly
|
||||||
|
if data.startswith('!') or data.startswith('='):
|
||||||
|
# Position without timestamp
|
||||||
|
packet['packet_type'] = 'position'
|
||||||
|
pos = parse_position(data[1:])
|
||||||
|
if pos:
|
||||||
|
packet.update(pos)
|
||||||
|
|
||||||
|
elif data.startswith('/') or data.startswith('@'):
|
||||||
|
# Position with timestamp
|
||||||
|
packet['packet_type'] = 'position'
|
||||||
|
# Skip timestamp (7 chars) and parse position
|
||||||
|
if len(data) > 8:
|
||||||
|
pos = parse_position(data[8:])
|
||||||
|
if pos:
|
||||||
|
packet.update(pos)
|
||||||
|
|
||||||
|
elif data.startswith('>'):
|
||||||
|
# Status message
|
||||||
|
packet['packet_type'] = 'status'
|
||||||
|
packet['status'] = data[1:]
|
||||||
|
|
||||||
|
elif data.startswith(':'):
|
||||||
|
# Message
|
||||||
|
packet['packet_type'] = 'message'
|
||||||
|
msg_match = re.match(r'^:([A-Z0-9 -]{9}):(.*)$', data, re.IGNORECASE)
|
||||||
|
if msg_match:
|
||||||
|
packet['addressee'] = msg_match.group(1).strip()
|
||||||
|
packet['message'] = msg_match.group(2)
|
||||||
|
|
||||||
|
elif data.startswith('_'):
|
||||||
|
# Weather report (Positionless)
|
||||||
|
packet['packet_type'] = 'weather'
|
||||||
|
packet['weather'] = parse_weather(data)
|
||||||
|
|
||||||
|
elif data.startswith(';'):
|
||||||
|
# Object
|
||||||
|
packet['packet_type'] = 'object'
|
||||||
|
|
||||||
|
elif data.startswith(')'):
|
||||||
|
# Item
|
||||||
|
packet['packet_type'] = 'item'
|
||||||
|
|
||||||
|
elif data.startswith('T'):
|
||||||
|
# Telemetry
|
||||||
|
packet['packet_type'] = 'telemetry'
|
||||||
|
|
||||||
|
else:
|
||||||
|
packet['packet_type'] = 'other'
|
||||||
|
packet['data'] = data
|
||||||
|
|
||||||
|
return packet
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to parse APRS packet: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_position(data: str) -> Optional[dict]:
|
||||||
|
"""Parse APRS position data."""
|
||||||
|
try:
|
||||||
|
# Format: DDMM.mmN/DDDMM.mmW (or similar with symbols)
|
||||||
|
# Example: 4903.50N/07201.75W
|
||||||
|
|
||||||
|
pos_match = re.match(
|
||||||
|
r'^(\d{2})(\d{2}\.\d+)([NS])(.)(\d{3})(\d{2}\.\d+)([EW])(.)?',
|
||||||
|
data
|
||||||
|
)
|
||||||
|
|
||||||
|
if pos_match:
|
||||||
|
lat_deg = int(pos_match.group(1))
|
||||||
|
lat_min = float(pos_match.group(2))
|
||||||
|
lat_dir = pos_match.group(3)
|
||||||
|
symbol_table = pos_match.group(4)
|
||||||
|
lon_deg = int(pos_match.group(5))
|
||||||
|
lon_min = float(pos_match.group(6))
|
||||||
|
lon_dir = pos_match.group(7)
|
||||||
|
symbol_code = pos_match.group(8) or ''
|
||||||
|
|
||||||
|
lat = lat_deg + lat_min / 60.0
|
||||||
|
if lat_dir == 'S':
|
||||||
|
lat = -lat
|
||||||
|
|
||||||
|
lon = lon_deg + lon_min / 60.0
|
||||||
|
if lon_dir == 'W':
|
||||||
|
lon = -lon
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'lat': round(lat, 6),
|
||||||
|
'lon': round(lon, 6),
|
||||||
|
'symbol': symbol_table + symbol_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse additional data after position (course/speed, altitude, etc.)
|
||||||
|
remaining = data[18:] if len(data) > 18 else ''
|
||||||
|
|
||||||
|
# Course/Speed: CCC/SSS
|
||||||
|
cs_match = re.search(r'(\d{3})/(\d{3})', remaining)
|
||||||
|
if cs_match:
|
||||||
|
result['course'] = int(cs_match.group(1))
|
||||||
|
result['speed'] = int(cs_match.group(2)) # knots
|
||||||
|
|
||||||
|
# Altitude: /A=NNNNNN
|
||||||
|
alt_match = re.search(r'/A=(-?\d+)', remaining)
|
||||||
|
if alt_match:
|
||||||
|
result['altitude'] = int(alt_match.group(1)) # feet
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to parse position: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_weather(data: str) -> dict:
|
||||||
|
"""Parse APRS weather data."""
|
||||||
|
weather = {}
|
||||||
|
|
||||||
|
# Wind direction: cCCC
|
||||||
|
match = re.search(r'c(\d{3})', data)
|
||||||
|
if match:
|
||||||
|
weather['wind_direction'] = int(match.group(1))
|
||||||
|
|
||||||
|
# Wind speed: sSSS (mph)
|
||||||
|
match = re.search(r's(\d{3})', data)
|
||||||
|
if match:
|
||||||
|
weather['wind_speed'] = int(match.group(1))
|
||||||
|
|
||||||
|
# Wind gust: gGGG (mph)
|
||||||
|
match = re.search(r'g(\d{3})', data)
|
||||||
|
if match:
|
||||||
|
weather['wind_gust'] = int(match.group(1))
|
||||||
|
|
||||||
|
# Temperature: tTTT (Fahrenheit)
|
||||||
|
match = re.search(r't(-?\d{2,3})', data)
|
||||||
|
if match:
|
||||||
|
weather['temperature'] = int(match.group(1))
|
||||||
|
|
||||||
|
# Rain last hour: rRRR (hundredths of inch)
|
||||||
|
match = re.search(r'r(\d{3})', data)
|
||||||
|
if match:
|
||||||
|
weather['rain_1h'] = int(match.group(1)) / 100.0
|
||||||
|
|
||||||
|
# Rain last 24h: pPPP
|
||||||
|
match = re.search(r'p(\d{3})', data)
|
||||||
|
if match:
|
||||||
|
weather['rain_24h'] = int(match.group(1)) / 100.0
|
||||||
|
|
||||||
|
# Humidity: hHH (%)
|
||||||
|
match = re.search(r'h(\d{2})', data)
|
||||||
|
if match:
|
||||||
|
h = int(match.group(1))
|
||||||
|
weather['humidity'] = 100 if h == 0 else h
|
||||||
|
|
||||||
|
# Barometric pressure: bBBBBB (tenths of millibars)
|
||||||
|
match = re.search(r'b(\d{5})', data)
|
||||||
|
if match:
|
||||||
|
weather['pressure'] = int(match.group(1)) / 10.0
|
||||||
|
|
||||||
|
return weather
|
||||||
|
|
||||||
|
|
||||||
|
def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None:
|
||||||
|
"""Stream decoded APRS packets to queue."""
|
||||||
|
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||||
|
|
||||||
|
try:
|
||||||
|
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
|
||||||
|
|
||||||
|
for line in iter(decoder_process.stdout.readline, b''):
|
||||||
|
line = line.decode('utf-8', errors='replace').strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# direwolf outputs decoded packets, multimon-ng outputs "AFSK1200: ..."
|
||||||
|
if line.startswith('AFSK1200:'):
|
||||||
|
line = line[9:].strip()
|
||||||
|
|
||||||
|
# Skip non-packet lines
|
||||||
|
if '>' not in line or ':' not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
packet = parse_aprs_packet(line)
|
||||||
|
if packet:
|
||||||
|
aprs_packet_count += 1
|
||||||
|
aprs_last_packet_time = time.time()
|
||||||
|
|
||||||
|
# Track unique stations
|
||||||
|
callsign = packet.get('callsign')
|
||||||
|
if callsign and callsign not in aprs_stations:
|
||||||
|
aprs_station_count += 1
|
||||||
|
|
||||||
|
# Update station data
|
||||||
|
if callsign:
|
||||||
|
aprs_stations[callsign] = {
|
||||||
|
'callsign': callsign,
|
||||||
|
'lat': packet.get('lat'),
|
||||||
|
'lon': packet.get('lon'),
|
||||||
|
'symbol': packet.get('symbol'),
|
||||||
|
'last_seen': packet.get('timestamp'),
|
||||||
|
'packet_type': packet.get('packet_type'),
|
||||||
|
}
|
||||||
|
|
||||||
|
app_module.aprs_queue.put(packet)
|
||||||
|
|
||||||
|
# Log if enabled
|
||||||
|
if app_module.logging_enabled:
|
||||||
|
try:
|
||||||
|
with open(app_module.log_file_path, 'a') as f:
|
||||||
|
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
f.write(f"{ts} | APRS | {json.dumps(packet)}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"APRS stream error: {e}")
|
||||||
|
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
||||||
|
finally:
|
||||||
|
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
|
# Cleanup processes
|
||||||
|
for proc in [rtl_process, decoder_process]:
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@aprs_bp.route('/tools')
|
||||||
|
def check_aprs_tools() -> Response:
|
||||||
|
"""Check for APRS decoding tools."""
|
||||||
|
has_rtl_fm = find_rtl_fm() is not None
|
||||||
|
has_direwolf = find_direwolf() is not None
|
||||||
|
has_multimon = find_multimon_ng() is not None
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'rtl_fm': has_rtl_fm,
|
||||||
|
'direwolf': has_direwolf,
|
||||||
|
'multimon_ng': has_multimon,
|
||||||
|
'ready': has_rtl_fm and (has_direwolf or has_multimon),
|
||||||
|
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@aprs_bp.route('/status')
|
||||||
|
def aprs_status() -> Response:
|
||||||
|
"""Get APRS decoder status."""
|
||||||
|
running = False
|
||||||
|
if app_module.aprs_process:
|
||||||
|
running = app_module.aprs_process.poll() is None
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'running': running,
|
||||||
|
'packet_count': aprs_packet_count,
|
||||||
|
'station_count': aprs_station_count,
|
||||||
|
'last_packet_time': aprs_last_packet_time,
|
||||||
|
'queue_size': app_module.aprs_queue.qsize()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@aprs_bp.route('/stations')
|
||||||
|
def get_stations() -> Response:
|
||||||
|
"""Get all tracked APRS stations."""
|
||||||
|
return jsonify({
|
||||||
|
'stations': list(aprs_stations.values()),
|
||||||
|
'count': len(aprs_stations)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@aprs_bp.route('/start', methods=['POST'])
|
||||||
|
def start_aprs() -> Response:
|
||||||
|
"""Start APRS decoder."""
|
||||||
|
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||||
|
|
||||||
|
with app_module.aprs_lock:
|
||||||
|
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'APRS decoder already running'
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
# Check for required tools
|
||||||
|
rtl_fm_path = find_rtl_fm()
|
||||||
|
if not rtl_fm_path:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||||
|
direwolf_path = find_direwolf()
|
||||||
|
multimon_path = find_multimon_ng()
|
||||||
|
|
||||||
|
if not direwolf_path and not multimon_path:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
try:
|
||||||
|
device = validate_device_index(data.get('device', '0'))
|
||||||
|
gain = validate_gain(data.get('gain', '40'))
|
||||||
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
# Get frequency for region
|
||||||
|
region = data.get('region', 'north_america')
|
||||||
|
frequency = APRS_FREQUENCIES.get(region, '144.390')
|
||||||
|
|
||||||
|
# Allow custom frequency override
|
||||||
|
if data.get('frequency'):
|
||||||
|
frequency = data.get('frequency')
|
||||||
|
|
||||||
|
# Clear queue and reset stats
|
||||||
|
while not app_module.aprs_queue.empty():
|
||||||
|
try:
|
||||||
|
app_module.aprs_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
aprs_packet_count = 0
|
||||||
|
aprs_station_count = 0
|
||||||
|
aprs_last_packet_time = None
|
||||||
|
aprs_stations = {}
|
||||||
|
|
||||||
|
# Build rtl_fm command
|
||||||
|
freq_hz = f"{float(frequency)}M"
|
||||||
|
rtl_cmd = [
|
||||||
|
rtl_fm_path,
|
||||||
|
'-f', freq_hz,
|
||||||
|
'-s', '22050', # Sample rate for AFSK1200
|
||||||
|
'-d', str(device),
|
||||||
|
]
|
||||||
|
|
||||||
|
if gain and str(gain) != '0':
|
||||||
|
rtl_cmd.extend(['-g', str(gain)])
|
||||||
|
if ppm and str(ppm) != '0':
|
||||||
|
rtl_cmd.extend(['-p', str(ppm)])
|
||||||
|
|
||||||
|
# Build decoder command
|
||||||
|
if direwolf_path:
|
||||||
|
decoder_cmd = [direwolf_path, '-r', '22050', '-D', '1', '-']
|
||||||
|
decoder_name = 'direwolf'
|
||||||
|
else:
|
||||||
|
decoder_cmd = [multimon_path, '-t', 'raw', '-a', 'AFSK1200', '-']
|
||||||
|
decoder_name = 'multimon-ng'
|
||||||
|
|
||||||
|
logger.info(f"Starting APRS decoder: {' '.join(rtl_cmd)} | {' '.join(decoder_cmd)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start rtl_fm
|
||||||
|
rtl_process = subprocess.Popen(
|
||||||
|
rtl_cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start decoder with rtl_fm output
|
||||||
|
decoder_process = subprocess.Popen(
|
||||||
|
decoder_cmd,
|
||||||
|
stdin=rtl_process.stdout,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Allow rtl_fm stdout to be consumed by decoder
|
||||||
|
rtl_process.stdout.close()
|
||||||
|
|
||||||
|
# Wait briefly to check if processes started
|
||||||
|
time.sleep(PROCESS_START_WAIT)
|
||||||
|
|
||||||
|
if rtl_process.poll() is not None:
|
||||||
|
stderr = rtl_process.stderr.read().decode('utf-8', errors='replace') if rtl_process.stderr else ''
|
||||||
|
error_msg = f'rtl_fm failed to start'
|
||||||
|
if stderr:
|
||||||
|
error_msg += f': {stderr[:200]}'
|
||||||
|
logger.error(error_msg)
|
||||||
|
decoder_process.kill()
|
||||||
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
|
|
||||||
|
# Store reference to decoder process (for status checks)
|
||||||
|
app_module.aprs_process = decoder_process
|
||||||
|
app_module.aprs_rtl_process = rtl_process
|
||||||
|
|
||||||
|
# Start output streaming thread
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=stream_aprs_output,
|
||||||
|
args=(rtl_process, decoder_process),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'frequency': frequency,
|
||||||
|
'region': region,
|
||||||
|
'device': device,
|
||||||
|
'decoder': decoder_name
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start APRS decoder: {e}")
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@aprs_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_aprs() -> Response:
|
||||||
|
"""Stop APRS decoder."""
|
||||||
|
with app_module.aprs_lock:
|
||||||
|
processes_to_stop = []
|
||||||
|
|
||||||
|
if hasattr(app_module, 'aprs_rtl_process') and app_module.aprs_rtl_process:
|
||||||
|
processes_to_stop.append(app_module.aprs_rtl_process)
|
||||||
|
|
||||||
|
if app_module.aprs_process:
|
||||||
|
processes_to_stop.append(app_module.aprs_process)
|
||||||
|
|
||||||
|
if not processes_to_stop:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'APRS decoder not running'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
for proc in processes_to_stop:
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping APRS process: {e}")
|
||||||
|
|
||||||
|
app_module.aprs_process = None
|
||||||
|
if hasattr(app_module, 'aprs_rtl_process'):
|
||||||
|
app_module.aprs_rtl_process = None
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@aprs_bp.route('/stream')
|
||||||
|
def stream_aprs() -> Response:
|
||||||
|
"""SSE stream for APRS packets."""
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
last_keepalive = time.time()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
yield format_sse(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= SSE_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'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@aprs_bp.route('/frequencies')
|
||||||
|
def get_frequencies() -> Response:
|
||||||
|
"""Get APRS frequencies by region."""
|
||||||
|
return jsonify(APRS_FREQUENCIES)
|
||||||
+5
-1
@@ -25,6 +25,7 @@ from utils.validation import (
|
|||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.process import safe_terminate, register_process
|
from utils.process import safe_terminate, register_process
|
||||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||||
|
from utils.dependencies import get_tool_path
|
||||||
|
|
||||||
pager_bp = Blueprint('pager', __name__)
|
pager_bp = Blueprint('pager', __name__)
|
||||||
|
|
||||||
@@ -245,7 +246,10 @@ def start_decoding() -> Response:
|
|||||||
bias_t=bias_t
|
bias_t=bias_t
|
||||||
)
|
)
|
||||||
|
|
||||||
multimon_cmd = ['multimon-ng', '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
|
multimon_path = get_tool_path('multimon-ng')
|
||||||
|
if not multimon_path:
|
||||||
|
return jsonify({'status': 'error', 'message': 'multimon-ng not found'}), 400
|
||||||
|
multimon_cmd = [multimon_path, '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
|
||||||
|
|
||||||
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd)
|
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd)
|
||||||
logger.info(f"Running: {full_cmd}")
|
logger.info(f"Running: {full_cmd}")
|
||||||
|
|||||||
+2276
File diff suppressed because it is too large
Load Diff
@@ -139,6 +139,7 @@ check_tools() {
|
|||||||
check_required "multimon-ng" "Pager decoder" multimon-ng
|
check_required "multimon-ng" "Pager decoder" multimon-ng
|
||||||
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
|
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
|
||||||
check_required "dump1090" "ADS-B decoder" dump1090
|
check_required "dump1090" "ADS-B decoder" dump1090
|
||||||
|
check_required "acarsdec" "ACARS decoder" acarsdec
|
||||||
|
|
||||||
echo
|
echo
|
||||||
info "GPS:"
|
info "GPS:"
|
||||||
@@ -265,12 +266,47 @@ brew_install() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
info "brew: installing ${pkg}..."
|
info "brew: installing ${pkg}..."
|
||||||
brew install "$pkg"
|
if brew install "$pkg" 2>&1; then
|
||||||
ok "brew: installed ${pkg}"
|
ok "brew: installed ${pkg}"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_multimon_ng_from_source_macos() {
|
||||||
|
info "multimon-ng not available via Homebrew. Building from source..."
|
||||||
|
|
||||||
|
# Ensure build dependencies are installed
|
||||||
|
brew_install cmake
|
||||||
|
brew_install libsndfile
|
||||||
|
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
info "Cloning multimon-ng..."
|
||||||
|
git clone --depth 1 https://github.com/EliasOewornal/multimon-ng.git "$tmp_dir/multimon-ng" >/dev/null 2>&1 \
|
||||||
|
|| { fail "Failed to clone multimon-ng"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/multimon-ng"
|
||||||
|
info "Compiling multimon-ng..."
|
||||||
|
mkdir -p build && cd build
|
||||||
|
cmake .. >/dev/null 2>&1 || { fail "cmake failed for multimon-ng"; exit 1; }
|
||||||
|
make >/dev/null 2>&1 || { fail "make failed for multimon-ng"; exit 1; }
|
||||||
|
|
||||||
|
# Install to /usr/local/bin (no sudo needed on Homebrew systems typically)
|
||||||
|
if [[ -w /usr/local/bin ]]; then
|
||||||
|
install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
||||||
|
else
|
||||||
|
sudo install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
||||||
|
fi
|
||||||
|
ok "multimon-ng installed successfully from source"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
install_macos_packages() {
|
install_macos_packages() {
|
||||||
TOTAL_STEPS=12
|
TOTAL_STEPS=13
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Checking Homebrew"
|
progress "Checking Homebrew"
|
||||||
@@ -280,7 +316,12 @@ install_macos_packages() {
|
|||||||
brew_install librtlsdr
|
brew_install librtlsdr
|
||||||
|
|
||||||
progress "Installing multimon-ng"
|
progress "Installing multimon-ng"
|
||||||
brew_install multimon-ng
|
# multimon-ng is not in Homebrew core, so build from source
|
||||||
|
if ! cmd_exists multimon-ng; then
|
||||||
|
install_multimon_ng_from_source_macos
|
||||||
|
else
|
||||||
|
ok "multimon-ng already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Installing ffmpeg"
|
progress "Installing ffmpeg"
|
||||||
brew_install ffmpeg
|
brew_install ffmpeg
|
||||||
@@ -291,6 +332,9 @@ install_macos_packages() {
|
|||||||
progress "Installing dump1090"
|
progress "Installing dump1090"
|
||||||
(brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew"
|
(brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew"
|
||||||
|
|
||||||
|
progress "Installing acarsdec"
|
||||||
|
(brew_install acarsdec) || warn "acarsdec not available via Homebrew"
|
||||||
|
|
||||||
progress "Installing aircrack-ng"
|
progress "Installing aircrack-ng"
|
||||||
brew_install aircrack-ng
|
brew_install aircrack-ng
|
||||||
|
|
||||||
@@ -304,6 +348,7 @@ install_macos_packages() {
|
|||||||
brew_install gpsd
|
brew_install gpsd
|
||||||
|
|
||||||
warn "macOS note: hcitool/hciconfig are Linux (BlueZ) utilities and often unavailable on macOS."
|
warn "macOS note: hcitool/hciconfig are Linux (BlueZ) utilities and often unavailable on macOS."
|
||||||
|
info "TSCM BLE scanning uses bleak library (installed via pip) for manufacturer data detection."
|
||||||
echo
|
echo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,6 +417,34 @@ install_dump1090_from_source_debian() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_acarsdec_from_source_debian() {
|
||||||
|
info "acarsdec not available via APT. Building from source..."
|
||||||
|
|
||||||
|
apt_install build-essential git cmake \
|
||||||
|
librtlsdr-dev libusb-1.0-0-dev libsndfile1-dev
|
||||||
|
|
||||||
|
# Run in subshell to isolate EXIT trap
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
info "Cloning acarsdec..."
|
||||||
|
git clone --depth 1 https://github.com/TLeconte/acarsdec.git "$tmp_dir/acarsdec" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone acarsdec"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/acarsdec"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
info "Compiling acarsdec..."
|
||||||
|
if cmake .. -Drtl=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||||
|
$SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec
|
||||||
|
ok "acarsdec installed successfully."
|
||||||
|
else
|
||||||
|
warn "Failed to build acarsdec from source. ACARS decoding will not be available."
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
setup_udev_rules_debian() {
|
setup_udev_rules_debian() {
|
||||||
[[ -d /etc/udev/rules.d ]] || { warn "udev not found; skipping RTL-SDR udev rules."; return 0; }
|
[[ -d /etc/udev/rules.d ]] || { warn "udev not found; skipping RTL-SDR udev rules."; return 0; }
|
||||||
|
|
||||||
@@ -389,6 +462,34 @@ EOF
|
|||||||
echo
|
echo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
blacklist_kernel_drivers_debian() {
|
||||||
|
local blacklist_file="/etc/modprobe.d/blacklist-rtlsdr.conf"
|
||||||
|
|
||||||
|
if [[ -f "$blacklist_file" ]]; then
|
||||||
|
ok "RTL-SDR kernel driver blacklist already present"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Blacklisting conflicting DVB kernel drivers..."
|
||||||
|
$SUDO tee "$blacklist_file" >/dev/null <<'EOF'
|
||||||
|
# Blacklist DVB-T drivers to allow rtl-sdr to access RTL2832U devices
|
||||||
|
blacklist dvb_usb_rtl28xxu
|
||||||
|
blacklist rtl2832
|
||||||
|
blacklist rtl2830
|
||||||
|
blacklist r820t
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Unload modules if currently loaded
|
||||||
|
for mod in dvb_usb_rtl28xxu rtl2832 rtl2830 r820t; do
|
||||||
|
if lsmod | grep -q "^$mod"; then
|
||||||
|
$SUDO modprobe -r "$mod" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
ok "Kernel drivers blacklisted. Unplug/replug your RTL-SDR if connected."
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
install_debian_packages() {
|
install_debian_packages() {
|
||||||
need_sudo
|
need_sudo
|
||||||
|
|
||||||
@@ -396,7 +497,7 @@ install_debian_packages() {
|
|||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
export NEEDRESTART_MODE=a
|
export NEEDRESTART_MODE=a
|
||||||
|
|
||||||
TOTAL_STEPS=15
|
TOTAL_STEPS=17
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Updating APT package lists"
|
progress "Updating APT package lists"
|
||||||
@@ -427,7 +528,9 @@ install_debian_packages() {
|
|||||||
apt_install bluez bluetooth || true
|
apt_install bluez bluetooth || true
|
||||||
|
|
||||||
progress "Installing SoapySDR"
|
progress "Installing SoapySDR"
|
||||||
apt_install soapysdr-tools || true
|
# Exclude xtrx-dkms - its kernel module fails to build on newer kernels (6.14+)
|
||||||
|
# and causes apt to hang. Most users don't have XTRX hardware anyway.
|
||||||
|
apt_install soapysdr-tools xtrx-dkms- || true
|
||||||
|
|
||||||
progress "Installing gpsd"
|
progress "Installing gpsd"
|
||||||
apt_install gpsd gpsd-clients || true
|
apt_install gpsd gpsd-clients || true
|
||||||
@@ -437,15 +540,32 @@ install_debian_packages() {
|
|||||||
# Install Python packages via apt (more reliable than pip on modern Debian/Ubuntu)
|
# Install Python packages via apt (more reliable than pip on modern Debian/Ubuntu)
|
||||||
$SUDO apt-get install -y python3-flask python3-requests python3-serial >/dev/null 2>&1 || true
|
$SUDO apt-get install -y python3-flask python3-requests python3-serial >/dev/null 2>&1 || true
|
||||||
$SUDO apt-get install -y python3-skyfield >/dev/null 2>&1 || true
|
$SUDO apt-get install -y python3-skyfield >/dev/null 2>&1 || true
|
||||||
|
# bleak for BLE scanning with manufacturer data (TSCM mode)
|
||||||
|
$SUDO apt-get install -y python3-bleak >/dev/null 2>&1 || true
|
||||||
|
|
||||||
progress "Installing dump1090"
|
progress "Installing dump1090"
|
||||||
if ! cmd_exists dump1090; then
|
if ! cmd_exists dump1090 && ! cmd_exists dump1090-mutability; then
|
||||||
|
#export DEBIAN_FRONTEND=noninteractive
|
||||||
apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
|
apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
|
||||||
fi
|
fi
|
||||||
|
if ! cmd_exists dump1090; then
|
||||||
|
if cmd_exists dump1090-mutability; then
|
||||||
|
$SUDO ln -s $(which dump1090-mutability) /usr/local/sbin/dump1090
|
||||||
|
fi
|
||||||
|
fi
|
||||||
cmd_exists dump1090 || install_dump1090_from_source_debian
|
cmd_exists dump1090 || install_dump1090_from_source_debian
|
||||||
|
|
||||||
|
progress "Installing acarsdec"
|
||||||
|
if ! cmd_exists acarsdec; then
|
||||||
|
apt_install acarsdec || true
|
||||||
|
fi
|
||||||
|
cmd_exists acarsdec || install_acarsdec_from_source_debian
|
||||||
|
|
||||||
progress "Configuring udev rules"
|
progress "Configuring udev rules"
|
||||||
setup_udev_rules_debian
|
setup_udev_rules_debian
|
||||||
|
|
||||||
|
progress "Blacklisting conflicting kernel drivers"
|
||||||
|
blacklist_kernel_drivers_debian
|
||||||
}
|
}
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
@@ -455,26 +575,28 @@ final_summary_and_hard_fail() {
|
|||||||
check_tools
|
check_tools
|
||||||
|
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
|
echo
|
||||||
|
echo "To start INTERCEPT:"
|
||||||
|
echo " sudo -E venv/bin/python intercept.py"
|
||||||
|
echo
|
||||||
|
echo "Then open http://localhost:5050 in your browser"
|
||||||
|
echo
|
||||||
|
echo "============================================"
|
||||||
|
|
||||||
if [[ "${#missing_required[@]}" -eq 0 ]]; then
|
if [[ "${#missing_required[@]}" -eq 0 ]]; then
|
||||||
ok "All REQUIRED tools are installed."
|
ok "All REQUIRED tools are installed."
|
||||||
else
|
else
|
||||||
fail "Missing REQUIRED tools:"
|
fail "Missing REQUIRED tools:"
|
||||||
for t in "${missing_required[@]}"; do echo " - $t"; done
|
for t in "${missing_required[@]}"; do echo " - $t"; done
|
||||||
echo
|
echo
|
||||||
fail "Exiting because required tools are missing."
|
if [[ "$OS" == "macos" ]]; then
|
||||||
echo
|
warn "macOS note: bluetoothctl/hcitool/hciconfig are Linux (BlueZ) tools and unavailable on macOS."
|
||||||
warn "If you are on macOS: hcitool/hciconfig are Linux (BlueZ) tools and may not be installable."
|
warn "Bluetooth functionality will be limited. Other features should work."
|
||||||
warn "If you truly require them everywhere, you must restrict supported platforms or provide alternatives."
|
else
|
||||||
exit 1
|
fail "Exiting because required tools are missing."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
|
||||||
echo "To start INTERCEPT:"
|
|
||||||
echo " source venv/bin/activate"
|
|
||||||
echo " sudo python intercept.py"
|
|
||||||
echo
|
|
||||||
echo "Then open http://localhost:5050 in your browser"
|
|
||||||
echo
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
|
|||||||
+178
-21
@@ -185,13 +185,144 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 340px;
|
grid-template-columns: auto 1fr 300px;
|
||||||
grid-template-rows: 1fr auto;
|
grid-template-rows: 1fr auto;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
height: calc(100vh - 60px);
|
height: calc(100vh - 60px);
|
||||||
min-height: 500px;
|
min-height: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ACARS sidebar (left of map) - Collapsible */
|
||||||
|
.acars-sidebar {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acars-collapse-btn {
|
||||||
|
width: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: none;
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 0;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acars-collapse-btn:hover {
|
||||||
|
background: rgba(74, 158, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acars-collapse-label {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-orientation: mixed;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acars-sidebar.collapsed .acars-collapse-label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acars-sidebar:not(.collapsed) .acars-collapse-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#acarsCollapseIcon {
|
||||||
|
font-size: 10px;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acars-sidebar.collapsed #acarsCollapseIcon {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acars-sidebar-content {
|
||||||
|
width: 250px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: width 0.3s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acars-sidebar.collapsed .acars-sidebar-content {
|
||||||
|
width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acars-sidebar .panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acars-sidebar .panel::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acars-sidebar .acars-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acars-sidebar .acars-btn {
|
||||||
|
background: var(--accent-green);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acars-sidebar .acars-btn:hover {
|
||||||
|
background: #1db954;
|
||||||
|
box-shadow: 0 0 10px rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acars-sidebar .acars-btn.active {
|
||||||
|
background: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acars-sidebar .acars-btn.active:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acars-message-item {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 10px;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acars-message-item:hover {
|
||||||
|
background: rgba(74, 158, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-5px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
/* Panels */
|
/* Panels */
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
@@ -228,8 +359,14 @@ body {
|
|||||||
.panel-indicator {
|
.panel-indicator {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
background: var(--accent-cyan);
|
background: var(--text-dim);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-indicator.active {
|
||||||
|
background: var(--accent-green);
|
||||||
|
opacity: 1;
|
||||||
animation: blink 1s ease-in-out infinite;
|
animation: blink 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +396,7 @@ body {
|
|||||||
|
|
||||||
/* Main display container (map + radar scope) */
|
/* Main display container (map + radar scope) */
|
||||||
.main-display {
|
.main-display {
|
||||||
grid-column: 1;
|
grid-column: 2;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -299,7 +436,7 @@ body {
|
|||||||
|
|
||||||
/* Right sidebar */
|
/* Right sidebar */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
grid-column: 2;
|
grid-column: 3;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -565,9 +702,9 @@ body {
|
|||||||
/* Start/stop button */
|
/* Start/stop button */
|
||||||
.start-btn {
|
.start-btn {
|
||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
border: 1px solid var(--accent-cyan);
|
border: none;
|
||||||
background: rgba(74, 158, 255, 0.1);
|
background: var(--accent-green);
|
||||||
color: var(--accent-cyan);
|
color: #fff;
|
||||||
font-family: 'Orbitron', monospace;
|
font-family: 'Orbitron', monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -580,19 +717,18 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.start-btn:hover {
|
.start-btn:hover {
|
||||||
background: var(--accent-cyan);
|
background: #1db954;
|
||||||
color: var(--bg-dark);
|
box-shadow: 0 0 20px rgba(34, 197, 94, 0.3);
|
||||||
box-shadow: 0 0 20px rgba(74, 158, 255, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.start-btn.active {
|
.start-btn.active {
|
||||||
background: var(--accent-red);
|
background: var(--accent-red);
|
||||||
border-color: var(--accent-red);
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.start-btn.active:hover {
|
.start-btn.active:hover {
|
||||||
box-shadow: 0 0 20px rgba(255, 68, 68, 0.3);
|
background: #dc2626;
|
||||||
|
box-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* GPS button */
|
/* GPS button */
|
||||||
@@ -656,8 +792,20 @@ body {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive - medium screens (hide ACARS sidebar, keep main sidebar) */
|
||||||
@media (max-width: 1000px) {
|
@media (max-width: 1200px) {
|
||||||
|
.dashboard {
|
||||||
|
grid-template-columns: 1fr 300px;
|
||||||
|
grid-template-rows: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acars-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive - small screens (single column) */
|
||||||
|
@media (max-width: 900px) {
|
||||||
.dashboard {
|
.dashboard {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: 1fr auto auto;
|
grid-template-rows: 1fr auto auto;
|
||||||
@@ -667,6 +815,10 @@ body {
|
|||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.acars-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
@@ -699,9 +851,9 @@ body {
|
|||||||
|
|
||||||
.airband-btn {
|
.airband-btn {
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background: rgba(74, 158, 255, 0.1);
|
background: var(--accent-green);
|
||||||
border: 1px solid var(--accent-cyan);
|
border: none;
|
||||||
color: var(--accent-cyan);
|
color: #fff;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -716,13 +868,18 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.airband-btn:hover {
|
.airband-btn:hover {
|
||||||
background: rgba(74, 158, 255, 0.2);
|
background: #1db954;
|
||||||
|
box-shadow: 0 0 10px rgba(34, 197, 94, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.airband-btn.active {
|
.airband-btn.active {
|
||||||
background: rgba(34, 197, 94, 0.2);
|
background: var(--accent-red);
|
||||||
border-color: var(--accent-green);
|
color: #fff;
|
||||||
color: var(--accent-green);
|
}
|
||||||
|
|
||||||
|
.airband-btn.active:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.airband-btn:disabled {
|
.airband-btn:disabled {
|
||||||
|
|||||||
+389
-135
@@ -83,10 +83,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
LANDING PAGE / SPLASH SCREEN
|
WELCOME PAGE
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
.landing-overlay {
|
.welcome-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -100,7 +100,7 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-overlay::before {
|
.welcome-overlay::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -113,13 +113,14 @@ body {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-content {
|
.welcome-container {
|
||||||
text-align: center;
|
width: 90%;
|
||||||
|
max-width: 900px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
animation: landingFadeIn 1s ease-out;
|
animation: welcomeFadeIn 0.8s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes landingFadeIn {
|
@keyframes welcomeFadeIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(20px);
|
transform: translateY(20px);
|
||||||
@@ -130,46 +131,44 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-logo {
|
/* Welcome Header */
|
||||||
|
.welcome-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-logo {
|
||||||
animation: logoPulse 3s ease-in-out infinite;
|
animation: logoPulse 3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes logoPulse {
|
@keyframes logoPulse {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
filter: drop-shadow(0 0 20px rgba(0, 212, 255, 0.3));
|
filter: drop-shadow(0 0 15px rgba(0, 212, 255, 0.3));
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
filter: drop-shadow(0 0 40px rgba(0, 212, 255, 0.6));
|
filter: drop-shadow(0 0 30px rgba(0, 212, 255, 0.6));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-logo .signal-wave {
|
.welcome-logo .signal-wave {
|
||||||
animation: signalPulse 2s ease-in-out infinite;
|
animation: signalPulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-logo .signal-wave-1 {
|
.welcome-logo .signal-wave-1 { animation-delay: 0s; }
|
||||||
animation-delay: 0s;
|
.welcome-logo .signal-wave-2 { animation-delay: 0.2s; }
|
||||||
}
|
.welcome-logo .signal-wave-3 { animation-delay: 0.4s; }
|
||||||
|
|
||||||
.landing-logo .signal-wave-2 {
|
|
||||||
animation-delay: 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-logo .signal-wave-3 {
|
|
||||||
animation-delay: 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes signalPulse {
|
@keyframes signalPulse {
|
||||||
0%, 100% {
|
0%, 100% { opacity: 0.3; }
|
||||||
opacity: 0.3;
|
50% { opacity: 1; }
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-logo .logo-dot {
|
.welcome-logo .logo-dot {
|
||||||
animation: dotPulse 1.5s ease-in-out infinite;
|
animation: dotPulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,119 +183,239 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-title {
|
.welcome-title-block {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 4rem;
|
font-size: 2.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
letter-spacing: 0.3em;
|
|
||||||
margin: 0 0 10px 0;
|
|
||||||
text-shadow: 0 0 30px rgba(0, 212, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-tagline {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: #00d4ff;
|
|
||||||
letter-spacing: 0.2em;
|
letter-spacing: 0.2em;
|
||||||
margin: 0 0 8px 0;
|
margin: 0;
|
||||||
opacity: 0.9;
|
text-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-subtitle {
|
.welcome-tagline {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-secondary);
|
color: var(--accent-cyan);
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-version {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--bg-primary);
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Welcome Content Grid */
|
||||||
|
.welcome-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.5fr;
|
||||||
|
gap: 30px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-content h2 {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin: 0 0 40px 0;
|
letter-spacing: 0.15em;
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-enter-btn {
|
/* Changelog Section */
|
||||||
background: transparent;
|
.welcome-changelog {
|
||||||
border: 2px solid #00d4ff;
|
background: var(--bg-secondary);
|
||||||
color: #00d4ff;
|
border: 1px solid var(--border-color);
|
||||||
padding: 15px 50px;
|
border-radius: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
padding: 20px;
|
||||||
font-size: 1rem;
|
max-height: 320px;
|
||||||
letter-spacing: 0.2em;
|
overflow-y: auto;
|
||||||
cursor: pointer;
|
}
|
||||||
transition: all 0.3s ease;
|
|
||||||
display: inline-flex;
|
.changelog-release {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-release:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-version-header {
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 15px;
|
gap: 10px;
|
||||||
position: relative;
|
margin-bottom: 10px;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-enter-btn::before {
|
.changelog-version {
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: -100%;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.2), transparent);
|
|
||||||
transition: left 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-enter-btn:hover::before {
|
|
||||||
left: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-enter-btn:hover {
|
|
||||||
background: rgba(0, 212, 255, 0.1);
|
|
||||||
box-shadow: 0 0 30px rgba(0, 212, 255, 0.3), inset 0 0 20px rgba(0, 212, 255, 0.1);
|
|
||||||
transform: scale(1.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-enter-btn .btn-icon {
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-enter-btn:hover .btn-icon {
|
|
||||||
transform: translateX(5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-version {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-date {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
margin-top: 30px;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-scanline {
|
.changelog-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-list li {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-list li::before {
|
||||||
|
content: '>';
|
||||||
|
position: absolute;
|
||||||
|
left: -15px;
|
||||||
|
color: var(--accent-green);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode Selection Grid */
|
||||||
|
.welcome-modes {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 15px 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 212, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card .mode-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card .mode-name {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card .mode-desc {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Welcome Footer */
|
||||||
|
.welcome-footer {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-footer p {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Welcome Scanline */
|
||||||
|
.welcome-scanline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 3px;
|
height: 2px;
|
||||||
background: linear-gradient(90deg, transparent, #00d4ff, transparent);
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
animation: scanlineMove 4s linear infinite;
|
animation: scanlineMove 5s linear infinite;
|
||||||
opacity: 0.5;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes scanlineMove {
|
@keyframes scanlineMove {
|
||||||
0% {
|
0% { top: 0; }
|
||||||
top: 0;
|
100% { top: 100%; }
|
||||||
}
|
|
||||||
100% {
|
|
||||||
top: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-overlay.fade-out {
|
/* Welcome Fade Out */
|
||||||
animation: landingFadeOut 0.5s ease-in forwards;
|
.welcome-overlay.fade-out {
|
||||||
|
animation: welcomeFadeOut 0.4s ease-in forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes landingFadeOut {
|
@keyframes welcomeFadeOut {
|
||||||
from {
|
from { opacity: 1; }
|
||||||
opacity: 1;
|
to { opacity: 0; visibility: hidden; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.welcome-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
to {
|
|
||||||
opacity: 0;
|
.welcome-header {
|
||||||
visibility: hidden;
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-title-block {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,6 +589,109 @@ header h1 {
|
|||||||
color: var(--bg-primary);
|
color: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dropdown Navigation */
|
||||||
|
.mode-nav-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .nav-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .nav-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-btn .dropdown-arrow {
|
||||||
|
font-size: 8px;
|
||||||
|
margin-left: 4px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .mode-nav-dropdown-btn {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .dropdown-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.has-active .mode-nav-dropdown-btn .nav-icon {
|
||||||
|
filter: brightness(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
min-width: 180px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown.open .mode-nav-dropdown-menu {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn:hover {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav-dropdown-menu .mode-nav-btn.active {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.version-badge {
|
.version-badge {
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -1229,8 +1451,8 @@ header h1 .tagline {
|
|||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
min-height: 100px;
|
min-height: 400px;
|
||||||
max-height: 250px;
|
max-height: 600px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.output-content::-webkit-scrollbar {
|
.output-content::-webkit-scrollbar {
|
||||||
@@ -1496,20 +1718,18 @@ header h1 .tagline {
|
|||||||
background: var(--accent-cyan);
|
background: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.waterfall-container {
|
/* Waterfall canvases (inside collapsible panels) */
|
||||||
padding: 0 15px;
|
#waterfallCanvas,
|
||||||
margin-bottom: 10px;
|
#sensorWaterfallCanvas {
|
||||||
}
|
|
||||||
|
|
||||||
#waterfallCanvas {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 60px;
|
height: 30px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: none;
|
||||||
transition: box-shadow 0.3s ease;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
#waterfallCanvas.active {
|
#waterfallCanvas.active,
|
||||||
|
#sensorWaterfallCanvas.active {
|
||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1637,20 +1857,54 @@ header h1 .tagline {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waterfall-container {
|
/* Removed - now using sensor-waterfall-panel structure for waterfalls */
|
||||||
position: relative;
|
|
||||||
background: #000;
|
/* Waterfall Panel (used for both pager and 433MHz modes) */
|
||||||
|
.sensor-waterfall-panel {
|
||||||
|
margin: 0 15px 10px 15px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#waterfallCanvas {
|
.sensor-waterfall-header {
|
||||||
width: 100%;
|
display: flex;
|
||||||
height: 200px;
|
align-items: center;
|
||||||
display: block;
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sensor-waterfall-header:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-waterfall-content {
|
||||||
|
background: #000;
|
||||||
|
transition: max-height 0.3s ease, padding 0.3s ease;
|
||||||
|
max-height: 50px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-waterfall-panel.collapsed .sensor-waterfall-content {
|
||||||
|
max-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-waterfall-panel.collapsed .sensor-waterfall-header {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Removed duplicate - consolidated above */
|
||||||
|
|
||||||
.waterfall-scale {
|
.waterfall-scale {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -4311,14 +4565,14 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.radio-action-btn.scan {
|
.radio-action-btn.scan {
|
||||||
background: var(--accent-cyan);
|
background: var(--accent-green);
|
||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-green);
|
||||||
color: var(--bg-primary);
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radio-action-btn.scan:hover {
|
.radio-action-btn.scan:hover {
|
||||||
background: #5aa8ff;
|
background: #1db954;
|
||||||
box-shadow: 0 0 20px var(--accent-cyan-dim);
|
box-shadow: 0 0 20px rgba(34, 197, 94, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.radio-action-btn.scan.active {
|
.radio-action-btn.scan.active {
|
||||||
|
|||||||
@@ -589,13 +589,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn.primary {
|
.btn.primary {
|
||||||
background: var(--accent-cyan);
|
background: var(--accent-green);
|
||||||
color: var(--bg-dark);
|
color: #fff;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.primary:hover {
|
.btn.primary:hover {
|
||||||
box-shadow: 0 0 25px rgba(0, 212, 255, 0.5);
|
background: #1db954;
|
||||||
|
box-shadow: 0 0 25px rgba(34, 197, 94, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Leaflet dark theme overrides */
|
/* Leaflet dark theme overrides */
|
||||||
|
|||||||
@@ -43,6 +43,52 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="dashboard">
|
<main class="dashboard">
|
||||||
|
<!-- ACARS Panel (left of map) - Collapsible -->
|
||||||
|
<div class="acars-sidebar" id="acarsSidebar">
|
||||||
|
<div class="acars-sidebar-content" id="acarsSidebarContent">
|
||||||
|
<div class="panel acars-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span>ACARS MESSAGES</span>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<span id="acarsCount" style="font-size: 10px; color: var(--accent-cyan);">0</span>
|
||||||
|
<div class="panel-indicator" id="acarsPanelIndicator"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="acarsPanelContent">
|
||||||
|
<div class="acars-info" style="font-size: 9px; color: var(--text-muted); padding: 5px 8px; border-bottom: 1px solid var(--border-color);">
|
||||||
|
<span style="color: var(--accent-yellow);">⚠</span> Requires separate SDR (VHF ~131 MHz)
|
||||||
|
</div>
|
||||||
|
<div class="acars-controls" style="padding: 8px; border-bottom: 1px solid var(--border-color);">
|
||||||
|
<div style="display: flex; gap: 5px; margin-bottom: 5px;">
|
||||||
|
<select id="acarsDeviceSelect" style="flex: 1; font-size: 10px;">
|
||||||
|
<option value="0">SDR 0</option>
|
||||||
|
<option value="1">SDR 1</option>
|
||||||
|
</select>
|
||||||
|
<select id="acarsRegionSelect" onchange="setAcarsFreqs()" style="flex: 1; font-size: 10px;">
|
||||||
|
<option value="na">N. America</option>
|
||||||
|
<option value="eu">Europe</option>
|
||||||
|
<option value="ap">Asia-Pac</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="acars-btn" id="acarsToggleBtn" onclick="toggleAcars()" style="width: 100%;">
|
||||||
|
▶ START ACARS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="acars-messages" id="acarsMessages">
|
||||||
|
<div class="no-aircraft" style="padding: 20px; text-align: center;">
|
||||||
|
<div style="font-size: 10px; color: var(--text-muted);">No ACARS messages</div>
|
||||||
|
<div style="font-size: 9px; color: var(--text-dim); margin-top: 5px;">Start ACARS to receive aircraft datalink messages</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="acars-collapse-btn" id="acarsCollapseBtn" onclick="toggleAcarsSidebar()" title="Toggle ACARS Panel">
|
||||||
|
<span id="acarsCollapseIcon">◀</span>
|
||||||
|
<span class="acars-collapse-label">ACARS</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main Display (Map or Radar Scope) -->
|
<!-- Main Display (Map or Radar Scope) -->
|
||||||
<div class="main-display">
|
<div class="main-display">
|
||||||
<div class="display-container">
|
<div class="display-container">
|
||||||
@@ -2215,6 +2261,179 @@ sudo make install</code>
|
|||||||
// Initialize airband on page load
|
// Initialize airband on page load
|
||||||
document.addEventListener('DOMContentLoaded', initAirband);
|
document.addEventListener('DOMContentLoaded', initAirband);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ACARS Functions
|
||||||
|
// ============================================
|
||||||
|
let acarsEventSource = null;
|
||||||
|
let isAcarsRunning = false;
|
||||||
|
let acarsMessageCount = 0;
|
||||||
|
let acarsSidebarCollapsed = localStorage.getItem('acarsSidebarCollapsed') === 'true';
|
||||||
|
let acarsFrequencies = {
|
||||||
|
'na': ['129.125', '130.025', '130.450', '131.550'],
|
||||||
|
'eu': ['131.525', '131.725', '131.550'],
|
||||||
|
'ap': ['131.550', '131.450']
|
||||||
|
};
|
||||||
|
|
||||||
|
function toggleAcarsSidebar() {
|
||||||
|
const sidebar = document.getElementById('acarsSidebar');
|
||||||
|
acarsSidebarCollapsed = !acarsSidebarCollapsed;
|
||||||
|
sidebar.classList.toggle('collapsed', acarsSidebarCollapsed);
|
||||||
|
localStorage.setItem('acarsSidebarCollapsed', acarsSidebarCollapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize ACARS sidebar state
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const sidebar = document.getElementById('acarsSidebar');
|
||||||
|
if (sidebar && acarsSidebarCollapsed) {
|
||||||
|
sidebar.classList.add('collapsed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function setAcarsFreqs() {
|
||||||
|
// Just updates the region selection - frequencies are sent on start
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAcarsRegionFreqs() {
|
||||||
|
const region = document.getElementById('acarsRegionSelect').value;
|
||||||
|
return acarsFrequencies[region] || acarsFrequencies['na'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAcars() {
|
||||||
|
if (isAcarsRunning) {
|
||||||
|
stopAcars();
|
||||||
|
} else {
|
||||||
|
startAcars();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAcars() {
|
||||||
|
const device = document.getElementById('acarsDeviceSelect').value;
|
||||||
|
const frequencies = getAcarsRegionFreqs();
|
||||||
|
|
||||||
|
// Warn if using same device as ADS-B
|
||||||
|
if (isTracking && device === '0') {
|
||||||
|
const useAnyway = confirm(
|
||||||
|
'Warning: ADS-B tracking may be using SDR device 0.\n\n' +
|
||||||
|
'ACARS uses VHF frequencies (129-131 MHz) while ADS-B uses 1090 MHz.\n' +
|
||||||
|
'You need TWO separate SDR devices to receive both simultaneously.\n\n' +
|
||||||
|
'Click OK to start ACARS on device ' + device + ' anyway.'
|
||||||
|
);
|
||||||
|
if (!useAnyway) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/acars/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ device, frequencies, gain: '40' })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'started') {
|
||||||
|
isAcarsRunning = true;
|
||||||
|
acarsMessageCount = 0;
|
||||||
|
document.getElementById('acarsToggleBtn').textContent = '■ STOP ACARS';
|
||||||
|
document.getElementById('acarsToggleBtn').classList.add('active');
|
||||||
|
document.getElementById('acarsPanelIndicator').classList.add('active');
|
||||||
|
startAcarsStream();
|
||||||
|
} else {
|
||||||
|
alert('ACARS Error: ' + data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => alert('ACARS Error: ' + err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAcars() {
|
||||||
|
fetch('/acars/stop', { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => {
|
||||||
|
isAcarsRunning = false;
|
||||||
|
document.getElementById('acarsToggleBtn').textContent = '▶ START ACARS';
|
||||||
|
document.getElementById('acarsToggleBtn').classList.remove('active');
|
||||||
|
document.getElementById('acarsPanelIndicator').classList.remove('active');
|
||||||
|
if (acarsEventSource) {
|
||||||
|
acarsEventSource.close();
|
||||||
|
acarsEventSource = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAcarsStream() {
|
||||||
|
if (acarsEventSource) acarsEventSource.close();
|
||||||
|
acarsEventSource = new EventSource('/acars/stream');
|
||||||
|
|
||||||
|
acarsEventSource.onmessage = function(e) {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.type === 'acars') {
|
||||||
|
acarsMessageCount++;
|
||||||
|
document.getElementById('acarsCount').textContent = acarsMessageCount;
|
||||||
|
addAcarsMessage(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
acarsEventSource.onerror = function() {
|
||||||
|
console.error('ACARS stream error');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAcarsMessage(data) {
|
||||||
|
const container = document.getElementById('acarsMessages');
|
||||||
|
|
||||||
|
// Remove "no messages" placeholder if present
|
||||||
|
const placeholder = container.querySelector('.no-aircraft');
|
||||||
|
if (placeholder) placeholder.remove();
|
||||||
|
|
||||||
|
const msg = document.createElement('div');
|
||||||
|
msg.className = 'acars-message-item';
|
||||||
|
msg.style.cssText = 'padding: 6px 8px; border-bottom: 1px solid var(--border-color); font-size: 10px;';
|
||||||
|
|
||||||
|
const flight = data.flight || 'UNKNOWN';
|
||||||
|
const reg = data.reg || '';
|
||||||
|
const label = data.label || '';
|
||||||
|
const text = data.text || data.msg || '';
|
||||||
|
const time = new Date().toLocaleTimeString();
|
||||||
|
|
||||||
|
msg.innerHTML = `
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 2px;">
|
||||||
|
<span style="color: var(--accent-cyan); font-weight: bold;">${flight}</span>
|
||||||
|
<span style="color: var(--text-muted);">${time}</span>
|
||||||
|
</div>
|
||||||
|
${reg ? `<div style="color: var(--text-muted); font-size: 9px;">Reg: ${reg}</div>` : ''}
|
||||||
|
${label ? `<div style="color: var(--accent-green);">Label: ${label}</div>` : ''}
|
||||||
|
${text ? `<div style="color: var(--text-primary); margin-top: 3px; word-break: break-word;">${text}</div>` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.insertBefore(msg, container.firstChild);
|
||||||
|
|
||||||
|
// Keep max 50 messages
|
||||||
|
while (container.children.length > 50) {
|
||||||
|
container.removeChild(container.lastChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate ACARS device selector
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
fetch('/devices')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(devices => {
|
||||||
|
const select = document.getElementById('acarsDeviceSelect');
|
||||||
|
select.innerHTML = '';
|
||||||
|
if (devices.length === 0) {
|
||||||
|
select.innerHTML = '<option value="0">No SDR detected</option>';
|
||||||
|
} else {
|
||||||
|
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'}`;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
// Default to device 1 if available (device 0 likely used for ADS-B)
|
||||||
|
if (devices.length > 1) {
|
||||||
|
select.value = '1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// SQUAWK CODE REFERENCE
|
// SQUAWK CODE REFERENCE
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
+3223
-123
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
|||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from flask import Flask
|
||||||
|
from routes.satellite import satellite_bp
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(satellite_bp)
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
return app
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
def test_predict_passes_invalid_coords(client):
|
||||||
|
"""Verify that invalid coordinates return a 400 error."""
|
||||||
|
payload = {
|
||||||
|
"latitude": 150.0, # Invalid (>90)
|
||||||
|
"longitude": -0.1278
|
||||||
|
}
|
||||||
|
response = client.post('/satellite/predict', json=payload)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json['status'] == 'error'
|
||||||
|
|
||||||
|
def test_fetch_celestrak_invalid_category(client):
|
||||||
|
"""Verify that an unauthorized category is rejected."""
|
||||||
|
response = client.get('/satellite/celestrak/category_fake')
|
||||||
|
# The code returns 200 but includes an error message in the JSON body
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json['status'] == 'error'
|
||||||
|
assert 'Invalid category' in response.json['message']
|
||||||
|
|
||||||
|
# Mocking Tests (External Calls and Skyfield)
|
||||||
|
@patch('urllib.request.urlopen')
|
||||||
|
def test_update_tle_success(mock_urlopen, client):
|
||||||
|
"""Simulate a successful response from CelesTrak."""
|
||||||
|
mock_content = (
|
||||||
|
"ISS (ZARYA)\n"
|
||||||
|
"1 25544U 98067A 23321.52083333 .00016717 00000-0 30171-3 0 9992\n"
|
||||||
|
"2 25544 51.6416 20.4567 0004561 45.3212 67.8912 15.49876543123456\n"
|
||||||
|
).encode('utf-8')
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = mock_content
|
||||||
|
mock_response.__enter__.return_value = mock_response
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
response = client.post('/satellite/update-tle')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json['status'] == 'success'
|
||||||
|
assert 'ISS' in response.json['updated']
|
||||||
|
|
||||||
|
@patch('skyfield.api.load')
|
||||||
|
def test_get_satellite_position_skyfield_error(mock_load, client):
|
||||||
|
"""Test behavior when Skyfield fails or data is missing."""
|
||||||
|
# Force the timescale load to fail
|
||||||
|
mock_load.side_effect = Exception("Skyfield error")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"latitude": 51.5,
|
||||||
|
"longitude": -0.1,
|
||||||
|
"satellites": ["ISS"]
|
||||||
|
}
|
||||||
|
response = client.post('/satellite/position', json=payload)
|
||||||
|
# Should return success but an empty positions list due to internal try-except
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json['positions'] == []
|
||||||
|
|
||||||
|
# Logic Integration Test (Simulating prediction)
|
||||||
|
def test_predict_passes_empty_cache(client):
|
||||||
|
"""Verify that if the satellite is not in cache, no passes are returned."""
|
||||||
|
payload = {
|
||||||
|
"latitude": 51.5,
|
||||||
|
"longitude": -0.1,
|
||||||
|
"satellites": ["SATELLITE_NON_EXISTENT"]
|
||||||
|
}
|
||||||
|
response = client.post('/satellite/predict', json=payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.json['passes']) == 0
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock, patch, mock_open
|
||||||
|
from flask import Flask
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||||
|
from routes.wifi import wifi_bp, parse_airodump_csv
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_app_module(mocker):
|
||||||
|
"""Mock the app_module imported inside routes.wifi."""
|
||||||
|
mock = mocker.patch("routes.wifi.app_module")
|
||||||
|
mock.wifi_lock = MagicMock()
|
||||||
|
mock.wifi_process = None
|
||||||
|
mock.wifi_monitor_interface = None
|
||||||
|
mock.wifi_queue = MagicMock()
|
||||||
|
mock.wifi_networks = {}
|
||||||
|
mock_app_module.wifi_clients = {}
|
||||||
|
return mock
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(wifi_bp)
|
||||||
|
return app
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
def test_parse_airodump_csv(mocker):
|
||||||
|
"""Test parsing logic for airodump CSV format."""
|
||||||
|
csv_content = (
|
||||||
|
"BSSID, First time seen, Last time seen, channel, Speed, Privacy, Cipher, Authentication, Power, # beacons, # IV, LAN IP, ID-length, ESSID, Key\n"
|
||||||
|
"AA:BB:CC:DD:EE:FF, 2023-01-01, 2023-01-01, 6, 54, WPA2, CCMP, PSK, -50, 10, 5, 0.0.0.0, 7, MyWiFi, \n"
|
||||||
|
"\n"
|
||||||
|
"Station MAC, First time seen, Last time seen, Power, # packets, BSSID, Probes\n"
|
||||||
|
"11:22:33:44:55:66, 2023-01-01, 2023-01-01, -60, 20, AA:BB:CC:DD:EE:FF, MyWiFi\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("builtins.open", mock_open(read_data=csv_content)):
|
||||||
|
mocker.patch("routes.wifi.get_manufacturer", return_value="Apple")
|
||||||
|
networks, clients = parse_airodump_csv("dummy.csv")
|
||||||
|
|
||||||
|
assert "AA:BB:CC:DD:EE:FF" in networks
|
||||||
|
assert networks["AA:BB:CC:DD:EE:FF"]["essid"] == "MyWiFi"
|
||||||
|
assert "11:22:33:44:55:66" in clients
|
||||||
|
assert clients["11:22:33:44:55:66"]["vendor"] == "Apple"
|
||||||
|
|
||||||
|
### --- ROUTE TESTS --- ###
|
||||||
|
|
||||||
|
def test_get_interfaces(client, mocker):
|
||||||
|
"""Test the /interfaces endpoint."""
|
||||||
|
mocker.patch("routes.wifi.detect_wifi_interfaces", return_value=[{'name': 'wlan0', 'type': 'managed'}])
|
||||||
|
mocker.patch("routes.wifi.check_tool", return_value=True)
|
||||||
|
|
||||||
|
response = client.get('/wifi/interfaces')
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(data['interfaces']) == 1
|
||||||
|
assert data['tools']['airmon'] is True
|
||||||
|
|
||||||
|
def test_toggle_monitor_start_success(client, mocker):
|
||||||
|
"""Test enabling monitor mode via airmon-ng."""
|
||||||
|
mocker.patch("routes.wifi.validate_network_interface", return_value="wlan0")
|
||||||
|
mocker.patch("routes.wifi.check_tool", return_value=True)
|
||||||
|
mock_run = mocker.patch("routes.wifi.subprocess.run")
|
||||||
|
mock_run.return_value = MagicMock(stdout="enabled on [phy0]wlan0mon", stderr="", returncode=0)
|
||||||
|
|
||||||
|
with patch("os.path.exists", return_value=True):
|
||||||
|
response = client.post('/wifi/monitor', json={'action': 'start', 'interface': 'wlan0'})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json()['status'] == 'success'
|
||||||
|
assert response.get_json()['monitor_interface'] == 'wlan0mon'
|
||||||
|
|
||||||
|
def test_start_scan_already_running(client, mock_app_module):
|
||||||
|
"""Test that we can't start a scan if one is already active."""
|
||||||
|
mock_app_module.wifi_process = MagicMock()
|
||||||
|
|
||||||
|
response = client.post('/wifi/scan/start', json={'interface': 'wlan0mon'})
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
assert 'already running' in data['message']
|
||||||
|
|
||||||
|
def test_start_scan_execution(client, mock_app_module, mocker):
|
||||||
|
"""Test the full command construction of airodump-ng."""
|
||||||
|
mock_app_module.wifi_process = None
|
||||||
|
mocker.patch("os.path.exists", return_value=True)
|
||||||
|
mocker.patch("routes.wifi.get_tool_path", return_value="/usr/bin/airodump-ng")
|
||||||
|
|
||||||
|
mock_popen = mocker.patch("routes.wifi.subprocess.Popen")
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
mock_proc.poll.return_value = None
|
||||||
|
mock_popen.return_value = mock_proc
|
||||||
|
|
||||||
|
payload = {'interface': 'wlan0mon', 'channel': 6, 'band': 'g'}
|
||||||
|
response = client.post('/wifi/scan/start', json=payload)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json()['status'] == 'started'
|
||||||
|
|
||||||
|
args, _ = mock_popen.call_args
|
||||||
|
cmd = args[0]
|
||||||
|
assert "-c" in cmd and "6" in cmd
|
||||||
|
assert "wlan0mon" in cmd
|
||||||
|
|
||||||
|
def test_stop_scan(client, mock_app_module):
|
||||||
|
"""Test terminating the scanning process."""
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
mock_app_module.wifi_process = mock_proc
|
||||||
|
|
||||||
|
response = client.post('/wifi/scan/stop')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json()['status'] == 'stopped'
|
||||||
|
mock_proc.terminate.assert_called_once()
|
||||||
|
|
||||||
|
def test_send_deauth_success(client, mock_app_module, mocker):
|
||||||
|
"""Verify deauth command construction and execution."""
|
||||||
|
mocker.patch("routes.wifi.check_tool", return_value=True)
|
||||||
|
mocker.patch("routes.wifi.get_tool_path", return_value="/usr/bin/aireplay-ng")
|
||||||
|
mock_run = mocker.patch("routes.wifi.subprocess.run")
|
||||||
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'bssid': 'AA:BB:CC:DD:EE:FF',
|
||||||
|
'count': 10,
|
||||||
|
'interface': 'wlan0mon'
|
||||||
|
}
|
||||||
|
response = client.post('/wifi/deauth', json=payload)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
args, _ = mock_run.call_args
|
||||||
|
cmd = args[0]
|
||||||
|
assert "--deauth" in cmd
|
||||||
|
assert "10" in cmd
|
||||||
|
assert "AA:BB:CC:DD:EE:FF" in cmd
|
||||||
|
|
||||||
|
### --- HANDSHAKE TESTS --- ###
|
||||||
|
|
||||||
|
def test_capture_handshake_start(client, mock_app_module, mocker):
|
||||||
|
"""Test starting airodump-ng for handshake capture."""
|
||||||
|
mock_app_module.wifi_process = None
|
||||||
|
mocker.patch("routes.wifi.get_tool_path", return_value="/usr/bin/airodump-ng")
|
||||||
|
mock_popen = mocker.patch("routes.wifi.subprocess.Popen")
|
||||||
|
|
||||||
|
payload = {'bssid': 'AA:BB:CC:DD:EE:FF', 'channel': '6', 'interface': 'wlan0mon'}
|
||||||
|
response = client.post('/wifi/handshake/capture', json=payload)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'capture_file' in response.get_json()
|
||||||
|
assert mock_popen.called
|
||||||
|
|
||||||
|
def test_check_handshake_status_found(client, mocker):
|
||||||
|
"""Verify detection of 'KEY FOUND' in aircrack output."""
|
||||||
|
mocker.patch("os.path.exists", return_value=True)
|
||||||
|
mocker.patch("os.path.getsize", return_value=1024)
|
||||||
|
mocker.patch("routes.wifi.get_tool_path", return_value="aircrack-ng")
|
||||||
|
|
||||||
|
mock_run = mocker.patch("routes.wifi.subprocess.run")
|
||||||
|
mock_run.return_value = MagicMock(stdout="WPA (1 handshake)", stderr="", returncode=0)
|
||||||
|
|
||||||
|
payload = {'file': '/tmp/intercept_handshake_test.cap', 'bssid': 'AA:BB:CC:DD:EE:FF'}
|
||||||
|
response = client.post('/wifi/handshake/status', json=payload)
|
||||||
|
|
||||||
|
assert response.get_json()['handshake_found'] is True
|
||||||
|
|
||||||
|
### --- PMKID TESTS --- ###
|
||||||
|
|
||||||
|
def test_capture_pmkid_path_traversal_prevention(client):
|
||||||
|
"""Ensure the status check rejects invalid paths."""
|
||||||
|
payload = {'file': '/etc/passwd'} # Malicious path
|
||||||
|
response = client.post('/wifi/pmkid/status', json=payload)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json()['status'] == 'error'
|
||||||
|
assert 'Invalid capture file path' in response.get_json()['message']
|
||||||
|
|
||||||
|
### --- CRACKING TESTS --- ###
|
||||||
|
|
||||||
|
def test_crack_handshake_success(client, mocker):
|
||||||
|
"""Test successful password extraction using Regex."""
|
||||||
|
mocker.patch("os.path.exists", return_value=True)
|
||||||
|
mocker.patch("routes.wifi.get_tool_path", return_value="aircrack-ng")
|
||||||
|
|
||||||
|
mock_run = mocker.patch("routes.wifi.subprocess.run")
|
||||||
|
# Simulate the actual aircrack-ng success output
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
stdout="KEY FOUND! [ secret123 ]",
|
||||||
|
stderr="",
|
||||||
|
returncode=0
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'capture_file': '/tmp/intercept_handshake_test.cap',
|
||||||
|
'wordlist': '/home/user/passwords.txt',
|
||||||
|
'bssid': 'AA:BB:CC:DD:EE:FF'
|
||||||
|
}
|
||||||
|
response = client.post('/wifi/handshake/crack', json=payload)
|
||||||
|
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert data['password'] == 'secret123'
|
||||||
|
|
||||||
|
### --- DATA FETCHING TESTS --- ###
|
||||||
|
|
||||||
|
def test_get_wifi_networks(client, mock_app_module):
|
||||||
|
"""Test that the networks endpoint correctly formats internal data."""
|
||||||
|
mock_app_module.wifi_networks = {
|
||||||
|
'AA:BB:CC:DD:EE:FF': {'essid': 'Home-WiFi', 'bssid': 'AA:BB:CC:DD:EE:FF'}
|
||||||
|
}
|
||||||
|
mock_app_module.wifi_handshakes = ['AA:BB:CC:DD:EE:FF']
|
||||||
|
|
||||||
|
response = client.get('/wifi/networks')
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert len(data['networks']) == 1
|
||||||
|
assert data['networks'][0]['essid'] == 'Home-WiFi'
|
||||||
|
assert 'AA:BB:CC:DD:EE:FF' in data['handshakes']
|
||||||
@@ -100,6 +100,100 @@ def init_db() -> None:
|
|||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# TSCM (Technical Surveillance Countermeasures) Tables
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
# TSCM Baselines - Environment snapshots for comparison
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS tscm_baselines (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
location TEXT,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
wifi_networks TEXT,
|
||||||
|
bt_devices TEXT,
|
||||||
|
rf_frequencies TEXT,
|
||||||
|
gps_coords TEXT,
|
||||||
|
is_active BOOLEAN DEFAULT 0
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# TSCM Sweeps - Individual sweep sessions
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS tscm_sweeps (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
baseline_id INTEGER,
|
||||||
|
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP,
|
||||||
|
status TEXT DEFAULT 'running',
|
||||||
|
sweep_type TEXT,
|
||||||
|
wifi_enabled BOOLEAN DEFAULT 1,
|
||||||
|
bt_enabled BOOLEAN DEFAULT 1,
|
||||||
|
rf_enabled BOOLEAN DEFAULT 1,
|
||||||
|
results TEXT,
|
||||||
|
anomalies TEXT,
|
||||||
|
threats_found INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY (baseline_id) REFERENCES tscm_baselines(id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# TSCM Threats - Detected threats/anomalies
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS tscm_threats (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sweep_id INTEGER,
|
||||||
|
detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
threat_type TEXT NOT NULL,
|
||||||
|
severity TEXT DEFAULT 'medium',
|
||||||
|
source TEXT,
|
||||||
|
identifier TEXT,
|
||||||
|
name TEXT,
|
||||||
|
signal_strength INTEGER,
|
||||||
|
frequency REAL,
|
||||||
|
details TEXT,
|
||||||
|
acknowledged BOOLEAN DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
gps_coords TEXT,
|
||||||
|
FOREIGN KEY (sweep_id) REFERENCES tscm_sweeps(id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# TSCM Scheduled Sweeps
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS tscm_schedules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
baseline_id INTEGER,
|
||||||
|
zone_name TEXT,
|
||||||
|
cron_expression TEXT,
|
||||||
|
sweep_type TEXT DEFAULT 'standard',
|
||||||
|
enabled BOOLEAN DEFAULT 1,
|
||||||
|
last_run TIMESTAMP,
|
||||||
|
next_run TIMESTAMP,
|
||||||
|
notify_on_threat BOOLEAN DEFAULT 1,
|
||||||
|
notify_email TEXT,
|
||||||
|
FOREIGN KEY (baseline_id) REFERENCES tscm_baselines(id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# TSCM indexes for performance
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tscm_threats_sweep
|
||||||
|
ON tscm_threats(sweep_id)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tscm_threats_severity
|
||||||
|
ON tscm_threats(severity, detected_at)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tscm_sweeps_baseline
|
||||||
|
ON tscm_sweeps(baseline_id)
|
||||||
|
''')
|
||||||
|
|
||||||
logger.info("Database initialized successfully")
|
logger.info("Database initialized successfully")
|
||||||
|
|
||||||
|
|
||||||
@@ -349,3 +443,353 @@ def get_correlations(min_confidence: float = 0.5) -> list[dict]:
|
|||||||
})
|
})
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TSCM Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def create_tscm_baseline(
|
||||||
|
name: str,
|
||||||
|
location: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
wifi_networks: list | None = None,
|
||||||
|
bt_devices: list | None = None,
|
||||||
|
rf_frequencies: list | None = None,
|
||||||
|
gps_coords: dict | None = None
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Create a new TSCM baseline.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ID of the created baseline
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
INSERT INTO tscm_baselines
|
||||||
|
(name, location, description, wifi_networks, bt_devices, rf_frequencies, gps_coords)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''', (
|
||||||
|
name,
|
||||||
|
location,
|
||||||
|
description,
|
||||||
|
json.dumps(wifi_networks) if wifi_networks else None,
|
||||||
|
json.dumps(bt_devices) if bt_devices else None,
|
||||||
|
json.dumps(rf_frequencies) if rf_frequencies else None,
|
||||||
|
json.dumps(gps_coords) if gps_coords else None
|
||||||
|
))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_tscm_baseline(baseline_id: int) -> dict | None:
|
||||||
|
"""Get a specific TSCM baseline by ID."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
SELECT * FROM tscm_baselines WHERE id = ?
|
||||||
|
''', (baseline_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': row['id'],
|
||||||
|
'name': row['name'],
|
||||||
|
'location': row['location'],
|
||||||
|
'description': row['description'],
|
||||||
|
'created_at': row['created_at'],
|
||||||
|
'wifi_networks': json.loads(row['wifi_networks']) if row['wifi_networks'] else [],
|
||||||
|
'bt_devices': json.loads(row['bt_devices']) if row['bt_devices'] else [],
|
||||||
|
'rf_frequencies': json.loads(row['rf_frequencies']) if row['rf_frequencies'] else [],
|
||||||
|
'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None,
|
||||||
|
'is_active': bool(row['is_active'])
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_tscm_baselines() -> list[dict]:
|
||||||
|
"""Get all TSCM baselines."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
SELECT id, name, location, description, created_at, is_active
|
||||||
|
FROM tscm_baselines
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
''')
|
||||||
|
|
||||||
|
return [dict(row) for row in cursor]
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_tscm_baseline() -> dict | None:
|
||||||
|
"""Get the currently active TSCM baseline."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
SELECT * FROM tscm_baselines WHERE is_active = 1 LIMIT 1
|
||||||
|
''')
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return get_tscm_baseline(row['id'])
|
||||||
|
|
||||||
|
|
||||||
|
def set_active_tscm_baseline(baseline_id: int) -> bool:
|
||||||
|
"""Set a baseline as active (deactivates others)."""
|
||||||
|
with get_db() as conn:
|
||||||
|
# Deactivate all
|
||||||
|
conn.execute('UPDATE tscm_baselines SET is_active = 0')
|
||||||
|
# Activate selected
|
||||||
|
cursor = conn.execute(
|
||||||
|
'UPDATE tscm_baselines SET is_active = 1 WHERE id = ?',
|
||||||
|
(baseline_id,)
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def update_tscm_baseline(
|
||||||
|
baseline_id: int,
|
||||||
|
wifi_networks: list | None = None,
|
||||||
|
bt_devices: list | None = None,
|
||||||
|
rf_frequencies: list | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""Update baseline device lists."""
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if wifi_networks is not None:
|
||||||
|
updates.append('wifi_networks = ?')
|
||||||
|
params.append(json.dumps(wifi_networks))
|
||||||
|
if bt_devices is not None:
|
||||||
|
updates.append('bt_devices = ?')
|
||||||
|
params.append(json.dumps(bt_devices))
|
||||||
|
if rf_frequencies is not None:
|
||||||
|
updates.append('rf_frequencies = ?')
|
||||||
|
params.append(json.dumps(rf_frequencies))
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return False
|
||||||
|
|
||||||
|
params.append(baseline_id)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
f'UPDATE tscm_baselines SET {", ".join(updates)} WHERE id = ?',
|
||||||
|
params
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def delete_tscm_baseline(baseline_id: int) -> bool:
|
||||||
|
"""Delete a TSCM baseline."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
'DELETE FROM tscm_baselines WHERE id = ?',
|
||||||
|
(baseline_id,)
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def create_tscm_sweep(
|
||||||
|
sweep_type: str,
|
||||||
|
baseline_id: int | None = None,
|
||||||
|
wifi_enabled: bool = True,
|
||||||
|
bt_enabled: bool = True,
|
||||||
|
rf_enabled: bool = True
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Create a new TSCM sweep session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ID of the created sweep
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
INSERT INTO tscm_sweeps
|
||||||
|
(baseline_id, sweep_type, wifi_enabled, bt_enabled, rf_enabled)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
''', (baseline_id, sweep_type, wifi_enabled, bt_enabled, rf_enabled))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def update_tscm_sweep(
|
||||||
|
sweep_id: int,
|
||||||
|
status: str | None = None,
|
||||||
|
results: dict | None = None,
|
||||||
|
anomalies: list | None = None,
|
||||||
|
threats_found: int | None = None,
|
||||||
|
completed: bool = False
|
||||||
|
) -> bool:
|
||||||
|
"""Update a TSCM sweep."""
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if status is not None:
|
||||||
|
updates.append('status = ?')
|
||||||
|
params.append(status)
|
||||||
|
if results is not None:
|
||||||
|
updates.append('results = ?')
|
||||||
|
params.append(json.dumps(results))
|
||||||
|
if anomalies is not None:
|
||||||
|
updates.append('anomalies = ?')
|
||||||
|
params.append(json.dumps(anomalies))
|
||||||
|
if threats_found is not None:
|
||||||
|
updates.append('threats_found = ?')
|
||||||
|
params.append(threats_found)
|
||||||
|
if completed:
|
||||||
|
updates.append('completed_at = CURRENT_TIMESTAMP')
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return False
|
||||||
|
|
||||||
|
params.append(sweep_id)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
f'UPDATE tscm_sweeps SET {", ".join(updates)} WHERE id = ?',
|
||||||
|
params
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_tscm_sweep(sweep_id: int) -> dict | None:
|
||||||
|
"""Get a specific TSCM sweep by ID."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('SELECT * FROM tscm_sweeps WHERE id = ?', (sweep_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': row['id'],
|
||||||
|
'baseline_id': row['baseline_id'],
|
||||||
|
'started_at': row['started_at'],
|
||||||
|
'completed_at': row['completed_at'],
|
||||||
|
'status': row['status'],
|
||||||
|
'sweep_type': row['sweep_type'],
|
||||||
|
'wifi_enabled': bool(row['wifi_enabled']),
|
||||||
|
'bt_enabled': bool(row['bt_enabled']),
|
||||||
|
'rf_enabled': bool(row['rf_enabled']),
|
||||||
|
'results': json.loads(row['results']) if row['results'] else None,
|
||||||
|
'anomalies': json.loads(row['anomalies']) if row['anomalies'] else [],
|
||||||
|
'threats_found': row['threats_found']
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def add_tscm_threat(
|
||||||
|
sweep_id: int,
|
||||||
|
threat_type: str,
|
||||||
|
severity: str,
|
||||||
|
source: str,
|
||||||
|
identifier: str,
|
||||||
|
name: str | None = None,
|
||||||
|
signal_strength: int | None = None,
|
||||||
|
frequency: float | None = None,
|
||||||
|
details: dict | None = None,
|
||||||
|
gps_coords: dict | None = None
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Add a detected threat to a TSCM sweep.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ID of the created threat
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
INSERT INTO tscm_threats
|
||||||
|
(sweep_id, threat_type, severity, source, identifier, name,
|
||||||
|
signal_strength, frequency, details, gps_coords)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''', (
|
||||||
|
sweep_id, threat_type, severity, source, identifier, name,
|
||||||
|
signal_strength, frequency,
|
||||||
|
json.dumps(details) if details else None,
|
||||||
|
json.dumps(gps_coords) if gps_coords else None
|
||||||
|
))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_tscm_threats(
|
||||||
|
sweep_id: int | None = None,
|
||||||
|
severity: str | None = None,
|
||||||
|
acknowledged: bool | None = None,
|
||||||
|
limit: int = 100
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Get TSCM threats with optional filters."""
|
||||||
|
conditions = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if sweep_id is not None:
|
||||||
|
conditions.append('sweep_id = ?')
|
||||||
|
params.append(sweep_id)
|
||||||
|
if severity is not None:
|
||||||
|
conditions.append('severity = ?')
|
||||||
|
params.append(severity)
|
||||||
|
if acknowledged is not None:
|
||||||
|
conditions.append('acknowledged = ?')
|
||||||
|
params.append(1 if acknowledged else 0)
|
||||||
|
|
||||||
|
where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else ''
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(f'''
|
||||||
|
SELECT * FROM tscm_threats
|
||||||
|
{where_clause}
|
||||||
|
ORDER BY detected_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
''', params)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in cursor:
|
||||||
|
results.append({
|
||||||
|
'id': row['id'],
|
||||||
|
'sweep_id': row['sweep_id'],
|
||||||
|
'detected_at': row['detected_at'],
|
||||||
|
'threat_type': row['threat_type'],
|
||||||
|
'severity': row['severity'],
|
||||||
|
'source': row['source'],
|
||||||
|
'identifier': row['identifier'],
|
||||||
|
'name': row['name'],
|
||||||
|
'signal_strength': row['signal_strength'],
|
||||||
|
'frequency': row['frequency'],
|
||||||
|
'details': json.loads(row['details']) if row['details'] else None,
|
||||||
|
'acknowledged': bool(row['acknowledged']),
|
||||||
|
'notes': row['notes'],
|
||||||
|
'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def acknowledge_tscm_threat(threat_id: int, notes: str | None = None) -> bool:
|
||||||
|
"""Acknowledge a TSCM threat."""
|
||||||
|
with get_db() as conn:
|
||||||
|
if notes:
|
||||||
|
cursor = conn.execute(
|
||||||
|
'UPDATE tscm_threats SET acknowledged = 1, notes = ? WHERE id = ?',
|
||||||
|
(notes, threat_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = conn.execute(
|
||||||
|
'UPDATE tscm_threats SET acknowledged = 1 WHERE id = ?',
|
||||||
|
(threat_id,)
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_tscm_threat_summary() -> dict:
|
||||||
|
"""Get summary counts of threats by severity."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
SELECT severity, COUNT(*) as count
|
||||||
|
FROM tscm_threats
|
||||||
|
WHERE acknowledged = 0
|
||||||
|
GROUP BY severity
|
||||||
|
''')
|
||||||
|
|
||||||
|
summary = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0, 'total': 0}
|
||||||
|
for row in cursor:
|
||||||
|
summary[row['severity']] = row['count']
|
||||||
|
summary['total'] += row['count']
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|||||||
+88
-1
@@ -52,7 +52,7 @@ TOOL_DEPENDENCIES = {
|
|||||||
'install': {
|
'install': {
|
||||||
'apt': 'sudo apt install multimon-ng',
|
'apt': 'sudo apt install multimon-ng',
|
||||||
'brew': 'brew install multimon-ng',
|
'brew': 'brew install multimon-ng',
|
||||||
'manual': 'https://github.com/EliasOewornal/multimon-ng'
|
'manual': 'https://github.com/EliasOenal/multimon-ng'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'rtl_test': {
|
'rtl_test': {
|
||||||
@@ -195,6 +195,43 @@ TOOL_DEPENDENCIES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
'acars': {
|
||||||
|
'name': 'Aircraft Messaging (ACARS)',
|
||||||
|
'tools': {
|
||||||
|
'acarsdec': {
|
||||||
|
'required': True,
|
||||||
|
'description': 'ACARS VHF decoder',
|
||||||
|
'install': {
|
||||||
|
'apt': 'Run ./setup.sh (builds from source)',
|
||||||
|
'brew': 'Run ./setup.sh (builds from source)',
|
||||||
|
'manual': 'https://github.com/TLeconte/acarsdec'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'aprs': {
|
||||||
|
'name': 'APRS Tracking',
|
||||||
|
'tools': {
|
||||||
|
'direwolf': {
|
||||||
|
'required': False,
|
||||||
|
'description': 'APRS/packet radio decoder (preferred)',
|
||||||
|
'install': {
|
||||||
|
'apt': 'sudo apt install direwolf',
|
||||||
|
'brew': 'brew install direwolf',
|
||||||
|
'manual': 'https://github.com/wb2osz/direwolf'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'multimon-ng': {
|
||||||
|
'required': False,
|
||||||
|
'description': 'Alternative AFSK1200 decoder',
|
||||||
|
'install': {
|
||||||
|
'apt': 'sudo apt install multimon-ng',
|
||||||
|
'brew': 'brew install multimon-ng',
|
||||||
|
'manual': 'https://github.com/EliasOenal/multimon-ng'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
'satellite': {
|
'satellite': {
|
||||||
'name': 'Satellite Tracking',
|
'name': 'Satellite Tracking',
|
||||||
'tools': {
|
'tools': {
|
||||||
@@ -274,6 +311,56 @@ TOOL_DEPENDENCIES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
'tscm': {
|
||||||
|
'name': 'TSCM Counter-Surveillance',
|
||||||
|
'tools': {
|
||||||
|
'rtl_power': {
|
||||||
|
'required': False,
|
||||||
|
'description': 'Wideband spectrum sweep for RF analysis',
|
||||||
|
'install': {
|
||||||
|
'apt': 'sudo apt install rtl-sdr',
|
||||||
|
'brew': 'brew install librtlsdr',
|
||||||
|
'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'rtl_fm': {
|
||||||
|
'required': True,
|
||||||
|
'description': 'RF signal demodulation',
|
||||||
|
'install': {
|
||||||
|
'apt': 'sudo apt install rtl-sdr',
|
||||||
|
'brew': 'brew install librtlsdr',
|
||||||
|
'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'rtl_433': {
|
||||||
|
'required': False,
|
||||||
|
'description': 'ISM band device decoding',
|
||||||
|
'install': {
|
||||||
|
'apt': 'sudo apt install rtl-433',
|
||||||
|
'brew': 'brew install rtl_433',
|
||||||
|
'manual': 'https://github.com/merbanan/rtl_433'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'airmon-ng': {
|
||||||
|
'required': False,
|
||||||
|
'description': 'WiFi monitor mode for network scanning',
|
||||||
|
'install': {
|
||||||
|
'apt': 'sudo apt install aircrack-ng',
|
||||||
|
'brew': 'Not available on macOS',
|
||||||
|
'manual': 'https://aircrack-ng.org'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'bluetoothctl': {
|
||||||
|
'required': False,
|
||||||
|
'description': 'Bluetooth device scanning',
|
||||||
|
'install': {
|
||||||
|
'apt': 'sudo apt install bluez',
|
||||||
|
'brew': 'Not available on macOS (use native)',
|
||||||
|
'manual': 'http://www.bluez.org'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -134,8 +134,14 @@ class HackRFCommandBuilder(CommandBuilder):
|
|||||||
Build rtl_433 command with SoapySDR support for ISM band decoding.
|
Build rtl_433 command with SoapySDR support for ISM band decoding.
|
||||||
|
|
||||||
rtl_433 has native SoapySDR support via -d flag.
|
rtl_433 has native SoapySDR support via -d flag.
|
||||||
|
|
||||||
|
Note: rtl_433's -T flag is for timeout, NOT bias-t.
|
||||||
|
For SoapySDR devices, bias-t is passed as a device setting.
|
||||||
"""
|
"""
|
||||||
|
# Build device string with optional bias-t setting
|
||||||
device_str = self._build_device_string(device)
|
device_str = self._build_device_string(device)
|
||||||
|
if bias_t:
|
||||||
|
device_str = f'{device_str},bias_t=1'
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
'rtl_433',
|
'rtl_433',
|
||||||
@@ -147,9 +153,6 @@ class HackRFCommandBuilder(CommandBuilder):
|
|||||||
if gain is not None and gain > 0:
|
if gain is not None and gain > 0:
|
||||||
cmd.extend(['-g', str(int(gain))])
|
cmd.extend(['-g', str(int(gain))])
|
||||||
|
|
||||||
if bias_t:
|
|
||||||
cmd.extend(['-T'])
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
def get_capabilities(self) -> SDRCapabilities:
|
def get_capabilities(self) -> SDRCapabilities:
|
||||||
|
|||||||
+19
-7
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||||
|
from utils.dependencies import get_tool_path
|
||||||
|
|
||||||
|
|
||||||
class RTLSDRCommandBuilder(CommandBuilder):
|
class RTLSDRCommandBuilder(CommandBuilder):
|
||||||
@@ -53,8 +54,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
|||||||
|
|
||||||
Used for pager decoding. Supports local devices and rtl_tcp connections.
|
Used for pager decoding. Supports local devices and rtl_tcp connections.
|
||||||
"""
|
"""
|
||||||
|
rtl_fm_path = get_tool_path('rtl_fm') or 'rtl_fm'
|
||||||
cmd = [
|
cmd = [
|
||||||
'rtl_fm',
|
rtl_fm_path,
|
||||||
'-d', self._get_device_arg(device),
|
'-d', self._get_device_arg(device),
|
||||||
'-f', f'{frequency_mhz}M',
|
'-f', f'{frequency_mhz}M',
|
||||||
'-M', modulation,
|
'-M', modulation,
|
||||||
@@ -99,8 +101,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
|||||||
"connect to its SBS output (port 30003)."
|
"connect to its SBS output (port 30003)."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
dump1090_path = get_tool_path('dump1090') or 'dump1090'
|
||||||
cmd = [
|
cmd = [
|
||||||
'dump1090',
|
dump1090_path,
|
||||||
'--net',
|
'--net',
|
||||||
'--device-index', str(device.index),
|
'--device-index', str(device.index),
|
||||||
'--quiet'
|
'--quiet'
|
||||||
@@ -126,10 +129,22 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
|||||||
Build rtl_433 command for ISM band sensor decoding.
|
Build rtl_433 command for ISM band sensor decoding.
|
||||||
|
|
||||||
Outputs JSON for easy parsing. Supports local devices and rtl_tcp connections.
|
Outputs JSON for easy parsing. Supports local devices and rtl_tcp connections.
|
||||||
|
|
||||||
|
Note: rtl_433's -T flag is for timeout, NOT bias-t.
|
||||||
|
Bias-t is enabled via the device string suffix :biast=1
|
||||||
"""
|
"""
|
||||||
|
rtl_433_path = get_tool_path('rtl_433') or 'rtl_433'
|
||||||
|
|
||||||
|
# Build device argument with optional bias-t suffix
|
||||||
|
# rtl_433 uses :biast=1 suffix on device string, not -T flag
|
||||||
|
# (-T is timeout in rtl_433)
|
||||||
|
device_arg = self._get_device_arg(device)
|
||||||
|
if bias_t:
|
||||||
|
device_arg = f'{device_arg}:biast=1'
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
'rtl_433',
|
rtl_433_path,
|
||||||
'-d', self._get_device_arg(device),
|
'-d', device_arg,
|
||||||
'-f', f'{frequency_mhz}M',
|
'-f', f'{frequency_mhz}M',
|
||||||
'-F', 'json'
|
'-F', 'json'
|
||||||
]
|
]
|
||||||
@@ -140,9 +155,6 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
|||||||
if ppm is not None and ppm != 0:
|
if ppm is not None and ppm != 0:
|
||||||
cmd.extend(['-p', str(ppm)])
|
cmd.extend(['-p', str(ppm)])
|
||||||
|
|
||||||
if bias_t:
|
|
||||||
cmd.extend(['-T'])
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
def get_capabilities(self) -> SDRCapabilities:
|
def get_capabilities(self) -> SDRCapabilities:
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""
|
||||||
|
TSCM (Technical Surveillance Countermeasures) Utilities Package
|
||||||
|
|
||||||
|
Provides baseline recording, threat detection, correlation analysis,
|
||||||
|
BLE scanning, and MAC-randomization resistant device identity tools
|
||||||
|
for counter-surveillance operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__all__ = ['detector', 'baseline', 'correlation', 'ble_scanner', 'device_identity']
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
"""
|
||||||
|
TSCM Baseline Recording and Comparison
|
||||||
|
|
||||||
|
Records environment "fingerprints" and compares current scans
|
||||||
|
against baselines to detect new or anomalous devices.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from utils.database import (
|
||||||
|
create_tscm_baseline,
|
||||||
|
get_active_tscm_baseline,
|
||||||
|
get_tscm_baseline,
|
||||||
|
update_tscm_baseline,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.tscm.baseline')
|
||||||
|
|
||||||
|
|
||||||
|
class BaselineRecorder:
|
||||||
|
"""
|
||||||
|
Records and manages TSCM environment baselines.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.recording = False
|
||||||
|
self.current_baseline_id: int | None = None
|
||||||
|
self.wifi_networks: dict[str, dict] = {} # BSSID -> network info
|
||||||
|
self.bt_devices: dict[str, dict] = {} # MAC -> device info
|
||||||
|
self.rf_frequencies: dict[float, dict] = {} # Frequency -> signal info
|
||||||
|
|
||||||
|
def start_recording(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
location: str | None = None,
|
||||||
|
description: str | None = None
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Start recording a new baseline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Baseline name
|
||||||
|
location: Optional location description
|
||||||
|
description: Optional description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Baseline ID
|
||||||
|
"""
|
||||||
|
self.recording = True
|
||||||
|
self.wifi_networks = {}
|
||||||
|
self.bt_devices = {}
|
||||||
|
self.rf_frequencies = {}
|
||||||
|
|
||||||
|
# Create baseline in database
|
||||||
|
self.current_baseline_id = create_tscm_baseline(
|
||||||
|
name=name,
|
||||||
|
location=location,
|
||||||
|
description=description
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Started baseline recording: {name} (ID: {self.current_baseline_id})")
|
||||||
|
return self.current_baseline_id
|
||||||
|
|
||||||
|
def stop_recording(self) -> dict:
|
||||||
|
"""
|
||||||
|
Stop recording and finalize baseline.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Final baseline summary
|
||||||
|
"""
|
||||||
|
if not self.recording or not self.current_baseline_id:
|
||||||
|
return {'error': 'Not recording'}
|
||||||
|
|
||||||
|
self.recording = False
|
||||||
|
|
||||||
|
# Convert to lists for storage
|
||||||
|
wifi_list = list(self.wifi_networks.values())
|
||||||
|
bt_list = list(self.bt_devices.values())
|
||||||
|
rf_list = list(self.rf_frequencies.values())
|
||||||
|
|
||||||
|
# Update database
|
||||||
|
update_tscm_baseline(
|
||||||
|
self.current_baseline_id,
|
||||||
|
wifi_networks=wifi_list,
|
||||||
|
bt_devices=bt_list,
|
||||||
|
rf_frequencies=rf_list
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
'baseline_id': self.current_baseline_id,
|
||||||
|
'wifi_count': len(wifi_list),
|
||||||
|
'bt_count': len(bt_list),
|
||||||
|
'rf_count': len(rf_list),
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Baseline recording complete: {summary['wifi_count']} WiFi, "
|
||||||
|
f"{summary['bt_count']} BT, {summary['rf_count']} RF"
|
||||||
|
)
|
||||||
|
|
||||||
|
baseline_id = self.current_baseline_id
|
||||||
|
self.current_baseline_id = None
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
def add_wifi_device(self, device: dict) -> None:
|
||||||
|
"""Add a WiFi device to the current baseline."""
|
||||||
|
if not self.recording:
|
||||||
|
return
|
||||||
|
|
||||||
|
mac = device.get('bssid', device.get('mac', '')).upper()
|
||||||
|
if not mac:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update or add device
|
||||||
|
if mac in self.wifi_networks:
|
||||||
|
# Update with latest info
|
||||||
|
self.wifi_networks[mac].update({
|
||||||
|
'last_seen': datetime.now().isoformat(),
|
||||||
|
'power': device.get('power', self.wifi_networks[mac].get('power')),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
self.wifi_networks[mac] = {
|
||||||
|
'bssid': mac,
|
||||||
|
'essid': device.get('essid', device.get('ssid', '')),
|
||||||
|
'channel': device.get('channel'),
|
||||||
|
'power': device.get('power', device.get('signal')),
|
||||||
|
'vendor': device.get('vendor', ''),
|
||||||
|
'encryption': device.get('privacy', device.get('encryption', '')),
|
||||||
|
'first_seen': datetime.now().isoformat(),
|
||||||
|
'last_seen': datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def add_bt_device(self, device: dict) -> None:
|
||||||
|
"""Add a Bluetooth device to the current baseline."""
|
||||||
|
if not self.recording:
|
||||||
|
return
|
||||||
|
|
||||||
|
mac = device.get('mac', device.get('address', '')).upper()
|
||||||
|
if not mac:
|
||||||
|
return
|
||||||
|
|
||||||
|
if mac in self.bt_devices:
|
||||||
|
self.bt_devices[mac].update({
|
||||||
|
'last_seen': datetime.now().isoformat(),
|
||||||
|
'rssi': device.get('rssi', self.bt_devices[mac].get('rssi')),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
self.bt_devices[mac] = {
|
||||||
|
'mac': mac,
|
||||||
|
'name': device.get('name', ''),
|
||||||
|
'rssi': device.get('rssi', device.get('signal')),
|
||||||
|
'manufacturer': device.get('manufacturer', ''),
|
||||||
|
'type': device.get('type', ''),
|
||||||
|
'first_seen': datetime.now().isoformat(),
|
||||||
|
'last_seen': datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def add_rf_signal(self, signal: dict) -> None:
|
||||||
|
"""Add an RF signal to the current baseline."""
|
||||||
|
if not self.recording:
|
||||||
|
return
|
||||||
|
|
||||||
|
frequency = signal.get('frequency')
|
||||||
|
if not frequency:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Round to 0.1 MHz for grouping
|
||||||
|
freq_key = round(frequency, 1)
|
||||||
|
|
||||||
|
if freq_key in self.rf_frequencies:
|
||||||
|
existing = self.rf_frequencies[freq_key]
|
||||||
|
existing['last_seen'] = datetime.now().isoformat()
|
||||||
|
existing['hit_count'] = existing.get('hit_count', 1) + 1
|
||||||
|
# Update max signal level
|
||||||
|
new_level = signal.get('level', signal.get('power', -100))
|
||||||
|
if new_level > existing.get('max_level', -100):
|
||||||
|
existing['max_level'] = new_level
|
||||||
|
else:
|
||||||
|
self.rf_frequencies[freq_key] = {
|
||||||
|
'frequency': freq_key,
|
||||||
|
'level': signal.get('level', signal.get('power')),
|
||||||
|
'max_level': signal.get('level', signal.get('power', -100)),
|
||||||
|
'modulation': signal.get('modulation', ''),
|
||||||
|
'first_seen': datetime.now().isoformat(),
|
||||||
|
'last_seen': datetime.now().isoformat(),
|
||||||
|
'hit_count': 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_recording_status(self) -> dict:
|
||||||
|
"""Get current recording status and counts."""
|
||||||
|
return {
|
||||||
|
'recording': self.recording,
|
||||||
|
'baseline_id': self.current_baseline_id,
|
||||||
|
'wifi_count': len(self.wifi_networks),
|
||||||
|
'bt_count': len(self.bt_devices),
|
||||||
|
'rf_count': len(self.rf_frequencies),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BaselineComparator:
|
||||||
|
"""
|
||||||
|
Compares current scan results against a baseline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, baseline: dict):
|
||||||
|
"""
|
||||||
|
Initialize comparator with a baseline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
baseline: Baseline dict from database
|
||||||
|
"""
|
||||||
|
self.baseline = baseline
|
||||||
|
self.baseline_wifi = {
|
||||||
|
d.get('bssid', d.get('mac', '')).upper(): d
|
||||||
|
for d in baseline.get('wifi_networks', [])
|
||||||
|
if d.get('bssid') or d.get('mac')
|
||||||
|
}
|
||||||
|
self.baseline_bt = {
|
||||||
|
d.get('mac', d.get('address', '')).upper(): d
|
||||||
|
for d in baseline.get('bt_devices', [])
|
||||||
|
if d.get('mac') or d.get('address')
|
||||||
|
}
|
||||||
|
self.baseline_rf = {
|
||||||
|
round(d.get('frequency', 0), 1): d
|
||||||
|
for d in baseline.get('rf_frequencies', [])
|
||||||
|
if d.get('frequency')
|
||||||
|
}
|
||||||
|
|
||||||
|
def compare_wifi(self, current_devices: list[dict]) -> dict:
|
||||||
|
"""
|
||||||
|
Compare current WiFi devices against baseline.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with new, missing, and matching devices
|
||||||
|
"""
|
||||||
|
current_macs = {
|
||||||
|
d.get('bssid', d.get('mac', '')).upper(): d
|
||||||
|
for d in current_devices
|
||||||
|
if d.get('bssid') or d.get('mac')
|
||||||
|
}
|
||||||
|
|
||||||
|
new_devices = []
|
||||||
|
missing_devices = []
|
||||||
|
matching_devices = []
|
||||||
|
|
||||||
|
# Find new devices
|
||||||
|
for mac, device in current_macs.items():
|
||||||
|
if mac not in self.baseline_wifi:
|
||||||
|
new_devices.append(device)
|
||||||
|
else:
|
||||||
|
matching_devices.append(device)
|
||||||
|
|
||||||
|
# Find missing devices
|
||||||
|
for mac, device in self.baseline_wifi.items():
|
||||||
|
if mac not in current_macs:
|
||||||
|
missing_devices.append(device)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'new': new_devices,
|
||||||
|
'missing': missing_devices,
|
||||||
|
'matching': matching_devices,
|
||||||
|
'new_count': len(new_devices),
|
||||||
|
'missing_count': len(missing_devices),
|
||||||
|
'matching_count': len(matching_devices),
|
||||||
|
}
|
||||||
|
|
||||||
|
def compare_bluetooth(self, current_devices: list[dict]) -> dict:
|
||||||
|
"""Compare current Bluetooth devices against baseline."""
|
||||||
|
current_macs = {
|
||||||
|
d.get('mac', d.get('address', '')).upper(): d
|
||||||
|
for d in current_devices
|
||||||
|
if d.get('mac') or d.get('address')
|
||||||
|
}
|
||||||
|
|
||||||
|
new_devices = []
|
||||||
|
missing_devices = []
|
||||||
|
matching_devices = []
|
||||||
|
|
||||||
|
for mac, device in current_macs.items():
|
||||||
|
if mac not in self.baseline_bt:
|
||||||
|
new_devices.append(device)
|
||||||
|
else:
|
||||||
|
matching_devices.append(device)
|
||||||
|
|
||||||
|
for mac, device in self.baseline_bt.items():
|
||||||
|
if mac not in current_macs:
|
||||||
|
missing_devices.append(device)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'new': new_devices,
|
||||||
|
'missing': missing_devices,
|
||||||
|
'matching': matching_devices,
|
||||||
|
'new_count': len(new_devices),
|
||||||
|
'missing_count': len(missing_devices),
|
||||||
|
'matching_count': len(matching_devices),
|
||||||
|
}
|
||||||
|
|
||||||
|
def compare_rf(self, current_signals: list[dict]) -> dict:
|
||||||
|
"""Compare current RF signals against baseline."""
|
||||||
|
current_freqs = {
|
||||||
|
round(s.get('frequency', 0), 1): s
|
||||||
|
for s in current_signals
|
||||||
|
if s.get('frequency')
|
||||||
|
}
|
||||||
|
|
||||||
|
new_signals = []
|
||||||
|
missing_signals = []
|
||||||
|
matching_signals = []
|
||||||
|
|
||||||
|
for freq, signal in current_freqs.items():
|
||||||
|
if freq not in self.baseline_rf:
|
||||||
|
new_signals.append(signal)
|
||||||
|
else:
|
||||||
|
matching_signals.append(signal)
|
||||||
|
|
||||||
|
for freq, signal in self.baseline_rf.items():
|
||||||
|
if freq not in current_freqs:
|
||||||
|
missing_signals.append(signal)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'new': new_signals,
|
||||||
|
'missing': missing_signals,
|
||||||
|
'matching': matching_signals,
|
||||||
|
'new_count': len(new_signals),
|
||||||
|
'missing_count': len(missing_signals),
|
||||||
|
'matching_count': len(matching_signals),
|
||||||
|
}
|
||||||
|
|
||||||
|
def compare_all(
|
||||||
|
self,
|
||||||
|
wifi_devices: list[dict] | None = None,
|
||||||
|
bt_devices: list[dict] | None = None,
|
||||||
|
rf_signals: list[dict] | None = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Compare all current data against baseline.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with comparison results for each category
|
||||||
|
"""
|
||||||
|
results = {
|
||||||
|
'wifi': None,
|
||||||
|
'bluetooth': None,
|
||||||
|
'rf': None,
|
||||||
|
'total_new': 0,
|
||||||
|
'total_missing': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if wifi_devices is not None:
|
||||||
|
results['wifi'] = self.compare_wifi(wifi_devices)
|
||||||
|
results['total_new'] += results['wifi']['new_count']
|
||||||
|
results['total_missing'] += results['wifi']['missing_count']
|
||||||
|
|
||||||
|
if bt_devices is not None:
|
||||||
|
results['bluetooth'] = self.compare_bluetooth(bt_devices)
|
||||||
|
results['total_new'] += results['bluetooth']['new_count']
|
||||||
|
results['total_missing'] += results['bluetooth']['missing_count']
|
||||||
|
|
||||||
|
if rf_signals is not None:
|
||||||
|
results['rf'] = self.compare_rf(rf_signals)
|
||||||
|
results['total_new'] += results['rf']['new_count']
|
||||||
|
results['total_missing'] += results['rf']['missing_count']
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def get_comparison_for_active_baseline(
|
||||||
|
wifi_devices: list[dict] | None = None,
|
||||||
|
bt_devices: list[dict] | None = None,
|
||||||
|
rf_signals: list[dict] | None = None
|
||||||
|
) -> dict | None:
|
||||||
|
"""
|
||||||
|
Convenience function to compare against the active baseline.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Comparison results or None if no active baseline
|
||||||
|
"""
|
||||||
|
baseline = get_active_tscm_baseline()
|
||||||
|
if not baseline:
|
||||||
|
return None
|
||||||
|
|
||||||
|
comparator = BaselineComparator(baseline)
|
||||||
|
return comparator.compare_all(wifi_devices, bt_devices, rf_signals)
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
"""
|
||||||
|
BLE Scanner for TSCM
|
||||||
|
|
||||||
|
Cross-platform BLE scanning with manufacturer data detection.
|
||||||
|
Supports macOS and Linux using the bleak library with fallback to system tools.
|
||||||
|
|
||||||
|
Detects:
|
||||||
|
- Apple AirTags (company ID 0x004C)
|
||||||
|
- Tile trackers
|
||||||
|
- Samsung SmartTags
|
||||||
|
- ESP32/ESP8266 devices (Espressif, company ID 0x02E5)
|
||||||
|
- Generic BLE devices with suspicious characteristics
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.tscm.ble')
|
||||||
|
|
||||||
|
# Manufacturer company IDs (Bluetooth SIG assigned)
|
||||||
|
COMPANY_IDS = {
|
||||||
|
0x004C: 'Apple',
|
||||||
|
0x02E5: 'Espressif',
|
||||||
|
0x0059: 'Nordic Semiconductor',
|
||||||
|
0x000D: 'Texas Instruments',
|
||||||
|
0x0075: 'Samsung',
|
||||||
|
0x00E0: 'Google',
|
||||||
|
0x0006: 'Microsoft',
|
||||||
|
0x01DA: 'Tile',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Known tracker signatures
|
||||||
|
TRACKER_SIGNATURES = {
|
||||||
|
# Apple AirTag detection patterns
|
||||||
|
'airtag': {
|
||||||
|
'company_id': 0x004C,
|
||||||
|
'data_patterns': [
|
||||||
|
b'\x12\x19', # AirTag/Find My advertisement prefix
|
||||||
|
b'\x07\x19', # Offline Finding
|
||||||
|
],
|
||||||
|
'name_patterns': ['airtag', 'findmy', 'find my'],
|
||||||
|
},
|
||||||
|
# Tile tracker
|
||||||
|
'tile': {
|
||||||
|
'company_id': 0x01DA,
|
||||||
|
'name_patterns': ['tile'],
|
||||||
|
},
|
||||||
|
# Samsung SmartTag
|
||||||
|
'smarttag': {
|
||||||
|
'company_id': 0x0075,
|
||||||
|
'name_patterns': ['smarttag', 'smart tag', 'galaxy smart'],
|
||||||
|
},
|
||||||
|
# ESP32/ESP8266
|
||||||
|
'espressif': {
|
||||||
|
'company_id': 0x02E5,
|
||||||
|
'name_patterns': ['esp32', 'esp8266', 'espressif'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BLEDevice:
|
||||||
|
"""Represents a detected BLE device with full advertisement data."""
|
||||||
|
mac: str
|
||||||
|
name: Optional[str] = None
|
||||||
|
rssi: Optional[int] = None
|
||||||
|
manufacturer_id: Optional[int] = None
|
||||||
|
manufacturer_name: Optional[str] = None
|
||||||
|
manufacturer_data: bytes = field(default_factory=bytes)
|
||||||
|
service_uuids: list = field(default_factory=list)
|
||||||
|
tx_power: Optional[int] = None
|
||||||
|
is_connectable: bool = True
|
||||||
|
|
||||||
|
# Detection flags
|
||||||
|
is_airtag: bool = False
|
||||||
|
is_tile: bool = False
|
||||||
|
is_smarttag: bool = False
|
||||||
|
is_espressif: bool = False
|
||||||
|
is_tracker: bool = False
|
||||||
|
tracker_type: Optional[str] = None
|
||||||
|
|
||||||
|
first_seen: datetime = field(default_factory=datetime.now)
|
||||||
|
last_seen: datetime = field(default_factory=datetime.now)
|
||||||
|
detection_count: int = 1
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for JSON serialization."""
|
||||||
|
return {
|
||||||
|
'mac': self.mac,
|
||||||
|
'name': self.name or 'Unknown',
|
||||||
|
'rssi': self.rssi,
|
||||||
|
'manufacturer_id': self.manufacturer_id,
|
||||||
|
'manufacturer_name': self.manufacturer_name,
|
||||||
|
'service_uuids': self.service_uuids,
|
||||||
|
'tx_power': self.tx_power,
|
||||||
|
'is_connectable': self.is_connectable,
|
||||||
|
'is_airtag': self.is_airtag,
|
||||||
|
'is_tile': self.is_tile,
|
||||||
|
'is_smarttag': self.is_smarttag,
|
||||||
|
'is_espressif': self.is_espressif,
|
||||||
|
'is_tracker': self.is_tracker,
|
||||||
|
'tracker_type': self.tracker_type,
|
||||||
|
'detection_count': self.detection_count,
|
||||||
|
'type': 'ble',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BLEScanner:
|
||||||
|
"""
|
||||||
|
Cross-platform BLE scanner with manufacturer data detection.
|
||||||
|
|
||||||
|
Uses bleak library for proper BLE scanning, with fallback to
|
||||||
|
system tools (hcitool/btmgmt on Linux, system_profiler on macOS).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.devices: dict[str, BLEDevice] = {}
|
||||||
|
self._bleak_available = self._check_bleak()
|
||||||
|
self._scanning = False
|
||||||
|
|
||||||
|
def _check_bleak(self) -> bool:
|
||||||
|
"""Check if bleak library is available."""
|
||||||
|
try:
|
||||||
|
import bleak
|
||||||
|
return True
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("bleak library not available - using fallback scanning")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def scan_async(self, duration: int = 10) -> list[BLEDevice]:
|
||||||
|
"""
|
||||||
|
Perform async BLE scan using bleak.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
duration: Scan duration in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of detected BLE devices
|
||||||
|
"""
|
||||||
|
if not self._bleak_available:
|
||||||
|
# Use synchronous fallback
|
||||||
|
return self._scan_fallback(duration)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from bleak import BleakScanner
|
||||||
|
from bleak.backends.device import BLEDevice as BleakDevice
|
||||||
|
from bleak.backends.scanner import AdvertisementData
|
||||||
|
|
||||||
|
detected = {}
|
||||||
|
|
||||||
|
def detection_callback(device: BleakDevice, adv_data: AdvertisementData):
|
||||||
|
"""Callback for each detected device."""
|
||||||
|
mac = device.address.upper()
|
||||||
|
|
||||||
|
if mac in detected:
|
||||||
|
# Update existing device
|
||||||
|
detected[mac].rssi = adv_data.rssi
|
||||||
|
detected[mac].last_seen = datetime.now()
|
||||||
|
detected[mac].detection_count += 1
|
||||||
|
else:
|
||||||
|
# Create new device entry
|
||||||
|
ble_device = BLEDevice(
|
||||||
|
mac=mac,
|
||||||
|
name=adv_data.local_name or device.name,
|
||||||
|
rssi=adv_data.rssi,
|
||||||
|
service_uuids=list(adv_data.service_uuids) if adv_data.service_uuids else [],
|
||||||
|
tx_power=adv_data.tx_power,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse manufacturer data
|
||||||
|
if adv_data.manufacturer_data:
|
||||||
|
for company_id, data in adv_data.manufacturer_data.items():
|
||||||
|
ble_device.manufacturer_id = company_id
|
||||||
|
ble_device.manufacturer_name = COMPANY_IDS.get(company_id, f'Unknown ({hex(company_id)})')
|
||||||
|
ble_device.manufacturer_data = bytes(data)
|
||||||
|
|
||||||
|
# Check for known trackers
|
||||||
|
self._identify_tracker(ble_device, company_id, data)
|
||||||
|
|
||||||
|
# Also check name patterns
|
||||||
|
self._check_name_patterns(ble_device)
|
||||||
|
|
||||||
|
detected[mac] = ble_device
|
||||||
|
|
||||||
|
logger.info(f"Starting BLE scan with bleak (duration={duration}s)")
|
||||||
|
|
||||||
|
scanner = BleakScanner(detection_callback=detection_callback)
|
||||||
|
await scanner.start()
|
||||||
|
await asyncio.sleep(duration)
|
||||||
|
await scanner.stop()
|
||||||
|
|
||||||
|
# Update internal device list
|
||||||
|
for mac, device in detected.items():
|
||||||
|
if mac in self.devices:
|
||||||
|
self.devices[mac].rssi = device.rssi
|
||||||
|
self.devices[mac].last_seen = device.last_seen
|
||||||
|
self.devices[mac].detection_count += 1
|
||||||
|
else:
|
||||||
|
self.devices[mac] = device
|
||||||
|
|
||||||
|
logger.info(f"BLE scan complete: {len(detected)} devices found")
|
||||||
|
return list(detected.values())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Bleak scan failed: {e}")
|
||||||
|
return self._scan_fallback(duration)
|
||||||
|
|
||||||
|
def scan(self, duration: int = 10) -> list[BLEDevice]:
|
||||||
|
"""
|
||||||
|
Synchronous wrapper for BLE scanning.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
duration: Scan duration in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of detected BLE devices
|
||||||
|
"""
|
||||||
|
if self._bleak_available:
|
||||||
|
try:
|
||||||
|
# Try to get existing event loop
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
# We're in an async context, can't use run()
|
||||||
|
future = asyncio.ensure_future(self.scan_async(duration))
|
||||||
|
return asyncio.get_event_loop().run_until_complete(future)
|
||||||
|
except RuntimeError:
|
||||||
|
# No running loop, create one
|
||||||
|
return asyncio.run(self.scan_async(duration))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Async scan failed: {e}")
|
||||||
|
return self._scan_fallback(duration)
|
||||||
|
else:
|
||||||
|
return self._scan_fallback(duration)
|
||||||
|
|
||||||
|
def _identify_tracker(self, device: BLEDevice, company_id: int, data: bytes):
|
||||||
|
"""Identify if device is a known tracker type."""
|
||||||
|
|
||||||
|
# Apple AirTag detection
|
||||||
|
if company_id == 0x004C: # Apple
|
||||||
|
# Check for Find My / AirTag advertisement patterns
|
||||||
|
if len(data) >= 2:
|
||||||
|
# AirTag advertisements have specific byte patterns
|
||||||
|
if data[0] == 0x12 and data[1] == 0x19:
|
||||||
|
device.is_airtag = True
|
||||||
|
device.is_tracker = True
|
||||||
|
device.tracker_type = 'AirTag'
|
||||||
|
logger.info(f"AirTag detected: {device.mac}")
|
||||||
|
elif data[0] == 0x07: # Offline Finding
|
||||||
|
device.is_airtag = True
|
||||||
|
device.is_tracker = True
|
||||||
|
device.tracker_type = 'AirTag (Offline)'
|
||||||
|
logger.info(f"AirTag (offline mode) detected: {device.mac}")
|
||||||
|
|
||||||
|
# Tile tracker
|
||||||
|
elif company_id == 0x01DA: # Tile
|
||||||
|
device.is_tile = True
|
||||||
|
device.is_tracker = True
|
||||||
|
device.tracker_type = 'Tile'
|
||||||
|
logger.info(f"Tile tracker detected: {device.mac}")
|
||||||
|
|
||||||
|
# Samsung SmartTag
|
||||||
|
elif company_id == 0x0075: # Samsung
|
||||||
|
# Check if it's specifically a SmartTag
|
||||||
|
device.is_smarttag = True
|
||||||
|
device.is_tracker = True
|
||||||
|
device.tracker_type = 'SmartTag'
|
||||||
|
logger.info(f"Samsung SmartTag detected: {device.mac}")
|
||||||
|
|
||||||
|
# Espressif (ESP32/ESP8266)
|
||||||
|
elif company_id == 0x02E5: # Espressif
|
||||||
|
device.is_espressif = True
|
||||||
|
device.tracker_type = 'ESP32/ESP8266'
|
||||||
|
logger.info(f"ESP32/ESP8266 device detected: {device.mac}")
|
||||||
|
|
||||||
|
def _check_name_patterns(self, device: BLEDevice):
|
||||||
|
"""Check device name for tracker patterns."""
|
||||||
|
if not device.name:
|
||||||
|
return
|
||||||
|
|
||||||
|
name_lower = device.name.lower()
|
||||||
|
|
||||||
|
# Check each tracker type
|
||||||
|
for tracker_type, sig in TRACKER_SIGNATURES.items():
|
||||||
|
patterns = sig.get('name_patterns', [])
|
||||||
|
for pattern in patterns:
|
||||||
|
if pattern in name_lower:
|
||||||
|
if tracker_type == 'airtag':
|
||||||
|
device.is_airtag = True
|
||||||
|
device.is_tracker = True
|
||||||
|
device.tracker_type = 'AirTag'
|
||||||
|
elif tracker_type == 'tile':
|
||||||
|
device.is_tile = True
|
||||||
|
device.is_tracker = True
|
||||||
|
device.tracker_type = 'Tile'
|
||||||
|
elif tracker_type == 'smarttag':
|
||||||
|
device.is_smarttag = True
|
||||||
|
device.is_tracker = True
|
||||||
|
device.tracker_type = 'SmartTag'
|
||||||
|
elif tracker_type == 'espressif':
|
||||||
|
device.is_espressif = True
|
||||||
|
device.tracker_type = 'ESP32/ESP8266'
|
||||||
|
|
||||||
|
logger.info(f"Tracker identified by name: {device.name} -> {tracker_type}")
|
||||||
|
return
|
||||||
|
|
||||||
|
def _scan_fallback(self, duration: int = 10) -> list[BLEDevice]:
|
||||||
|
"""
|
||||||
|
Fallback scanning using system tools when bleak is unavailable.
|
||||||
|
Works on both macOS and Linux.
|
||||||
|
"""
|
||||||
|
system = platform.system()
|
||||||
|
|
||||||
|
if system == 'Darwin':
|
||||||
|
return self._scan_macos(duration)
|
||||||
|
else:
|
||||||
|
return self._scan_linux(duration)
|
||||||
|
|
||||||
|
def _scan_macos(self, duration: int = 10) -> list[BLEDevice]:
|
||||||
|
"""Fallback BLE scanning on macOS using system_profiler."""
|
||||||
|
devices = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
result = subprocess.run(
|
||||||
|
['system_profiler', 'SPBluetoothDataType', '-json'],
|
||||||
|
capture_output=True, text=True, timeout=15
|
||||||
|
)
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
bt_data = data.get('SPBluetoothDataType', [{}])[0]
|
||||||
|
|
||||||
|
# Get connected/paired devices
|
||||||
|
for section in ['device_connected', 'device_title']:
|
||||||
|
section_data = bt_data.get(section, {})
|
||||||
|
if isinstance(section_data, dict):
|
||||||
|
for name, info in section_data.items():
|
||||||
|
if isinstance(info, dict):
|
||||||
|
mac = info.get('device_address', '').upper()
|
||||||
|
if mac:
|
||||||
|
device = BLEDevice(
|
||||||
|
mac=mac,
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
# Check name patterns
|
||||||
|
self._check_name_patterns(device)
|
||||||
|
devices.append(device)
|
||||||
|
|
||||||
|
logger.info(f"macOS fallback scan found {len(devices)} devices")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"macOS fallback scan failed: {e}")
|
||||||
|
|
||||||
|
return devices
|
||||||
|
|
||||||
|
def _scan_linux(self, duration: int = 10) -> list[BLEDevice]:
|
||||||
|
"""Fallback BLE scanning on Linux using bluetoothctl/btmgmt."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
devices = []
|
||||||
|
seen_macs = set()
|
||||||
|
|
||||||
|
# Method 1: Try btmgmt for BLE devices
|
||||||
|
if shutil.which('btmgmt'):
|
||||||
|
try:
|
||||||
|
logger.info("Trying btmgmt find...")
|
||||||
|
result = subprocess.run(
|
||||||
|
['btmgmt', 'find'],
|
||||||
|
capture_output=True, text=True, timeout=duration + 5
|
||||||
|
)
|
||||||
|
|
||||||
|
for line in result.stdout.split('\n'):
|
||||||
|
if 'dev_found' in line.lower() or ('type' in line.lower() and ':' in line):
|
||||||
|
mac_match = re.search(
|
||||||
|
r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:'
|
||||||
|
r'[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})',
|
||||||
|
line
|
||||||
|
)
|
||||||
|
if mac_match:
|
||||||
|
mac = mac_match.group(1).upper()
|
||||||
|
if mac not in seen_macs:
|
||||||
|
seen_macs.add(mac)
|
||||||
|
name_match = re.search(r'name\s+(.+?)(?:\s|$)', line, re.I)
|
||||||
|
name = name_match.group(1) if name_match else None
|
||||||
|
|
||||||
|
device = BLEDevice(mac=mac, name=name)
|
||||||
|
self._check_name_patterns(device)
|
||||||
|
devices.append(device)
|
||||||
|
|
||||||
|
logger.info(f"btmgmt found {len(devices)} devices")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"btmgmt failed: {e}")
|
||||||
|
|
||||||
|
# Method 2: Try hcitool lescan
|
||||||
|
if not devices and shutil.which('hcitool'):
|
||||||
|
try:
|
||||||
|
logger.info("Trying hcitool lescan...")
|
||||||
|
# Start lescan in background
|
||||||
|
process = subprocess.Popen(
|
||||||
|
['hcitool', 'lescan', '--duplicates'],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
import time
|
||||||
|
time.sleep(duration)
|
||||||
|
process.terminate()
|
||||||
|
|
||||||
|
stdout, _ = process.communicate(timeout=2)
|
||||||
|
|
||||||
|
for line in stdout.split('\n'):
|
||||||
|
mac_match = re.search(
|
||||||
|
r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:'
|
||||||
|
r'[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})',
|
||||||
|
line
|
||||||
|
)
|
||||||
|
if mac_match:
|
||||||
|
mac = mac_match.group(1).upper()
|
||||||
|
if mac not in seen_macs:
|
||||||
|
seen_macs.add(mac)
|
||||||
|
# Extract name (comes after MAC)
|
||||||
|
parts = line.strip().split()
|
||||||
|
name = ' '.join(parts[1:]) if len(parts) > 1 else None
|
||||||
|
|
||||||
|
device = BLEDevice(mac=mac, name=name if name != '(unknown)' else None)
|
||||||
|
self._check_name_patterns(device)
|
||||||
|
devices.append(device)
|
||||||
|
|
||||||
|
logger.info(f"hcitool lescan found {len(devices)} devices")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"hcitool lescan failed: {e}")
|
||||||
|
|
||||||
|
return devices
|
||||||
|
|
||||||
|
def get_trackers(self) -> list[BLEDevice]:
|
||||||
|
"""Get all detected tracker devices."""
|
||||||
|
return [d for d in self.devices.values() if d.is_tracker]
|
||||||
|
|
||||||
|
def get_espressif_devices(self) -> list[BLEDevice]:
|
||||||
|
"""Get all detected ESP32/ESP8266 devices."""
|
||||||
|
return [d for d in self.devices.values() if d.is_espressif]
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
"""Clear all detected devices."""
|
||||||
|
self.devices.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_scanner: Optional[BLEScanner] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_ble_scanner() -> BLEScanner:
|
||||||
|
"""Get the global BLE scanner instance."""
|
||||||
|
global _scanner
|
||||||
|
if _scanner is None:
|
||||||
|
_scanner = BLEScanner()
|
||||||
|
return _scanner
|
||||||
|
|
||||||
|
|
||||||
|
def scan_ble_devices(duration: int = 10) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Convenience function to scan for BLE devices.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
duration: Scan duration in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of device dictionaries
|
||||||
|
"""
|
||||||
|
scanner = get_ble_scanner()
|
||||||
|
devices = scanner.scan(duration)
|
||||||
|
return [d.to_dict() for d in devices]
|
||||||
@@ -0,0 +1,959 @@
|
|||||||
|
"""
|
||||||
|
TSCM Cross-Protocol Correlation Engine
|
||||||
|
|
||||||
|
Correlates Bluetooth, Wi-Fi, and RF indicators to detect potential surveillance activity.
|
||||||
|
Implements scoring model for risk assessment and provides actionable intelligence.
|
||||||
|
|
||||||
|
DISCLAIMER: This system performs wireless and RF surveillance screening.
|
||||||
|
Findings indicate anomalies and indicators, not confirmed surveillance devices.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.tscm.correlation')
|
||||||
|
|
||||||
|
|
||||||
|
class RiskLevel(Enum):
|
||||||
|
"""Risk classification levels."""
|
||||||
|
INFORMATIONAL = 'informational' # Score 0-2
|
||||||
|
NEEDS_REVIEW = 'review' # Score 3-5
|
||||||
|
HIGH_INTEREST = 'high_interest' # Score 6+
|
||||||
|
|
||||||
|
|
||||||
|
class IndicatorType(Enum):
|
||||||
|
"""Types of risk indicators."""
|
||||||
|
UNKNOWN_DEVICE = 'unknown_device'
|
||||||
|
AUDIO_CAPABLE = 'audio_capable'
|
||||||
|
PERSISTENT = 'persistent'
|
||||||
|
MEETING_CORRELATED = 'meeting_correlated'
|
||||||
|
CROSS_PROTOCOL = 'cross_protocol'
|
||||||
|
HIDDEN_IDENTITY = 'hidden_identity'
|
||||||
|
ROGUE_AP = 'rogue_ap'
|
||||||
|
BURST_TRANSMISSION = 'burst_transmission'
|
||||||
|
STABLE_RSSI = 'stable_rssi'
|
||||||
|
HIGH_FREQ_ADVERTISING = 'high_freq_advertising'
|
||||||
|
MAC_ROTATION = 'mac_rotation'
|
||||||
|
NARROWBAND_SIGNAL = 'narrowband_signal'
|
||||||
|
ALWAYS_ON_CARRIER = 'always_on_carrier'
|
||||||
|
# Tracker-specific indicators
|
||||||
|
KNOWN_TRACKER = 'known_tracker'
|
||||||
|
AIRTAG_DETECTED = 'airtag_detected'
|
||||||
|
TILE_DETECTED = 'tile_detected'
|
||||||
|
SMARTTAG_DETECTED = 'smarttag_detected'
|
||||||
|
ESP32_DEVICE = 'esp32_device'
|
||||||
|
GENERIC_CHIPSET = 'generic_chipset'
|
||||||
|
|
||||||
|
|
||||||
|
# Scoring weights for each indicator
|
||||||
|
INDICATOR_SCORES = {
|
||||||
|
IndicatorType.UNKNOWN_DEVICE: 1,
|
||||||
|
IndicatorType.AUDIO_CAPABLE: 2,
|
||||||
|
IndicatorType.PERSISTENT: 2,
|
||||||
|
IndicatorType.MEETING_CORRELATED: 2,
|
||||||
|
IndicatorType.CROSS_PROTOCOL: 3,
|
||||||
|
IndicatorType.HIDDEN_IDENTITY: 2,
|
||||||
|
IndicatorType.ROGUE_AP: 3,
|
||||||
|
IndicatorType.BURST_TRANSMISSION: 2,
|
||||||
|
IndicatorType.STABLE_RSSI: 1,
|
||||||
|
IndicatorType.HIGH_FREQ_ADVERTISING: 1,
|
||||||
|
IndicatorType.MAC_ROTATION: 1,
|
||||||
|
IndicatorType.NARROWBAND_SIGNAL: 2,
|
||||||
|
IndicatorType.ALWAYS_ON_CARRIER: 2,
|
||||||
|
# Tracker scores - higher for covert tracking devices
|
||||||
|
IndicatorType.KNOWN_TRACKER: 3,
|
||||||
|
IndicatorType.AIRTAG_DETECTED: 3,
|
||||||
|
IndicatorType.TILE_DETECTED: 2,
|
||||||
|
IndicatorType.SMARTTAG_DETECTED: 2,
|
||||||
|
IndicatorType.ESP32_DEVICE: 2,
|
||||||
|
IndicatorType.GENERIC_CHIPSET: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Known tracker device signatures
|
||||||
|
TRACKER_SIGNATURES = {
|
||||||
|
# Apple AirTag - OUI prefixes
|
||||||
|
'airtag_oui': ['4C:E6:76', '7C:04:D0', 'DC:A4:CA', 'F0:B3:EC'],
|
||||||
|
# Tile trackers
|
||||||
|
'tile_oui': ['D0:03:DF', 'EC:2E:4E'],
|
||||||
|
# Samsung SmartTag
|
||||||
|
'smarttag_oui': ['8C:71:F8', 'CC:2D:83', 'F0:5C:D5'],
|
||||||
|
# ESP32/ESP8266 Espressif chipsets
|
||||||
|
'espressif_oui': ['24:0A:C4', '24:6F:28', '24:62:AB', '30:AE:A4',
|
||||||
|
'3C:61:05', '3C:71:BF', '40:F5:20', '48:3F:DA',
|
||||||
|
'4C:11:AE', '54:43:B2', '58:BF:25', '5C:CF:7F',
|
||||||
|
'60:01:94', '68:C6:3A', '7C:9E:BD', '84:0D:8E',
|
||||||
|
'84:CC:A8', '84:F3:EB', '8C:AA:B5', '90:38:0C',
|
||||||
|
'94:B5:55', '98:CD:AC', 'A4:7B:9D', 'A4:CF:12',
|
||||||
|
'AC:67:B2', 'B4:E6:2D', 'BC:DD:C2', 'C4:4F:33',
|
||||||
|
'C8:2B:96', 'CC:50:E3', 'D8:A0:1D', 'DC:4F:22',
|
||||||
|
'E0:98:06', 'E8:68:E7', 'EC:FA:BC', 'F4:CF:A2'],
|
||||||
|
# Generic/suspicious chipset vendors (potential covert devices)
|
||||||
|
'generic_chipset_oui': [
|
||||||
|
'00:1A:7D', # cyber-blue(HK)
|
||||||
|
'00:25:00', # Apple (but generic BLE)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Indicator:
|
||||||
|
"""A single risk indicator."""
|
||||||
|
type: IndicatorType
|
||||||
|
description: str
|
||||||
|
score: int
|
||||||
|
details: dict = field(default_factory=dict)
|
||||||
|
timestamp: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeviceProfile:
|
||||||
|
"""Complete profile for a detected device."""
|
||||||
|
# Identity
|
||||||
|
identifier: str # MAC, BSSID, or frequency
|
||||||
|
protocol: str # 'bluetooth', 'wifi', 'rf'
|
||||||
|
|
||||||
|
# Device info
|
||||||
|
name: Optional[str] = None
|
||||||
|
manufacturer: Optional[str] = None
|
||||||
|
device_type: Optional[str] = None
|
||||||
|
|
||||||
|
# Bluetooth-specific
|
||||||
|
services: list[str] = field(default_factory=list)
|
||||||
|
company_id: Optional[int] = None
|
||||||
|
advertising_interval: Optional[int] = None
|
||||||
|
|
||||||
|
# Wi-Fi-specific
|
||||||
|
ssid: Optional[str] = None
|
||||||
|
channel: Optional[int] = None
|
||||||
|
encryption: Optional[str] = None
|
||||||
|
beacon_interval: Optional[int] = None
|
||||||
|
is_hidden: bool = False
|
||||||
|
|
||||||
|
# RF-specific
|
||||||
|
frequency: Optional[float] = None
|
||||||
|
bandwidth: Optional[float] = None
|
||||||
|
modulation: Optional[str] = None
|
||||||
|
|
||||||
|
# Common measurements
|
||||||
|
rssi_samples: list[tuple[datetime, int]] = field(default_factory=list)
|
||||||
|
first_seen: Optional[datetime] = None
|
||||||
|
last_seen: Optional[datetime] = None
|
||||||
|
detection_count: int = 0
|
||||||
|
|
||||||
|
# Behavioral analysis
|
||||||
|
indicators: list[Indicator] = field(default_factory=list)
|
||||||
|
total_score: int = 0
|
||||||
|
risk_level: RiskLevel = RiskLevel.INFORMATIONAL
|
||||||
|
|
||||||
|
# Correlation
|
||||||
|
correlated_devices: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Output
|
||||||
|
confidence: float = 0.0
|
||||||
|
recommended_action: str = 'monitor'
|
||||||
|
|
||||||
|
def add_rssi_sample(self, rssi: int) -> None:
|
||||||
|
"""Add an RSSI sample with timestamp."""
|
||||||
|
self.rssi_samples.append((datetime.now(), rssi))
|
||||||
|
# Keep last 100 samples
|
||||||
|
if len(self.rssi_samples) > 100:
|
||||||
|
self.rssi_samples = self.rssi_samples[-100:]
|
||||||
|
|
||||||
|
def get_rssi_stability(self) -> float:
|
||||||
|
"""Calculate RSSI stability (0-1, higher = more stable)."""
|
||||||
|
if len(self.rssi_samples) < 3:
|
||||||
|
return 0.0
|
||||||
|
values = [r for _, r in self.rssi_samples[-20:]]
|
||||||
|
if not values:
|
||||||
|
return 0.0
|
||||||
|
avg = sum(values) / len(values)
|
||||||
|
variance = sum((v - avg) ** 2 for v in values) / len(values)
|
||||||
|
# Convert variance to stability score (lower variance = higher stability)
|
||||||
|
# Variance of ~0 = 1.0, variance of 100+ = ~0
|
||||||
|
return max(0, 1 - (variance / 100))
|
||||||
|
|
||||||
|
def add_indicator(self, indicator_type: IndicatorType, description: str,
|
||||||
|
details: dict = None) -> None:
|
||||||
|
"""Add a risk indicator and update score."""
|
||||||
|
score = INDICATOR_SCORES.get(indicator_type, 1)
|
||||||
|
self.indicators.append(Indicator(
|
||||||
|
type=indicator_type,
|
||||||
|
description=description,
|
||||||
|
score=score,
|
||||||
|
details=details or {}
|
||||||
|
))
|
||||||
|
self._recalculate_score()
|
||||||
|
|
||||||
|
def _recalculate_score(self) -> None:
|
||||||
|
"""Recalculate total score and risk level."""
|
||||||
|
self.total_score = sum(i.score for i in self.indicators)
|
||||||
|
|
||||||
|
if self.total_score >= 6:
|
||||||
|
self.risk_level = RiskLevel.HIGH_INTEREST
|
||||||
|
self.recommended_action = 'investigate'
|
||||||
|
elif self.total_score >= 3:
|
||||||
|
self.risk_level = RiskLevel.NEEDS_REVIEW
|
||||||
|
self.recommended_action = 'review'
|
||||||
|
else:
|
||||||
|
self.risk_level = RiskLevel.INFORMATIONAL
|
||||||
|
self.recommended_action = 'monitor'
|
||||||
|
|
||||||
|
# Calculate confidence based on number and quality of indicators
|
||||||
|
indicator_count = len(self.indicators)
|
||||||
|
self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05))
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for JSON serialization."""
|
||||||
|
return {
|
||||||
|
'identifier': self.identifier,
|
||||||
|
'protocol': self.protocol,
|
||||||
|
'name': self.name,
|
||||||
|
'manufacturer': self.manufacturer,
|
||||||
|
'device_type': self.device_type,
|
||||||
|
'ssid': self.ssid,
|
||||||
|
'frequency': self.frequency,
|
||||||
|
'first_seen': self.first_seen.isoformat() if self.first_seen else None,
|
||||||
|
'last_seen': self.last_seen.isoformat() if self.last_seen else None,
|
||||||
|
'detection_count': self.detection_count,
|
||||||
|
'rssi_current': self.rssi_samples[-1][1] if self.rssi_samples else None,
|
||||||
|
'rssi_stability': self.get_rssi_stability(),
|
||||||
|
'indicators': [
|
||||||
|
{
|
||||||
|
'type': i.type.value,
|
||||||
|
'description': i.description,
|
||||||
|
'score': i.score,
|
||||||
|
}
|
||||||
|
for i in self.indicators
|
||||||
|
],
|
||||||
|
'total_score': self.total_score,
|
||||||
|
'risk_level': self.risk_level.value,
|
||||||
|
'confidence': round(self.confidence, 2),
|
||||||
|
'recommended_action': self.recommended_action,
|
||||||
|
'correlated_devices': self.correlated_devices,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Known audio-capable BLE service UUIDs
|
||||||
|
AUDIO_SERVICE_UUIDS = [
|
||||||
|
'0000110b-0000-1000-8000-00805f9b34fb', # A2DP Sink
|
||||||
|
'0000110a-0000-1000-8000-00805f9b34fb', # A2DP Source
|
||||||
|
'0000111e-0000-1000-8000-00805f9b34fb', # Handsfree
|
||||||
|
'0000111f-0000-1000-8000-00805f9b34fb', # Handsfree Audio Gateway
|
||||||
|
'00001108-0000-1000-8000-00805f9b34fb', # Headset
|
||||||
|
'00001203-0000-1000-8000-00805f9b34fb', # Generic Audio
|
||||||
|
]
|
||||||
|
|
||||||
|
# Generic chipset vendors (often used in covert devices)
|
||||||
|
GENERIC_CHIPSET_VENDORS = [
|
||||||
|
'espressif',
|
||||||
|
'nordic',
|
||||||
|
'texas instruments',
|
||||||
|
'silicon labs',
|
||||||
|
'realtek',
|
||||||
|
'mediatek',
|
||||||
|
'qualcomm',
|
||||||
|
'broadcom',
|
||||||
|
'cypress',
|
||||||
|
'dialog',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Suspicious frequency ranges for RF
|
||||||
|
SUSPICIOUS_RF_BANDS = [
|
||||||
|
{'start': 136, 'end': 174, 'name': 'VHF', 'risk': 'high'},
|
||||||
|
{'start': 400, 'end': 470, 'name': 'UHF', 'risk': 'high'},
|
||||||
|
{'start': 315, 'end': 316, 'name': '315 MHz ISM', 'risk': 'medium'},
|
||||||
|
{'start': 433, 'end': 435, 'name': '433 MHz ISM', 'risk': 'medium'},
|
||||||
|
{'start': 868, 'end': 870, 'name': '868 MHz ISM', 'risk': 'medium'},
|
||||||
|
{'start': 902, 'end': 928, 'name': '915 MHz ISM', 'risk': 'medium'},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CorrelationEngine:
|
||||||
|
"""
|
||||||
|
Cross-protocol correlation engine for TSCM analysis.
|
||||||
|
|
||||||
|
Correlates Bluetooth, Wi-Fi, and RF indicators to identify
|
||||||
|
potential surveillance activity patterns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.device_profiles: dict[str, DeviceProfile] = {}
|
||||||
|
self.meeting_windows: list[tuple[datetime, datetime]] = []
|
||||||
|
self.correlation_window = timedelta(minutes=5)
|
||||||
|
|
||||||
|
def start_meeting_window(self) -> None:
|
||||||
|
"""Mark the start of a sensitive period (meeting)."""
|
||||||
|
self.meeting_windows.append((datetime.now(), None))
|
||||||
|
logger.info("Meeting window started")
|
||||||
|
|
||||||
|
def end_meeting_window(self) -> None:
|
||||||
|
"""Mark the end of a sensitive period."""
|
||||||
|
if self.meeting_windows and self.meeting_windows[-1][1] is None:
|
||||||
|
start = self.meeting_windows[-1][0]
|
||||||
|
self.meeting_windows[-1] = (start, datetime.now())
|
||||||
|
logger.info("Meeting window ended")
|
||||||
|
|
||||||
|
def is_during_meeting(self, timestamp: datetime = None) -> bool:
|
||||||
|
"""Check if timestamp falls within a meeting window."""
|
||||||
|
ts = timestamp or datetime.now()
|
||||||
|
for start, end in self.meeting_windows:
|
||||||
|
if end is None:
|
||||||
|
if ts >= start:
|
||||||
|
return True
|
||||||
|
elif start <= ts <= end:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_or_create_profile(self, identifier: str, protocol: str) -> DeviceProfile:
|
||||||
|
"""Get existing profile or create new one."""
|
||||||
|
key = f"{protocol}:{identifier}"
|
||||||
|
if key not in self.device_profiles:
|
||||||
|
self.device_profiles[key] = DeviceProfile(
|
||||||
|
identifier=identifier,
|
||||||
|
protocol=protocol,
|
||||||
|
first_seen=datetime.now()
|
||||||
|
)
|
||||||
|
profile = self.device_profiles[key]
|
||||||
|
profile.last_seen = datetime.now()
|
||||||
|
profile.detection_count += 1
|
||||||
|
return profile
|
||||||
|
|
||||||
|
def analyze_bluetooth_device(self, device: dict) -> DeviceProfile:
|
||||||
|
"""
|
||||||
|
Analyze a Bluetooth device for suspicious indicators.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device: Dict with mac, name, rssi, services, manufacturer, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DeviceProfile with risk assessment
|
||||||
|
"""
|
||||||
|
mac = device.get('mac', device.get('address', '')).upper()
|
||||||
|
profile = self.get_or_create_profile(mac, 'bluetooth')
|
||||||
|
|
||||||
|
# Update profile data
|
||||||
|
profile.name = device.get('name') or profile.name
|
||||||
|
profile.manufacturer = device.get('manufacturer') or profile.manufacturer
|
||||||
|
profile.device_type = device.get('type') or profile.device_type
|
||||||
|
profile.services = device.get('services', []) or profile.services
|
||||||
|
profile.company_id = device.get('company_id') or profile.company_id
|
||||||
|
profile.advertising_interval = device.get('advertising_interval') or profile.advertising_interval
|
||||||
|
|
||||||
|
# Add RSSI sample
|
||||||
|
rssi = device.get('rssi', device.get('signal'))
|
||||||
|
if rssi:
|
||||||
|
try:
|
||||||
|
profile.add_rssi_sample(int(rssi))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Clear previous indicators for fresh analysis
|
||||||
|
profile.indicators = []
|
||||||
|
|
||||||
|
# === Detection Logic ===
|
||||||
|
|
||||||
|
# 1. Unknown manufacturer or generic chipset
|
||||||
|
if not profile.manufacturer:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.UNKNOWN_DEVICE,
|
||||||
|
'Unknown manufacturer',
|
||||||
|
{'manufacturer': None}
|
||||||
|
)
|
||||||
|
elif any(v in profile.manufacturer.lower() for v in GENERIC_CHIPSET_VENDORS):
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.UNKNOWN_DEVICE,
|
||||||
|
f'Generic chipset vendor: {profile.manufacturer}',
|
||||||
|
{'manufacturer': profile.manufacturer}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. No human-readable name
|
||||||
|
if not profile.name or profile.name in ['Unknown', '', 'N/A']:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.HIDDEN_IDENTITY,
|
||||||
|
'No device name advertised',
|
||||||
|
{'name': profile.name}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Audio-capable services
|
||||||
|
if profile.services:
|
||||||
|
audio_services = [s for s in profile.services
|
||||||
|
if s.lower() in [u.lower() for u in AUDIO_SERVICE_UUIDS]]
|
||||||
|
if audio_services:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.AUDIO_CAPABLE,
|
||||||
|
'Audio-capable BLE services detected',
|
||||||
|
{'services': audio_services}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check name for audio keywords
|
||||||
|
if profile.name:
|
||||||
|
audio_keywords = ['headphone', 'headset', 'earphone', 'speaker',
|
||||||
|
'mic', 'audio', 'airpod', 'buds', 'jabra', 'bose']
|
||||||
|
if any(k in profile.name.lower() for k in audio_keywords):
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.AUDIO_CAPABLE,
|
||||||
|
f'Audio device name: {profile.name}',
|
||||||
|
{'name': profile.name}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. High-frequency advertising (< 100ms interval is suspicious)
|
||||||
|
if profile.advertising_interval and profile.advertising_interval < 100:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.HIGH_FREQ_ADVERTISING,
|
||||||
|
f'High advertising frequency: {profile.advertising_interval}ms',
|
||||||
|
{'interval': profile.advertising_interval}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Persistent presence
|
||||||
|
if profile.detection_count >= 3:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.PERSISTENT,
|
||||||
|
f'Persistent device ({profile.detection_count} detections)',
|
||||||
|
{'count': profile.detection_count}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6. Stable RSSI (suggests fixed placement)
|
||||||
|
rssi_stability = profile.get_rssi_stability()
|
||||||
|
if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.STABLE_RSSI,
|
||||||
|
f'Stable signal strength (stability: {rssi_stability:.0%})',
|
||||||
|
{'stability': rssi_stability}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 7. Meeting correlation
|
||||||
|
if self.is_during_meeting():
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.MEETING_CORRELATED,
|
||||||
|
'Detected during sensitive period',
|
||||||
|
{'during_meeting': True}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 8. MAC rotation pattern (random MAC prefix)
|
||||||
|
if mac and mac[1] in ['2', '6', 'A', 'E', 'a', 'e']:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.MAC_ROTATION,
|
||||||
|
'Random/rotating MAC address detected',
|
||||||
|
{'mac': mac}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 9. Known tracker detection (AirTag, Tile, SmartTag, ESP32)
|
||||||
|
mac_prefix = mac[:8] if len(mac) >= 8 else ''
|
||||||
|
tracker_detected = False
|
||||||
|
|
||||||
|
# Check for tracker flags from BLE scanner (manufacturer ID detection)
|
||||||
|
if device.get('is_airtag'):
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.AIRTAG_DETECTED,
|
||||||
|
'Apple AirTag detected via manufacturer data',
|
||||||
|
{'mac': mac, 'tracker_type': 'AirTag'}
|
||||||
|
)
|
||||||
|
profile.device_type = device.get('tracker_type', 'AirTag')
|
||||||
|
tracker_detected = True
|
||||||
|
|
||||||
|
if device.get('is_tile'):
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.TILE_DETECTED,
|
||||||
|
'Tile tracker detected via manufacturer data',
|
||||||
|
{'mac': mac, 'tracker_type': 'Tile'}
|
||||||
|
)
|
||||||
|
profile.device_type = 'Tile Tracker'
|
||||||
|
tracker_detected = True
|
||||||
|
|
||||||
|
if device.get('is_smarttag'):
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.SMARTTAG_DETECTED,
|
||||||
|
'Samsung SmartTag detected via manufacturer data',
|
||||||
|
{'mac': mac, 'tracker_type': 'SmartTag'}
|
||||||
|
)
|
||||||
|
profile.device_type = 'Samsung SmartTag'
|
||||||
|
tracker_detected = True
|
||||||
|
|
||||||
|
if device.get('is_espressif'):
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.ESP32_DEVICE,
|
||||||
|
'ESP32/ESP8266 detected via Espressif manufacturer ID',
|
||||||
|
{'mac': mac, 'chipset': 'Espressif'}
|
||||||
|
)
|
||||||
|
profile.manufacturer = 'Espressif'
|
||||||
|
profile.device_type = device.get('tracker_type', 'ESP32/ESP8266')
|
||||||
|
tracker_detected = True
|
||||||
|
|
||||||
|
# Check manufacturer_id directly
|
||||||
|
mfg_id = device.get('manufacturer_id')
|
||||||
|
if mfg_id:
|
||||||
|
if mfg_id == 0x004C and not device.get('is_airtag'):
|
||||||
|
# Apple device - could be AirTag
|
||||||
|
profile.manufacturer = 'Apple'
|
||||||
|
elif mfg_id == 0x02E5 and not device.get('is_espressif'):
|
||||||
|
# Espressif device
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.ESP32_DEVICE,
|
||||||
|
'ESP32/ESP8266 detected via manufacturer ID',
|
||||||
|
{'mac': mac, 'manufacturer_id': mfg_id}
|
||||||
|
)
|
||||||
|
profile.manufacturer = 'Espressif'
|
||||||
|
tracker_detected = True
|
||||||
|
|
||||||
|
# Fallback: Check for Apple AirTag by OUI
|
||||||
|
if not tracker_detected and mac_prefix in TRACKER_SIGNATURES.get('airtag_oui', []):
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.AIRTAG_DETECTED,
|
||||||
|
'Apple AirTag detected - potential tracking device',
|
||||||
|
{'mac': mac, 'tracker_type': 'AirTag'}
|
||||||
|
)
|
||||||
|
profile.device_type = 'AirTag'
|
||||||
|
tracker_detected = True
|
||||||
|
|
||||||
|
# Check for Tile tracker
|
||||||
|
if mac_prefix in TRACKER_SIGNATURES.get('tile_oui', []):
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.TILE_DETECTED,
|
||||||
|
'Tile tracker detected',
|
||||||
|
{'mac': mac, 'tracker_type': 'Tile'}
|
||||||
|
)
|
||||||
|
profile.device_type = 'Tile Tracker'
|
||||||
|
tracker_detected = True
|
||||||
|
|
||||||
|
# Check for Samsung SmartTag
|
||||||
|
if mac_prefix in TRACKER_SIGNATURES.get('smarttag_oui', []):
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.SMARTTAG_DETECTED,
|
||||||
|
'Samsung SmartTag detected',
|
||||||
|
{'mac': mac, 'tracker_type': 'SmartTag'}
|
||||||
|
)
|
||||||
|
profile.device_type = 'Samsung SmartTag'
|
||||||
|
tracker_detected = True
|
||||||
|
|
||||||
|
# Check for ESP32/ESP8266 devices
|
||||||
|
if mac_prefix in TRACKER_SIGNATURES.get('espressif_oui', []):
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.ESP32_DEVICE,
|
||||||
|
'ESP32/ESP8266 device detected - programmable hardware',
|
||||||
|
{'mac': mac, 'chipset': 'Espressif'}
|
||||||
|
)
|
||||||
|
profile.manufacturer = 'Espressif'
|
||||||
|
tracker_detected = True
|
||||||
|
|
||||||
|
# Check for generic/suspicious chipsets
|
||||||
|
if mac_prefix in TRACKER_SIGNATURES.get('generic_chipset_oui', []):
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.GENERIC_CHIPSET,
|
||||||
|
'Generic chipset vendor - often used in covert devices',
|
||||||
|
{'mac': mac}
|
||||||
|
)
|
||||||
|
tracker_detected = True
|
||||||
|
|
||||||
|
# If any tracker detected, add general tracker indicator
|
||||||
|
if tracker_detected:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.KNOWN_TRACKER,
|
||||||
|
'Known tracking device signature detected',
|
||||||
|
{'mac': mac}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Also check name for tracker keywords
|
||||||
|
if profile.name:
|
||||||
|
name_lower = profile.name.lower()
|
||||||
|
if 'airtag' in name_lower or 'findmy' in name_lower:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.AIRTAG_DETECTED,
|
||||||
|
f'AirTag identified by name: {profile.name}',
|
||||||
|
{'name': profile.name}
|
||||||
|
)
|
||||||
|
profile.device_type = 'AirTag'
|
||||||
|
elif 'tile' in name_lower:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.TILE_DETECTED,
|
||||||
|
f'Tile tracker identified by name: {profile.name}',
|
||||||
|
{'name': profile.name}
|
||||||
|
)
|
||||||
|
profile.device_type = 'Tile Tracker'
|
||||||
|
elif 'smarttag' in name_lower:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.SMARTTAG_DETECTED,
|
||||||
|
f'SmartTag identified by name: {profile.name}',
|
||||||
|
{'name': profile.name}
|
||||||
|
)
|
||||||
|
profile.device_type = 'Samsung SmartTag'
|
||||||
|
|
||||||
|
return profile
|
||||||
|
|
||||||
|
def analyze_wifi_device(self, device: dict) -> DeviceProfile:
|
||||||
|
"""
|
||||||
|
Analyze a Wi-Fi device/AP for suspicious indicators.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device: Dict with bssid, ssid, channel, rssi, encryption, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DeviceProfile with risk assessment
|
||||||
|
"""
|
||||||
|
bssid = device.get('bssid', device.get('mac', '')).upper()
|
||||||
|
profile = self.get_or_create_profile(bssid, 'wifi')
|
||||||
|
|
||||||
|
# Update profile data
|
||||||
|
ssid = device.get('ssid', device.get('essid', ''))
|
||||||
|
profile.ssid = ssid if ssid else profile.ssid
|
||||||
|
profile.name = ssid or f'Hidden Network ({bssid[-8:]})'
|
||||||
|
profile.channel = device.get('channel') or profile.channel
|
||||||
|
profile.encryption = device.get('encryption', device.get('privacy')) or profile.encryption
|
||||||
|
profile.beacon_interval = device.get('beacon_interval') or profile.beacon_interval
|
||||||
|
profile.is_hidden = not ssid or ssid in ['', 'Hidden', '[Hidden]']
|
||||||
|
|
||||||
|
# Extract manufacturer from OUI
|
||||||
|
if bssid and len(bssid) >= 8:
|
||||||
|
profile.manufacturer = device.get('vendor') or profile.manufacturer
|
||||||
|
|
||||||
|
# Add RSSI sample
|
||||||
|
rssi = device.get('rssi', device.get('power', device.get('signal')))
|
||||||
|
if rssi:
|
||||||
|
try:
|
||||||
|
profile.add_rssi_sample(int(rssi))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Clear previous indicators
|
||||||
|
profile.indicators = []
|
||||||
|
|
||||||
|
# === Detection Logic ===
|
||||||
|
|
||||||
|
# 1. Hidden or unnamed SSID
|
||||||
|
if profile.is_hidden:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.HIDDEN_IDENTITY,
|
||||||
|
'Hidden or empty SSID',
|
||||||
|
{'ssid': ssid}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. BSSID not in authorized list (would need baseline)
|
||||||
|
# For now, mark as unknown if no manufacturer
|
||||||
|
if not profile.manufacturer:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.UNKNOWN_DEVICE,
|
||||||
|
'Unknown AP manufacturer',
|
||||||
|
{'bssid': bssid}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Consumer device OUI in restricted environment
|
||||||
|
consumer_ouis = ['tp-link', 'netgear', 'd-link', 'linksys', 'asus']
|
||||||
|
if profile.manufacturer and any(c in profile.manufacturer.lower() for c in consumer_ouis):
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.ROGUE_AP,
|
||||||
|
f'Consumer-grade AP detected: {profile.manufacturer}',
|
||||||
|
{'manufacturer': profile.manufacturer}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Camera device patterns
|
||||||
|
camera_keywords = ['cam', 'camera', 'ipcam', 'dvr', 'nvr', 'wyze',
|
||||||
|
'ring', 'arlo', 'nest', 'blink', 'eufy', 'yi']
|
||||||
|
if ssid and any(k in ssid.lower() for k in camera_keywords):
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.AUDIO_CAPABLE, # Cameras often have mics
|
||||||
|
f'Potential camera device: {ssid}',
|
||||||
|
{'ssid': ssid}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Persistent presence
|
||||||
|
if profile.detection_count >= 3:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.PERSISTENT,
|
||||||
|
f'Persistent AP ({profile.detection_count} detections)',
|
||||||
|
{'count': profile.detection_count}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6. Stable RSSI (fixed placement)
|
||||||
|
rssi_stability = profile.get_rssi_stability()
|
||||||
|
if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.STABLE_RSSI,
|
||||||
|
f'Stable signal (stability: {rssi_stability:.0%})',
|
||||||
|
{'stability': rssi_stability}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 7. Meeting correlation
|
||||||
|
if self.is_during_meeting():
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.MEETING_CORRELATED,
|
||||||
|
'Detected during sensitive period',
|
||||||
|
{'during_meeting': True}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 8. Strong hidden AP (very suspicious)
|
||||||
|
if profile.is_hidden and profile.rssi_samples:
|
||||||
|
latest_rssi = profile.rssi_samples[-1][1]
|
||||||
|
if latest_rssi > -50:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.ROGUE_AP,
|
||||||
|
f'Strong hidden AP (RSSI: {latest_rssi} dBm)',
|
||||||
|
{'rssi': latest_rssi}
|
||||||
|
)
|
||||||
|
|
||||||
|
return profile
|
||||||
|
|
||||||
|
def analyze_rf_signal(self, signal: dict) -> DeviceProfile:
|
||||||
|
"""
|
||||||
|
Analyze an RF signal for suspicious indicators.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
signal: Dict with frequency, power, bandwidth, modulation, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DeviceProfile with risk assessment
|
||||||
|
"""
|
||||||
|
frequency = signal.get('frequency', 0)
|
||||||
|
freq_key = f"{frequency:.3f}"
|
||||||
|
profile = self.get_or_create_profile(freq_key, 'rf')
|
||||||
|
|
||||||
|
# Update profile data
|
||||||
|
profile.frequency = frequency
|
||||||
|
profile.name = f'{frequency:.3f} MHz'
|
||||||
|
profile.bandwidth = signal.get('bandwidth') or profile.bandwidth
|
||||||
|
profile.modulation = signal.get('modulation') or profile.modulation
|
||||||
|
|
||||||
|
# Add power sample
|
||||||
|
power = signal.get('power', signal.get('level'))
|
||||||
|
if power:
|
||||||
|
try:
|
||||||
|
profile.add_rssi_sample(int(float(power)))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Clear previous indicators
|
||||||
|
profile.indicators = []
|
||||||
|
|
||||||
|
# === Detection Logic ===
|
||||||
|
|
||||||
|
# 1. Determine frequency band risk
|
||||||
|
band_info = None
|
||||||
|
for band in SUSPICIOUS_RF_BANDS:
|
||||||
|
if band['start'] <= frequency <= band['end']:
|
||||||
|
band_info = band
|
||||||
|
break
|
||||||
|
|
||||||
|
if band_info:
|
||||||
|
if band_info['risk'] == 'high':
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.NARROWBAND_SIGNAL,
|
||||||
|
f"Signal in high-risk band: {band_info['name']}",
|
||||||
|
{'band': band_info['name'], 'frequency': frequency}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.UNKNOWN_DEVICE,
|
||||||
|
f"Signal in ISM band: {band_info['name']}",
|
||||||
|
{'band': band_info['name'], 'frequency': frequency}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Narrowband FM/AM (potential bug)
|
||||||
|
if profile.modulation and profile.modulation.lower() in ['fm', 'nfm', 'am']:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.NARROWBAND_SIGNAL,
|
||||||
|
f'Narrowband {profile.modulation.upper()} signal',
|
||||||
|
{'modulation': profile.modulation}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Persistent/always-on carrier
|
||||||
|
if profile.detection_count >= 2:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.ALWAYS_ON_CARRIER,
|
||||||
|
f'Persistent carrier ({profile.detection_count} detections)',
|
||||||
|
{'count': profile.detection_count}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Strong signal (close proximity)
|
||||||
|
if profile.rssi_samples:
|
||||||
|
latest_power = profile.rssi_samples[-1][1]
|
||||||
|
if latest_power > -40:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.STABLE_RSSI,
|
||||||
|
f'Strong signal suggesting close proximity ({latest_power} dBm)',
|
||||||
|
{'power': latest_power}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Meeting correlation
|
||||||
|
if self.is_during_meeting():
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.MEETING_CORRELATED,
|
||||||
|
'Signal detected during sensitive period',
|
||||||
|
{'during_meeting': True}
|
||||||
|
)
|
||||||
|
|
||||||
|
return profile
|
||||||
|
|
||||||
|
def correlate_devices(self) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Perform cross-protocol correlation analysis.
|
||||||
|
|
||||||
|
Identifies devices across protocols that may be related.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of correlation findings
|
||||||
|
"""
|
||||||
|
correlations = []
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
# Get recent devices by protocol
|
||||||
|
bt_devices = [p for p in self.device_profiles.values()
|
||||||
|
if p.protocol == 'bluetooth' and
|
||||||
|
p.last_seen and (now - p.last_seen) < self.correlation_window]
|
||||||
|
wifi_devices = [p for p in self.device_profiles.values()
|
||||||
|
if p.protocol == 'wifi' and
|
||||||
|
p.last_seen and (now - p.last_seen) < self.correlation_window]
|
||||||
|
rf_signals = [p for p in self.device_profiles.values()
|
||||||
|
if p.protocol == 'rf' and
|
||||||
|
p.last_seen and (now - p.last_seen) < self.correlation_window]
|
||||||
|
|
||||||
|
# Correlation 1: BLE audio device + RF narrowband signal
|
||||||
|
audio_bt = [p for p in bt_devices
|
||||||
|
if any(i.type == IndicatorType.AUDIO_CAPABLE for i in p.indicators)]
|
||||||
|
narrowband_rf = [p for p in rf_signals
|
||||||
|
if any(i.type == IndicatorType.NARROWBAND_SIGNAL for i in p.indicators)]
|
||||||
|
|
||||||
|
for bt in audio_bt:
|
||||||
|
for rf in narrowband_rf:
|
||||||
|
correlation = {
|
||||||
|
'type': 'bt_audio_rf_narrowband',
|
||||||
|
'description': 'Audio-capable BLE device detected alongside narrowband RF signal',
|
||||||
|
'devices': [bt.identifier, rf.identifier],
|
||||||
|
'protocols': ['bluetooth', 'rf'],
|
||||||
|
'score_boost': 3,
|
||||||
|
'significance': 'high',
|
||||||
|
}
|
||||||
|
correlations.append(correlation)
|
||||||
|
|
||||||
|
# Add cross-protocol indicator to both
|
||||||
|
bt.add_indicator(
|
||||||
|
IndicatorType.CROSS_PROTOCOL,
|
||||||
|
f'Correlated with RF signal at {rf.frequency:.3f} MHz',
|
||||||
|
{'correlated_device': rf.identifier}
|
||||||
|
)
|
||||||
|
rf.add_indicator(
|
||||||
|
IndicatorType.CROSS_PROTOCOL,
|
||||||
|
f'Correlated with BLE device {bt.identifier}',
|
||||||
|
{'correlated_device': bt.identifier}
|
||||||
|
)
|
||||||
|
bt.correlated_devices.append(rf.identifier)
|
||||||
|
rf.correlated_devices.append(bt.identifier)
|
||||||
|
|
||||||
|
# Correlation 2: Rogue WiFi AP + RF burst activity
|
||||||
|
rogue_aps = [p for p in wifi_devices
|
||||||
|
if any(i.type == IndicatorType.ROGUE_AP for i in p.indicators)]
|
||||||
|
rf_bursts = [p for p in rf_signals
|
||||||
|
if any(i.type in [IndicatorType.BURST_TRANSMISSION,
|
||||||
|
IndicatorType.ALWAYS_ON_CARRIER] for i in p.indicators)]
|
||||||
|
|
||||||
|
for ap in rogue_aps:
|
||||||
|
for rf in rf_bursts:
|
||||||
|
correlation = {
|
||||||
|
'type': 'rogue_ap_rf_burst',
|
||||||
|
'description': 'Rogue AP detected alongside RF transmission',
|
||||||
|
'devices': [ap.identifier, rf.identifier],
|
||||||
|
'protocols': ['wifi', 'rf'],
|
||||||
|
'score_boost': 3,
|
||||||
|
'significance': 'high',
|
||||||
|
}
|
||||||
|
correlations.append(correlation)
|
||||||
|
|
||||||
|
ap.add_indicator(
|
||||||
|
IndicatorType.CROSS_PROTOCOL,
|
||||||
|
f'Correlated with RF at {rf.frequency:.3f} MHz',
|
||||||
|
{'correlated_device': rf.identifier}
|
||||||
|
)
|
||||||
|
rf.add_indicator(
|
||||||
|
IndicatorType.CROSS_PROTOCOL,
|
||||||
|
f'Correlated with AP {ap.ssid or ap.identifier}',
|
||||||
|
{'correlated_device': ap.identifier}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Correlation 3: Same vendor BLE + WiFi
|
||||||
|
for bt in bt_devices:
|
||||||
|
if bt.manufacturer:
|
||||||
|
for wifi in wifi_devices:
|
||||||
|
if wifi.manufacturer and bt.manufacturer.lower() in wifi.manufacturer.lower():
|
||||||
|
correlation = {
|
||||||
|
'type': 'same_vendor_bt_wifi',
|
||||||
|
'description': f'Same vendor ({bt.manufacturer}) on BLE and WiFi',
|
||||||
|
'devices': [bt.identifier, wifi.identifier],
|
||||||
|
'protocols': ['bluetooth', 'wifi'],
|
||||||
|
'score_boost': 2,
|
||||||
|
'significance': 'medium',
|
||||||
|
}
|
||||||
|
correlations.append(correlation)
|
||||||
|
|
||||||
|
return correlations
|
||||||
|
|
||||||
|
def get_high_interest_devices(self) -> list[DeviceProfile]:
|
||||||
|
"""Get all devices classified as high interest."""
|
||||||
|
return [p for p in self.device_profiles.values()
|
||||||
|
if p.risk_level == RiskLevel.HIGH_INTEREST]
|
||||||
|
|
||||||
|
def get_all_findings(self) -> dict:
|
||||||
|
"""
|
||||||
|
Get comprehensive findings report.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with all device profiles, correlations, and summary
|
||||||
|
"""
|
||||||
|
correlations = self.correlate_devices()
|
||||||
|
|
||||||
|
devices_by_risk = {
|
||||||
|
'high_interest': [],
|
||||||
|
'needs_review': [],
|
||||||
|
'informational': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for profile in self.device_profiles.values():
|
||||||
|
devices_by_risk[profile.risk_level.value].append(profile.to_dict())
|
||||||
|
|
||||||
|
return {
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'summary': {
|
||||||
|
'total_devices': len(self.device_profiles),
|
||||||
|
'high_interest': len(devices_by_risk['high_interest']),
|
||||||
|
'needs_review': len(devices_by_risk['needs_review']),
|
||||||
|
'informational': len(devices_by_risk['informational']),
|
||||||
|
'correlations_found': len(correlations),
|
||||||
|
},
|
||||||
|
'devices': devices_by_risk,
|
||||||
|
'correlations': correlations,
|
||||||
|
'disclaimer': (
|
||||||
|
"This system performs wireless and RF surveillance screening. "
|
||||||
|
"Findings indicate anomalies and indicators, not confirmed surveillance devices."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def clear_old_profiles(self, max_age_hours: int = 24) -> int:
|
||||||
|
"""Remove profiles older than specified age."""
|
||||||
|
cutoff = datetime.now() - timedelta(hours=max_age_hours)
|
||||||
|
old_keys = [
|
||||||
|
k for k, v in self.device_profiles.items()
|
||||||
|
if v.last_seen and v.last_seen < cutoff
|
||||||
|
]
|
||||||
|
for key in old_keys:
|
||||||
|
del self.device_profiles[key]
|
||||||
|
return len(old_keys)
|
||||||
|
|
||||||
|
|
||||||
|
# Global correlation engine instance
|
||||||
|
_correlation_engine: CorrelationEngine | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_correlation_engine() -> CorrelationEngine:
|
||||||
|
"""Get or create the global correlation engine."""
|
||||||
|
global _correlation_engine
|
||||||
|
if _correlation_engine is None:
|
||||||
|
_correlation_engine = CorrelationEngine()
|
||||||
|
return _correlation_engine
|
||||||
|
|
||||||
|
|
||||||
|
def reset_correlation_engine() -> None:
|
||||||
|
"""Reset the global correlation engine."""
|
||||||
|
global _correlation_engine
|
||||||
|
_correlation_engine = CorrelationEngine()
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
"""
|
||||||
|
TSCM Threat Detection Engine
|
||||||
|
|
||||||
|
Analyzes WiFi, Bluetooth, and RF data to identify potential surveillance devices
|
||||||
|
and classify threats based on known patterns and baseline comparison.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from data.tscm_frequencies import (
|
||||||
|
BLE_TRACKER_SIGNATURES,
|
||||||
|
THREAT_TYPES,
|
||||||
|
WIFI_CAMERA_PATTERNS,
|
||||||
|
get_frequency_risk,
|
||||||
|
get_threat_severity,
|
||||||
|
is_known_tracker,
|
||||||
|
is_potential_camera,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.tscm.detector')
|
||||||
|
|
||||||
|
# Classification levels for TSCM devices
|
||||||
|
CLASSIFICATION_LEVELS = {
|
||||||
|
'informational': {
|
||||||
|
'color': '#00cc00', # Green
|
||||||
|
'label': 'Informational',
|
||||||
|
'description': 'Known device, expected infrastructure, or background noise',
|
||||||
|
},
|
||||||
|
'review': {
|
||||||
|
'color': '#ffcc00', # Yellow
|
||||||
|
'label': 'Needs Review',
|
||||||
|
'description': 'Unknown device requiring investigation',
|
||||||
|
},
|
||||||
|
'high_interest': {
|
||||||
|
'color': '#ff3333', # Red
|
||||||
|
'label': 'High Interest',
|
||||||
|
'description': 'Suspicious device requiring immediate attention',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# BLE device types that can transmit audio (potential bugs)
|
||||||
|
AUDIO_CAPABLE_BLE_NAMES = [
|
||||||
|
'headphone', 'headset', 'earphone', 'earbud', 'speaker',
|
||||||
|
'audio', 'mic', 'microphone', 'airpod', 'buds',
|
||||||
|
'jabra', 'bose', 'sony wf', 'sony wh', 'beats',
|
||||||
|
'jbl', 'soundcore', 'anker', 'skullcandy',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Device history for tracking repeat detections across scans
|
||||||
|
_device_history: dict[str, list[datetime]] = {}
|
||||||
|
_history_window_hours = 24 # Consider detections within 24 hours
|
||||||
|
|
||||||
|
|
||||||
|
def _record_device_seen(identifier: str) -> int:
|
||||||
|
"""Record a device sighting and return count of times seen."""
|
||||||
|
now = datetime.now()
|
||||||
|
if identifier not in _device_history:
|
||||||
|
_device_history[identifier] = []
|
||||||
|
|
||||||
|
# Clean old entries
|
||||||
|
cutoff = now.timestamp() - (_history_window_hours * 3600)
|
||||||
|
_device_history[identifier] = [
|
||||||
|
dt for dt in _device_history[identifier]
|
||||||
|
if dt.timestamp() > cutoff
|
||||||
|
]
|
||||||
|
|
||||||
|
_device_history[identifier].append(now)
|
||||||
|
return len(_device_history[identifier])
|
||||||
|
|
||||||
|
|
||||||
|
def _is_audio_capable_ble(name: str | None, device_type: str | None = None) -> bool:
|
||||||
|
"""Check if a BLE device might be audio-capable."""
|
||||||
|
if name:
|
||||||
|
name_lower = name.lower()
|
||||||
|
for pattern in AUDIO_CAPABLE_BLE_NAMES:
|
||||||
|
if pattern in name_lower:
|
||||||
|
return True
|
||||||
|
if device_type:
|
||||||
|
type_lower = device_type.lower()
|
||||||
|
if any(t in type_lower for t in ['audio', 'headset', 'headphone', 'speaker']):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ThreatDetector:
|
||||||
|
"""
|
||||||
|
Analyzes scan results to detect potential surveillance threats.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, baseline: dict | None = None):
|
||||||
|
"""
|
||||||
|
Initialize the threat detector.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
baseline: Optional baseline dict containing expected devices
|
||||||
|
"""
|
||||||
|
self.baseline = baseline
|
||||||
|
self.baseline_wifi_macs = set()
|
||||||
|
self.baseline_bt_macs = set()
|
||||||
|
self.baseline_rf_freqs = set()
|
||||||
|
|
||||||
|
if baseline:
|
||||||
|
self._load_baseline(baseline)
|
||||||
|
|
||||||
|
def _load_baseline(self, baseline: dict) -> None:
|
||||||
|
"""Load baseline device identifiers for comparison."""
|
||||||
|
# WiFi networks and clients
|
||||||
|
for network in baseline.get('wifi_networks', []):
|
||||||
|
if 'bssid' in network:
|
||||||
|
self.baseline_wifi_macs.add(network['bssid'].upper())
|
||||||
|
if 'clients' in network:
|
||||||
|
for client in network['clients']:
|
||||||
|
if 'mac' in client:
|
||||||
|
self.baseline_wifi_macs.add(client['mac'].upper())
|
||||||
|
|
||||||
|
# Bluetooth devices
|
||||||
|
for device in baseline.get('bt_devices', []):
|
||||||
|
if 'mac' in device:
|
||||||
|
self.baseline_bt_macs.add(device['mac'].upper())
|
||||||
|
|
||||||
|
# RF frequencies (rounded to nearest 0.1 MHz)
|
||||||
|
for freq in baseline.get('rf_frequencies', []):
|
||||||
|
if isinstance(freq, dict):
|
||||||
|
self.baseline_rf_freqs.add(round(freq.get('frequency', 0), 1))
|
||||||
|
else:
|
||||||
|
self.baseline_rf_freqs.add(round(freq, 1))
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Loaded baseline: {len(self.baseline_wifi_macs)} WiFi, "
|
||||||
|
f"{len(self.baseline_bt_macs)} BT, {len(self.baseline_rf_freqs)} RF"
|
||||||
|
)
|
||||||
|
|
||||||
|
def classify_wifi_device(self, device: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Classify a WiFi device into informational/review/high_interest.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'classification', 'reasons', and metadata
|
||||||
|
"""
|
||||||
|
mac = device.get('bssid', device.get('mac', '')).upper()
|
||||||
|
ssid = device.get('essid', device.get('ssid', ''))
|
||||||
|
signal = device.get('power', device.get('signal', -100))
|
||||||
|
|
||||||
|
reasons = []
|
||||||
|
classification = 'informational'
|
||||||
|
|
||||||
|
# Track repeat detections
|
||||||
|
times_seen = _record_device_seen(f'wifi:{mac}') if mac else 1
|
||||||
|
|
||||||
|
# Check if in baseline (known device)
|
||||||
|
in_baseline = mac in self.baseline_wifi_macs if self.baseline else False
|
||||||
|
|
||||||
|
if in_baseline:
|
||||||
|
reasons.append('Known device in baseline')
|
||||||
|
classification = 'informational'
|
||||||
|
else:
|
||||||
|
# New/unknown device
|
||||||
|
reasons.append('New WiFi access point')
|
||||||
|
classification = 'review'
|
||||||
|
|
||||||
|
# Check for suspicious patterns -> high interest
|
||||||
|
if is_potential_camera(ssid=ssid, mac=mac):
|
||||||
|
reasons.append('Matches camera device patterns')
|
||||||
|
classification = 'high_interest'
|
||||||
|
|
||||||
|
if not ssid and signal and int(signal) > -60:
|
||||||
|
reasons.append('Hidden SSID with strong signal')
|
||||||
|
classification = 'high_interest'
|
||||||
|
|
||||||
|
# Repeat detections across scans
|
||||||
|
if times_seen >= 3:
|
||||||
|
reasons.append(f'Repeat detection ({times_seen} times)')
|
||||||
|
if classification != 'high_interest':
|
||||||
|
classification = 'high_interest'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'classification': classification,
|
||||||
|
'reasons': reasons,
|
||||||
|
'in_baseline': in_baseline,
|
||||||
|
'times_seen': times_seen,
|
||||||
|
}
|
||||||
|
|
||||||
|
def classify_bt_device(self, device: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Classify a Bluetooth device into informational/review/high_interest.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'classification', 'reasons', and metadata
|
||||||
|
"""
|
||||||
|
mac = device.get('mac', device.get('address', '')).upper()
|
||||||
|
name = device.get('name', '')
|
||||||
|
rssi = device.get('rssi', device.get('signal', -100))
|
||||||
|
device_type = device.get('type', '')
|
||||||
|
manufacturer_data = device.get('manufacturer_data')
|
||||||
|
|
||||||
|
reasons = []
|
||||||
|
classification = 'informational'
|
||||||
|
tracker_info = None
|
||||||
|
|
||||||
|
# Track repeat detections
|
||||||
|
times_seen = _record_device_seen(f'bt:{mac}') if mac else 1
|
||||||
|
|
||||||
|
# Check if in baseline (known device)
|
||||||
|
in_baseline = mac in self.baseline_bt_macs if self.baseline else False
|
||||||
|
|
||||||
|
# Check for trackers (do this early for all devices)
|
||||||
|
tracker_info = is_known_tracker(name, manufacturer_data)
|
||||||
|
|
||||||
|
if in_baseline:
|
||||||
|
reasons.append('Known device in baseline')
|
||||||
|
classification = 'informational'
|
||||||
|
else:
|
||||||
|
# New/unknown BLE device
|
||||||
|
if not name or name == 'Unknown':
|
||||||
|
reasons.append('Unknown BLE device')
|
||||||
|
classification = 'review'
|
||||||
|
else:
|
||||||
|
reasons.append('New Bluetooth device')
|
||||||
|
classification = 'review'
|
||||||
|
|
||||||
|
# Check for trackers -> high interest
|
||||||
|
if tracker_info:
|
||||||
|
reasons.append(f"Known tracker: {tracker_info.get('name', 'Unknown')}")
|
||||||
|
classification = 'high_interest'
|
||||||
|
|
||||||
|
# Check for audio-capable devices -> high interest
|
||||||
|
if _is_audio_capable_ble(name, device_type):
|
||||||
|
reasons.append('Audio-capable BLE device')
|
||||||
|
classification = 'high_interest'
|
||||||
|
|
||||||
|
# Strong signal from unknown device
|
||||||
|
if rssi and int(rssi) > -50 and not name:
|
||||||
|
reasons.append('Strong signal from unnamed device')
|
||||||
|
classification = 'high_interest'
|
||||||
|
|
||||||
|
# Repeat detections across scans
|
||||||
|
if times_seen >= 3:
|
||||||
|
reasons.append(f'Repeat detection ({times_seen} times)')
|
||||||
|
if classification != 'high_interest':
|
||||||
|
classification = 'high_interest'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'classification': classification,
|
||||||
|
'reasons': reasons,
|
||||||
|
'in_baseline': in_baseline,
|
||||||
|
'times_seen': times_seen,
|
||||||
|
'is_tracker': tracker_info is not None,
|
||||||
|
'is_audio_capable': _is_audio_capable_ble(name, device_type),
|
||||||
|
}
|
||||||
|
|
||||||
|
def classify_rf_signal(self, signal: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Classify an RF signal into informational/review/high_interest.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'classification', 'reasons', and metadata
|
||||||
|
"""
|
||||||
|
frequency = signal.get('frequency', 0)
|
||||||
|
power = signal.get('power', signal.get('level', -100))
|
||||||
|
band = signal.get('band', '')
|
||||||
|
|
||||||
|
reasons = []
|
||||||
|
classification = 'informational'
|
||||||
|
freq_rounded = round(frequency, 1)
|
||||||
|
|
||||||
|
# Track repeat detections
|
||||||
|
times_seen = _record_device_seen(f'rf:{freq_rounded}')
|
||||||
|
|
||||||
|
# Check if in baseline (known frequency)
|
||||||
|
in_baseline = freq_rounded in self.baseline_rf_freqs if self.baseline else False
|
||||||
|
|
||||||
|
# Get frequency risk info
|
||||||
|
risk, band_name = get_frequency_risk(frequency)
|
||||||
|
|
||||||
|
if in_baseline:
|
||||||
|
reasons.append('Known frequency in baseline')
|
||||||
|
classification = 'informational'
|
||||||
|
else:
|
||||||
|
# New/unidentified RF carrier
|
||||||
|
reasons.append(f'Unidentified RF carrier in {band_name}')
|
||||||
|
|
||||||
|
if risk == 'low':
|
||||||
|
reasons.append('Background RF noise band')
|
||||||
|
classification = 'review'
|
||||||
|
elif risk == 'medium':
|
||||||
|
reasons.append('ISM band signal')
|
||||||
|
classification = 'review'
|
||||||
|
elif risk in ['high', 'critical']:
|
||||||
|
reasons.append(f'High-risk surveillance band: {band_name}')
|
||||||
|
classification = 'high_interest'
|
||||||
|
|
||||||
|
# Strong persistent signal
|
||||||
|
if power and float(power) > -40:
|
||||||
|
reasons.append('Strong persistent transmitter')
|
||||||
|
classification = 'high_interest'
|
||||||
|
|
||||||
|
# Repeat detections (persistent transmitter)
|
||||||
|
if times_seen >= 2:
|
||||||
|
reasons.append(f'Persistent transmitter ({times_seen} detections)')
|
||||||
|
classification = 'high_interest'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'classification': classification,
|
||||||
|
'reasons': reasons,
|
||||||
|
'in_baseline': in_baseline,
|
||||||
|
'times_seen': times_seen,
|
||||||
|
'risk_level': risk,
|
||||||
|
'band_name': band_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
def analyze_wifi_device(self, device: dict) -> dict | None:
|
||||||
|
"""
|
||||||
|
Analyze a WiFi device for threats.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device: WiFi device dict with bssid, essid, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Threat dict if threat detected, None otherwise
|
||||||
|
"""
|
||||||
|
mac = device.get('bssid', device.get('mac', '')).upper()
|
||||||
|
ssid = device.get('essid', device.get('ssid', ''))
|
||||||
|
vendor = device.get('vendor', '')
|
||||||
|
signal = device.get('power', device.get('signal', -100))
|
||||||
|
|
||||||
|
threats = []
|
||||||
|
|
||||||
|
# Check if new device (not in baseline)
|
||||||
|
if self.baseline and mac and mac not in self.baseline_wifi_macs:
|
||||||
|
threats.append({
|
||||||
|
'type': 'new_device',
|
||||||
|
'severity': get_threat_severity('new_device', {'signal_strength': signal}),
|
||||||
|
'reason': 'Device not present in baseline',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check for hidden camera patterns
|
||||||
|
if is_potential_camera(ssid=ssid, mac=mac, vendor=vendor):
|
||||||
|
threats.append({
|
||||||
|
'type': 'hidden_camera',
|
||||||
|
'severity': get_threat_severity('hidden_camera', {'signal_strength': signal}),
|
||||||
|
'reason': 'Device matches WiFi camera patterns',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check for hidden SSID with strong signal
|
||||||
|
if not ssid and signal and signal > -60:
|
||||||
|
threats.append({
|
||||||
|
'type': 'anomaly',
|
||||||
|
'severity': 'medium',
|
||||||
|
'reason': 'Hidden SSID with strong signal',
|
||||||
|
})
|
||||||
|
|
||||||
|
if not threats:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Return highest severity threat
|
||||||
|
threats.sort(key=lambda t: ['low', 'medium', 'high', 'critical'].index(t['severity']), reverse=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'threat_type': threats[0]['type'],
|
||||||
|
'severity': threats[0]['severity'],
|
||||||
|
'source': 'wifi',
|
||||||
|
'identifier': mac,
|
||||||
|
'name': ssid or 'Hidden Network',
|
||||||
|
'signal_strength': signal,
|
||||||
|
'details': {
|
||||||
|
'all_threats': threats,
|
||||||
|
'vendor': vendor,
|
||||||
|
'ssid': ssid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def analyze_bt_device(self, device: dict) -> dict | None:
|
||||||
|
"""
|
||||||
|
Analyze a Bluetooth device for threats.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device: BT device dict with mac, name, rssi, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Threat dict if threat detected, None otherwise
|
||||||
|
"""
|
||||||
|
mac = device.get('mac', device.get('address', '')).upper()
|
||||||
|
name = device.get('name', '')
|
||||||
|
rssi = device.get('rssi', device.get('signal', -100))
|
||||||
|
manufacturer = device.get('manufacturer', '')
|
||||||
|
device_type = device.get('type', '')
|
||||||
|
manufacturer_data = device.get('manufacturer_data')
|
||||||
|
|
||||||
|
threats = []
|
||||||
|
|
||||||
|
# Check if new device (not in baseline)
|
||||||
|
if self.baseline and mac and mac not in self.baseline_bt_macs:
|
||||||
|
threats.append({
|
||||||
|
'type': 'new_device',
|
||||||
|
'severity': get_threat_severity('new_device', {'signal_strength': rssi}),
|
||||||
|
'reason': 'Device not present in baseline',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check for known trackers
|
||||||
|
tracker_info = is_known_tracker(name, manufacturer_data)
|
||||||
|
if tracker_info:
|
||||||
|
threats.append({
|
||||||
|
'type': 'tracker',
|
||||||
|
'severity': tracker_info.get('risk', 'high'),
|
||||||
|
'reason': f"Known tracker detected: {tracker_info.get('name', 'Unknown')}",
|
||||||
|
'tracker_type': tracker_info.get('name'),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check for suspicious BLE beacons (unnamed, persistent)
|
||||||
|
if not name and rssi and rssi > -70:
|
||||||
|
threats.append({
|
||||||
|
'type': 'anomaly',
|
||||||
|
'severity': 'medium',
|
||||||
|
'reason': 'Unnamed BLE device with strong signal',
|
||||||
|
})
|
||||||
|
|
||||||
|
if not threats:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Return highest severity threat
|
||||||
|
threats.sort(key=lambda t: ['low', 'medium', 'high', 'critical'].index(t['severity']), reverse=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'threat_type': threats[0]['type'],
|
||||||
|
'severity': threats[0]['severity'],
|
||||||
|
'source': 'bluetooth',
|
||||||
|
'identifier': mac,
|
||||||
|
'name': name or 'Unknown BLE Device',
|
||||||
|
'signal_strength': rssi,
|
||||||
|
'details': {
|
||||||
|
'all_threats': threats,
|
||||||
|
'manufacturer': manufacturer,
|
||||||
|
'device_type': device_type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def analyze_rf_signal(self, signal: dict) -> dict | None:
|
||||||
|
"""
|
||||||
|
Analyze an RF signal for threats.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
signal: RF signal dict with frequency, level, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Threat dict if threat detected, None otherwise
|
||||||
|
"""
|
||||||
|
frequency = signal.get('frequency', 0)
|
||||||
|
level = signal.get('level', signal.get('power', -100))
|
||||||
|
modulation = signal.get('modulation', '')
|
||||||
|
|
||||||
|
if not frequency:
|
||||||
|
return None
|
||||||
|
|
||||||
|
threats = []
|
||||||
|
freq_rounded = round(frequency, 1)
|
||||||
|
|
||||||
|
# Check if new frequency (not in baseline)
|
||||||
|
if self.baseline and freq_rounded not in self.baseline_rf_freqs:
|
||||||
|
risk, band_name = get_frequency_risk(frequency)
|
||||||
|
threats.append({
|
||||||
|
'type': 'unknown_signal',
|
||||||
|
'severity': risk,
|
||||||
|
'reason': f'New signal in {band_name}',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check frequency risk even without baseline
|
||||||
|
risk, band_name = get_frequency_risk(frequency)
|
||||||
|
if risk in ['high', 'critical']:
|
||||||
|
threats.append({
|
||||||
|
'type': 'unknown_signal',
|
||||||
|
'severity': risk,
|
||||||
|
'reason': f'Signal in high-risk band: {band_name}',
|
||||||
|
})
|
||||||
|
|
||||||
|
if not threats:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Return highest severity threat
|
||||||
|
threats.sort(key=lambda t: ['low', 'medium', 'high', 'critical'].index(t['severity']), reverse=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'threat_type': threats[0]['type'],
|
||||||
|
'severity': threats[0]['severity'],
|
||||||
|
'source': 'rf',
|
||||||
|
'identifier': f'{frequency:.3f} MHz',
|
||||||
|
'name': f'RF Signal @ {frequency:.3f} MHz',
|
||||||
|
'signal_strength': level,
|
||||||
|
'frequency': frequency,
|
||||||
|
'details': {
|
||||||
|
'all_threats': threats,
|
||||||
|
'modulation': modulation,
|
||||||
|
'band_name': band_name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def analyze_all(
|
||||||
|
self,
|
||||||
|
wifi_devices: list[dict] | None = None,
|
||||||
|
bt_devices: list[dict] | None = None,
|
||||||
|
rf_signals: list[dict] | None = None
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Analyze all provided devices and signals for threats.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of detected threats sorted by severity
|
||||||
|
"""
|
||||||
|
threats = []
|
||||||
|
|
||||||
|
if wifi_devices:
|
||||||
|
for device in wifi_devices:
|
||||||
|
threat = self.analyze_wifi_device(device)
|
||||||
|
if threat:
|
||||||
|
threats.append(threat)
|
||||||
|
|
||||||
|
if bt_devices:
|
||||||
|
for device in bt_devices:
|
||||||
|
threat = self.analyze_bt_device(device)
|
||||||
|
if threat:
|
||||||
|
threats.append(threat)
|
||||||
|
|
||||||
|
if rf_signals:
|
||||||
|
for signal in rf_signals:
|
||||||
|
threat = self.analyze_rf_signal(signal)
|
||||||
|
if threat:
|
||||||
|
threats.append(threat)
|
||||||
|
|
||||||
|
# Sort by severity (critical first)
|
||||||
|
severity_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3}
|
||||||
|
threats.sort(key=lambda t: severity_order.get(t.get('severity', 'low'), 3))
|
||||||
|
|
||||||
|
return threats
|
||||||
|
|
||||||
|
|
||||||
|
def classify_device_threat(
|
||||||
|
source: str,
|
||||||
|
device: dict,
|
||||||
|
baseline: dict | None = None
|
||||||
|
) -> dict | None:
|
||||||
|
"""
|
||||||
|
Convenience function to classify a single device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: Device source ('wifi', 'bluetooth', 'rf')
|
||||||
|
device: Device data dict
|
||||||
|
baseline: Optional baseline for comparison
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Threat dict if threat detected, None otherwise
|
||||||
|
"""
|
||||||
|
detector = ThreatDetector(baseline)
|
||||||
|
|
||||||
|
if source == 'wifi':
|
||||||
|
return detector.analyze_wifi_device(device)
|
||||||
|
elif source == 'bluetooth':
|
||||||
|
return detector.analyze_bt_device(device)
|
||||||
|
elif source == 'rf':
|
||||||
|
return detector.analyze_rf_signal(device)
|
||||||
|
|
||||||
|
return None
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user