mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Merge branch 'main' into feature/login-system
This commit is contained in:
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
buy_me_a_coffee: smittix
|
||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -2,6 +2,30 @@
|
||||
|
||||
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
|
||||
|
||||
### Added
|
||||
|
||||
41
Dockerfile
41
Dockerfile
@@ -35,13 +35,40 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install dump1090 for ADS-B (package name varies by distribution)
|
||||
RUN apt-get update && \
|
||||
(apt-get install -y --no-install-recommends dump1090-mutability || \
|
||||
apt-get install -y --no-install-recommends dump1090-fa || \
|
||||
apt-get install -y --no-install-recommends dump1090 || \
|
||||
echo "Note: dump1090 not available in repos, ADS-B features limited") && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
# Build dump1090-fa and acarsdec from source (packages not available in slim repos)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
git \
|
||||
pkg-config \
|
||||
cmake \
|
||||
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.txt .
|
||||
|
||||
22
README.md
22
README.md
@@ -6,13 +6,20 @@
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Support the developer of this open-source project
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.buymeacoffee.com/smittix" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<strong>Signal Intelligence Platform</strong><br>
|
||||
A web-based interface for software-defined radio tools.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="static/images/screenshots/logo-banner.png" alt="Screenshot">
|
||||
<img src="static/images/screenshots/intercept-main.png" alt="Screenshot">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -22,6 +29,7 @@
|
||||
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
||||
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
|
||||
- **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
|
||||
- **Satellite Tracking** - Pass prediction using TLE data
|
||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||
@@ -38,7 +46,7 @@
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
./setup.sh
|
||||
sudo python3 intercept.py
|
||||
sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### Docker (Alternative)
|
||||
@@ -46,7 +54,7 @@ sudo python3 intercept.py
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
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.
|
||||
@@ -81,9 +89,10 @@ Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
|
||||
## Discord Server
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/z3g3NJMe">Join our Discord</a>
|
||||
<a href="https://discord.gg/EyeksEJmWE">Join our Discord</a>
|
||||
</p>
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
@@ -121,6 +130,7 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
||||
[multimon-ng](https://github.com/EliasOenal/multimon-ng) |
|
||||
[rtl_433](https://github.com/merbanan/rtl_433) |
|
||||
[dump1090](https://github.com/flightaware/dump1090) |
|
||||
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||
[Leaflet.js](https://leafletjs.com/) |
|
||||
[Celestrak](https://celestrak.org/)
|
||||
@@ -128,3 +138,7 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2026-01-04_e27bf619",
|
||||
"downloaded": "2026-01-07T14:55:20.680977Z"
|
||||
"version": "2026-01-11_fae1348c",
|
||||
"downloaded": "2026-01-12T15:55:42.769654Z"
|
||||
}
|
||||
173
app.py
173
app.py
@@ -27,7 +27,7 @@ from typing import Any
|
||||
|
||||
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
|
||||
from werkzeug.security import check_password_hash
|
||||
from config import VERSION
|
||||
from config import VERSION, CHANGELOG
|
||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||
from utils.process import cleanup_stale_processes
|
||||
from utils.sdr import SDRFactory
|
||||
@@ -106,6 +106,21 @@ satellite_process = None
|
||||
satellite_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
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
|
||||
# ============================================
|
||||
@@ -195,7 +210,7 @@ def index() -> str:
|
||||
'rtl_433': check_tool('rtl_433')
|
||||
}
|
||||
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')
|
||||
@@ -210,6 +225,120 @@ def get_devices() -> Response:
|
||||
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')
|
||||
def get_dependencies() -> Response:
|
||||
"""Get status of all tool dependencies."""
|
||||
@@ -348,6 +477,8 @@ def health_check() -> Response:
|
||||
'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),
|
||||
'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),
|
||||
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
||||
},
|
||||
@@ -363,7 +494,8 @@ def health_check() -> Response:
|
||||
@app.route('/killall', methods=['POST'])
|
||||
def kill_all() -> Response:
|
||||
"""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
|
||||
from routes import adsb as adsb_module
|
||||
@@ -372,7 +504,7 @@ def kill_all() -> Response:
|
||||
processes_to_kill = [
|
||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||
'dump1090'
|
||||
'dump1090', 'acarsdec', 'direwolf'
|
||||
]
|
||||
|
||||
for proc in processes_to_kill:
|
||||
@@ -397,6 +529,15 @@ def kill_all() -> Response:
|
||||
adsb_process = None
|
||||
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})
|
||||
|
||||
|
||||
@@ -449,10 +590,32 @@ def main() -> None:
|
||||
|
||||
print("=" * 50)
|
||||
print(" INTERCEPT // Signal Intelligence")
|
||||
print(" Pager / 433MHz / Aircraft / Satellite / WiFi / BT")
|
||||
print(" Pager / 433MHz / Aircraft / ACARS / Satellite / WiFi / BT")
|
||||
print("=" * 50)
|
||||
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
|
||||
cleanup_stale_processes()
|
||||
|
||||
|
||||
46
config.py
46
config.py
@@ -7,7 +7,51 @@ import os
|
||||
import sys
|
||||
|
||||
# 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:
|
||||
|
||||
436
data/tscm_frequencies.py
Normal file
436
data/tscm_frequencies.py
Normal file
@@ -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
|
||||
@@ -13,9 +13,9 @@ services:
|
||||
# USB device mapping (alternative to privileged mode)
|
||||
# devices:
|
||||
# - /dev/bus/usb:/dev/bus/usb
|
||||
volumes:
|
||||
# volumes:
|
||||
# Persist data directory
|
||||
- ./data:/app/data
|
||||
# - ./data:/app/data
|
||||
# Optional: mount logs directory
|
||||
# - ./logs:/app/logs
|
||||
environment:
|
||||
|
||||
@@ -75,13 +75,47 @@ Complete feature list for all modules.
|
||||
## Bluetooth 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
|
||||
- **Device classification** - phones, audio, wearables, computers
|
||||
- **Manufacturer lookup** via OUI database
|
||||
- **Manufacturer lookup** via OUI database and Bluetooth Company IDs
|
||||
- **Proximity radar** visualization
|
||||
- **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
|
||||
|
||||
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
||||
|
||||
@@ -139,14 +139,10 @@ pip install -r requirements.txt
|
||||
After installation:
|
||||
|
||||
```bash
|
||||
# Standard
|
||||
sudo python3 intercept.py
|
||||
|
||||
# With virtual environment
|
||||
sudo venv/bin/python intercept.py
|
||||
sudo -E venv/bin/python intercept.py
|
||||
|
||||
# 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.
|
||||
@@ -183,6 +179,7 @@ Open **http://localhost:5050** in your browser.
|
||||
|---------|---------|
|
||||
| `flask` | Web server |
|
||||
| `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
|
||||
|
||||
- **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
|
||||
- **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:
|
||||
```bash
|
||||
sudo python3 intercept.py
|
||||
# Or with venv:
|
||||
sudo venv/bin/python intercept.py
|
||||
sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### Interface not found after enabling monitor mode
|
||||
|
||||
@@ -110,7 +110,7 @@ INTERCEPT can be configured via environment variables:
|
||||
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
|
||||
| `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
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "intercept"
|
||||
version = "2.0.0"
|
||||
version = "2.9.5"
|
||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
flask>=2.0.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)
|
||||
skyfield>=1.45
|
||||
|
||||
@@ -14,4 +17,4 @@ pyserial>=3.5
|
||||
# ruff>=0.1.0
|
||||
# black>=23.0.0
|
||||
# mypy>=1.0.0
|
||||
flask-sock
|
||||
flask-sock
|
||||
|
||||
@@ -7,19 +7,30 @@ def register_blueprints(app):
|
||||
from .wifi import wifi_bp
|
||||
from .bluetooth import bluetooth_bp
|
||||
from .adsb import adsb_bp
|
||||
from .acars import acars_bp
|
||||
from .aprs import aprs_bp
|
||||
from .satellite import satellite_bp
|
||||
from .gps import gps_bp
|
||||
from .settings import settings_bp
|
||||
from .correlation import correlation_bp
|
||||
from .listening_post import listening_post_bp
|
||||
from .tscm import tscm_bp, init_tscm_state
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
app.register_blueprint(wifi_bp)
|
||||
app.register_blueprint(bluetooth_bp)
|
||||
app.register_blueprint(adsb_bp)
|
||||
app.register_blueprint(acars_bp)
|
||||
app.register_blueprint(aprs_bp)
|
||||
app.register_blueprint(satellite_bp)
|
||||
app.register_blueprint(gps_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(correlation_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)
|
||||
|
||||
341
routes/acars.py
Normal file
341
routes/acars.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""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 get_acarsdec_json_flag(acarsdec_path: str) -> str:
|
||||
"""Detect which JSON output flag acarsdec supports.
|
||||
|
||||
Newer forks (TLeconte) use -j, older versions use -o 4.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[acarsdec_path, '-h'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
help_text = result.stdout + result.stderr
|
||||
# Check if -j flag is documented in help
|
||||
if ' -j' in help_text or '\n-j' in help_text:
|
||||
return '-j'
|
||||
except Exception:
|
||||
pass
|
||||
# Default to older -o 4 syntax
|
||||
return '-o'
|
||||
|
||||
|
||||
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 -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||
# Note: -j is JSON stdout (newer forks), -o 4 was the old syntax
|
||||
# gain/ppm must come BEFORE -r
|
||||
json_flag = get_acarsdec_json_flag(acarsdec_path)
|
||||
cmd = [acarsdec_path]
|
||||
if json_flag == '-j':
|
||||
cmd.append('-j') # JSON output (newer TLeconte fork)
|
||||
else:
|
||||
cmd.extend(['-o', '4']) # JSON output (older versions)
|
||||
|
||||
# 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'],
|
||||
}
|
||||
})
|
||||
@@ -46,6 +46,8 @@ adsb_messages_received = 0
|
||||
adsb_last_message_time = None
|
||||
adsb_bytes_received = 0
|
||||
adsb_lines_received = 0
|
||||
adsb_active_device = None # Track which device index is being used
|
||||
_sbs_error_logged = False # Suppress repeated connection error logs
|
||||
|
||||
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
|
||||
_looked_up_icaos: set[str] = set()
|
||||
@@ -100,7 +102,7 @@ def check_dump1090_service():
|
||||
|
||||
def parse_sbs_stream(service_addr):
|
||||
"""Parse SBS format data from dump1090 SBS port."""
|
||||
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received
|
||||
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received, _sbs_error_logged
|
||||
|
||||
host, port = service_addr.split(':')
|
||||
port = int(port)
|
||||
@@ -108,6 +110,7 @@ def parse_sbs_stream(service_addr):
|
||||
logger.info(f"SBS stream parser started, connecting to {host}:{port}")
|
||||
adsb_connected = False
|
||||
adsb_messages_received = 0
|
||||
_sbs_error_logged = False
|
||||
|
||||
while adsb_using_service:
|
||||
try:
|
||||
@@ -115,6 +118,7 @@ def parse_sbs_stream(service_addr):
|
||||
sock.settimeout(SBS_SOCKET_TIMEOUT)
|
||||
sock.connect((host, port))
|
||||
adsb_connected = True
|
||||
_sbs_error_logged = False # Reset so we log next error
|
||||
logger.info("Connected to SBS stream")
|
||||
|
||||
buffer = ""
|
||||
@@ -241,7 +245,9 @@ def parse_sbs_stream(service_addr):
|
||||
adsb_connected = False
|
||||
except OSError as e:
|
||||
adsb_connected = False
|
||||
logger.warning(f"SBS connection error: {e}, reconnecting...")
|
||||
if not _sbs_error_logged:
|
||||
logger.warning(f"SBS connection error: {e}, reconnecting...")
|
||||
_sbs_error_logged = True
|
||||
time.sleep(SBS_RECONNECT_DELAY)
|
||||
|
||||
adsb_connected = False
|
||||
@@ -286,6 +292,7 @@ def adsb_status():
|
||||
|
||||
return jsonify({
|
||||
'tracking_active': adsb_using_service,
|
||||
'active_device': adsb_active_device,
|
||||
'connected_to_sbs': adsb_connected,
|
||||
'messages_received': adsb_messages_received,
|
||||
'bytes_received': adsb_bytes_received,
|
||||
@@ -303,7 +310,7 @@ def adsb_status():
|
||||
@adsb_bp.route('/start', methods=['POST'])
|
||||
def start_adsb():
|
||||
"""Start ADS-B tracking."""
|
||||
global adsb_using_service
|
||||
global adsb_using_service, adsb_active_device
|
||||
|
||||
with app_module.adsb_lock:
|
||||
if adsb_using_service:
|
||||
@@ -364,17 +371,20 @@ def start_adsb():
|
||||
if not dump1090_path:
|
||||
return jsonify({'status': 'error', 'message': f'readsb or dump1090 not found for {sdr_type.value}. Install readsb with SoapySDR support.'})
|
||||
|
||||
# Kill any stale app-started process
|
||||
# Kill any stale app-started process (use process group to ensure full cleanup)
|
||||
if app_module.adsb_process:
|
||||
try:
|
||||
app_module.adsb_process.terminate()
|
||||
pgid = os.getpgid(app_module.adsb_process.pid)
|
||||
os.killpg(pgid, 15) # SIGTERM
|
||||
app_module.adsb_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
||||
try:
|
||||
app_module.adsb_process.kill()
|
||||
except OSError:
|
||||
pgid = os.getpgid(app_module.adsb_process.pid)
|
||||
os.killpg(pgid, 9) # SIGKILL
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
app_module.adsb_process = None
|
||||
logger.info("Killed stale ADS-B process")
|
||||
|
||||
# Create device object and build command via abstraction layer
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
@@ -393,10 +403,12 @@ def start_adsb():
|
||||
cmd[0] = dump1090_path
|
||||
|
||||
try:
|
||||
logger.info(f"Starting dump1090 with device index {device}: {' '.join(cmd)}")
|
||||
app_module.adsb_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True # Create new process group for clean shutdown
|
||||
)
|
||||
|
||||
time.sleep(DUMP1090_START_WAIT)
|
||||
@@ -421,10 +433,11 @@ def start_adsb():
|
||||
return jsonify({'status': 'error', 'message': error_msg})
|
||||
|
||||
adsb_using_service = True
|
||||
adsb_active_device = device # Track which device is being used
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
|
||||
thread.start()
|
||||
|
||||
return jsonify({'status': 'started', 'message': 'ADS-B tracking started'})
|
||||
return jsonify({'status': 'started', 'message': 'ADS-B tracking started', 'device': device})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
@@ -432,17 +445,26 @@ def start_adsb():
|
||||
@adsb_bp.route('/stop', methods=['POST'])
|
||||
def stop_adsb():
|
||||
"""Stop ADS-B tracking."""
|
||||
global adsb_using_service
|
||||
global adsb_using_service, adsb_active_device
|
||||
|
||||
with app_module.adsb_lock:
|
||||
if app_module.adsb_process:
|
||||
app_module.adsb_process.terminate()
|
||||
try:
|
||||
# Kill the entire process group to ensure all child processes are terminated
|
||||
pgid = os.getpgid(app_module.adsb_process.pid)
|
||||
os.killpg(pgid, 15) # SIGTERM
|
||||
app_module.adsb_process.wait(timeout=ADSB_TERMINATE_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.adsb_process.kill()
|
||||
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
||||
try:
|
||||
# Force kill if terminate didn't work
|
||||
pgid = os.getpgid(app_module.adsb_process.pid)
|
||||
os.killpg(pgid, 9) # SIGKILL
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
app_module.adsb_process = None
|
||||
logger.info("ADS-B process stopped")
|
||||
adsb_using_service = False
|
||||
adsb_active_device = None
|
||||
|
||||
app_module.adsb_aircraft.clear()
|
||||
_looked_up_icaos.clear()
|
||||
|
||||
1887
routes/aprs.py
Normal file
1887
routes/aprs.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -398,6 +398,8 @@ def _start_audio_stream(frequency: float, modulation: str):
|
||||
]
|
||||
if scanner_config.get('bias_t', False):
|
||||
sdr_cmd.append('-T')
|
||||
# Explicitly output to stdout (some rtl_fm versions need this)
|
||||
sdr_cmd.append('-')
|
||||
else:
|
||||
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
|
||||
rx_fm_path = find_rx_fm()
|
||||
@@ -438,9 +440,12 @@ def _start_audio_stream(frequency: float, modulation: str):
|
||||
]
|
||||
|
||||
try:
|
||||
# Use shell pipe for reliable streaming (Python subprocess piping can be unreliable)
|
||||
shell_cmd = f"{' '.join(sdr_cmd)} 2>/dev/null | {' '.join(encoder_cmd)}"
|
||||
logger.info(f"Starting audio pipeline: {shell_cmd}")
|
||||
# Use shell pipe for reliable streaming
|
||||
# Log stderr to temp files for error diagnosis
|
||||
rtl_stderr_log = '/tmp/rtl_fm_stderr.log'
|
||||
ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log'
|
||||
shell_cmd = f"{' '.join(sdr_cmd)} 2>{rtl_stderr_log} | {' '.join(encoder_cmd)} 2>{ffmpeg_stderr_log}"
|
||||
logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={scanner_config['device']}")
|
||||
|
||||
audio_rtl_process = None # Not used in shell mode
|
||||
audio_process = subprocess.Popen(
|
||||
@@ -456,8 +461,20 @@ def _start_audio_stream(frequency: float, modulation: str):
|
||||
time.sleep(0.3)
|
||||
|
||||
if audio_process.poll() is not None:
|
||||
stderr = audio_process.stderr.read().decode() if audio_process.stderr else ''
|
||||
logger.error(f"Audio pipeline exited immediately: {stderr}")
|
||||
# Read stderr from temp files
|
||||
rtl_stderr = ''
|
||||
ffmpeg_stderr = ''
|
||||
try:
|
||||
with open(rtl_stderr_log, 'r') as f:
|
||||
rtl_stderr = f.read().strip()
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
with open(ffmpeg_stderr_log, 'r') as f:
|
||||
ffmpeg_stderr = f.read().strip()
|
||||
except:
|
||||
pass
|
||||
logger.error(f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}")
|
||||
return
|
||||
|
||||
audio_running = True
|
||||
@@ -775,8 +792,6 @@ def start_audio() -> Response:
|
||||
"""Start audio at specific frequency (manual mode)."""
|
||||
global scanner_running
|
||||
|
||||
logger.info("Audio start request received")
|
||||
|
||||
# Stop scanner if running
|
||||
if scanner_running:
|
||||
scanner_running = False
|
||||
@@ -868,20 +883,28 @@ def stream_audio() -> Response:
|
||||
return Response(b'', mimetype='audio/mpeg', status=204)
|
||||
|
||||
def generate():
|
||||
# Capture local reference to avoid race condition with stop
|
||||
proc = audio_process
|
||||
if not proc or not proc.stdout:
|
||||
return
|
||||
try:
|
||||
while audio_running and audio_process and audio_process.poll() is None:
|
||||
while audio_running and proc.poll() is None:
|
||||
# Use select to avoid blocking forever
|
||||
ready, _, _ = select.select([audio_process.stdout], [], [], 2.0)
|
||||
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
|
||||
if ready:
|
||||
chunk = audio_process.stdout.read(4096)
|
||||
chunk = proc.stdout.read(4096)
|
||||
if chunk:
|
||||
yield chunk
|
||||
else:
|
||||
break
|
||||
else:
|
||||
# Timeout - check if process died
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
except GeneratorExit:
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Audio stream error: {e}")
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
|
||||
@@ -25,6 +25,7 @@ from utils.validation import (
|
||||
from utils.sse import format_sse
|
||||
from utils.process import safe_terminate, register_process
|
||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||
from utils.dependencies import get_tool_path
|
||||
|
||||
pager_bp = Blueprint('pager', __name__)
|
||||
|
||||
@@ -245,7 +246,10 @@ def start_decoding() -> Response:
|
||||
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)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.database import (
|
||||
@@ -164,3 +168,123 @@ def get_device_correlations() -> Response:
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RTL-SDR DVB Driver Management
|
||||
# =============================================================================
|
||||
|
||||
DVB_MODULES = ['dvb_usb_rtl28xxu', 'rtl2832_sdr', 'rtl2832', 'rtl2830', 'r820t']
|
||||
BLACKLIST_FILE = '/etc/modprobe.d/blacklist-rtlsdr.conf'
|
||||
|
||||
|
||||
@settings_bp.route('/rtlsdr/driver-status', methods=['GET'])
|
||||
def check_dvb_driver_status() -> Response:
|
||||
"""Check if DVB kernel drivers are loaded and blocking RTL-SDR devices."""
|
||||
if sys.platform != 'linux':
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'platform': sys.platform,
|
||||
'issue_detected': False,
|
||||
'message': 'DVB driver conflict only affects Linux systems'
|
||||
})
|
||||
|
||||
# Check which DVB modules are currently loaded
|
||||
loaded_modules = []
|
||||
try:
|
||||
result = subprocess.run(['lsmod'], capture_output=True, text=True, timeout=5)
|
||||
lsmod_output = result.stdout
|
||||
for mod in DVB_MODULES:
|
||||
if mod in lsmod_output:
|
||||
loaded_modules.append(mod)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not check loaded modules: {e}")
|
||||
|
||||
# Check if blacklist file exists
|
||||
blacklist_exists = os.path.exists(BLACKLIST_FILE)
|
||||
|
||||
# Check blacklist file contents
|
||||
blacklist_contents = []
|
||||
if blacklist_exists:
|
||||
try:
|
||||
with open(BLACKLIST_FILE, 'r') as f:
|
||||
blacklist_contents = [line.strip() for line in f if line.strip() and not line.startswith('#')]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
issue_detected = len(loaded_modules) > 0
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'platform': 'linux',
|
||||
'issue_detected': issue_detected,
|
||||
'loaded_modules': loaded_modules,
|
||||
'blacklist_file_exists': blacklist_exists,
|
||||
'blacklist_contents': blacklist_contents,
|
||||
'message': 'DVB drivers are claiming RTL-SDR devices' if issue_detected else 'No DVB driver conflict detected'
|
||||
})
|
||||
|
||||
|
||||
@settings_bp.route('/rtlsdr/blacklist-drivers', methods=['POST'])
|
||||
def blacklist_dvb_drivers() -> Response:
|
||||
"""Blacklist DVB kernel drivers to prevent them from claiming RTL-SDR devices."""
|
||||
if sys.platform != 'linux':
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'This feature is only available on Linux'
|
||||
}), 400
|
||||
|
||||
# Check if we have permission (need to be running as root or with sudo)
|
||||
if os.geteuid() != 0:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t'
|
||||
}), 403
|
||||
|
||||
errors = []
|
||||
successes = []
|
||||
|
||||
# Create blacklist file if it doesn't exist
|
||||
if not os.path.exists(BLACKLIST_FILE):
|
||||
try:
|
||||
blacklist_content = """# RTL-SDR blacklist - prevents DVB drivers from claiming RTL-SDR devices
|
||||
# Created by INTERCEPT
|
||||
blacklist dvb_usb_rtl28xxu
|
||||
blacklist rtl2832
|
||||
blacklist rtl2830
|
||||
blacklist r820t
|
||||
"""
|
||||
with open(BLACKLIST_FILE, 'w') as f:
|
||||
f.write(blacklist_content)
|
||||
successes.append(f'Created {BLACKLIST_FILE}')
|
||||
except Exception as e:
|
||||
errors.append(f'Failed to create blacklist file: {e}')
|
||||
|
||||
# Unload the modules
|
||||
for mod in DVB_MODULES:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['modprobe', '-r', mod],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
successes.append(f'Unloaded module: {mod}')
|
||||
# returncode != 0 is OK - module might not be loaded
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not unload {mod}: {e}")
|
||||
|
||||
if errors:
|
||||
return jsonify({
|
||||
'status': 'partial',
|
||||
'message': 'Some operations failed. Please unplug and replug your RTL-SDR device.',
|
||||
'successes': successes,
|
||||
'errors': errors
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'DVB drivers blacklisted. Please unplug and replug your RTL-SDR device.',
|
||||
'successes': successes
|
||||
})
|
||||
|
||||
3311
routes/tscm.py
Normal file
3311
routes/tscm.py
Normal file
File diff suppressed because it is too large
Load Diff
241
setup.sh
241
setup.sh
@@ -139,6 +139,7 @@ check_tools() {
|
||||
check_required "multimon-ng" "Pager decoder" multimon-ng
|
||||
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
|
||||
check_required "dump1090" "ADS-B decoder" dump1090
|
||||
check_required "acarsdec" "ACARS decoder" acarsdec
|
||||
|
||||
echo
|
||||
info "GPS:"
|
||||
@@ -265,12 +266,47 @@ brew_install() {
|
||||
return 0
|
||||
fi
|
||||
info "brew: installing ${pkg}..."
|
||||
brew install "$pkg"
|
||||
ok "brew: installed ${pkg}"
|
||||
if brew install "$pkg" 2>&1; then
|
||||
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/EliasOenal/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() {
|
||||
TOTAL_STEPS=12
|
||||
TOTAL_STEPS=13
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Checking Homebrew"
|
||||
@@ -280,7 +316,15 @@ install_macos_packages() {
|
||||
brew_install librtlsdr
|
||||
|
||||
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 direwolf (APRS decoder)"
|
||||
(brew_install direwolf) || warn "direwolf not available via Homebrew"
|
||||
|
||||
progress "Installing ffmpeg"
|
||||
brew_install ffmpeg
|
||||
@@ -291,6 +335,9 @@ install_macos_packages() {
|
||||
progress "Installing dump1090"
|
||||
(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"
|
||||
brew_install aircrack-ng
|
||||
|
||||
@@ -304,6 +351,7 @@ install_macos_packages() {
|
||||
brew_install gpsd
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -372,6 +420,81 @@ 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
|
||||
)
|
||||
}
|
||||
|
||||
install_rtlsdr_blog_drivers_debian() {
|
||||
# The RTL-SDR Blog drivers provide better support for:
|
||||
# - RTL-SDR Blog V4 (R828D tuner)
|
||||
# - RTL-SDR Blog V3 with bias-t improvements
|
||||
# - Better overall compatibility with all RTL-SDR devices
|
||||
# These drivers are backward compatible with standard RTL-SDR devices.
|
||||
|
||||
info "Installing RTL-SDR Blog drivers (improved V4 support)..."
|
||||
|
||||
# Install build dependencies
|
||||
apt_install build-essential git cmake libusb-1.0-0-dev pkg-config
|
||||
|
||||
# Run in subshell to isolate EXIT trap
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning RTL-SDR Blog driver fork..."
|
||||
git clone https://github.com/rtlsdrblog/rtl-sdr-blog.git "$tmp_dir/rtl-sdr-blog" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone RTL-SDR Blog drivers"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/rtl-sdr-blog"
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling RTL-SDR Blog drivers..."
|
||||
if cmake .. -DINSTALL_UDEV_RULES=ON -DDETACH_KERNEL_DRIVER=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||
$SUDO make install >/dev/null 2>&1
|
||||
$SUDO ldconfig
|
||||
|
||||
# Copy udev rules if they exist
|
||||
if [[ -f ../rtl-sdr.rules ]]; then
|
||||
$SUDO cp ../rtl-sdr.rules /etc/udev/rules.d/20-rtlsdr-blog.rules
|
||||
$SUDO udevadm control --reload-rules || true
|
||||
$SUDO udevadm trigger || true
|
||||
fi
|
||||
|
||||
ok "RTL-SDR Blog drivers installed successfully."
|
||||
info "These drivers provide improved support for RTL-SDR Blog V4 and other devices."
|
||||
warn "Unplug and replug your RTL-SDR devices for the new drivers to take effect."
|
||||
else
|
||||
warn "Failed to build RTL-SDR Blog drivers. Using stock drivers."
|
||||
warn "If you have an RTL-SDR Blog V4, you may need to install drivers manually."
|
||||
warn "See: https://github.com/rtlsdrblog/rtl-sdr-blog"
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
setup_udev_rules_debian() {
|
||||
[[ -d /etc/udev/rules.d ]] || { warn "udev not found; skipping RTL-SDR udev rules."; return 0; }
|
||||
|
||||
@@ -389,6 +512,34 @@ EOF
|
||||
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() {
|
||||
need_sudo
|
||||
|
||||
@@ -396,18 +547,55 @@ install_debian_packages() {
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
export NEEDRESTART_MODE=a
|
||||
|
||||
TOTAL_STEPS=15
|
||||
TOTAL_STEPS=18
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Updating APT package lists"
|
||||
$SUDO apt-get update -y >/dev/null
|
||||
|
||||
progress "Installing RTL-SDR"
|
||||
# Handle package conflict between librtlsdr0 and librtlsdr2
|
||||
# The newer librtlsdr0 (2.0.2) conflicts with older librtlsdr2 (2.0.1)
|
||||
if dpkg -l | grep -q "librtlsdr2"; then
|
||||
info "Detected librtlsdr2 conflict - upgrading to librtlsdr0..."
|
||||
|
||||
# Remove packages that depend on librtlsdr2, then remove librtlsdr2
|
||||
# These will be reinstalled with librtlsdr0 support
|
||||
$SUDO apt-get remove -y dump1090-mutability libgnuradio-osmosdr0.2.0t64 rtl-433 librtlsdr2 rtl-sdr 2>/dev/null || true
|
||||
$SUDO apt-get autoremove -y 2>/dev/null || true
|
||||
|
||||
ok "Removed conflicting librtlsdr2 packages"
|
||||
fi
|
||||
|
||||
# If rtl-sdr is in broken state, remove it completely first
|
||||
if dpkg -l | grep -q "^.[^i].*rtl-sdr" || ! dpkg -l rtl-sdr 2>/dev/null | grep -q "^ii"; then
|
||||
info "Removing broken rtl-sdr package..."
|
||||
$SUDO dpkg --remove --force-remove-reinstreq rtl-sdr 2>/dev/null || true
|
||||
$SUDO dpkg --purge --force-remove-reinstreq rtl-sdr 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Force remove librtlsdr2 if it still exists
|
||||
if dpkg -l | grep -q "librtlsdr2"; then
|
||||
info "Force removing librtlsdr2..."
|
||||
$SUDO dpkg --remove --force-all librtlsdr2 2>/dev/null || true
|
||||
$SUDO dpkg --purge --force-all librtlsdr2 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Clean up any partial installations
|
||||
$SUDO dpkg --configure -a 2>/dev/null || true
|
||||
$SUDO apt-get --fix-broken install -y 2>/dev/null || true
|
||||
|
||||
apt_install rtl-sdr
|
||||
|
||||
progress "Installing RTL-SDR Blog drivers (V4 support)"
|
||||
install_rtlsdr_blog_drivers_debian
|
||||
|
||||
progress "Installing multimon-ng"
|
||||
apt_install multimon-ng
|
||||
|
||||
progress "Installing direwolf (APRS decoder)"
|
||||
apt_install direwolf || true
|
||||
|
||||
progress "Installing ffmpeg"
|
||||
apt_install ffmpeg
|
||||
|
||||
@@ -427,7 +615,9 @@ install_debian_packages() {
|
||||
apt_install bluez bluetooth || true
|
||||
|
||||
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"
|
||||
apt_install gpsd gpsd-clients || true
|
||||
@@ -437,6 +627,8 @@ install_debian_packages() {
|
||||
# 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-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"
|
||||
if ! cmd_exists dump1090 && ! cmd_exists dump1090-mutability; then
|
||||
@@ -450,8 +642,17 @@ install_debian_packages() {
|
||||
fi
|
||||
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"
|
||||
setup_udev_rules_debian
|
||||
|
||||
progress "Blacklisting conflicting kernel drivers"
|
||||
blacklist_kernel_drivers_debian
|
||||
}
|
||||
|
||||
# ----------------------------
|
||||
@@ -461,26 +662,28 @@ final_summary_and_hard_fail() {
|
||||
check_tools
|
||||
|
||||
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
|
||||
ok "All REQUIRED tools are installed."
|
||||
else
|
||||
fail "Missing REQUIRED tools:"
|
||||
for t in "${missing_required[@]}"; do echo " - $t"; done
|
||||
echo
|
||||
fail "Exiting because required tools are missing."
|
||||
echo
|
||||
warn "If you are on macOS: hcitool/hciconfig are Linux (BlueZ) tools and may not be installable."
|
||||
warn "If you truly require them everywhere, you must restrict supported platforms or provide alternatives."
|
||||
exit 1
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
warn "macOS note: bluetoothctl/hcitool/hciconfig are Linux (BlueZ) tools and unavailable on macOS."
|
||||
warn "Bluetooth functionality will be limited. Other features should work."
|
||||
else
|
||||
fail "Exiting because required tools are missing."
|
||||
exit 1
|
||||
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
|
||||
}
|
||||
|
||||
# ----------------------------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -83,10 +83,10 @@ body {
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LANDING PAGE / SPLASH SCREEN
|
||||
WELCOME PAGE
|
||||
============================================ */
|
||||
|
||||
.landing-overlay {
|
||||
.welcome-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -100,7 +100,7 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.landing-overlay::before {
|
||||
.welcome-overlay::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -113,13 +113,14 @@ body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.landing-content {
|
||||
text-align: center;
|
||||
.welcome-container {
|
||||
width: 90%;
|
||||
max-width: 900px;
|
||||
z-index: 1;
|
||||
animation: landingFadeIn 1s ease-out;
|
||||
animation: welcomeFadeIn 0.8s ease-out;
|
||||
}
|
||||
|
||||
@keyframes landingFadeIn {
|
||||
@keyframes welcomeFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
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;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.welcome-logo {
|
||||
animation: logoPulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes logoPulse {
|
||||
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% {
|
||||
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;
|
||||
}
|
||||
|
||||
.landing-logo .signal-wave-1 {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.landing-logo .signal-wave-2 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.landing-logo .signal-wave-3 {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
.welcome-logo .signal-wave-1 { animation-delay: 0s; }
|
||||
.welcome-logo .signal-wave-2 { animation-delay: 0.2s; }
|
||||
.welcome-logo .signal-wave-3 { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes signalPulse {
|
||||
0%, 100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.landing-logo .logo-dot {
|
||||
.welcome-logo .logo-dot {
|
||||
animation: dotPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -184,119 +183,261 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.landing-title {
|
||||
.welcome-title-block {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 4rem;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
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;
|
||||
margin: 0 0 8px 0;
|
||||
opacity: 0.9;
|
||||
margin: 0;
|
||||
text-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.landing-subtitle {
|
||||
font-family: 'Inter', sans-serif;
|
||||
.welcome-tagline {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
color: var(--accent-cyan);
|
||||
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;
|
||||
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 {
|
||||
background: transparent;
|
||||
border: 2px solid #00d4ff;
|
||||
color: #00d4ff;
|
||||
padding: 15px 50px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.2em;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
/* Changelog Section */
|
||||
.welcome-changelog {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.changelog-release {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.changelog-release:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.changelog-version-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.landing-enter-btn::before {
|
||||
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 {
|
||||
.changelog-version {
|
||||
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;
|
||||
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;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, transparent, #00d4ff, transparent);
|
||||
animation: scanlineMove 4s linear infinite;
|
||||
opacity: 0.5;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
animation: scanlineMove 5s linear infinite;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@keyframes scanlineMove {
|
||||
0% {
|
||||
top: 0;
|
||||
}
|
||||
100% {
|
||||
top: 100%;
|
||||
0% { top: 0; }
|
||||
100% { top: 100%; }
|
||||
}
|
||||
|
||||
/* Welcome Fade Out */
|
||||
.welcome-overlay.fade-out {
|
||||
animation: welcomeFadeOut 0.4s ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes welcomeFadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; visibility: hidden; }
|
||||
}
|
||||
|
||||
/* Responsive - Mobile First */
|
||||
/* Base: Mobile styles */
|
||||
.welcome-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.welcome-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcome-title-block {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mode-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
/* Larger phones: 3 columns for mode grid */
|
||||
@media (min-width: 480px) {
|
||||
.mode-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.landing-overlay.fade-out {
|
||||
animation: landingFadeOut 0.5s ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes landingFadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
/* Tablet and up: Side-by-side layout */
|
||||
@media (min-width: 768px) {
|
||||
.welcome-content {
|
||||
grid-template-columns: 1fr 1.5fr;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
|
||||
.welcome-header {
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.welcome-title-block {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,11 +453,28 @@ body {
|
||||
|
||||
header {
|
||||
background: var(--bg-secondary);
|
||||
padding: 12px 20px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: relative;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
header {
|
||||
justify-content: center;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
header {
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
header::before {
|
||||
@@ -363,13 +521,19 @@ header h1 {
|
||||
|
||||
/* Mode Navigation Bar */
|
||||
.mode-nav {
|
||||
display: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.mode-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
.mode-nav-group {
|
||||
@@ -470,6 +634,215 @@ header h1 {
|
||||
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);
|
||||
}
|
||||
|
||||
/* Nav Bar Utilities (clock, theme, tools) */
|
||||
.nav-utilities {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.nav-utilities {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-clock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.nav-clock .utc-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.nav-clock .utc-time {
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.nav-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-tool-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-tool-btn:hover {
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--border-color);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Theme toggle icon states in nav bar */
|
||||
.nav-tool-btn .icon-sun,
|
||||
.nav-tool-btn .icon-moon {
|
||||
position: absolute;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nav-tool-btn .icon-sun {
|
||||
opacity: 0;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.nav-tool-btn .icon-moon {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-tool-btn .icon-sun {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-tool-btn .icon-moon {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Deps button status colors */
|
||||
#depsBtn.all-ok {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
#depsBtn.has-missing {
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 500;
|
||||
@@ -501,16 +874,29 @@ header h1 .tagline {
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
font-size: 0.6em;
|
||||
opacity: 0.9;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
header h1 .tagline {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
/* Header stat badges */
|
||||
.header-stats {
|
||||
display: flex;
|
||||
display: none;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header-stats {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -580,32 +966,6 @@ header h1 .tagline {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* UTC Clock in header */
|
||||
.header-clock {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 20px;
|
||||
transform: translateY(-50%);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-clock .utc-time {
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-clock .utc-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Active mode indicator */
|
||||
.active-mode-indicator {
|
||||
display: inline-flex;
|
||||
@@ -888,16 +1248,20 @@ header h1 .tagline {
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
height: calc(100vh - 96px);
|
||||
height: calc(100dvh - 96px);
|
||||
height: calc(100vh - 96px); /* Fallback */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@media (min-width: 1024px) {
|
||||
.main-content {
|
||||
grid-template-columns: 1fr;
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
height: calc(100dvh - 96px);
|
||||
height: calc(100vh - 96px); /* Fallback */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -911,6 +1275,13 @@ header h1 .tagline {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Mobile: Sidebar is controlled by mobile-drawer class from responsive.css */
|
||||
@media (max-width: 1023px) {
|
||||
.sidebar:not(.open) {
|
||||
/* Hidden by mobile-drawer transform, but ensure no layout impact */
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar::before {
|
||||
display: none;
|
||||
}
|
||||
@@ -1233,8 +1604,8 @@ header h1 .tagline {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
background: var(--bg-primary);
|
||||
min-height: 100px;
|
||||
max-height: 250px;
|
||||
min-height: 400px;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.output-content::-webkit-scrollbar {
|
||||
@@ -1500,20 +1871,18 @@ header h1 .tagline {
|
||||
background: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.waterfall-container {
|
||||
padding: 0 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#waterfallCanvas {
|
||||
/* Waterfall canvases (inside collapsible panels) */
|
||||
#waterfallCanvas,
|
||||
#sensorWaterfallCanvas {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
height: 30px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: box-shadow 0.3s ease;
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#waterfallCanvas.active {
|
||||
#waterfallCanvas.active,
|
||||
#sensorWaterfallCanvas.active {
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
@@ -1641,20 +2010,54 @@ header h1 .tagline {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.waterfall-container {
|
||||
position: relative;
|
||||
background: #000;
|
||||
/* Removed - now using sensor-waterfall-panel structure for waterfalls */
|
||||
|
||||
/* 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-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#waterfallCanvas {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
display: block;
|
||||
.sensor-waterfall-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -4315,14 +4718,14 @@ body::before {
|
||||
}
|
||||
|
||||
.radio-action-btn.scan {
|
||||
background: var(--accent-cyan);
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--bg-primary);
|
||||
background: var(--accent-green);
|
||||
border-color: var(--accent-green);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.radio-action-btn.scan:hover {
|
||||
background: #5aa8ff;
|
||||
box-shadow: 0 0 20px var(--accent-cyan-dim);
|
||||
background: #1db954;
|
||||
box-shadow: 0 0 20px rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
|
||||
.radio-action-btn.scan.active {
|
||||
|
||||
91
static/css/modes/acars.css
Normal file
91
static/css/modes/acars.css
Normal file
@@ -0,0 +1,91 @@
|
||||
/* ACARS Sidebar Styles */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Main ACARS Sidebar (Collapsible) */
|
||||
.main-acars-sidebar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background: var(--bg-panel);
|
||||
border-left: 1px solid var(--border-color);
|
||||
}
|
||||
.main-acars-collapse-btn {
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
background: rgba(0,0,0,0.4);
|
||||
border: none;
|
||||
border-right: 1px solid var(--border-color);
|
||||
color: var(--accent-cyan);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 6px 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.main-acars-collapse-btn:hover {
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
}
|
||||
.main-acars-collapse-label {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.main-acars-sidebar.collapsed .main-acars-collapse-label { display: block; }
|
||||
.main-acars-sidebar:not(.collapsed) .main-acars-collapse-label { display: none; }
|
||||
#mainAcarsCollapseIcon {
|
||||
font-size: 10px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.main-acars-sidebar.collapsed #mainAcarsCollapseIcon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.main-acars-content {
|
||||
width: 196px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: width 0.3s ease, opacity 0.2s ease;
|
||||
}
|
||||
.main-acars-sidebar.collapsed .main-acars-content {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.main-acars-messages {
|
||||
max-height: 350px;
|
||||
}
|
||||
.main-acars-msg {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
animation: fadeInMsg 0.3s ease;
|
||||
}
|
||||
.main-acars-msg:hover {
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
}
|
||||
@keyframes fadeInMsg {
|
||||
from { opacity: 0; transform: translateY(-3px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ACARS Status Indicator */
|
||||
.acars-status-dot.listening {
|
||||
background: var(--accent-cyan) !important;
|
||||
animation: acars-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.acars-status-dot.receiving {
|
||||
background: var(--accent-green) !important;
|
||||
}
|
||||
.acars-status-dot.error {
|
||||
background: var(--accent-red) !important;
|
||||
}
|
||||
@keyframes acars-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
|
||||
50% { opacity: 0.6; box-shadow: 0 0 6px 3px rgba(74, 158, 255, 0.3); }
|
||||
}
|
||||
328
static/css/modes/aprs.css
Normal file
328
static/css/modes/aprs.css
Normal file
@@ -0,0 +1,328 @@
|
||||
/* APRS Function Bar (Stats Strip) Styles */
|
||||
.aprs-strip {
|
||||
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
margin-bottom: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.aprs-strip-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: max-content;
|
||||
}
|
||||
.aprs-strip .strip-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
border: 1px solid rgba(74, 158, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
min-width: 55px;
|
||||
}
|
||||
.aprs-strip .strip-stat:hover {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border-color: rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
.aprs-strip .strip-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.aprs-strip .strip-label {
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.aprs-strip .strip-divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: var(--border-color);
|
||||
margin: 0 4px;
|
||||
}
|
||||
/* Signal stat coloring */
|
||||
.aprs-strip .signal-stat.good .strip-value { color: var(--accent-green); }
|
||||
.aprs-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
|
||||
.aprs-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
|
||||
|
||||
/* Controls */
|
||||
.aprs-strip .strip-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.aprs-strip .strip-select {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.aprs-strip .strip-select:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
.aprs-strip .strip-input-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.aprs-strip .strip-input {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
.aprs-strip .strip-input:hover,
|
||||
.aprs-strip .strip-input:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Tool Status Indicators */
|
||||
.aprs-strip .strip-tools {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.aprs-strip .strip-tool {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 59, 48, 0.2);
|
||||
color: var(--accent-red);
|
||||
border: 1px solid rgba(255, 59, 48, 0.3);
|
||||
}
|
||||
.aprs-strip .strip-tool.ok {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
color: var(--accent-green);
|
||||
border-color: rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.aprs-strip .strip-btn {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border: 1px solid rgba(74, 158, 255, 0.2);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.aprs-strip .strip-btn:hover:not(:disabled) {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
border-color: rgba(74, 158, 255, 0.4);
|
||||
}
|
||||
.aprs-strip .strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
||||
border: none;
|
||||
color: #000;
|
||||
}
|
||||
.aprs-strip .strip-btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.aprs-strip .strip-btn.stop {
|
||||
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
||||
border: none;
|
||||
color: #fff;
|
||||
}
|
||||
.aprs-strip .strip-btn.stop:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.aprs-strip .strip-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.aprs-strip .strip-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.aprs-strip .status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.aprs-strip .status-dot.listening {
|
||||
background: var(--accent-cyan);
|
||||
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.aprs-strip .status-dot.tracking {
|
||||
background: var(--accent-green);
|
||||
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.aprs-strip .status-dot.error {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
@keyframes aprs-strip-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
|
||||
50% { opacity: 0.6; box-shadow: none; }
|
||||
}
|
||||
|
||||
/* Time display */
|
||||
.aprs-strip .strip-time {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* APRS Status Bar Styles (Sidebar - legacy) */
|
||||
.aprs-status-bar {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.aprs-status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.aprs-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.aprs-status-dot.standby { background: var(--text-muted); }
|
||||
.aprs-status-dot.listening {
|
||||
background: var(--accent-cyan);
|
||||
animation: aprs-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.aprs-status-dot.tracking { background: var(--accent-green); }
|
||||
.aprs-status-dot.error { background: var(--accent-red); }
|
||||
@keyframes aprs-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
|
||||
50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); }
|
||||
}
|
||||
.aprs-status-text {
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.aprs-status-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.aprs-stat {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.aprs-stat-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Signal Meter Styles */
|
||||
.aprs-signal-meter {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.aprs-meter-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.aprs-meter-label {
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.aprs-meter-value {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
color: var(--accent-cyan);
|
||||
min-width: 24px;
|
||||
}
|
||||
.aprs-meter-burst {
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
color: var(--accent-yellow);
|
||||
background: rgba(255, 193, 7, 0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
animation: burst-flash 0.3s ease-out;
|
||||
}
|
||||
@keyframes burst-flash {
|
||||
0% { opacity: 1; transform: scale(1.1); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.aprs-meter-bar-container {
|
||||
position: relative;
|
||||
height: 16px;
|
||||
background: rgba(0,0,0,0.4);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.aprs-meter-bar {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg,
|
||||
var(--accent-green) 0%,
|
||||
var(--accent-cyan) 50%,
|
||||
var(--accent-yellow) 75%,
|
||||
var(--accent-red) 100%
|
||||
);
|
||||
border-radius: 3px;
|
||||
transition: width 0.1s ease-out;
|
||||
}
|
||||
.aprs-meter-bar.no-signal {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.aprs-meter-ticks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 8px;
|
||||
color: var(--text-muted);
|
||||
padding: 0 2px;
|
||||
}
|
||||
.aprs-meter-status {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.aprs-meter-status.active {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
.aprs-meter-status.no-signal {
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
1252
static/css/modes/tscm.css
Normal file
1252
static/css/modes/tscm.css
Normal file
File diff suppressed because it is too large
Load Diff
660
static/css/responsive.css
Normal file
660
static/css/responsive.css
Normal file
@@ -0,0 +1,660 @@
|
||||
/* ============================================
|
||||
RESPONSIVE UTILITIES - iNTERCEPT
|
||||
Shared responsive foundation for all pages
|
||||
============================================ */
|
||||
|
||||
/* ============== CSS VARIABLES ============== */
|
||||
:root {
|
||||
/* Touch targets */
|
||||
--touch-min: 44px;
|
||||
--touch-comfortable: 48px;
|
||||
|
||||
/* Responsive spacing */
|
||||
--spacing-xs: clamp(4px, 1vw, 8px);
|
||||
--spacing-sm: clamp(8px, 2vw, 12px);
|
||||
--spacing-md: clamp(12px, 3vw, 20px);
|
||||
--spacing-lg: clamp(16px, 4vw, 32px);
|
||||
|
||||
/* Responsive typography */
|
||||
--font-xs: clamp(10px, 2.5vw, 11px);
|
||||
--font-sm: clamp(11px, 2.8vw, 12px);
|
||||
--font-base: clamp(13px, 3vw, 14px);
|
||||
--font-md: clamp(14px, 3.5vw, 16px);
|
||||
--font-lg: clamp(16px, 4vw, 20px);
|
||||
--font-xl: clamp(20px, 5vw, 28px);
|
||||
--font-2xl: clamp(24px, 6vw, 40px);
|
||||
|
||||
/* Header height for calculations */
|
||||
--header-height: 52px;
|
||||
--nav-height: 44px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
:root {
|
||||
--header-height: 60px;
|
||||
--nav-height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
:root {
|
||||
--header-height: 96px;
|
||||
--nav-height: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== VIEWPORT HEIGHT FIX ============== */
|
||||
/* Handles iOS Safari address bar and dynamic viewport */
|
||||
.full-height {
|
||||
height: 100dvh;
|
||||
height: 100vh; /* Fallback */
|
||||
}
|
||||
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.full-height {
|
||||
height: -webkit-fill-available;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== HAMBURGER BUTTON ============== */
|
||||
.hamburger-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: var(--touch-min);
|
||||
height: var(--touch-min);
|
||||
padding: 10px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color, #1f2937);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 1001;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.hamburger-btn:hover {
|
||||
background: var(--bg-tertiary, #151a23);
|
||||
border-color: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
.hamburger-btn span {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 2px;
|
||||
background: var(--accent-cyan, #4a9eff);
|
||||
margin: 2px 0;
|
||||
border-radius: 1px;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.hamburger-btn.active span:nth-child(1) {
|
||||
transform: rotate(45deg) translate(4px, 4px);
|
||||
}
|
||||
|
||||
.hamburger-btn.active span:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.hamburger-btn.active span:nth-child(3) {
|
||||
transform: rotate(-45deg) translate(4px, -4px);
|
||||
}
|
||||
|
||||
/* Hide hamburger on desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.hamburger-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== MOBILE DRAWER ============== */
|
||||
.mobile-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: min(320px, 85vw);
|
||||
height: 100dvh;
|
||||
height: 100vh; /* Fallback */
|
||||
background: var(--bg-secondary, #0f1218);
|
||||
border-right: 1px solid var(--border-color, #1f2937);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.mobile-drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Show sidebar normally on desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.mobile-drawer {
|
||||
position: static;
|
||||
transform: none;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding-top: 0;
|
||||
z-index: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== DRAWER OVERLAY ============== */
|
||||
.drawer-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(2px);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.drawer-overlay.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Hide overlay on desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.drawer-overlay {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== TOUCH TARGETS ============== */
|
||||
@media (max-width: 1023px) {
|
||||
/* Ensure minimum touch target size for interactive elements */
|
||||
button,
|
||||
.btn,
|
||||
.preset-btn,
|
||||
.mode-nav-btn,
|
||||
.control-btn,
|
||||
.nav-action-btn,
|
||||
.icon-btn {
|
||||
min-height: var(--touch-min);
|
||||
min-width: var(--touch-min);
|
||||
}
|
||||
|
||||
select,
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="search"] {
|
||||
min-height: var(--touch-min);
|
||||
padding: 10px 12px;
|
||||
font-size: 16px; /* Prevents iOS zoom on focus */
|
||||
}
|
||||
|
||||
.checkbox-group label,
|
||||
.radio-group label {
|
||||
min-height: var(--touch-min);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== RESPONSIVE UTILITIES ============== */
|
||||
/* Hide on mobile */
|
||||
.hide-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.hide-mobile {
|
||||
display: initial;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide on tablet and up */
|
||||
.show-mobile-only {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.show-mobile-only {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide on desktop */
|
||||
.hide-desktop {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.hide-desktop {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Show only on desktop */
|
||||
.show-desktop-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.show-desktop-only {
|
||||
display: initial;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== SCROLLABLE AREAS ============== */
|
||||
.scroll-x {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.scroll-x::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.scroll-x::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color, #1f2937);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Hide scrollbar on mobile for cleaner look */
|
||||
@media (max-width: 767px) {
|
||||
.scroll-x-mobile-hidden {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scroll-x-mobile-hidden::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== MOBILE NAVIGATION BAR ============== */
|
||||
.mobile-nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-tertiary, #151a23);
|
||||
border-bottom: 1px solid var(--border-color, #1f2937);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.mobile-nav-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-card, #121620);
|
||||
border: 1px solid var(--border-color, #1f2937);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
font-size: var(--font-xs);
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.mobile-nav-btn:hover,
|
||||
.mobile-nav-btn.active {
|
||||
background: var(--bg-elevated, #1a202c);
|
||||
border-color: var(--accent-cyan, #4a9eff);
|
||||
color: var(--text-primary, #e8eaed);
|
||||
}
|
||||
|
||||
.mobile-nav-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Hide mobile nav bar on desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.mobile-nav-bar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== RESPONSIVE GRID UTILITIES ============== */
|
||||
.grid-responsive {
|
||||
display: grid;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* 1 column base */
|
||||
.grid-1-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.grid-1-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.grid-2-3 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.grid-2-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== TYPOGRAPHY RESPONSIVE ============== */
|
||||
.text-responsive-xs { font-size: var(--font-xs); }
|
||||
.text-responsive-sm { font-size: var(--font-sm); }
|
||||
.text-responsive-base { font-size: var(--font-base); }
|
||||
.text-responsive-md { font-size: var(--font-md); }
|
||||
.text-responsive-lg { font-size: var(--font-lg); }
|
||||
.text-responsive-xl { font-size: var(--font-xl); }
|
||||
.text-responsive-2xl { font-size: var(--font-2xl); }
|
||||
|
||||
/* Ensure minimum readable sizes for tiny text */
|
||||
.text-min-readable {
|
||||
font-size: max(10px, var(--font-xs));
|
||||
}
|
||||
|
||||
/* ============== MOBILE LAYOUT FIXES ============== */
|
||||
@media (max-width: 1023px) {
|
||||
/* Fix main content to allow scrolling on mobile */
|
||||
.main-content {
|
||||
height: auto !important;
|
||||
min-height: calc(100dvh - var(--header-height) - var(--nav-height));
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Container should not clip content */
|
||||
.container {
|
||||
overflow: visible;
|
||||
height: auto;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* Layout containers need to stack vertically on mobile */
|
||||
.wifi-layout-container,
|
||||
.bt-layout-container {
|
||||
flex-direction: column !important;
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
min-height: auto !important;
|
||||
overflow: visible !important;
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
/* Visual panels should be scrollable, not clipped */
|
||||
.wifi-visuals,
|
||||
.bt-visuals {
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Device lists should have reasonable height on mobile */
|
||||
.wifi-device-list,
|
||||
.bt-device-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Visual panels should stack in single column on mobile when visible */
|
||||
.wifi-visuals,
|
||||
.bt-visuals {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Only apply flex when aircraft visuals are shown (via JS setting display: grid) */
|
||||
#aircraftVisuals[style*="grid"] {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* APRS visuals - only when visible */
|
||||
#aprsVisuals[style*="flex"] {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.wifi-visual-panel {
|
||||
grid-column: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== MOBILE MAP FIXES ============== */
|
||||
@media (max-width: 1023px) {
|
||||
/* Aircraft map container needs explicit height on mobile */
|
||||
.aircraft-map-container {
|
||||
height: 300px !important;
|
||||
min-height: 300px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
#aircraftMap {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
/* APRS map container */
|
||||
#aprsMap {
|
||||
min-height: 300px !important;
|
||||
height: 300px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Satellite embed */
|
||||
.satellite-dashboard-embed {
|
||||
height: 400px !important;
|
||||
min-height: 400px !important;
|
||||
}
|
||||
|
||||
/* Map panels should be full width */
|
||||
.wifi-visual-panel[style*="grid-column: span 2"] {
|
||||
grid-column: auto !important;
|
||||
}
|
||||
|
||||
/* Make map container full width when it has ACARS sidebar */
|
||||
.wifi-visual-panel[style*="display: flex"][style*="gap: 0"] {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* ACARS sidebar should be below map on mobile */
|
||||
.main-acars-sidebar {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
border-left: none !important;
|
||||
border-top: 1px solid var(--border-color, #1f2937) !important;
|
||||
}
|
||||
|
||||
.main-acars-sidebar.collapsed {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.main-acars-content {
|
||||
max-height: 200px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== LEAFLET MOBILE TOUCH FIXES ============== */
|
||||
.leaflet-container {
|
||||
touch-action: pan-x pan-y;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a {
|
||||
min-width: var(--touch-min, 44px) !important;
|
||||
min-height: var(--touch-min, 44px) !important;
|
||||
line-height: var(--touch-min, 44px) !important;
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
/* ============== MOBILE HEADER STATS ============== */
|
||||
@media (max-width: 1023px) {
|
||||
.header-stats {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Simplify header on mobile */
|
||||
header h1 {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
header h1 .tagline,
|
||||
header h1 .version-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
header .subtitle {
|
||||
font-size: 10px !important;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
header .logo svg {
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== MOBILE MODE PANELS ============== */
|
||||
@media (max-width: 1023px) {
|
||||
/* Mode panel grids should be single column */
|
||||
.data-grid,
|
||||
.stats-grid,
|
||||
.sensor-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
/* Section headers should be easier to tap */
|
||||
.section h3 {
|
||||
min-height: var(--touch-min);
|
||||
padding: 12px !important;
|
||||
}
|
||||
|
||||
/* Tables need horizontal scroll */
|
||||
.message-table,
|
||||
.sensor-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Ensure messages list is scrollable */
|
||||
#messageList,
|
||||
#sensorGrid,
|
||||
.aprs-list {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== WELCOME PAGE MOBILE ============== */
|
||||
@media (max-width: 767px) {
|
||||
.welcome-container {
|
||||
padding: 15px !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.welcome-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.welcome-logo svg {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 24px !important;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
.mode-grid {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.mode-card {
|
||||
padding: 12px 8px !important;
|
||||
}
|
||||
|
||||
.mode-icon {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
|
||||
.mode-name {
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
.mode-desc {
|
||||
font-size: 9px !important;
|
||||
}
|
||||
|
||||
.changelog-release {
|
||||
padding: 10px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== TSCM MODE MOBILE ============== */
|
||||
@media (max-width: 1023px) {
|
||||
.tscm-layout {
|
||||
flex-direction: column !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.tscm-spectrum-panel,
|
||||
.tscm-detection-panel {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
height: auto !important;
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== LISTENING POST MOBILE ============== */
|
||||
@media (max-width: 1023px) {
|
||||
.radio-controls-section {
|
||||
flex-direction: column !important;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.knobs-row {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.radio-module-box {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,28 @@ body {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Mobile header adjustments */
|
||||
@media (max-width: 800px) {
|
||||
.header {
|
||||
padding: 10px 12px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 14px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stats-badges {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
@@ -589,13 +611,14 @@ body {
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--accent-cyan);
|
||||
color: var(--bg-dark);
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.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 */
|
||||
@@ -673,24 +696,28 @@ body {
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.dashboard {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto auto auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
min-height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.polar-container,
|
||||
.map-container {
|
||||
grid-column: 1;
|
||||
min-height: 300px;
|
||||
min-height: 250px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
grid-column: 1;
|
||||
flex-direction: column;
|
||||
max-height: none;
|
||||
border-left: none;
|
||||
border-top: 1px solid rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.controls-bar {
|
||||
grid-row: 4;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
BIN
static/images/screenshots/intercept-main.png
Normal file
BIN
static/images/screenshots/intercept-main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 585 KiB |
@@ -175,10 +175,20 @@ function switchMode(mode) {
|
||||
'satellite': 'SATELLITE',
|
||||
'wifi': 'WIFI',
|
||||
'bluetooth': 'BLUETOOTH',
|
||||
'listening': 'LISTENING POST'
|
||||
'listening': 'LISTENING POST',
|
||||
'tscm': 'TSCM',
|
||||
'aprs': 'APRS'
|
||||
};
|
||||
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>' + modeNames[mode];
|
||||
|
||||
// Update mobile nav buttons
|
||||
updateMobileNavButtons(mode);
|
||||
|
||||
// Close mobile drawer when mode is switched (on mobile)
|
||||
if (window.innerWidth < 1024 && typeof window.closeMobileDrawer === 'function') {
|
||||
window.closeMobileDrawer();
|
||||
}
|
||||
|
||||
// Toggle layout containers
|
||||
document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
@@ -498,6 +508,66 @@ function exportJSON() {
|
||||
|
||||
// ============== INITIALIZATION ==============
|
||||
|
||||
// ============== MOBILE NAVIGATION ==============
|
||||
|
||||
function initMobileNav() {
|
||||
const hamburgerBtn = document.getElementById('hamburgerBtn');
|
||||
const sidebar = document.getElementById('mainSidebar');
|
||||
const overlay = document.getElementById('drawerOverlay');
|
||||
|
||||
if (!hamburgerBtn || !sidebar || !overlay) return;
|
||||
|
||||
function openDrawer() {
|
||||
sidebar.classList.add('open');
|
||||
overlay.classList.add('visible');
|
||||
hamburgerBtn.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
sidebar.classList.remove('open');
|
||||
overlay.classList.remove('visible');
|
||||
hamburgerBtn.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function toggleDrawer() {
|
||||
if (sidebar.classList.contains('open')) {
|
||||
closeDrawer();
|
||||
} else {
|
||||
openDrawer();
|
||||
}
|
||||
}
|
||||
|
||||
hamburgerBtn.addEventListener('click', toggleDrawer);
|
||||
overlay.addEventListener('click', closeDrawer);
|
||||
|
||||
// Close drawer when resizing to desktop
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth >= 1024) {
|
||||
closeDrawer();
|
||||
}
|
||||
});
|
||||
|
||||
// Expose for external use
|
||||
window.toggleMobileDrawer = toggleDrawer;
|
||||
window.closeMobileDrawer = closeDrawer;
|
||||
}
|
||||
|
||||
function setViewportHeight() {
|
||||
// Fix for iOS Safari address bar height
|
||||
const vh = window.innerHeight * 0.01;
|
||||
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||
}
|
||||
|
||||
function updateMobileNavButtons(mode) {
|
||||
// Update mobile nav bar buttons
|
||||
document.querySelectorAll('.mobile-nav-btn').forEach(btn => {
|
||||
const btnMode = btn.getAttribute('data-mode');
|
||||
btn.classList.toggle('active', btnMode === mode);
|
||||
});
|
||||
}
|
||||
|
||||
function initApp() {
|
||||
// Check disclaimer
|
||||
checkDisclaimer();
|
||||
@@ -541,6 +611,13 @@ function initApp() {
|
||||
section.classList.add('collapsed');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize mobile navigation
|
||||
initMobileNav();
|
||||
|
||||
// Set viewport height for mobile browsers
|
||||
setViewportHeight();
|
||||
window.addEventListener('resize', setViewportHeight);
|
||||
}
|
||||
|
||||
// Run initialization when DOM is ready
|
||||
|
||||
@@ -516,6 +516,14 @@ function handleSignalFound(data) {
|
||||
const streamUrl = getStreamUrl(data.frequency, data.modulation);
|
||||
console.log('[SCANNER] Starting audio for signal:', data.frequency, 'MHz');
|
||||
scannerAudio.src = streamUrl;
|
||||
// Apply current volume from knob
|
||||
const volumeKnob = document.getElementById('radioVolumeKnob');
|
||||
if (volumeKnob && volumeKnob._knob) {
|
||||
scannerAudio.volume = volumeKnob._knob.getValue() / 100;
|
||||
} else if (volumeKnob) {
|
||||
const knobValue = parseFloat(volumeKnob.dataset.value) || 80;
|
||||
scannerAudio.volume = knobValue / 100;
|
||||
}
|
||||
scannerAudio.play().catch(e => console.warn('[SCANNER] Audio autoplay blocked:', e));
|
||||
}
|
||||
}
|
||||
@@ -991,7 +999,7 @@ async function tuneToFrequency(freq, mod) {
|
||||
// ============== AUDIO VISUALIZER ==============
|
||||
|
||||
function initAudioVisualizer() {
|
||||
const audioPlayer = document.getElementById('audioPlayer');
|
||||
const audioPlayer = document.getElementById('scannerAudioPlayer');
|
||||
if (!audioPlayer) return;
|
||||
|
||||
if (!visualizerContext) {
|
||||
@@ -1163,10 +1171,7 @@ function initRadioKnobControls() {
|
||||
const audioPlayer = document.getElementById('scannerAudioPlayer');
|
||||
if (audioPlayer) {
|
||||
audioPlayer.volume = e.detail.value / 100;
|
||||
}
|
||||
const manualPlayer = document.getElementById('audioPlayer');
|
||||
if (manualPlayer) {
|
||||
manualPlayer.volume = e.detail.value / 100;
|
||||
console.log('[VOLUME] Set to', Math.round(e.detail.value) + '%');
|
||||
}
|
||||
// Update knob value display
|
||||
const valueDisplay = document.getElementById('radioVolumeValue');
|
||||
@@ -1787,6 +1792,15 @@ async function _startDirectListenInternal() {
|
||||
console.log('[LISTEN] Connecting to stream:', streamUrl);
|
||||
audioPlayer.src = streamUrl;
|
||||
|
||||
// Apply current volume from knob
|
||||
const volumeKnob = document.getElementById('radioVolumeKnob');
|
||||
if (volumeKnob && volumeKnob._knob) {
|
||||
audioPlayer.volume = volumeKnob._knob.getValue() / 100;
|
||||
} else if (volumeKnob) {
|
||||
const knobValue = parseFloat(volumeKnob.dataset.value) || 80;
|
||||
audioPlayer.volume = knobValue / 100;
|
||||
}
|
||||
|
||||
// Wait for audio to be ready then play
|
||||
audioPlayer.oncanplay = () => {
|
||||
console.log('[LISTEN] Audio can play');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
18931
templates/index.html
18931
templates/index.html
File diff suppressed because it is too large
Load Diff
16
templates/partials/modes/aprs.html
Normal file
16
templates/partials/modes/aprs.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!-- APRS MODE -->
|
||||
<div id="aprsMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>APRS Tracking</h3>
|
||||
<p style="color: var(--text-secondary); font-size: 11px; line-height: 1.5; margin-bottom: 15px;">
|
||||
Decode APRS (Automatic Packet Reporting System) amateur radio position reports on VHF.
|
||||
</p>
|
||||
<div style="background: rgba(255,193,7,0.1); border: 1px solid var(--accent-yellow); border-radius: 4px; padding: 8px; margin-bottom: 10px; font-size: 10px;">
|
||||
<strong style="color: var(--accent-yellow);">Amateur Radio</strong><br>
|
||||
<span style="color: var(--text-secondary);">APRS operates on 144.390 MHz (N. America) or 144.800 MHz (Europe). Decodes position, weather, and messages from ham radio operators.</span>
|
||||
</div>
|
||||
<div style="background: rgba(74, 158, 255, 0.1); border: 1px solid rgba(74, 158, 255, 0.3); border-radius: 4px; padding: 8px; font-size: 10px;">
|
||||
<span style="color: var(--accent-cyan);">Controls in function bar above map</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
70
templates/partials/modes/bluetooth.html
Normal file
70
templates/partials/modes/bluetooth.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<!-- BLUETOOTH MODE -->
|
||||
<div id="bluetoothMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>Bluetooth Interface</h3>
|
||||
<div class="form-group">
|
||||
<select id="btInterfaceSelect">
|
||||
<option value="">Detecting interfaces...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="preset-btn" onclick="refreshBtInterfaces()" style="width: 100%;">
|
||||
Refresh Interfaces
|
||||
</button>
|
||||
<div class="info-text" style="margin-top: 8px; display: grid; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;" id="btToolStatus">
|
||||
<span>hcitool:</span><span class="tool-status missing">Checking...</span>
|
||||
<span>bluetoothctl:</span><span class="tool-status missing">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Scan Mode</h3>
|
||||
<div class="checkbox-group" style="margin-bottom: 10px;">
|
||||
<label><input type="radio" name="btScanMode" value="bluetoothctl" checked> bluetoothctl (Recommended)</label>
|
||||
<label><input type="radio" name="btScanMode" value="hcitool"> hcitool (Legacy)</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Scan Duration (sec)</label>
|
||||
<input type="text" id="btScanDuration" value="30" placeholder="30">
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" id="btScanBLE" checked>
|
||||
Scan BLE Devices
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="btScanClassic" checked>
|
||||
Scan Classic BT
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="btDetectBeacons" checked>
|
||||
Detect Trackers (AirTag/Tile)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Device Actions</h3>
|
||||
<div class="form-group">
|
||||
<label>Target MAC</label>
|
||||
<input type="text" id="btTargetMac" placeholder="AA:BB:CC:DD:EE:FF">
|
||||
</div>
|
||||
<button class="preset-btn" onclick="btEnumServices()" style="width: 100%;">
|
||||
Enumerate Services
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tracker Following Alert -->
|
||||
<div id="trackerFollowingAlert" class="tracker-following-alert" style="display: none;">
|
||||
<!-- Populated by JavaScript -->
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="startBtBtn" onclick="startBtScan()">
|
||||
Start Scanning
|
||||
</button>
|
||||
<button class="stop-btn" id="stopBtBtn" onclick="stopBtScan()" style="display: none;">
|
||||
Stop Scanning
|
||||
</button>
|
||||
<button class="preset-btn" onclick="resetBtAdapter()" style="margin-top: 5px; width: 100%;">
|
||||
Reset Adapter
|
||||
</button>
|
||||
</div>
|
||||
49
templates/partials/modes/listening-post.html
Normal file
49
templates/partials/modes/listening-post.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<!-- LISTENING POST MODE -->
|
||||
<div id="listeningPostMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>Status</h3>
|
||||
|
||||
<!-- Dependency Warning -->
|
||||
<div id="scannerToolsWarning" style="display: none; background: rgba(255, 100, 100, 0.1); border: 1px solid var(--accent-red); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<p style="color: var(--accent-red); margin: 0; font-size: 0.85em;">
|
||||
<strong>Missing:</strong><br>
|
||||
<span id="scannerToolsWarningText"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Status -->
|
||||
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 12px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Status</span>
|
||||
<span id="lpQuickStatus" style="font-size: 11px; color: var(--accent-cyan);">IDLE</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Frequency</span>
|
||||
<span id="lpQuickFreq" style="font-size: 14px; font-family: 'JetBrains Mono', monospace; color: var(--text-primary);">---.--- MHz</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Signals</span>
|
||||
<span id="lpQuickSignals" style="font-size: 14px; font-weight: bold; color: var(--accent-green);">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Bookmarks</h3>
|
||||
<div style="display: flex; gap: 4px; margin-bottom: 8px;">
|
||||
<input type="text" id="bookmarkFreqInput" placeholder="Freq (MHz)" style="flex: 1; padding: 6px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
||||
<button class="preset-btn" onclick="addFrequencyBookmark()" style="background: var(--accent-green); color: #000; padding: 6px 10px;">+</button>
|
||||
</div>
|
||||
<div id="bookmarksList" style="max-height: 150px; overflow-y: auto; font-size: 11px;">
|
||||
<div style="color: var(--text-muted); text-align: center; padding: 10px;">No bookmarks saved</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Recent Signals</h3>
|
||||
<div id="sidebarRecentSignals" style="max-height: 150px; overflow-y: auto; font-size: 11px;">
|
||||
<div style="color: var(--text-muted); text-align: center; padding: 10px;">No signals yet</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
84
templates/partials/modes/pager.html
Normal file
84
templates/partials/modes/pager.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<!-- PAGER MODE -->
|
||||
<div id="pagerMode" class="mode-content active">
|
||||
<div class="section">
|
||||
<h3>Frequency</h3>
|
||||
<div class="form-group">
|
||||
<label>Frequency (MHz)</label>
|
||||
<input type="text" id="frequency" value="153.350" placeholder="e.g., 153.350">
|
||||
</div>
|
||||
<div class="preset-buttons" id="presetButtons">
|
||||
<!-- Populated by JavaScript -->
|
||||
</div>
|
||||
<div style="margin-top: 8px; display: flex; gap: 5px;">
|
||||
<input type="text" id="newPresetFreq" placeholder="New freq (MHz)" style="flex: 1; padding: 6px; background: #0f3460; border: 1px solid #1a1a2e; color: #fff; border-radius: 4px; font-size: 12px;">
|
||||
<button class="preset-btn" onclick="addPreset()" style="background: #2ecc71;">Add</button>
|
||||
</div>
|
||||
<div style="margin-top: 5px;">
|
||||
<button class="preset-btn" onclick="resetPresets()" style="font-size: 11px;">Reset to Defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Protocols</h3>
|
||||
<div class="checkbox-group">
|
||||
<label><input type="checkbox" id="proto_pocsag512" checked> POCSAG-512</label>
|
||||
<label><input type="checkbox" id="proto_pocsag1200" checked> POCSAG-1200</label>
|
||||
<label><input type="checkbox" id="proto_pocsag2400" checked> POCSAG-2400</label>
|
||||
<label><input type="checkbox" id="proto_flex" checked> FLEX</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Settings</h3>
|
||||
<div class="form-group">
|
||||
<label>Gain (dB, 0 = auto)</label>
|
||||
<input type="text" id="gain" value="0" placeholder="0-49 or 0 for auto">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Squelch Level</label>
|
||||
<input type="text" id="squelch" value="0" placeholder="0 = off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>PPM Correction</label>
|
||||
<input type="text" id="ppm" value="0" placeholder="Frequency correction">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Logging</h3>
|
||||
<div class="checkbox-group" style="margin-bottom: 15px;">
|
||||
<label>
|
||||
<input type="checkbox" id="loggingEnabled" onchange="toggleLogging()">
|
||||
Enable Logging
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Log file path</label>
|
||||
<input type="text" id="logFilePath" value="pager_messages.log" placeholder="pager_messages.log">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Message Filters</h3>
|
||||
<div class="checkbox-group" style="margin-bottom: 10px;">
|
||||
<label>
|
||||
<input type="checkbox" id="filterToneOnly" checked onchange="savePagerFilters()">
|
||||
Hide "Tone Only" messages
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Hide messages containing (comma-separated)</label>
|
||||
<input type="text" id="filterKeywords" placeholder="e.g. test, spam, alert" onchange="savePagerFilters()">
|
||||
</div>
|
||||
<div class="info-text" style="font-size: 10px; color: #666; margin-top: 5px;">
|
||||
Messages matching these keywords will be hidden from display but still logged.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="startBtn" onclick="startDecoding()">
|
||||
Start Decoding
|
||||
</button>
|
||||
<button class="stop-btn" id="stopBtn" onclick="stopDecoding()" style="display: none;">
|
||||
Stop Decoding
|
||||
</button>
|
||||
</div>
|
||||
50
templates/partials/modes/satellite.html
Normal file
50
templates/partials/modes/satellite.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!-- SATELLITE MODE -->
|
||||
<div id="satelliteMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>Satellite Command</h3>
|
||||
<p style="color: var(--text-secondary); font-size: 11px; line-height: 1.5; margin-bottom: 15px;">
|
||||
Full satellite tracking dashboard with polar plot, ground track map, pass predictions, and live telemetry.
|
||||
</p>
|
||||
<a href="/satellite/dashboard" target="_blank" class="preset-btn" style="display: block; text-align: center; text-decoration: none; width: 100%; margin-bottom: 10px;">
|
||||
Open in New Window
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Satellite Database</h3>
|
||||
<p style="color: var(--text-secondary); font-size: 11px; margin-bottom: 10px;">
|
||||
Add satellites via TLE data or fetch from Celestrak.
|
||||
</p>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<button class="preset-btn" onclick="showAddSatelliteModal()" style="width: 100%;">
|
||||
Add Satellite (TLE)
|
||||
</button>
|
||||
<button class="preset-btn" onclick="fetchCelestrak()" style="width: 100%;">
|
||||
Update from Celestrak
|
||||
</button>
|
||||
</div>
|
||||
<div style="margin-top: 10px; padding: 8px; background: rgba(0,0,0,0.2); border-radius: 4px;">
|
||||
<div style="font-size: 10px; color: var(--text-muted); margin-bottom: 6px;">TRACKED SATELLITES</div>
|
||||
<div id="satTrackingList" style="font-size: 11px; color: var(--text-secondary); max-height: 120px; overflow-y: auto;">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 4px;">
|
||||
<span>ISS (ZARYA)</span>
|
||||
<span>NOAA 15</span>
|
||||
<span>NOAA 18</span>
|
||||
<span>NOAA 19</span>
|
||||
<span>NOAA 20</span>
|
||||
<span>METEOR-M2</span>
|
||||
<span>METEOR-M2-3</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Quick Info</h3>
|
||||
<div style="font-size: 11px; color: var(--text-secondary); line-height: 1.6;">
|
||||
<p><strong>Polar Plot:</strong> Shows satellite path across the sky. Center = overhead, edge = horizon.</p>
|
||||
<p style="margin-top: 8px;"><strong>Ground Track:</strong> Real-time satellite position and orbital path on world map.</p>
|
||||
<p style="margin-top: 8px;"><strong>Pass Quality:</strong> Excellent (60°+), Good (30°+), Fair (below 30°).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
48
templates/partials/modes/sensor.html
Normal file
48
templates/partials/modes/sensor.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!-- 433MHz SENSOR MODE -->
|
||||
<div id="sensorMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>Frequency</h3>
|
||||
<div class="form-group">
|
||||
<label>Frequency (MHz)</label>
|
||||
<input type="text" id="sensorFrequency" value="433.92" placeholder="e.g., 433.92">
|
||||
</div>
|
||||
<div class="preset-buttons">
|
||||
<button class="preset-btn" onclick="setSensorFreq('433.92')">433.92</button>
|
||||
<button class="preset-btn" onclick="setSensorFreq('315.00')">315.00</button>
|
||||
<button class="preset-btn" onclick="setSensorFreq('868.00')">868.00</button>
|
||||
<button class="preset-btn" onclick="setSensorFreq('915.00')">915.00</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Settings</h3>
|
||||
<div class="form-group">
|
||||
<label>Gain (dB, 0 = auto)</label>
|
||||
<input type="text" id="sensorGain" value="0" placeholder="0-49 or 0 for auto">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>PPM Correction</label>
|
||||
<input type="text" id="sensorPpm" value="0" placeholder="Frequency correction">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Protocols</h3>
|
||||
<div class="info-text" style="margin-bottom: 10px;">
|
||||
rtl_433 auto-detects 200+ device protocols including weather stations, TPMS, doorbells, and more.
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" id="sensorLogging" onchange="toggleSensorLogging()">
|
||||
Enable Logging
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="startSensorBtn" onclick="startSensorDecoding()">
|
||||
Start Listening
|
||||
</button>
|
||||
<button class="stop-btn" id="stopSensorBtn" onclick="stopSensorDecoding()" style="display: none;">
|
||||
Stop Listening
|
||||
</button>
|
||||
</div>
|
||||
169
templates/partials/modes/tscm.html
Normal file
169
templates/partials/modes/tscm.html
Normal file
@@ -0,0 +1,169 @@
|
||||
<!-- TSCM MODE (Counter-Surveillance) -->
|
||||
<div id="tscmMode" class="mode-content">
|
||||
<!-- Configuration -->
|
||||
<div class="section">
|
||||
<h3 style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px;">TSCM Sweep <span style="font-size: 9px; font-weight: normal; background: var(--accent-orange); color: #000; padding: 2px 6px; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.5px;">Alpha</span></h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Sweep Type</label>
|
||||
<select id="tscmSweepType">
|
||||
<option value="quick">Quick Scan (2 min)</option>
|
||||
<option value="standard" selected>Standard (5 min)</option>
|
||||
<option value="full">Full Sweep (15 min)</option>
|
||||
<option value="wireless_cameras">Wireless Cameras</option>
|
||||
<option value="body_worn">Body-Worn Devices</option>
|
||||
<option value="gps_trackers">GPS Trackers</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Compare Against</label>
|
||||
<select id="tscmBaselineSelect">
|
||||
<option value="">No Baseline</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="border-top: 1px solid var(--border-color); padding-top: 12px; margin-top: 12px;">
|
||||
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 8px; color: var(--text-secondary);">Scan Sources</label>
|
||||
<div class="form-group" style="margin-bottom: 8px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="checkbox" id="tscmWifiEnabled" checked style="margin: 0;">
|
||||
<label for="tscmWifiEnabled" style="flex: 1; margin: 0; font-size: 12px;">WiFi</label>
|
||||
</div>
|
||||
<select id="tscmWifiInterface" style="width: 100%; margin-top: 4px; font-size: 11px;">
|
||||
<option value="">Select WiFi interface...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 8px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="checkbox" id="tscmBtEnabled" checked style="margin: 0;">
|
||||
<label for="tscmBtEnabled" style="flex: 1; margin: 0; font-size: 12px;">Bluetooth</label>
|
||||
</div>
|
||||
<select id="tscmBtInterface" style="width: 100%; margin-top: 4px; font-size: 11px;">
|
||||
<option value="">Select Bluetooth adapter...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 8px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="checkbox" id="tscmRfEnabled" style="margin: 0;">
|
||||
<label for="tscmRfEnabled" style="flex: 1; margin: 0; font-size: 12px;">RF/SDR</label>
|
||||
</div>
|
||||
<select id="tscmSdrDevice" style="width: 100%; margin-top: 4px; font-size: 11px;">
|
||||
<option value="">Select SDR device...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="preset-btn" onclick="refreshTscmDevices()" style="width: 100%; margin-top: 8px; font-size: 10px;">
|
||||
🔄 Refresh Devices
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<button class="run-btn" id="startTscmBtn" onclick="startTscmSweep()" style="margin-top: 12px;">
|
||||
Start Sweep
|
||||
</button>
|
||||
<button class="stop-btn" id="stopTscmBtn" onclick="stopTscmSweep()" style="display: none; margin-top: 12px;">
|
||||
Stop Sweep
|
||||
</button>
|
||||
|
||||
<!-- Futuristic Scanner Progress -->
|
||||
<div id="tscmProgress" class="tscm-scanner-progress" style="display: none; margin-top: 12px;">
|
||||
<div class="scanner-ring">
|
||||
<svg viewBox="0 0 100 100">
|
||||
<circle class="scanner-track" cx="50" cy="50" r="45" />
|
||||
<circle class="scanner-progress" id="tscmScannerCircle" cx="50" cy="50" r="45" />
|
||||
<line class="scanner-sweep" x1="50" y1="50" x2="50" y2="8" />
|
||||
</svg>
|
||||
<div class="scanner-center">
|
||||
<span class="scanner-percent" id="tscmProgressPercent">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scanner-info">
|
||||
<div class="scanner-status" id="tscmProgressLabel">INITIALIZING</div>
|
||||
<div class="scanner-devices">
|
||||
<span class="device-indicator" id="tscmWifiIndicator" title="WiFi">📶</span>
|
||||
<span class="device-indicator" id="tscmBtIndicator" title="Bluetooth">🔵</span>
|
||||
<span class="device-indicator" id="tscmRfIndicator" title="RF/SDR">📡</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="preset-btn" id="tscmReportBtn" onclick="generateTscmReport()" style="display: none; width: 100%; margin-top: 8px; background: var(--accent-cyan); color: #000; font-weight: 600;">
|
||||
📄 Generate Report
|
||||
</button>
|
||||
|
||||
<!-- Export Options -->
|
||||
<div id="tscmExportSection" style="display: none; margin-top: 8px;">
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button class="preset-btn" onclick="tscmDownloadPdf()" style="flex: 1; font-size: 10px;">
|
||||
📄 PDF
|
||||
</button>
|
||||
<button class="preset-btn" onclick="tscmDownloadAnnex('json')" style="flex: 1; font-size: 10px;">
|
||||
📊 JSON
|
||||
</button>
|
||||
<button class="preset-btn" onclick="tscmDownloadAnnex('csv')" style="flex: 1; font-size: 10px;">
|
||||
📈 CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced -->
|
||||
<div class="section" style="margin-top: 12px;">
|
||||
<h3 style="margin-bottom: 12px;">Advanced</h3>
|
||||
|
||||
<div style="margin-bottom: 16px;">
|
||||
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 6px; color: var(--text-secondary);">Baseline Recording</label>
|
||||
<div class="form-group">
|
||||
<input type="text" id="tscmBaselineName" placeholder="Baseline name...">
|
||||
</div>
|
||||
<button class="run-btn" id="tscmRecordBaselineBtn" onclick="tscmRecordBaseline()" style="width: 100%; padding: 8px;">
|
||||
Record New Baseline
|
||||
</button>
|
||||
<button class="stop-btn" id="tscmStopBaselineBtn" onclick="tscmStopBaseline()" style="width: 100%; padding: 8px; display: none;">
|
||||
Stop Recording
|
||||
</button>
|
||||
<div id="tscmBaselineStatus" style="margin-top: 8px; font-size: 11px; color: var(--text-muted);"></div>
|
||||
</div>
|
||||
|
||||
<div style="border-top: 1px solid var(--border-color); padding-top: 12px;">
|
||||
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 6px; color: var(--text-secondary);">Meeting Window</label>
|
||||
<div id="tscmMeetingStatus" style="font-size: 11px; color: var(--text-muted); margin-bottom: 8px;">
|
||||
No active meeting
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="text" id="tscmMeetingName" placeholder="Meeting name (optional)">
|
||||
</div>
|
||||
<button class="run-btn" id="tscmStartMeetingBtn" onclick="tscmStartMeeting()" style="width: 100%; padding: 8px;">
|
||||
🎯 Start Meeting Window
|
||||
</button>
|
||||
<button class="stop-btn" id="tscmEndMeetingBtn" onclick="tscmEndMeeting()" style="width: 100%; padding: 8px; display: none;">
|
||||
⏹ End Meeting Window
|
||||
</button>
|
||||
<div style="font-size: 9px; color: var(--text-muted); margin-top: 4px;">
|
||||
Devices detected during meetings get flagged
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tools -->
|
||||
<div class="section" style="margin-top: 12px;">
|
||||
<h3 style="margin-bottom: 10px;">Tools</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px;">
|
||||
<button class="preset-btn" onclick="tscmShowCapabilities()" style="font-size: 10px; padding: 8px;">
|
||||
⚙️ Capabilities
|
||||
</button>
|
||||
<button class="preset-btn" onclick="tscmShowKnownDevices()" style="font-size: 10px; padding: 8px;">
|
||||
✅ Known Devices
|
||||
</button>
|
||||
<button class="preset-btn" onclick="tscmShowCases()" style="font-size: 10px; padding: 8px;">
|
||||
📁 Cases
|
||||
</button>
|
||||
<button class="preset-btn" onclick="tscmShowPlaybooks()" style="font-size: 10px; padding: 8px;">
|
||||
📋 Playbooks
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Warnings -->
|
||||
<div id="tscmDeviceWarnings" style="display: none; margin-top: 8px; padding: 8px; background: rgba(255,153,51,0.1); border: 1px solid rgba(255,153,51,0.3); border-radius: 4px;"></div>
|
||||
</div>
|
||||
142
templates/partials/modes/wifi.html
Normal file
142
templates/partials/modes/wifi.html
Normal file
@@ -0,0 +1,142 @@
|
||||
<!-- WiFi MODE -->
|
||||
<div id="wifiMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>WiFi Adapter</h3>
|
||||
<div class="form-group">
|
||||
<label>Select Device</label>
|
||||
<select id="wifiInterfaceSelect" style="font-size: 12px;">
|
||||
<option value="">Detecting interfaces...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="preset-btn" onclick="refreshWifiInterfaces()" style="width: 100%;">
|
||||
🔄 Refresh Devices
|
||||
</button>
|
||||
<div class="info-text" style="margin-top: 8px; display: grid; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;" id="wifiToolStatus">
|
||||
<span>airmon-ng:</span><span class="tool-status missing">Checking...</span>
|
||||
<span>airodump-ng:</span><span class="tool-status missing">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Monitor Mode</h3>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="preset-btn" id="monitorStartBtn" onclick="enableMonitorMode()" style="flex: 1; background: var(--accent-green); color: #000;">
|
||||
Enable Monitor
|
||||
</button>
|
||||
<button class="preset-btn" id="monitorStopBtn" onclick="disableMonitorMode()" style="flex: 1; display: none;">
|
||||
Disable Monitor
|
||||
</button>
|
||||
</div>
|
||||
<div class="checkbox-group" style="margin-top: 8px;">
|
||||
<label style="font-size: 10px;">
|
||||
<input type="checkbox" id="killProcesses">
|
||||
Kill interfering processes (may drop other connections)
|
||||
</label>
|
||||
</div>
|
||||
<div id="monitorStatus" class="info-text" style="margin-top: 8px;">
|
||||
Monitor mode: <span style="color: var(--accent-red);">Inactive</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Scan Settings</h3>
|
||||
<div class="form-group">
|
||||
<label>Band</label>
|
||||
<select id="wifiBand">
|
||||
<option value="abg">All (2.4 + 5 GHz)</option>
|
||||
<option value="bg">2.4 GHz only</option>
|
||||
<option value="a">5 GHz only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Channel (empty = hop)</label>
|
||||
<input type="text" id="wifiChannel" placeholder="e.g., 6 or 36">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Proximity Alerts</h3>
|
||||
<div class="info-text" style="margin-bottom: 8px;">
|
||||
Alert when specific MAC addresses appear
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="text" id="watchMacInput" placeholder="AA:BB:CC:DD:EE:FF">
|
||||
</div>
|
||||
<button class="preset-btn" onclick="addWatchMac()" style="width: 100%; margin-bottom: 8px;">
|
||||
Add to Watch List
|
||||
</button>
|
||||
<div id="watchList" style="max-height: 80px; overflow-y: auto; font-size: 10px; color: var(--text-dim);"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Attack Options</h3>
|
||||
<div class="info-text" style="color: var(--accent-red); margin-bottom: 10px;">
|
||||
⚠ Only use on authorized networks
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Target BSSID</label>
|
||||
<input type="text" id="targetBssid" placeholder="AA:BB:CC:DD:EE:FF">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Target Client (optional)</label>
|
||||
<input type="text" id="targetClient" placeholder="FF:FF:FF:FF:FF:FF (broadcast)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Deauth Count</label>
|
||||
<input type="text" id="deauthCount" value="5" placeholder="5">
|
||||
</div>
|
||||
<button class="preset-btn" onclick="sendDeauth()" style="width: 100%; border-color: var(--accent-red); color: var(--accent-red);">
|
||||
Send Deauth
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Handshake Capture Status Panel -->
|
||||
<div class="section" id="captureStatusPanel" style="display: none; border: 1px solid var(--accent-orange); border-radius: 4px; padding: 10px; background: rgba(255, 165, 0, 0.1);">
|
||||
<h3 style="color: var(--accent-orange); margin: 0 0 8px 0;">🎯 Handshake Capture</h3>
|
||||
<div style="font-size: 11px;">
|
||||
<div style="margin-bottom: 4px;">
|
||||
<span style="color: var(--text-dim);">Target:</span>
|
||||
<span id="captureTargetBssid" style="font-family: monospace;">--</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 4px;">
|
||||
<span style="color: var(--text-dim);">Channel:</span>
|
||||
<span id="captureTargetChannel">--</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 4px;">
|
||||
<span style="color: var(--text-dim);">File:</span>
|
||||
<span id="captureFilePath" style="font-size: 9px; word-break: break-all;">--</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 8px;">
|
||||
<span style="color: var(--text-dim);">Status:</span>
|
||||
<span id="captureStatus" style="font-weight: bold;">--</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="preset-btn" onclick="checkCaptureStatus()" style="flex: 1; font-size: 10px; padding: 4px;">
|
||||
Check Status
|
||||
</button>
|
||||
<button class="preset-btn" onclick="stopHandshakeCapture()" style="flex: 1; font-size: 10px; padding: 4px; background: var(--accent-red); border: none; color: #fff;">
|
||||
Stop Capture
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Beacon Flood Alert Panel -->
|
||||
<div id="beaconFloodAlert" class="beacon-flood-alert" style="display: none;">
|
||||
<h4 style="color: #ff4444; margin: 0 0 8px 0;">⚠️ BEACON FLOOD DETECTED</h4>
|
||||
<div style="font-size: 11px;">
|
||||
<div id="beaconFloodDetails">Multiple beacon frames detected from same channel</div>
|
||||
<div style="margin-top: 8px;">
|
||||
<span style="color: var(--text-dim);">Networks/sec:</span>
|
||||
<span id="beaconFloodRate" style="font-weight: bold; color: #ff4444;">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="startWifiBtn" onclick="startWifiScan()">
|
||||
Start Scanning
|
||||
</button>
|
||||
<button class="stop-btn" id="stopWifiBtn" onclick="stopWifiScan()" style="display: none;">
|
||||
Stop Scanning
|
||||
</button>
|
||||
</div>
|
||||
@@ -7,6 +7,7 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
1079
utils/database.py
1079
utils/database.py
File diff suppressed because it is too large
Load Diff
@@ -52,7 +52,7 @@ TOOL_DEPENDENCIES = {
|
||||
'install': {
|
||||
'apt': 'sudo apt install multimon-ng',
|
||||
'brew': 'brew install multimon-ng',
|
||||
'manual': 'https://github.com/EliasOewornal/multimon-ng'
|
||||
'manual': 'https://github.com/EliasOenal/multimon-ng'
|
||||
}
|
||||
},
|
||||
'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': {
|
||||
'name': 'Satellite Tracking',
|
||||
'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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ def get_logger(name: str) -> logging.Logger:
|
||||
handler.setFormatter(logging.Formatter(LOG_FORMAT))
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(LOG_LEVEL)
|
||||
logger.propagate = False # Prevent duplicate logs from parent handlers
|
||||
return logger
|
||||
|
||||
|
||||
|
||||
@@ -134,8 +134,14 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
Build rtl_433 command with SoapySDR support for ISM band decoding.
|
||||
|
||||
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)
|
||||
if bias_t:
|
||||
device_str = f'{device_str},bias_t=1'
|
||||
|
||||
cmd = [
|
||||
'rtl_433',
|
||||
@@ -147,9 +153,6 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-g', str(int(gain))])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
return cmd
|
||||
|
||||
def get_capabilities(self) -> SDRCapabilities:
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
from typing import Optional
|
||||
|
||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||
from utils.dependencies import get_tool_path
|
||||
|
||||
|
||||
class RTLSDRCommandBuilder(CommandBuilder):
|
||||
@@ -53,8 +54,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
|
||||
Used for pager decoding. Supports local devices and rtl_tcp connections.
|
||||
"""
|
||||
rtl_fm_path = get_tool_path('rtl_fm') or 'rtl_fm'
|
||||
cmd = [
|
||||
'rtl_fm',
|
||||
rtl_fm_path,
|
||||
'-d', self._get_device_arg(device),
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-M', modulation,
|
||||
@@ -99,8 +101,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
"connect to its SBS output (port 30003)."
|
||||
)
|
||||
|
||||
dump1090_path = get_tool_path('dump1090') or 'dump1090'
|
||||
cmd = [
|
||||
'dump1090',
|
||||
dump1090_path,
|
||||
'--net',
|
||||
'--device-index', str(device.index),
|
||||
'--quiet'
|
||||
@@ -126,10 +129,22 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
Build rtl_433 command for ISM band sensor decoding.
|
||||
|
||||
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 = [
|
||||
'rtl_433',
|
||||
'-d', self._get_device_arg(device),
|
||||
rtl_433_path,
|
||||
'-d', device_arg,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-F', 'json'
|
||||
]
|
||||
@@ -140,9 +155,6 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
if ppm is not None and ppm != 0:
|
||||
cmd.extend(['-p', str(ppm)])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
return cmd
|
||||
|
||||
def get_capabilities(self) -> SDRCapabilities:
|
||||
|
||||
11
utils/tscm/__init__.py
Normal file
11
utils/tscm/__init__.py
Normal file
@@ -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']
|
||||
2185
utils/tscm/advanced.py
Normal file
2185
utils/tscm/advanced.py
Normal file
File diff suppressed because it is too large
Load Diff
388
utils/tscm/baseline.py
Normal file
388
utils/tscm/baseline.py
Normal file
@@ -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)
|
||||
476
utils/tscm/ble_scanner.py
Normal file
476
utils/tscm/ble_scanner.py
Normal file
@@ -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]
|
||||
959
utils/tscm/correlation.py
Normal file
959
utils/tscm/correlation.py
Normal file
@@ -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()
|
||||
580
utils/tscm/detector.py
Normal file
580
utils/tscm/detector.py
Normal file
@@ -0,0 +1,580 @@
|
||||
"""
|
||||
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'
|
||||
|
||||
try:
|
||||
signal_val = int(signal) if signal else -100
|
||||
except (ValueError, TypeError):
|
||||
signal_val = -100
|
||||
if not ssid and signal and signal_val > -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
|
||||
try:
|
||||
rssi_val = int(rssi) if rssi else -100
|
||||
except (ValueError, TypeError):
|
||||
rssi_val = -100
|
||||
if rssi and rssi_val > -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
|
||||
try:
|
||||
signal_int = int(signal) if signal else -100
|
||||
except (ValueError, TypeError):
|
||||
signal_int = -100
|
||||
if not ssid and signal and signal_int > -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)
|
||||
try:
|
||||
rssi_int = int(rssi) if rssi else -100
|
||||
except (ValueError, TypeError):
|
||||
rssi_int = -100
|
||||
if not name and rssi and rssi_int > -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
|
||||
1219
utils/tscm/device_identity.py
Normal file
1219
utils/tscm/device_identity.py
Normal file
File diff suppressed because it is too large
Load Diff
813
utils/tscm/reports.py
Normal file
813
utils/tscm/reports.py
Normal file
@@ -0,0 +1,813 @@
|
||||
"""
|
||||
TSCM Report Generation Module
|
||||
|
||||
Generates:
|
||||
1. Client-safe PDF reports with executive summary
|
||||
2. Technical annex (JSON + CSV) with device timelines and indicators
|
||||
|
||||
DISCLAIMER: All reports include mandatory disclaimers.
|
||||
No packet data. No claims of confirmed surveillance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger('intercept.tscm.reports')
|
||||
|
||||
# =============================================================================
|
||||
# Report Data Structures
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class ReportFinding:
|
||||
"""A single finding for the report."""
|
||||
identifier: str
|
||||
protocol: str
|
||||
name: Optional[str]
|
||||
risk_level: str
|
||||
risk_score: int
|
||||
description: str
|
||||
indicators: list[dict] = field(default_factory=list)
|
||||
recommended_action: str = ''
|
||||
playbook_reference: str = ''
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReportMeetingSummary:
|
||||
"""Meeting window summary for report."""
|
||||
name: Optional[str]
|
||||
start_time: str
|
||||
end_time: Optional[str]
|
||||
duration_minutes: float
|
||||
devices_first_seen: int
|
||||
behavior_changes: int
|
||||
high_interest_devices: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class TSCMReport:
|
||||
"""
|
||||
Complete TSCM sweep report.
|
||||
|
||||
Contains all data needed for both client-safe PDF and technical annex.
|
||||
"""
|
||||
# Report metadata
|
||||
report_id: str
|
||||
generated_at: datetime
|
||||
sweep_id: int
|
||||
sweep_type: str
|
||||
|
||||
# Location and context
|
||||
location: Optional[str] = None
|
||||
baseline_id: Optional[int] = None
|
||||
baseline_name: Optional[str] = None
|
||||
|
||||
# Executive summary
|
||||
executive_summary: str = ''
|
||||
overall_risk_assessment: str = 'low' # low, moderate, elevated, high
|
||||
key_findings_count: int = 0
|
||||
|
||||
# Capabilities used
|
||||
capabilities: dict = field(default_factory=dict)
|
||||
limitations: list[str] = field(default_factory=list)
|
||||
|
||||
# Findings by risk tier
|
||||
high_interest_findings: list[ReportFinding] = field(default_factory=list)
|
||||
needs_review_findings: list[ReportFinding] = field(default_factory=list)
|
||||
informational_findings: list[ReportFinding] = field(default_factory=list)
|
||||
|
||||
# Meeting window summaries
|
||||
meeting_summaries: list[ReportMeetingSummary] = field(default_factory=list)
|
||||
|
||||
# Statistics
|
||||
total_devices_scanned: int = 0
|
||||
wifi_devices: int = 0
|
||||
bluetooth_devices: int = 0
|
||||
rf_signals: int = 0
|
||||
new_devices: int = 0
|
||||
missing_devices: int = 0
|
||||
|
||||
# Sweep duration
|
||||
sweep_start: Optional[datetime] = None
|
||||
sweep_end: Optional[datetime] = None
|
||||
duration_minutes: float = 0.0
|
||||
|
||||
# Technical data (for annex only)
|
||||
device_timelines: list[dict] = field(default_factory=list)
|
||||
all_indicators: list[dict] = field(default_factory=list)
|
||||
baseline_diff: Optional[dict] = None
|
||||
correlation_data: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Disclaimer Text
|
||||
# =============================================================================
|
||||
|
||||
REPORT_DISCLAIMER = """
|
||||
IMPORTANT DISCLAIMER
|
||||
|
||||
This report documents the findings of a Technical Surveillance Countermeasures
|
||||
(TSCM) sweep conducted using electronic detection equipment. The following
|
||||
limitations and considerations apply:
|
||||
|
||||
1. DETECTION LIMITATIONS: No TSCM sweep can guarantee detection of all
|
||||
surveillance devices. Sophisticated devices may evade detection.
|
||||
|
||||
2. FINDINGS ARE INDICATORS: All findings represent patterns and indicators,
|
||||
NOT confirmed surveillance devices. Each finding requires professional
|
||||
interpretation and may have legitimate explanations.
|
||||
|
||||
3. ENVIRONMENTAL FACTORS: Wireless signals are affected by building
|
||||
construction, interference, and other environmental factors that may
|
||||
impact detection accuracy.
|
||||
|
||||
4. POINT-IN-TIME ASSESSMENT: This report reflects conditions at the time
|
||||
of the sweep. Conditions may change after the assessment.
|
||||
|
||||
5. NOT LEGAL ADVICE: This report does not constitute legal advice. Consult
|
||||
qualified legal counsel for guidance on surveillance-related matters.
|
||||
|
||||
6. PRIVACY CONSIDERATIONS: Some detected devices may be legitimate personal
|
||||
devices of authorized individuals.
|
||||
|
||||
This report should be treated as confidential and distributed only to
|
||||
authorized personnel on a need-to-know basis.
|
||||
"""
|
||||
|
||||
ANNEX_DISCLAIMER = """
|
||||
TECHNICAL ANNEX DISCLAIMER
|
||||
|
||||
This annex contains detailed technical data from the TSCM sweep. This data
|
||||
is provided for documentation and audit purposes.
|
||||
|
||||
- No raw packet captures or intercepted communications are included
|
||||
- Device identifiers (MAC addresses) are included for tracking purposes
|
||||
- Signal strength values are approximate and environment-dependent
|
||||
- Timeline data is time-bucketed to preserve privacy
|
||||
- All interpretations require professional TSCM expertise
|
||||
|
||||
This data should be handled according to organizational data protection
|
||||
policies and applicable privacy regulations.
|
||||
"""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Report Generation Functions
|
||||
# =============================================================================
|
||||
|
||||
def generate_executive_summary(report: TSCMReport) -> str:
|
||||
"""Generate executive summary text."""
|
||||
lines = []
|
||||
|
||||
# Opening
|
||||
lines.append(f"TSCM Sweep Report - {report.location or 'Location Not Specified'}")
|
||||
lines.append(f"Conducted: {report.sweep_start.strftime('%Y-%m-%d %H:%M') if report.sweep_start else 'Unknown'}")
|
||||
lines.append(f"Duration: {report.duration_minutes:.0f} minutes")
|
||||
lines.append("")
|
||||
|
||||
# Overall assessment
|
||||
assessment_text = {
|
||||
'low': 'No significant indicators of surveillance activity were detected.',
|
||||
'moderate': 'Some devices require review but no confirmed surveillance indicators.',
|
||||
'elevated': 'Multiple indicators warrant further investigation.',
|
||||
'high': 'Significant indicators detected requiring immediate attention.',
|
||||
}
|
||||
lines.append(f"OVERALL ASSESSMENT: {report.overall_risk_assessment.upper()}")
|
||||
lines.append(assessment_text.get(report.overall_risk_assessment, ''))
|
||||
lines.append("")
|
||||
|
||||
# Key statistics
|
||||
lines.append("SCAN STATISTICS:")
|
||||
lines.append(f" - Total devices scanned: {report.total_devices_scanned}")
|
||||
lines.append(f" - WiFi access points: {report.wifi_devices}")
|
||||
lines.append(f" - Bluetooth devices: {report.bluetooth_devices}")
|
||||
lines.append(f" - RF signals: {report.rf_signals}")
|
||||
lines.append("")
|
||||
|
||||
# Findings summary
|
||||
lines.append("FINDINGS SUMMARY:")
|
||||
lines.append(f" - High Interest (require investigation): {len(report.high_interest_findings)}")
|
||||
lines.append(f" - Needs Review: {len(report.needs_review_findings)}")
|
||||
lines.append(f" - Informational: {len(report.informational_findings)}")
|
||||
lines.append("")
|
||||
|
||||
# Baseline comparison if available
|
||||
if report.baseline_name:
|
||||
lines.append(f"BASELINE COMPARISON (vs '{report.baseline_name}'):")
|
||||
lines.append(f" - New devices: {report.new_devices}")
|
||||
lines.append(f" - Missing devices: {report.missing_devices}")
|
||||
lines.append("")
|
||||
|
||||
# Meeting window summary if available
|
||||
if report.meeting_summaries:
|
||||
lines.append("MEETING WINDOW ACTIVITY:")
|
||||
for meeting in report.meeting_summaries:
|
||||
lines.append(f" - {meeting.name or 'Unnamed meeting'}: "
|
||||
f"{meeting.devices_first_seen} new devices, "
|
||||
f"{meeting.high_interest_devices} high interest")
|
||||
lines.append("")
|
||||
|
||||
# Limitations
|
||||
if report.limitations:
|
||||
lines.append("SWEEP LIMITATIONS:")
|
||||
for limit in report.limitations[:3]: # Top 3 limitations
|
||||
lines.append(f" - {limit}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_findings_section(findings: list[ReportFinding], title: str) -> str:
|
||||
"""Generate a findings section for the report."""
|
||||
if not findings:
|
||||
return f"{title}\n\nNo findings in this category.\n"
|
||||
|
||||
lines = [title, "=" * len(title), ""]
|
||||
|
||||
for i, finding in enumerate(findings, 1):
|
||||
lines.append(f"{i}. {finding.name or finding.identifier}")
|
||||
lines.append(f" Protocol: {finding.protocol.upper()}")
|
||||
lines.append(f" Identifier: {finding.identifier}")
|
||||
lines.append(f" Risk Score: {finding.risk_score}")
|
||||
lines.append(f" Description: {finding.description}")
|
||||
if finding.indicators:
|
||||
lines.append(" Indicators:")
|
||||
for ind in finding.indicators[:5]: # Limit to 5 indicators
|
||||
lines.append(f" - {ind.get('type', 'unknown')}: {ind.get('description', '')}")
|
||||
lines.append(f" Recommended Action: {finding.recommended_action}")
|
||||
if finding.playbook_reference:
|
||||
lines.append(f" Reference: {finding.playbook_reference}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_meeting_section(summaries: list[ReportMeetingSummary]) -> str:
|
||||
"""Generate meeting window summary section."""
|
||||
if not summaries:
|
||||
return "MEETING WINDOW SUMMARY\n\nNo meeting windows were marked during this sweep.\n"
|
||||
|
||||
lines = ["MEETING WINDOW SUMMARY", "=" * 22, ""]
|
||||
|
||||
for meeting in summaries:
|
||||
lines.append(f"Meeting: {meeting.name or 'Unnamed'}")
|
||||
lines.append(f" Time: {meeting.start_time} - {meeting.end_time or 'ongoing'}")
|
||||
lines.append(f" Duration: {meeting.duration_minutes:.0f} minutes")
|
||||
lines.append(f" Devices first seen during meeting: {meeting.devices_first_seen}")
|
||||
lines.append(f" Behavior changes detected: {meeting.behavior_changes}")
|
||||
lines.append(f" High interest devices active: {meeting.high_interest_devices}")
|
||||
|
||||
if meeting.devices_first_seen > 0 or meeting.high_interest_devices > 0:
|
||||
lines.append(" NOTE: Meeting-correlated activity detected - see findings for details")
|
||||
lines.append("")
|
||||
|
||||
lines.append("Meeting-correlated activity indicates temporal correlation only.")
|
||||
lines.append("Devices appearing during meetings may have legitimate explanations.")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_pdf_content(report: TSCMReport) -> str:
|
||||
"""
|
||||
Generate complete PDF report content.
|
||||
|
||||
Returns plain text that can be converted to PDF.
|
||||
For actual PDF generation, use a library like reportlab or weasyprint.
|
||||
"""
|
||||
sections = []
|
||||
|
||||
# Header
|
||||
sections.append("=" * 70)
|
||||
sections.append("TECHNICAL SURVEILLANCE COUNTERMEASURES (TSCM) SWEEP REPORT")
|
||||
sections.append("=" * 70)
|
||||
sections.append("")
|
||||
sections.append(f"Report ID: {report.report_id}")
|
||||
sections.append(f"Generated: {report.generated_at.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
sections.append(f"Sweep ID: {report.sweep_id}")
|
||||
sections.append("")
|
||||
|
||||
# Executive Summary
|
||||
sections.append("-" * 70)
|
||||
sections.append("EXECUTIVE SUMMARY")
|
||||
sections.append("-" * 70)
|
||||
sections.append(report.executive_summary or generate_executive_summary(report))
|
||||
sections.append("")
|
||||
|
||||
# High Interest Findings
|
||||
if report.high_interest_findings:
|
||||
sections.append("-" * 70)
|
||||
sections.append(generate_findings_section(
|
||||
report.high_interest_findings,
|
||||
"HIGH INTEREST FINDINGS"
|
||||
))
|
||||
|
||||
# Needs Review Findings
|
||||
if report.needs_review_findings:
|
||||
sections.append("-" * 70)
|
||||
sections.append(generate_findings_section(
|
||||
report.needs_review_findings,
|
||||
"FINDINGS REQUIRING REVIEW"
|
||||
))
|
||||
|
||||
# Meeting Window Summary
|
||||
if report.meeting_summaries:
|
||||
sections.append("-" * 70)
|
||||
sections.append(generate_meeting_section(report.meeting_summaries))
|
||||
|
||||
# Capabilities & Limitations
|
||||
sections.append("-" * 70)
|
||||
sections.append("SWEEP CAPABILITIES & LIMITATIONS")
|
||||
sections.append("=" * 33)
|
||||
sections.append("")
|
||||
|
||||
if report.capabilities:
|
||||
caps = report.capabilities
|
||||
sections.append("Equipment Used:")
|
||||
if caps.get('wifi', {}).get('mode') != 'unavailable':
|
||||
sections.append(f" - WiFi: {caps.get('wifi', {}).get('mode', 'unknown')} mode")
|
||||
if caps.get('bluetooth', {}).get('mode') != 'unavailable':
|
||||
sections.append(f" - Bluetooth: {caps.get('bluetooth', {}).get('mode', 'unknown')}")
|
||||
if caps.get('rf', {}).get('available'):
|
||||
sections.append(f" - RF/SDR: {caps.get('rf', {}).get('device_type', 'unknown')}")
|
||||
sections.append("")
|
||||
|
||||
if report.limitations:
|
||||
sections.append("Limitations:")
|
||||
for limit in report.limitations:
|
||||
sections.append(f" - {limit}")
|
||||
sections.append("")
|
||||
|
||||
# Disclaimer
|
||||
sections.append("-" * 70)
|
||||
sections.append(REPORT_DISCLAIMER)
|
||||
|
||||
# Footer
|
||||
sections.append("")
|
||||
sections.append("=" * 70)
|
||||
sections.append("END OF REPORT")
|
||||
sections.append("=" * 70)
|
||||
|
||||
return "\n".join(sections)
|
||||
|
||||
|
||||
def generate_technical_annex_json(report: TSCMReport) -> dict:
|
||||
"""
|
||||
Generate technical annex as JSON.
|
||||
|
||||
Contains detailed device timelines, all indicators, and raw data
|
||||
for audit and further analysis.
|
||||
"""
|
||||
return {
|
||||
'annex_type': 'tscm_technical_annex',
|
||||
'report_id': report.report_id,
|
||||
'generated_at': report.generated_at.isoformat(),
|
||||
'sweep_id': report.sweep_id,
|
||||
'disclaimer': ANNEX_DISCLAIMER.strip(),
|
||||
|
||||
'sweep_details': {
|
||||
'type': report.sweep_type,
|
||||
'location': report.location,
|
||||
'start_time': report.sweep_start.isoformat() if report.sweep_start else None,
|
||||
'end_time': report.sweep_end.isoformat() if report.sweep_end else None,
|
||||
'duration_minutes': report.duration_minutes,
|
||||
'baseline_id': report.baseline_id,
|
||||
'baseline_name': report.baseline_name,
|
||||
},
|
||||
|
||||
'capabilities': report.capabilities,
|
||||
'limitations': report.limitations,
|
||||
|
||||
'statistics': {
|
||||
'total_devices': report.total_devices_scanned,
|
||||
'wifi_devices': report.wifi_devices,
|
||||
'bluetooth_devices': report.bluetooth_devices,
|
||||
'rf_signals': report.rf_signals,
|
||||
'new_devices': report.new_devices,
|
||||
'missing_devices': report.missing_devices,
|
||||
'high_interest_count': len(report.high_interest_findings),
|
||||
'needs_review_count': len(report.needs_review_findings),
|
||||
'informational_count': len(report.informational_findings),
|
||||
},
|
||||
|
||||
'findings': {
|
||||
'high_interest': [
|
||||
{
|
||||
'identifier': f.identifier,
|
||||
'protocol': f.protocol,
|
||||
'name': f.name,
|
||||
'risk_score': f.risk_score,
|
||||
'description': f.description,
|
||||
'indicators': f.indicators,
|
||||
'recommended_action': f.recommended_action,
|
||||
}
|
||||
for f in report.high_interest_findings
|
||||
],
|
||||
'needs_review': [
|
||||
{
|
||||
'identifier': f.identifier,
|
||||
'protocol': f.protocol,
|
||||
'name': f.name,
|
||||
'risk_score': f.risk_score,
|
||||
'description': f.description,
|
||||
'indicators': f.indicators,
|
||||
}
|
||||
for f in report.needs_review_findings
|
||||
],
|
||||
},
|
||||
|
||||
'meeting_windows': [
|
||||
{
|
||||
'name': m.name,
|
||||
'start_time': m.start_time,
|
||||
'end_time': m.end_time,
|
||||
'duration_minutes': m.duration_minutes,
|
||||
'devices_first_seen': m.devices_first_seen,
|
||||
'behavior_changes': m.behavior_changes,
|
||||
'high_interest_devices': m.high_interest_devices,
|
||||
}
|
||||
for m in report.meeting_summaries
|
||||
],
|
||||
|
||||
'device_timelines': report.device_timelines,
|
||||
'all_indicators': report.all_indicators,
|
||||
'baseline_diff': report.baseline_diff,
|
||||
'correlations': report.correlation_data,
|
||||
}
|
||||
|
||||
|
||||
def generate_technical_annex_csv(report: TSCMReport) -> str:
|
||||
"""
|
||||
Generate device timeline data as CSV.
|
||||
|
||||
Provides spreadsheet-compatible format for further analysis.
|
||||
"""
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# Header
|
||||
writer.writerow([
|
||||
'identifier',
|
||||
'protocol',
|
||||
'name',
|
||||
'risk_level',
|
||||
'risk_score',
|
||||
'first_seen',
|
||||
'last_seen',
|
||||
'observation_count',
|
||||
'rssi_min',
|
||||
'rssi_max',
|
||||
'rssi_mean',
|
||||
'rssi_stability',
|
||||
'movement_pattern',
|
||||
'meeting_correlated',
|
||||
'indicators',
|
||||
])
|
||||
|
||||
# Device data from timelines
|
||||
for timeline in report.device_timelines:
|
||||
indicators_str = '; '.join(
|
||||
f"{i.get('type', '')}({i.get('score', 0)})"
|
||||
for i in timeline.get('indicators', [])
|
||||
)
|
||||
|
||||
signal = timeline.get('signal', {})
|
||||
metrics = timeline.get('metrics', {})
|
||||
movement = timeline.get('movement', {})
|
||||
meeting = timeline.get('meeting_correlation', {})
|
||||
|
||||
writer.writerow([
|
||||
timeline.get('identifier', ''),
|
||||
timeline.get('protocol', ''),
|
||||
timeline.get('name', ''),
|
||||
timeline.get('risk_level', 'informational'),
|
||||
timeline.get('risk_score', 0),
|
||||
metrics.get('first_seen', ''),
|
||||
metrics.get('last_seen', ''),
|
||||
metrics.get('total_observations', 0),
|
||||
signal.get('rssi_min', ''),
|
||||
signal.get('rssi_max', ''),
|
||||
signal.get('rssi_mean', ''),
|
||||
signal.get('stability', ''),
|
||||
movement.get('pattern', ''),
|
||||
meeting.get('correlated', False),
|
||||
indicators_str,
|
||||
])
|
||||
|
||||
# Also add findings summary
|
||||
writer.writerow([])
|
||||
writer.writerow(['--- FINDINGS SUMMARY ---'])
|
||||
writer.writerow(['identifier', 'protocol', 'risk_level', 'risk_score', 'description', 'recommended_action'])
|
||||
|
||||
all_findings = (
|
||||
report.high_interest_findings +
|
||||
report.needs_review_findings
|
||||
)
|
||||
|
||||
for finding in all_findings:
|
||||
writer.writerow([
|
||||
finding.identifier,
|
||||
finding.protocol,
|
||||
finding.risk_level,
|
||||
finding.risk_score,
|
||||
finding.description,
|
||||
finding.recommended_action,
|
||||
])
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Report Builder
|
||||
# =============================================================================
|
||||
|
||||
class TSCMReportBuilder:
|
||||
"""
|
||||
Builder for constructing TSCM reports from sweep data.
|
||||
|
||||
Usage:
|
||||
builder = TSCMReportBuilder(sweep_id=123)
|
||||
builder.set_location("Conference Room A")
|
||||
builder.add_capabilities(capabilities_dict)
|
||||
builder.add_finding(finding)
|
||||
report = builder.build()
|
||||
"""
|
||||
|
||||
def __init__(self, sweep_id: int):
|
||||
self.sweep_id = sweep_id
|
||||
self.report = TSCMReport(
|
||||
report_id=f"TSCM-{sweep_id}-{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
||||
generated_at=datetime.now(),
|
||||
sweep_id=sweep_id,
|
||||
sweep_type='standard',
|
||||
)
|
||||
|
||||
def set_sweep_type(self, sweep_type: str) -> 'TSCMReportBuilder':
|
||||
self.report.sweep_type = sweep_type
|
||||
return self
|
||||
|
||||
def set_location(self, location: str) -> 'TSCMReportBuilder':
|
||||
self.report.location = location
|
||||
return self
|
||||
|
||||
def set_baseline(self, baseline_id: int, baseline_name: str) -> 'TSCMReportBuilder':
|
||||
self.report.baseline_id = baseline_id
|
||||
self.report.baseline_name = baseline_name
|
||||
return self
|
||||
|
||||
def set_sweep_times(
|
||||
self,
|
||||
start: datetime,
|
||||
end: Optional[datetime] = None
|
||||
) -> 'TSCMReportBuilder':
|
||||
self.report.sweep_start = start
|
||||
self.report.sweep_end = end or datetime.now()
|
||||
self.report.duration_minutes = (
|
||||
(self.report.sweep_end - self.report.sweep_start).total_seconds() / 60
|
||||
)
|
||||
return self
|
||||
|
||||
def add_capabilities(self, capabilities: dict) -> 'TSCMReportBuilder':
|
||||
self.report.capabilities = capabilities
|
||||
self.report.limitations = capabilities.get('all_limitations', [])
|
||||
return self
|
||||
|
||||
def add_finding(self, finding: ReportFinding) -> 'TSCMReportBuilder':
|
||||
if finding.risk_level == 'high_interest':
|
||||
self.report.high_interest_findings.append(finding)
|
||||
elif finding.risk_level in ['review', 'needs_review']:
|
||||
self.report.needs_review_findings.append(finding)
|
||||
else:
|
||||
self.report.informational_findings.append(finding)
|
||||
return self
|
||||
|
||||
def add_findings_from_profiles(self, profiles: list[dict]) -> 'TSCMReportBuilder':
|
||||
"""Add findings from correlation engine device profiles."""
|
||||
for profile in profiles:
|
||||
finding = ReportFinding(
|
||||
identifier=profile.get('identifier', ''),
|
||||
protocol=profile.get('protocol', ''),
|
||||
name=profile.get('name'),
|
||||
risk_level=profile.get('risk_level', 'informational'),
|
||||
risk_score=profile.get('total_score', 0),
|
||||
description=self._generate_finding_description(profile),
|
||||
indicators=profile.get('indicators', []),
|
||||
recommended_action=profile.get('recommended_action', 'monitor'),
|
||||
playbook_reference=self._get_playbook_reference(profile),
|
||||
)
|
||||
self.add_finding(finding)
|
||||
|
||||
return self
|
||||
|
||||
def _generate_finding_description(self, profile: dict) -> str:
|
||||
"""Generate description from profile indicators."""
|
||||
indicators = profile.get('indicators', [])
|
||||
if not indicators:
|
||||
return f"{profile.get('protocol', 'Unknown').upper()} device detected"
|
||||
|
||||
# Use first indicator as primary description
|
||||
primary = indicators[0]
|
||||
desc = primary.get('description', 'Pattern detected')
|
||||
|
||||
if len(indicators) > 1:
|
||||
desc += f" (+{len(indicators) - 1} additional indicators)"
|
||||
|
||||
return desc
|
||||
|
||||
def _get_playbook_reference(self, profile: dict) -> str:
|
||||
"""Get playbook reference based on profile."""
|
||||
risk_level = profile.get('risk_level', 'informational')
|
||||
indicators = profile.get('indicators', [])
|
||||
|
||||
# Check for tracker
|
||||
tracker_types = ['airtag_detected', 'tile_detected', 'smarttag_detected', 'known_tracker']
|
||||
if any(i.get('type') in tracker_types for i in indicators):
|
||||
if risk_level == 'high_interest':
|
||||
return 'PB-001 (Tracker Detection)'
|
||||
|
||||
if risk_level == 'high_interest':
|
||||
return 'PB-002 (Suspicious Device)'
|
||||
elif risk_level in ['review', 'needs_review']:
|
||||
return 'PB-003 (Unknown Device)'
|
||||
|
||||
return ''
|
||||
|
||||
def add_meeting_summary(self, summary: dict) -> 'TSCMReportBuilder':
|
||||
"""Add meeting window summary."""
|
||||
meeting = ReportMeetingSummary(
|
||||
name=summary.get('name'),
|
||||
start_time=summary.get('start_time', ''),
|
||||
end_time=summary.get('end_time'),
|
||||
duration_minutes=summary.get('duration_minutes', 0),
|
||||
devices_first_seen=summary.get('devices_first_seen', 0),
|
||||
behavior_changes=summary.get('behavior_changes', 0),
|
||||
high_interest_devices=summary.get('high_interest_devices', 0),
|
||||
)
|
||||
self.report.meeting_summaries.append(meeting)
|
||||
return self
|
||||
|
||||
def add_statistics(
|
||||
self,
|
||||
wifi: int = 0,
|
||||
bluetooth: int = 0,
|
||||
rf: int = 0,
|
||||
new: int = 0,
|
||||
missing: int = 0
|
||||
) -> 'TSCMReportBuilder':
|
||||
self.report.wifi_devices = wifi
|
||||
self.report.bluetooth_devices = bluetooth
|
||||
self.report.rf_signals = rf
|
||||
self.report.total_devices_scanned = wifi + bluetooth + rf
|
||||
self.report.new_devices = new
|
||||
self.report.missing_devices = missing
|
||||
return self
|
||||
|
||||
def add_device_timelines(self, timelines: list[dict]) -> 'TSCMReportBuilder':
|
||||
self.report.device_timelines = timelines
|
||||
return self
|
||||
|
||||
def add_all_indicators(self, indicators: list[dict]) -> 'TSCMReportBuilder':
|
||||
self.report.all_indicators = indicators
|
||||
return self
|
||||
|
||||
def add_baseline_diff(self, diff: dict) -> 'TSCMReportBuilder':
|
||||
self.report.baseline_diff = diff
|
||||
return self
|
||||
|
||||
def add_correlations(self, correlations: list[dict]) -> 'TSCMReportBuilder':
|
||||
self.report.correlation_data = correlations
|
||||
return self
|
||||
|
||||
def build(self) -> TSCMReport:
|
||||
"""Build and return the complete report."""
|
||||
# Calculate overall risk assessment
|
||||
if self.report.high_interest_findings:
|
||||
if len(self.report.high_interest_findings) >= 3:
|
||||
self.report.overall_risk_assessment = 'high'
|
||||
else:
|
||||
self.report.overall_risk_assessment = 'elevated'
|
||||
elif self.report.needs_review_findings:
|
||||
self.report.overall_risk_assessment = 'moderate'
|
||||
else:
|
||||
self.report.overall_risk_assessment = 'low'
|
||||
|
||||
self.report.key_findings_count = (
|
||||
len(self.report.high_interest_findings) +
|
||||
len(self.report.needs_review_findings)
|
||||
)
|
||||
|
||||
# Generate executive summary
|
||||
self.report.executive_summary = generate_executive_summary(self.report)
|
||||
|
||||
return self.report
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Report Generation API Functions
|
||||
# =============================================================================
|
||||
|
||||
def generate_report(
|
||||
sweep_id: int,
|
||||
sweep_data: dict,
|
||||
device_profiles: list[dict],
|
||||
capabilities: dict,
|
||||
timelines: list[dict],
|
||||
baseline_diff: Optional[dict] = None,
|
||||
meeting_summaries: Optional[list[dict]] = None,
|
||||
correlations: Optional[list[dict]] = None,
|
||||
) -> TSCMReport:
|
||||
"""
|
||||
Generate a complete TSCM report from sweep data.
|
||||
|
||||
Args:
|
||||
sweep_id: Sweep ID
|
||||
sweep_data: Sweep dict from database
|
||||
device_profiles: List of DeviceProfile dicts from correlation engine
|
||||
capabilities: Capabilities dict
|
||||
timelines: Device timeline dicts
|
||||
baseline_diff: Optional baseline diff dict
|
||||
meeting_summaries: Optional meeting summaries
|
||||
correlations: Optional correlation data
|
||||
|
||||
Returns:
|
||||
Complete TSCMReport
|
||||
"""
|
||||
builder = TSCMReportBuilder(sweep_id)
|
||||
|
||||
# Basic info
|
||||
builder.set_sweep_type(sweep_data.get('sweep_type', 'standard'))
|
||||
|
||||
# Parse times
|
||||
started_at = sweep_data.get('started_at')
|
||||
completed_at = sweep_data.get('completed_at')
|
||||
if started_at:
|
||||
if isinstance(started_at, str):
|
||||
started_at = datetime.fromisoformat(started_at.replace('Z', '+00:00')).replace(tzinfo=None)
|
||||
if completed_at:
|
||||
if isinstance(completed_at, str):
|
||||
completed_at = datetime.fromisoformat(completed_at.replace('Z', '+00:00')).replace(tzinfo=None)
|
||||
builder.set_sweep_times(started_at, completed_at)
|
||||
|
||||
# Capabilities
|
||||
builder.add_capabilities(capabilities)
|
||||
|
||||
# Add findings from profiles
|
||||
builder.add_findings_from_profiles(device_profiles)
|
||||
|
||||
# Statistics
|
||||
results = sweep_data.get('results', {})
|
||||
builder.add_statistics(
|
||||
wifi=len(results.get('wifi', [])),
|
||||
bluetooth=len(results.get('bluetooth', [])),
|
||||
rf=len(results.get('rf', [])),
|
||||
new=baseline_diff.get('summary', {}).get('new_devices', 0) if baseline_diff else 0,
|
||||
missing=baseline_diff.get('summary', {}).get('missing_devices', 0) if baseline_diff else 0,
|
||||
)
|
||||
|
||||
# Technical data
|
||||
builder.add_device_timelines(timelines)
|
||||
|
||||
if baseline_diff:
|
||||
builder.add_baseline_diff(baseline_diff)
|
||||
|
||||
if meeting_summaries:
|
||||
for summary in meeting_summaries:
|
||||
builder.add_meeting_summary(summary)
|
||||
|
||||
if correlations:
|
||||
builder.add_correlations(correlations)
|
||||
|
||||
# Extract all indicators
|
||||
all_indicators = []
|
||||
for profile in device_profiles:
|
||||
for ind in profile.get('indicators', []):
|
||||
all_indicators.append({
|
||||
'device': profile.get('identifier'),
|
||||
'protocol': profile.get('protocol'),
|
||||
**ind
|
||||
})
|
||||
builder.add_all_indicators(all_indicators)
|
||||
|
||||
return builder.build()
|
||||
|
||||
|
||||
def get_pdf_report(report: TSCMReport) -> str:
|
||||
"""Get PDF-ready report content."""
|
||||
return generate_pdf_content(report)
|
||||
|
||||
|
||||
def get_json_annex(report: TSCMReport) -> dict:
|
||||
"""Get JSON technical annex."""
|
||||
return generate_technical_annex_json(report)
|
||||
|
||||
|
||||
def get_csv_annex(report: TSCMReport) -> str:
|
||||
"""Get CSV technical annex."""
|
||||
return generate_technical_annex_csv(report)
|
||||
Reference in New Issue
Block a user