mirror of
https://github.com/smittix/intercept.git
synced 2026-06-13 08:13:32 -07:00
Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0eed4a2649 | |||
| 7b49c95967 | |||
| 30126b1709 | |||
| 66c7db73e2 | |||
| 07af3acb84 | |||
| b2feccdb90 | |||
| db2f46b46e | |||
| ff7c768287 | |||
| 236fbf061c | |||
| 21b0a153e8 | |||
| 35ca3f3a07 | |||
| 87f72db8ad | |||
| 93b763865b | |||
| b15b5ad9ba | |||
| 364600e545 | |||
| 23b2a2a0c0 | |||
| ef6eec3cf8 | |||
| 94f4682f2f | |||
| f407a3cb54 | |||
| c11c1200e2 | |||
| 0acbf87dde | |||
| 153336d757 | |||
| 570710c556 | |||
| de13d5ea74 | |||
| f36e528086 | |||
| 52ce930c31 | |||
| bb694c9926 | |||
| a8c77c8db3 | |||
| 3263638c57 | |||
| c30e5800df | |||
| 161e0d8ea8 | |||
| 93f68aa29d | |||
| c5ce35ff13 | |||
| 7069c8b636 | |||
| 6149427753 | |||
| 536b762f97 | |||
| b423dcedf7 | |||
| 16cd1fef2d | |||
| c94d0a642d | |||
| 135390788d | |||
| 98e4e38809 | |||
| 6d5a12a21f | |||
| fe3b3b536c | |||
| aa8a6baac4 | |||
| b0982249c3 | |||
| b3a8a69244 | |||
| 8cd1ecffc4 | |||
| 7967b71405 | |||
| cd0d5971e2 | |||
| b52b4db989 | |||
| ef5cfb4908 | |||
| ee7781ee67 | |||
| 8c5bb32ec6 | |||
| 007400d2a7 | |||
| 1f60e64217 | |||
| 69de7e4afd | |||
| 29025059af | |||
| 6229c25872 | |||
| 73ac74a9d6 | |||
| ebb1e233d8 | |||
| e719e32c73 | |||
| 46ab5fe78d | |||
| dc467aef91 | |||
| 0bc915fe1f | |||
| b7f9ad786a | |||
| 6c80521cf8 | |||
| a174884269 | |||
| f3b1865a79 | |||
| 6c99651ac9 | |||
| 0aaf888dd1 | |||
| d947ce17a3 | |||
| 97c957b70f | |||
| 82830c86ac | |||
| d8e4189100 | |||
| 6bcde56525 | |||
| 88ebe3c337 | |||
| 5f4d1b05a8 | |||
| 370c46bddb | |||
| 47b5e03bbb | |||
| 556ca59a99 | |||
| 81c5af474d | |||
| cdaee3f62f | |||
| aab4288f67 | |||
| bab49e4442 | |||
| 7608aca681 | |||
| 58907bdc4d | |||
| 8dfd92082c | |||
| e39304da90 | |||
| 31fd3f3f63 | |||
| e1ab24b36b | |||
| f5b92ddcf9 | |||
| d9ee87d4b4 | |||
| 5e83db54ac | |||
| de7b12a759 | |||
| 1236011174 | |||
| b60f2cdf81 | |||
| 0c310ab068 | |||
| a87f66cc0c | |||
| c05756357f | |||
| f4b4b5febd | |||
| 805290b17f | |||
| fecc2237b8 | |||
| 471cc1ee94 | |||
| 41ebf59964 | |||
| a5e9a3e1ce | |||
| 23689d9fe1 | |||
| 601d432fbf | |||
| a21e9c508e | |||
| 55b0c0509d | |||
| 563c6b79fa | |||
| 8d9e5f9d56 | |||
| c0f6ccaf2a | |||
| 9b3e4ec7fb | |||
| 9d45eb21a4 | |||
| bcf8fe59f5 | |||
| 5b411456c7 | |||
| 4432816934 | |||
| 5277537445 | |||
| e73ce8cd8f | |||
| 120015d133 | |||
| f85cf61019 | |||
| 41226d173a | |||
| 83244c85fe | |||
| 27dd868d97 | |||
| 45b35ea5b0 | |||
| ac8b9f82cd | |||
| 9d0e417f2a | |||
| 40369ccb7b | |||
| 61ef3f7bdd | |||
| bcb1a825d3 | |||
| 1f7a3fe664 | |||
| dcd855896e | |||
| 4778134ab6 | |||
| 300b19d1d6 | |||
| 945ae33361 | |||
| dbbcb6c5cc | |||
| 016959ad7c | |||
| 7a9599786c | |||
| fa537390c5 | |||
| bb24bdb06c | |||
| b55100d5c3 | |||
| 02cb9c751a | |||
| 8555938f52 | |||
| a2a3ea62f1 | |||
| 0d5310eb4b | |||
| a5a2692a5f |
@@ -8,6 +8,7 @@ env/
|
||||
venv/
|
||||
.venv/
|
||||
ENV/
|
||||
uv.lock
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -28,3 +29,8 @@ Thumbs.db
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Package manager lock files & DB files
|
||||
uv.lock
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
+64
-9
@@ -1,19 +1,73 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to INTERCEPT will be documented in this file.
|
||||
All notable changes to iNTERCEPT will be documented in this file.
|
||||
|
||||
## [2.0.0] - 2025-01-06
|
||||
## [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
|
||||
- **Landing Page** - Animated welcome screen with logo reveal and "See the Invisible" tagline
|
||||
- **New Branding** - Redesigned logo featuring 'i' with signal wave brackets
|
||||
- **Logo Assets** - Full-size SVG logos in `/static/img/` for external use
|
||||
- **Instagram Promo** - Animated HTML promo video template in `/promo/` directory
|
||||
- **Listening Post Scanner** - Fully functional frequency scanning with signal detection
|
||||
- Scan button toggles between start/stop states
|
||||
- Signal hits logged with Listen button to tune directly
|
||||
- Proper 4-column display (Time, Frequency, Modulation, Action)
|
||||
|
||||
### Changed
|
||||
- **Rebranding** - Application renamed from "INTERCEPT" to "iNTERCEPT"
|
||||
- **Updated Tagline** - "Signal Intelligence & Counter Surveillance Platform"
|
||||
- **Setup Script** - Now installs Python packages via apt first (more reliable on Debian/Ubuntu)
|
||||
- Uses `--system-site-packages` for venv to leverage apt packages
|
||||
- Added fallback logic when pip fails
|
||||
- **Troubleshooting Docs** - Added sections for pip install issues and apt alternatives
|
||||
|
||||
### Fixed
|
||||
- **Tuning Dial Audio** - Fixed audio stopping when using tuning knob
|
||||
- Added restart prevention flags to avoid overlapping restarts
|
||||
- Increased debounce time for smoother operation
|
||||
- Added silent mode for programmatic value changes
|
||||
- **Scanner Signal Hits** - Fixed table column count and colspan
|
||||
- **Favicon** - Updated to new 'i' logo design
|
||||
|
||||
---
|
||||
|
||||
## [2.0.0] - 2026-01-06
|
||||
|
||||
### Added
|
||||
- **Listening Post Mode** - New frequency scanner with automatic signal detection
|
||||
- Scans frequency ranges and stops on detected signals
|
||||
- Real-time audio monitoring with sox integration
|
||||
- Real-time audio monitoring with ffmpeg integration
|
||||
- Skip button to continue scanning after signal detection
|
||||
- Configurable dwell time, squelch, and step size
|
||||
- Preset frequency bands (FM broadcast, Air band, Marine, etc.)
|
||||
- Activity log of detected signals
|
||||
- **Aircraft Dashboard Improvements**
|
||||
- Dependency warning when rtl_fm or sox not installed
|
||||
- Dependency warning when rtl_fm or ffmpeg not installed
|
||||
- Auto-restart audio when switching frequencies
|
||||
- Fixed toolbar overflow with custom frequency input
|
||||
- **Device Correlation** - Match WiFi and Bluetooth devices by manufacturer
|
||||
@@ -29,10 +83,10 @@ All notable changes to INTERCEPT will be documented in this file.
|
||||
- **Setup Script Rewrite**
|
||||
- Full macOS support with Homebrew auto-installation
|
||||
- Improved Debian/Ubuntu package detection
|
||||
- Added sox to tool checks
|
||||
- Added ffmpeg to tool checks
|
||||
- Better error messages with platform-specific install commands
|
||||
- **Dockerfile Updated**
|
||||
- Added sox and libsox-fmt-all for Listening Post audio
|
||||
- Added ffmpeg for Listening Post audio encoding
|
||||
- Added dump1090 with fallback for different package names
|
||||
|
||||
### Fixed
|
||||
@@ -50,7 +104,7 @@ All notable changes to INTERCEPT will be documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] - 2024-12-XX
|
||||
## [1.2.0] - 2026-12-29
|
||||
|
||||
### Added
|
||||
- Airspy SDR support
|
||||
@@ -62,7 +116,7 @@ All notable changes to INTERCEPT will be documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] - 2024-XX-XX
|
||||
## [1.1.0] - 2026-12-18
|
||||
|
||||
### Added
|
||||
- Satellite tracking with TLE data
|
||||
@@ -71,7 +125,7 @@ All notable changes to INTERCEPT will be documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2024-XX-XX
|
||||
## [1.0.0] - 2026-12-15
|
||||
|
||||
### Initial Release
|
||||
- Pager decoding (POCSAG/FLEX)
|
||||
@@ -80,3 +134,4 @@ All notable changes to INTERCEPT will be documented in this file.
|
||||
- WiFi reconnaissance
|
||||
- Bluetooth scanning
|
||||
- Multi-SDR support (RTL-SDR, LimeSDR, HackRF)
|
||||
|
||||
|
||||
+35
-9
@@ -20,8 +20,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Pager decoder
|
||||
multimon-ng \
|
||||
# Audio tools for Listening Post
|
||||
sox \
|
||||
libsox-fmt-all \
|
||||
ffmpeg \
|
||||
# WiFi tools (aircrack-ng suite)
|
||||
aircrack-ng \
|
||||
iw \
|
||||
@@ -36,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 .
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="static/images/screenshots/screenshot_main.png" alt="Screenshot">
|
||||
<img src="static/images/screenshots/logo-banner.png" alt="Screenshot">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -22,6 +22,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
|
||||
@@ -29,71 +30,24 @@
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
## Installation / Debian / Ubuntu / MacOS
|
||||
|
||||
### macOS
|
||||
|
||||
**1. Install Homebrew** (if not already installed):
|
||||
```bash
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
```
|
||||
|
||||
**2. Install dependencies:**
|
||||
```bash
|
||||
# Required
|
||||
brew install python@3.11 librtlsdr multimon-ng rtl_433 sox
|
||||
|
||||
# For ADS-B aircraft tracking
|
||||
brew install dump1090-mutability
|
||||
|
||||
# For WiFi scanning (optional)
|
||||
brew install aircrack-ng
|
||||
```
|
||||
|
||||
**3. Clone and run:**
|
||||
**1. Clone and run:**
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
./setup.sh
|
||||
sudo python3 intercept.py
|
||||
sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### Debian / Ubuntu / Raspberry Pi OS
|
||||
|
||||
**1. Install dependencies:**
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y python3 python3-pip python3-venv git
|
||||
|
||||
# Required SDR tools
|
||||
sudo apt install -y rtl-sdr multimon-ng rtl-433 sox
|
||||
|
||||
# For ADS-B aircraft tracking (package name varies)
|
||||
sudo apt install -y dump1090-mutability # or dump1090-fa
|
||||
|
||||
# For WiFi scanning (optional)
|
||||
sudo apt install -y aircrack-ng
|
||||
|
||||
# For Bluetooth scanning (optional)
|
||||
sudo apt install -y bluez bluetooth
|
||||
```
|
||||
|
||||
**2. Clone and run:**
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
./setup.sh
|
||||
sudo python3 intercept.py
|
||||
```
|
||||
|
||||
> **Note:** On Raspberry Pi or headless systems, you may need to run `sudo venv/bin/python intercept.py` if a virtual environment was created.
|
||||
|
||||
### Docker (Alternative)
|
||||
|
||||
```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.
|
||||
@@ -109,50 +63,23 @@ After starting, open **http://localhost:5050** in your browser.
|
||||
| Hardware | Purpose | Price |
|
||||
|----------|---------|-------|
|
||||
| **RTL-SDR** | Required for all SDR features | ~$25-35 |
|
||||
| **WiFi adapter** | Monitor mode scanning (optional) | ~$20-40 |
|
||||
| **WiFi adapter** | Must support promiscuous (monitor) mode | ~$20-40 |
|
||||
| **Bluetooth adapter** | Device scanning (usually built-in) | - |
|
||||
| **GPS** | Any Linux supported GPS Unit | ~10 |
|
||||
|
||||
Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
|
||||
|
||||
---
|
||||
| :exclamation: Not using an RTL-SDR Device? |
|
||||
|-----------------------------------------------
|
||||
|Intercept supports any device that SoapySDR supports. You must however have the correct module for your device installed! For example if you have an SDRPlay device you'd need to install soapysdr-module-sdrplay.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### RTL-SDR not detected (Linux)
|
||||
|
||||
Add udev rules:
|
||||
```bash
|
||||
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"
|
||||
EOF'
|
||||
sudo udevadm control --reload-rules && sudo udevadm trigger
|
||||
```
|
||||
Then unplug and replug your RTL-SDR.
|
||||
|
||||
### "externally-managed-environment" error (Ubuntu 23.04+)
|
||||
|
||||
The setup script handles this automatically by creating a virtual environment. Run:
|
||||
```bash
|
||||
./setup.sh
|
||||
source venv/bin/activate
|
||||
sudo venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### dump1090 not available (Debian Trixie)
|
||||
|
||||
On newer Debian versions, dump1090 may not be in repositories. Install from FlightAware:
|
||||
- https://flightaware.com/adsb/piaware/install
|
||||
|
||||
### Verify installation
|
||||
|
||||
```bash
|
||||
python3 intercept.py --check-deps
|
||||
```
|
||||
| :exclamation: GPS Usage |
|
||||
|-----------------------------------------------
|
||||
|gpsd is needed for real time location. Intercept automatically checks to see if you're running gpsd in the background when any maps are rendered.
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
## Discord Server
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/z3g3NJMe">Join our Discord</a>
|
||||
@@ -165,11 +92,14 @@ python3 intercept.py --check-deps
|
||||
- [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode
|
||||
- [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup
|
||||
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
|
||||
- [Security](docs/SECURITY.md) - Network security and best practices
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This project was developed using AI as a coding partner, combining human direction with AI-assisted implementation. The goal: make Software Defined Radio more accessible by providing a clean, unified interface for common SDR tools.
|
||||
|
||||
**This software is for educational and authorized testing purposes only.**
|
||||
|
||||
- Only use with proper authorization
|
||||
@@ -192,6 +122,12 @@ 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/)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": "2026-01-11_fae1348c",
|
||||
"downloaded": "2026-01-12T15:55:42.769654Z"
|
||||
}
|
||||
@@ -25,7 +25,7 @@ from typing import Any
|
||||
|
||||
from flask import Flask, render_template, jsonify, send_file, Response, request
|
||||
|
||||
from 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
|
||||
@@ -45,6 +45,30 @@ _app_start_time = _time.time()
|
||||
# Create Flask app
|
||||
app = Flask(__name__)
|
||||
|
||||
# Disable Werkzeug debugger PIN (not needed for local development tool)
|
||||
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
|
||||
|
||||
|
||||
# ============================================
|
||||
# SECURITY HEADERS
|
||||
# ============================================
|
||||
|
||||
@app.after_request
|
||||
def add_security_headers(response):
|
||||
"""Add security headers to all responses."""
|
||||
# Prevent MIME type sniffing
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
# Prevent clickjacking
|
||||
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
# Enable XSS filter
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
# Referrer policy
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
# Permissions policy (disable unnecessary features)
|
||||
response.headers['Permissions-Policy'] = 'geolocation=(self), microphone=()'
|
||||
return response
|
||||
|
||||
|
||||
# ============================================
|
||||
# GLOBAL PROCESS MANAGEMENT
|
||||
# ============================================
|
||||
@@ -79,6 +103,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
|
||||
# ============================================
|
||||
@@ -125,7 +164,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')
|
||||
@@ -140,6 +179,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."""
|
||||
@@ -278,6 +431,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),
|
||||
},
|
||||
@@ -293,7 +448,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
|
||||
@@ -302,7 +458,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:
|
||||
@@ -327,6 +483,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})
|
||||
|
||||
|
||||
@@ -379,10 +544,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()
|
||||
|
||||
@@ -397,6 +584,14 @@ def main() -> None:
|
||||
from routes import register_blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
# Initialize WebSocket for audio streaming
|
||||
try:
|
||||
from routes.audio_websocket import init_audio_websocket
|
||||
init_audio_websocket(app)
|
||||
print("WebSocket audio streaming enabled")
|
||||
except ImportError as e:
|
||||
print(f"WebSocket audio disabled (install flask-sock): {e}")
|
||||
|
||||
print(f"Open http://localhost:{args.port} in your browser")
|
||||
print()
|
||||
print("Press Ctrl+C to stop")
|
||||
|
||||
@@ -7,7 +7,51 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "2.0.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:
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
"""
|
||||
TSCM (Technical Surveillance Countermeasures) Frequency Database
|
||||
|
||||
Known surveillance device frequencies, sweep presets, and threat signatures
|
||||
for counter-surveillance operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# =============================================================================
|
||||
# Known Surveillance Frequencies (MHz)
|
||||
# =============================================================================
|
||||
|
||||
SURVEILLANCE_FREQUENCIES = {
|
||||
'wireless_mics': [
|
||||
{'start': 49.0, 'end': 50.0, 'name': '49 MHz Wireless Mics', 'risk': 'medium'},
|
||||
{'start': 72.0, 'end': 76.0, 'name': 'VHF Low Band Mics', 'risk': 'medium'},
|
||||
{'start': 170.0, 'end': 216.0, 'name': 'VHF High Band Wireless', 'risk': 'medium'},
|
||||
{'start': 470.0, 'end': 698.0, 'name': 'UHF TV Band Wireless', 'risk': 'medium'},
|
||||
{'start': 902.0, 'end': 928.0, 'name': '900 MHz ISM Wireless', 'risk': 'high'},
|
||||
{'start': 1880.0, 'end': 1920.0, 'name': 'DECT Wireless', 'risk': 'high'},
|
||||
],
|
||||
|
||||
'wireless_cameras': [
|
||||
{'start': 900.0, 'end': 930.0, 'name': '900 MHz Video TX', 'risk': 'high'},
|
||||
{'start': 1200.0, 'end': 1300.0, 'name': '1.2 GHz Video', 'risk': 'high'},
|
||||
{'start': 2400.0, 'end': 2483.5, 'name': '2.4 GHz WiFi Cameras', 'risk': 'high'},
|
||||
{'start': 5150.0, 'end': 5850.0, 'name': '5.8 GHz Video', 'risk': 'high'},
|
||||
],
|
||||
|
||||
'gps_trackers': [
|
||||
{'start': 824.0, 'end': 849.0, 'name': 'Cellular 850 Uplink', 'risk': 'high'},
|
||||
{'start': 869.0, 'end': 894.0, 'name': 'Cellular 850 Downlink', 'risk': 'high'},
|
||||
{'start': 1710.0, 'end': 1755.0, 'name': 'AWS Uplink', 'risk': 'high'},
|
||||
{'start': 1850.0, 'end': 1910.0, 'name': 'PCS Uplink', 'risk': 'high'},
|
||||
{'start': 1930.0, 'end': 1990.0, 'name': 'PCS Downlink', 'risk': 'high'},
|
||||
],
|
||||
|
||||
'body_worn': [
|
||||
{'start': 49.0, 'end': 50.0, 'name': '49 MHz Body Wires', 'risk': 'critical'},
|
||||
{'start': 72.0, 'end': 76.0, 'name': 'VHF Low Band Wires', 'risk': 'critical'},
|
||||
{'start': 150.0, 'end': 174.0, 'name': 'VHF High Band', 'risk': 'critical'},
|
||||
{'start': 380.0, 'end': 400.0, 'name': 'TETRA Band', 'risk': 'high'},
|
||||
{'start': 406.0, 'end': 420.0, 'name': 'Federal/Government', 'risk': 'critical'},
|
||||
{'start': 450.0, 'end': 470.0, 'name': 'UHF Business Band', 'risk': 'high'},
|
||||
],
|
||||
|
||||
'common_bugs': [
|
||||
{'start': 88.0, 'end': 108.0, 'name': 'FM Broadcast Band Bugs', 'risk': 'low'},
|
||||
{'start': 140.0, 'end': 150.0, 'name': 'Low VHF Bugs', 'risk': 'high'},
|
||||
{'start': 418.0, 'end': 419.0, 'name': '418 MHz ISM', 'risk': 'medium'},
|
||||
{'start': 433.0, 'end': 434.8, 'name': '433 MHz ISM Band', 'risk': 'medium'},
|
||||
{'start': 868.0, 'end': 870.0, 'name': '868 MHz ISM (Europe)', 'risk': 'medium'},
|
||||
{'start': 315.0, 'end': 316.0, 'name': '315 MHz ISM (US)', 'risk': 'medium'},
|
||||
],
|
||||
|
||||
'ism_bands': [
|
||||
{'start': 26.96, 'end': 27.41, 'name': 'CB Radio / ISM 27 MHz', 'risk': 'low'},
|
||||
{'start': 40.66, 'end': 40.70, 'name': 'ISM 40 MHz', 'risk': 'low'},
|
||||
{'start': 315.0, 'end': 316.0, 'name': 'ISM 315 MHz (US)', 'risk': 'medium'},
|
||||
{'start': 433.05, 'end': 434.79, 'name': 'ISM 433 MHz (EU)', 'risk': 'medium'},
|
||||
{'start': 868.0, 'end': 868.6, 'name': 'ISM 868 MHz (EU)', 'risk': 'medium'},
|
||||
{'start': 902.0, 'end': 928.0, 'name': 'ISM 915 MHz (US)', 'risk': 'medium'},
|
||||
{'start': 2400.0, 'end': 2483.5, 'name': 'ISM 2.4 GHz', 'risk': 'medium'},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Sweep Presets
|
||||
# =============================================================================
|
||||
|
||||
SWEEP_PRESETS = {
|
||||
'quick': {
|
||||
'name': 'Quick Scan',
|
||||
'description': 'Fast 2-minute check of most common bug frequencies',
|
||||
'duration_seconds': 120,
|
||||
'ranges': [
|
||||
{'start': 88.0, 'end': 108.0, 'step': 0.1, 'name': 'FM Band'},
|
||||
{'start': 433.0, 'end': 435.0, 'step': 0.025, 'name': '433 MHz ISM'},
|
||||
{'start': 868.0, 'end': 870.0, 'step': 0.025, 'name': '868 MHz ISM'},
|
||||
],
|
||||
'wifi': True,
|
||||
'bluetooth': True,
|
||||
'rf': True,
|
||||
},
|
||||
|
||||
'standard': {
|
||||
'name': 'Standard Sweep',
|
||||
'description': 'Comprehensive 5-minute sweep of common surveillance bands',
|
||||
'duration_seconds': 300,
|
||||
'ranges': [
|
||||
{'start': 25.0, 'end': 50.0, 'step': 0.1, 'name': 'HF/Low VHF'},
|
||||
{'start': 88.0, 'end': 108.0, 'step': 0.1, 'name': 'FM Band'},
|
||||
{'start': 140.0, 'end': 175.0, 'step': 0.025, 'name': 'VHF'},
|
||||
{'start': 380.0, 'end': 450.0, 'step': 0.025, 'name': 'UHF Low'},
|
||||
{'start': 868.0, 'end': 930.0, 'step': 0.05, 'name': 'ISM 868/915'},
|
||||
],
|
||||
'wifi': True,
|
||||
'bluetooth': True,
|
||||
'rf': True,
|
||||
},
|
||||
|
||||
'full': {
|
||||
'name': 'Full Spectrum',
|
||||
'description': 'Complete 15-minute spectrum sweep (24 MHz - 1.7 GHz)',
|
||||
'duration_seconds': 900,
|
||||
'ranges': [
|
||||
{'start': 24.0, 'end': 1700.0, 'step': 0.1, 'name': 'Full Spectrum'},
|
||||
],
|
||||
'wifi': True,
|
||||
'bluetooth': True,
|
||||
'rf': True,
|
||||
},
|
||||
|
||||
'wireless_cameras': {
|
||||
'name': 'Wireless Cameras',
|
||||
'description': 'Focus on video transmission frequencies',
|
||||
'duration_seconds': 180,
|
||||
'ranges': [
|
||||
{'start': 900.0, 'end': 930.0, 'step': 0.1, 'name': '900 MHz Video'},
|
||||
{'start': 1200.0, 'end': 1300.0, 'step': 0.5, 'name': '1.2 GHz Video'},
|
||||
],
|
||||
'wifi': True, # WiFi cameras
|
||||
'bluetooth': False,
|
||||
'rf': True,
|
||||
},
|
||||
|
||||
'body_worn': {
|
||||
'name': 'Body-Worn Devices',
|
||||
'description': 'Detect body wires and covert transmitters',
|
||||
'duration_seconds': 240,
|
||||
'ranges': [
|
||||
{'start': 49.0, 'end': 50.0, 'step': 0.01, 'name': '49 MHz'},
|
||||
{'start': 72.0, 'end': 76.0, 'step': 0.01, 'name': 'VHF Low'},
|
||||
{'start': 150.0, 'end': 174.0, 'step': 0.0125, 'name': 'VHF High'},
|
||||
{'start': 406.0, 'end': 420.0, 'step': 0.0125, 'name': 'Federal'},
|
||||
{'start': 450.0, 'end': 470.0, 'step': 0.0125, 'name': 'UHF'},
|
||||
],
|
||||
'wifi': False,
|
||||
'bluetooth': True, # BLE bugs
|
||||
'rf': True,
|
||||
},
|
||||
|
||||
'gps_trackers': {
|
||||
'name': 'GPS Trackers',
|
||||
'description': 'Detect cellular-based GPS tracking devices',
|
||||
'duration_seconds': 180,
|
||||
'ranges': [
|
||||
{'start': 824.0, 'end': 894.0, 'step': 0.1, 'name': 'Cellular 850'},
|
||||
{'start': 1850.0, 'end': 1990.0, 'step': 0.1, 'name': 'PCS Band'},
|
||||
],
|
||||
'wifi': False,
|
||||
'bluetooth': True, # BLE trackers
|
||||
'rf': True,
|
||||
},
|
||||
|
||||
'bluetooth_only': {
|
||||
'name': 'Bluetooth/BLE Trackers',
|
||||
'description': 'Focus on BLE tracking devices (AirTag, Tile, etc.)',
|
||||
'duration_seconds': 60,
|
||||
'ranges': [],
|
||||
'wifi': False,
|
||||
'bluetooth': True,
|
||||
'rf': False,
|
||||
},
|
||||
|
||||
'wifi_only': {
|
||||
'name': 'WiFi Devices',
|
||||
'description': 'Scan for hidden WiFi cameras and access points',
|
||||
'duration_seconds': 60,
|
||||
'ranges': [],
|
||||
'wifi': True,
|
||||
'bluetooth': False,
|
||||
'rf': False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Known Tracker Signatures
|
||||
# =============================================================================
|
||||
|
||||
BLE_TRACKER_SIGNATURES = {
|
||||
'apple_airtag': {
|
||||
'name': 'Apple AirTag',
|
||||
'company_id': 0x004C,
|
||||
'patterns': ['findmy', 'airtag'],
|
||||
'risk': 'high',
|
||||
'description': 'Apple Find My network tracker',
|
||||
},
|
||||
'tile': {
|
||||
'name': 'Tile Tracker',
|
||||
'company_id': 0x00ED,
|
||||
'patterns': ['tile'],
|
||||
'oui_prefixes': ['C4:E7', 'DC:54', 'E6:43'],
|
||||
'risk': 'high',
|
||||
'description': 'Tile Bluetooth tracker',
|
||||
},
|
||||
'samsung_smarttag': {
|
||||
'name': 'Samsung SmartTag',
|
||||
'company_id': 0x0075,
|
||||
'patterns': ['smarttag', 'smartthings'],
|
||||
'risk': 'high',
|
||||
'description': 'Samsung SmartThings tracker',
|
||||
},
|
||||
'chipolo': {
|
||||
'name': 'Chipolo',
|
||||
'company_id': 0x0A09,
|
||||
'patterns': ['chipolo'],
|
||||
'risk': 'high',
|
||||
'description': 'Chipolo Bluetooth tracker',
|
||||
},
|
||||
'generic_beacon': {
|
||||
'name': 'Unknown BLE Beacon',
|
||||
'company_id': None,
|
||||
'patterns': [],
|
||||
'risk': 'medium',
|
||||
'description': 'Unidentified BLE beacon device',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Threat Classification
|
||||
# =============================================================================
|
||||
|
||||
THREAT_TYPES = {
|
||||
'new_device': {
|
||||
'name': 'New Device',
|
||||
'description': 'Device not present in baseline',
|
||||
'default_severity': 'medium',
|
||||
},
|
||||
'tracker': {
|
||||
'name': 'Tracking Device',
|
||||
'description': 'Known BLE tracker detected',
|
||||
'default_severity': 'high',
|
||||
},
|
||||
'unknown_signal': {
|
||||
'name': 'Unknown Signal',
|
||||
'description': 'Unidentified RF transmission',
|
||||
'default_severity': 'medium',
|
||||
},
|
||||
'burst_transmission': {
|
||||
'name': 'Burst Transmission',
|
||||
'description': 'Intermittent/store-and-forward signal detected',
|
||||
'default_severity': 'high',
|
||||
},
|
||||
'hidden_camera': {
|
||||
'name': 'Potential Hidden Camera',
|
||||
'description': 'WiFi camera or video transmitter detected',
|
||||
'default_severity': 'critical',
|
||||
},
|
||||
'gsm_bug': {
|
||||
'name': 'GSM/Cellular Bug',
|
||||
'description': 'Cellular transmission in non-phone device context',
|
||||
'default_severity': 'critical',
|
||||
},
|
||||
'rogue_ap': {
|
||||
'name': 'Rogue Access Point',
|
||||
'description': 'Unauthorized WiFi access point',
|
||||
'default_severity': 'high',
|
||||
},
|
||||
'anomaly': {
|
||||
'name': 'Signal Anomaly',
|
||||
'description': 'Unusual signal pattern or behavior',
|
||||
'default_severity': 'low',
|
||||
},
|
||||
}
|
||||
|
||||
SEVERITY_LEVELS = {
|
||||
'critical': {
|
||||
'level': 4,
|
||||
'color': '#ff0000',
|
||||
'description': 'Immediate action required - active surveillance likely',
|
||||
},
|
||||
'high': {
|
||||
'level': 3,
|
||||
'color': '#ff6600',
|
||||
'description': 'Strong indicator of surveillance device',
|
||||
},
|
||||
'medium': {
|
||||
'level': 2,
|
||||
'color': '#ffcc00',
|
||||
'description': 'Potential threat - requires investigation',
|
||||
},
|
||||
'low': {
|
||||
'level': 1,
|
||||
'color': '#00cc00',
|
||||
'description': 'Minor anomaly - low probability of threat',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WiFi Camera Detection Patterns
|
||||
# =============================================================================
|
||||
|
||||
WIFI_CAMERA_PATTERNS = {
|
||||
'ssid_patterns': [
|
||||
'cam', 'camera', 'ipcam', 'webcam', 'dvr', 'nvr',
|
||||
'hikvision', 'dahua', 'reolink', 'wyze', 'ring',
|
||||
'arlo', 'nest', 'blink', 'eufy', 'yi',
|
||||
],
|
||||
'oui_manufacturers': [
|
||||
'Hikvision',
|
||||
'Dahua',
|
||||
'Axis Communications',
|
||||
'Hanwha Techwin',
|
||||
'Vivotek',
|
||||
'Ubiquiti',
|
||||
'Wyze Labs',
|
||||
'Amazon Technologies', # Ring
|
||||
'Google', # Nest
|
||||
],
|
||||
'mac_prefixes': {
|
||||
'C0:25:E9': 'TP-Link Camera',
|
||||
'A4:DA:22': 'TP-Link Camera',
|
||||
'78:8C:B5': 'TP-Link Camera',
|
||||
'D4:6E:0E': 'TP-Link Camera',
|
||||
'2C:AA:8E': 'Wyze Camera',
|
||||
'AC:CF:85': 'Hikvision',
|
||||
'54:C4:15': 'Hikvision',
|
||||
'C0:56:E3': 'Hikvision',
|
||||
'3C:EF:8C': 'Dahua',
|
||||
'A0:BD:1D': 'Dahua',
|
||||
'E4:24:6C': 'Dahua',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Utility Functions
|
||||
# =============================================================================
|
||||
|
||||
def get_frequency_risk(frequency_mhz: float) -> tuple[str, str]:
|
||||
"""
|
||||
Determine the risk level for a given frequency.
|
||||
|
||||
Returns:
|
||||
Tuple of (risk_level, category_name)
|
||||
"""
|
||||
for category, ranges in SURVEILLANCE_FREQUENCIES.items():
|
||||
for freq_range in ranges:
|
||||
if freq_range['start'] <= frequency_mhz <= freq_range['end']:
|
||||
return freq_range['risk'], freq_range['name']
|
||||
|
||||
return 'low', 'Unknown Band'
|
||||
|
||||
|
||||
def get_sweep_preset(preset_name: str) -> dict | None:
|
||||
"""Get a sweep preset by name."""
|
||||
return SWEEP_PRESETS.get(preset_name)
|
||||
|
||||
|
||||
def get_all_sweep_presets() -> dict:
|
||||
"""Get all available sweep presets."""
|
||||
return {
|
||||
name: {
|
||||
'name': preset['name'],
|
||||
'description': preset['description'],
|
||||
'duration_seconds': preset['duration_seconds'],
|
||||
}
|
||||
for name, preset in SWEEP_PRESETS.items()
|
||||
}
|
||||
|
||||
|
||||
def is_known_tracker(device_name: str | None, manufacturer_data: bytes | None = None) -> dict | None:
|
||||
"""
|
||||
Check if a BLE device matches known tracker signatures.
|
||||
|
||||
Returns:
|
||||
Tracker info dict if match found, None otherwise
|
||||
"""
|
||||
if device_name:
|
||||
name_lower = device_name.lower()
|
||||
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
||||
for pattern in tracker_info.get('patterns', []):
|
||||
if pattern in name_lower:
|
||||
return tracker_info
|
||||
|
||||
if manufacturer_data and len(manufacturer_data) >= 2:
|
||||
company_id = int.from_bytes(manufacturer_data[:2], 'little')
|
||||
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
||||
if tracker_info.get('company_id') == company_id:
|
||||
return tracker_info
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_potential_camera(ssid: str | None = None, mac: str | None = None, vendor: str | None = None) -> bool:
|
||||
"""Check if a WiFi device might be a hidden camera."""
|
||||
if ssid:
|
||||
ssid_lower = ssid.lower()
|
||||
for pattern in WIFI_CAMERA_PATTERNS['ssid_patterns']:
|
||||
if pattern in ssid_lower:
|
||||
return True
|
||||
|
||||
if mac:
|
||||
mac_prefix = mac[:8].upper()
|
||||
if mac_prefix in WIFI_CAMERA_PATTERNS['mac_prefixes']:
|
||||
return True
|
||||
|
||||
if vendor:
|
||||
vendor_lower = vendor.lower()
|
||||
for manufacturer in WIFI_CAMERA_PATTERNS['oui_manufacturers']:
|
||||
if manufacturer.lower() in vendor_lower:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_threat_severity(threat_type: str, context: dict | None = None) -> str:
|
||||
"""
|
||||
Determine threat severity based on type and context.
|
||||
|
||||
Args:
|
||||
threat_type: Type of threat from THREAT_TYPES
|
||||
context: Optional context dict with signal_strength, etc.
|
||||
|
||||
Returns:
|
||||
Severity level string
|
||||
"""
|
||||
threat_info = THREAT_TYPES.get(threat_type, {})
|
||||
base_severity = threat_info.get('default_severity', 'medium')
|
||||
|
||||
if context:
|
||||
# Upgrade severity based on signal strength (closer = more concerning)
|
||||
signal = context.get('signal_strength')
|
||||
if signal and signal > -50: # Very strong signal
|
||||
if base_severity == 'medium':
|
||||
return 'high'
|
||||
elif base_severity == 'high':
|
||||
return 'critical'
|
||||
|
||||
return base_severity
|
||||
+36
-2
@@ -75,13 +75,47 @@ Complete feature list for all modules.
|
||||
## Bluetooth Scanning
|
||||
|
||||
- **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
|
||||
|
||||
+58
-13
@@ -21,7 +21,7 @@ INTERCEPT automatically detects connected devices.
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
|
||||
# Core tools (required)
|
||||
brew install python@3.11 librtlsdr multimon-ng rtl_433 sox
|
||||
brew install python@3.11 librtlsdr multimon-ng rtl_433 ffmpeg
|
||||
|
||||
# ADS-B aircraft tracking
|
||||
brew install dump1090-mutability
|
||||
@@ -43,8 +43,8 @@ brew install hackrf soapyhackrf
|
||||
sudo apt update
|
||||
|
||||
# Core tools (required)
|
||||
sudo apt install -y python3 python3-pip python3-venv
|
||||
sudo apt install -y rtl-sdr multimon-ng rtl-433 sox
|
||||
sudo apt install -y python3 python3-pip python3-venv python3-skyfield
|
||||
sudo apt install -y rtl-sdr multimon-ng rtl-433 ffmpeg
|
||||
|
||||
# ADS-B aircraft tracking
|
||||
sudo apt install -y dump1090-mutability
|
||||
@@ -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.
|
||||
@@ -162,7 +158,7 @@ Open **http://localhost:5050** in your browser.
|
||||
| `multimon-ng` | multimon-ng | multimon-ng | Pager decoding |
|
||||
| `rtl_433` | rtl-433 | rtl_433 | 433MHz sensors |
|
||||
| `dump1090` | dump1090-mutability | dump1090-mutability | ADS-B tracking |
|
||||
| `sox` | sox | sox | Listening Post audio |
|
||||
| `ffmpeg` | ffmpeg | ffmpeg | Listening Post audio |
|
||||
| `airmon-ng` | aircrack-ng | aircrack-ng | WiFi monitor mode |
|
||||
| `airodump-ng` | aircrack-ng | aircrack-ng | WiFi scanning |
|
||||
| `aireplay-ng` | aircrack-ng | aircrack-ng | WiFi deauth (optional) |
|
||||
@@ -182,8 +178,8 @@ Open **http://localhost:5050** in your browser.
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `flask` | Web server |
|
||||
| `skyfield` | Satellite tracking (optional) |
|
||||
| `pyserial` | USB GPS dongle support (optional) |
|
||||
| `skyfield` | Satellite tracking |
|
||||
| `bleak` | BLE scanning with manufacturer data (TSCM) |
|
||||
|
||||
---
|
||||
|
||||
@@ -204,8 +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
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Security Considerations
|
||||
|
||||
INTERCEPT is designed as a **local signal intelligence tool** for personal use on trusted networks. This document outlines security considerations and best practices.
|
||||
|
||||
## Network Binding
|
||||
|
||||
By default, INTERCEPT binds to `0.0.0.0:5050`, making it accessible from any network interface. This is convenient for accessing the web UI from other devices on your local network, but has security implications:
|
||||
|
||||
### Recommendations
|
||||
|
||||
1. **Firewall Rules**: If you don't need remote access, configure your firewall to block external access to port 5050:
|
||||
```bash
|
||||
# Linux (iptables)
|
||||
sudo iptables -A INPUT -p tcp --dport 5050 -s 127.0.0.1 -j ACCEPT
|
||||
sudo iptables -A INPUT -p tcp --dport 5050 -j DROP
|
||||
|
||||
# macOS (pf)
|
||||
echo "block in on en0 proto tcp from any to any port 5050" | sudo pfctl -ef -
|
||||
```
|
||||
|
||||
2. **Bind to Localhost**: For local-only access, set the host environment variable:
|
||||
```bash
|
||||
export INTERCEPT_HOST=127.0.0.1
|
||||
python intercept.py
|
||||
```
|
||||
|
||||
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
|
||||
|
||||
## Authentication
|
||||
|
||||
INTERCEPT does **not** include authentication. This is by design for ease of use as a personal tool. If you need to expose INTERCEPT to untrusted networks:
|
||||
|
||||
1. Use a reverse proxy (nginx, Caddy) with authentication
|
||||
2. Use a VPN to access your home network
|
||||
3. Use SSH port forwarding: `ssh -L 5050:localhost:5050 your-server`
|
||||
|
||||
## Security Headers
|
||||
|
||||
INTERCEPT includes the following security headers on all responses:
|
||||
|
||||
| Header | Value | Purpose |
|
||||
|--------|-------|---------|
|
||||
| `X-Content-Type-Options` | `nosniff` | Prevent MIME type sniffing |
|
||||
| `X-Frame-Options` | `SAMEORIGIN` | Prevent clickjacking |
|
||||
| `X-XSS-Protection` | `1; mode=block` | Enable browser XSS filter |
|
||||
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Control referrer information |
|
||||
| `Permissions-Policy` | `geolocation=(self), microphone=()` | Restrict browser features |
|
||||
|
||||
## Input Validation
|
||||
|
||||
All user inputs are validated before use:
|
||||
|
||||
- **Network interface names**: Validated against strict regex pattern
|
||||
- **Bluetooth interface names**: Must match `hciX` format
|
||||
- **MAC addresses**: Validated format
|
||||
- **Frequencies**: Validated range and format
|
||||
- **File paths**: Protected against directory traversal
|
||||
- **HTML output**: All user-provided content is escaped
|
||||
|
||||
## Subprocess Execution
|
||||
|
||||
INTERCEPT executes external tools (rtl_fm, airodump-ng, etc.) via subprocess. Security measures:
|
||||
|
||||
- **No shell execution**: All subprocess calls use list arguments, not shell strings
|
||||
- **Input validation**: All user-provided arguments are validated before use
|
||||
- **Process isolation**: Each tool runs in its own process with limited permissions
|
||||
|
||||
## Debug Mode
|
||||
|
||||
Debug mode is **disabled by default**. If enabled via `INTERCEPT_DEBUG=true`:
|
||||
|
||||
- The Werkzeug debugger PIN is disabled (not needed for local tool)
|
||||
- Additional logging is enabled
|
||||
- Stack traces are shown on errors
|
||||
|
||||
**Never run in debug mode on untrusted networks.**
|
||||
|
||||
## Reporting Security Issues
|
||||
|
||||
If you discover a security vulnerability, please report it by:
|
||||
|
||||
1. Opening a GitHub issue (for non-sensitive issues)
|
||||
2. Emailing the maintainer directly (for sensitive issues)
|
||||
|
||||
Please include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if any)
|
||||
+236
-30
@@ -14,6 +14,37 @@ pip install -r requirements.txt
|
||||
python3 -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### pip install fails for flask or skyfield
|
||||
|
||||
On newer Debian/Ubuntu systems, pip may fail with permission errors or dependency conflicts. **Use apt instead:**
|
||||
|
||||
```bash
|
||||
# Install Python packages via apt (recommended for Debian/Ubuntu)
|
||||
sudo apt install python3-flask python3-requests python3-serial python3-skyfield
|
||||
|
||||
# Then create venv with system packages
|
||||
python3 -m venv --system-site-packages venv
|
||||
source venv/bin/activate
|
||||
sudo venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### "error: externally-managed-environment" (pip blocked)
|
||||
|
||||
This is PEP 668 protection on Ubuntu 23.04+, Debian 12+, and similar systems. Solutions:
|
||||
|
||||
```bash
|
||||
# Option 1: Use apt packages (recommended)
|
||||
sudo apt install python3-flask python3-requests python3-serial python3-skyfield
|
||||
python3 -m venv --system-site-packages venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Option 2: Use pipx for isolated install
|
||||
pipx install flask
|
||||
|
||||
# Option 3: Force pip (not recommended)
|
||||
pip install --break-system-packages flask
|
||||
```
|
||||
|
||||
### "TypeError: 'type' object is not subscriptable"
|
||||
|
||||
This error occurs on Python 3.7 or 3.8. **INTERCEPT requires Python 3.9 or later.**
|
||||
@@ -33,18 +64,12 @@ pip install -r requirements.txt
|
||||
sudo venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### "externally-managed-environment" error (Ubuntu 23.04+, Debian 12+)
|
||||
### Alternative: Use the setup script
|
||||
|
||||
Modern systems use PEP 668 to protect system Python. Use a virtual environment:
|
||||
The setup script handles all installation automatically, including apt packages:
|
||||
|
||||
```bash
|
||||
# Option 1: Virtual environment (recommended)
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
sudo venv/bin/python intercept.py
|
||||
|
||||
# Option 2: Use the setup script (auto-creates venv if needed)
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
@@ -101,11 +126,204 @@ Then unplug and replug your RTL-SDR.
|
||||
3. Check for other applications: `lsof | grep rtl`
|
||||
|
||||
### LimeSDR/HackRF not detected
|
||||
Ensure the correct SoapySDR module for your hardware is installed first
|
||||
|
||||
1. Verify SoapySDR is installed: `SoapySDRUtil --info`
|
||||
2. Check driver is loaded: `SoapySDRUtil --find`
|
||||
3. May need udev rules or run as root
|
||||
|
||||
### Using HackRF/Airspy/LimeSDR with ADS-B
|
||||
|
||||
For non-RTL-SDR devices, ADS-B requires `readsb` compiled with SoapySDR support (standard dump1090 won't work).
|
||||
|
||||
**Option 1: Run readsb separately and connect via Remote mode**
|
||||
|
||||
1. Start readsb with your device:
|
||||
```bash
|
||||
# HackRF
|
||||
readsb --device-type soapysdr --device driver=hackrf --net --quiet
|
||||
|
||||
# Airspy
|
||||
readsb --device-type soapysdr --device driver=airspy --net --quiet
|
||||
|
||||
# LimeSDR
|
||||
readsb --device-type soapysdr --device driver=lime --net --quiet
|
||||
```
|
||||
|
||||
2. In Intercept's ADS-B dashboard:
|
||||
- Check the **"Remote"** checkbox
|
||||
- Enter Host: `localhost` and Port: `30003`
|
||||
- Click **START**
|
||||
|
||||
3. Intercept will connect to readsb's SBS output on port 30003
|
||||
|
||||
**Option 2: Install readsb with SoapySDR support**
|
||||
|
||||
On Debian/Ubuntu:
|
||||
```bash
|
||||
# Install dependencies
|
||||
sudo apt install build-essential debhelper librtlsdr-dev pkg-config \
|
||||
libncurses5-dev libbladerf-dev libhackrf-dev liblimesuite-dev libsoapysdr-dev
|
||||
|
||||
# Clone and build
|
||||
git clone https://github.com/wiedehopf/readsb.git
|
||||
cd readsb
|
||||
dpkg-buildpackage -b --no-sign
|
||||
sudo dpkg -i ../readsb_*.deb
|
||||
```
|
||||
|
||||
### Using HackRF/Airspy with Listening Post
|
||||
|
||||
The Listening Post requires `rx_fm` from SoapySDR utilities for non-RTL-SDR devices.
|
||||
|
||||
```bash
|
||||
# Install SoapySDR utilities (includes rx_fm)
|
||||
sudo apt install soapysdr-tools
|
||||
|
||||
# Verify rx_fm is available
|
||||
which rx_fm
|
||||
```
|
||||
|
||||
If `rx_fm` is installed, select your device from the SDR dropdown in the Listening Post - HackRF, Airspy, LimeSDR, and SDRPlay are all supported.
|
||||
|
||||
### Setting up Icecast for Listening Post Audio
|
||||
|
||||
The Listening Post uses Icecast for low-latency audio streaming (2-10 second latency). Intercept will automatically start Icecast when you begin listening, but you must install and configure it first.
|
||||
|
||||
**Install Icecast:**
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install icecast2
|
||||
|
||||
# macOS
|
||||
brew install icecast
|
||||
```
|
||||
|
||||
**Configure Icecast:**
|
||||
|
||||
During installation on Debian/Ubuntu, you'll be prompted to configure. Otherwise, edit `/etc/icecast2/icecast.xml`:
|
||||
|
||||
```xml
|
||||
<icecast>
|
||||
<authentication>
|
||||
<!-- Source password - used by ffmpeg to send audio -->
|
||||
<source-password>hackme</source-password>
|
||||
<!-- Admin password for web interface -->
|
||||
<admin-password>your-admin-password</admin-password>
|
||||
</authentication>
|
||||
<hostname>localhost</hostname>
|
||||
<listen-socket>
|
||||
<port>8000</port>
|
||||
</listen-socket>
|
||||
</icecast>
|
||||
```
|
||||
|
||||
**Start Icecast:**
|
||||
```bash
|
||||
# Ubuntu/Debian (as service)
|
||||
sudo systemctl enable icecast2
|
||||
sudo systemctl start icecast2
|
||||
|
||||
# Or run directly
|
||||
icecast -c /etc/icecast2/icecast.xml
|
||||
|
||||
# macOS
|
||||
brew services start icecast
|
||||
# Or: icecast -c /usr/local/etc/icecast.xml
|
||||
```
|
||||
|
||||
**Verify Icecast is running:**
|
||||
- Open http://localhost:8000 in your browser
|
||||
- You should see the Icecast status page
|
||||
|
||||
**Configure Intercept (optional):**
|
||||
|
||||
The default configuration expects Icecast on `127.0.0.1:8000` with source password `hackme` and mount point `/listen.mp3`. To change these, modify the scanner config in your API calls or update the defaults in `routes/listening_post.py`:
|
||||
|
||||
```python
|
||||
scanner_config = {
|
||||
# ... other settings ...
|
||||
'icecast_host': '127.0.0.1',
|
||||
'icecast_port': 8000,
|
||||
'icecast_mount': '/listen.mp3',
|
||||
'icecast_source_password': 'hackme',
|
||||
}
|
||||
```
|
||||
|
||||
**Troubleshooting Icecast:**
|
||||
|
||||
- **"Connection refused" errors**: Ensure Icecast is running on the configured port
|
||||
- **"Authentication failed"**: Check the source password matches between Icecast config and Intercept
|
||||
- **No audio playing**: Check Icecast status page (http://localhost:8000) to verify the mount point is active
|
||||
- **High latency**: Ensure nginx/reverse proxy isn't buffering - add `proxy_buffering off;` to nginx config
|
||||
|
||||
### Audio Streaming Issues - Detailed Debugging
|
||||
|
||||
If the Listening Post shows "Icecast mount not active" errors or audio doesn't play:
|
||||
|
||||
**1. Check the console output for errors**
|
||||
|
||||
Intercept now logs detailed error output. Look for lines starting with `[AUDIO]`:
|
||||
```
|
||||
[AUDIO] SDR errors: ... # Problems with rtl_fm/rx_fm (SDR not connected, device busy)
|
||||
[AUDIO] FFmpeg errors: ... # Problems with ffmpeg (wrong password, codec issues)
|
||||
```
|
||||
|
||||
**2. Verify SDR is connected and working**
|
||||
```bash
|
||||
# For RTL-SDR
|
||||
rtl_test -t
|
||||
|
||||
# You should see: "Found 1 device(s)"
|
||||
# If not, check USB connection and drivers
|
||||
```
|
||||
|
||||
**3. Check Icecast password (macOS Homebrew)**
|
||||
|
||||
On macOS with Homebrew, the Icecast config is at `/opt/homebrew/etc/icecast.xml`. Check the source password:
|
||||
```bash
|
||||
grep source-password /opt/homebrew/etc/icecast.xml
|
||||
```
|
||||
|
||||
If it's different from `hackme`, update it in the Listening Post Icecast config panel, or change the Icecast config and restart:
|
||||
```bash
|
||||
brew services restart icecast
|
||||
```
|
||||
|
||||
**4. Verify ffmpeg has required codecs**
|
||||
```bash
|
||||
# Check MP3 encoder is available
|
||||
ffmpeg -encoders 2>/dev/null | grep mp3
|
||||
|
||||
# Should show: libmp3lame
|
||||
# If not, reinstall ffmpeg with all codecs:
|
||||
# macOS: brew reinstall ffmpeg
|
||||
# Linux: sudo apt install ffmpeg
|
||||
```
|
||||
|
||||
**5. Test the pipeline manually**
|
||||
|
||||
Try running the audio pipeline directly to see errors:
|
||||
```bash
|
||||
# Test rtl_fm (should produce raw audio data)
|
||||
rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>&1 | head -c 1000 | xxd | head
|
||||
|
||||
# Test ffmpeg to Icecast (replace PASSWORD with your source password)
|
||||
rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
|
||||
ffmpeg -f s16le -ar 24000 -ac 1 -i pipe:0 -c:a libmp3lame -b:a 64k \
|
||||
-f mp3 -content_type audio/mpeg icecast://source:PASSWORD@127.0.0.1:8000/listen.mp3
|
||||
```
|
||||
|
||||
**6. Common error messages and solutions**
|
||||
|
||||
| Error | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| `No supported devices found` | SDR not connected | Plug in SDR, check USB |
|
||||
| `Device or resource busy` | Another process using SDR | Click "Kill All Processes" |
|
||||
| `401 Unauthorized` | Wrong Icecast password | Check password in Icecast config |
|
||||
| `Connection refused` | Icecast not running | Start Icecast service |
|
||||
| `Encoder libmp3lame not found` | ffmpeg missing codec | Reinstall ffmpeg with codecs |
|
||||
|
||||
## WiFi Issues
|
||||
|
||||
### Monitor mode fails
|
||||
@@ -118,9 +336,7 @@ Then unplug and replug your RTL-SDR.
|
||||
|
||||
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
|
||||
@@ -146,21 +362,6 @@ Run with sudo or add your user to the bluetooth group:
|
||||
sudo usermod -a -G bluetooth $USER
|
||||
```
|
||||
|
||||
## GPS Issues
|
||||
|
||||
### GPS dongle not detected
|
||||
|
||||
1. Install pyserial: `pip install pyserial`
|
||||
2. Check device is connected:
|
||||
- Linux: `ls /dev/ttyUSB* /dev/ttyACM*`
|
||||
- macOS: `ls /dev/tty.usb*`
|
||||
3. Add user to dialout group (Linux):
|
||||
```bash
|
||||
sudo usermod -a -G dialout $USER
|
||||
```
|
||||
4. Most GPS dongles use 9600 baud (default in INTERCEPT)
|
||||
5. GPS needs clear sky view to get a fix
|
||||
|
||||
## Decoding Issues
|
||||
|
||||
### No messages appearing (Pager mode)
|
||||
@@ -170,15 +371,20 @@ sudo usermod -a -G bluetooth $USER
|
||||
3. Check pager services are active in your area
|
||||
4. Ensure antenna is connected
|
||||
|
||||
### Cannot install dump1090 in Debian (ADS-B mode)
|
||||
|
||||
On newer Debian versions, dump1090 may not be in repositories. The recommended action is to build from source or use the setup.sh script which will do it for you.
|
||||
|
||||
### No aircraft appearing (ADS-B mode)
|
||||
|
||||
1. Verify dump1090 or readsb is installed
|
||||
1. Verify dump1090 is installed
|
||||
2. Check antenna is connected (1090 MHz antenna recommended)
|
||||
3. Ensure clear view of sky
|
||||
4. Set correct observer location for range calculations
|
||||
4. Set correct observer location for range calculations or use gpsd
|
||||
|
||||
### Satellite passes not calculating
|
||||
|
||||
1. Ensure skyfield is installed: `pip install skyfield`
|
||||
1. Ensure skyfield is installed: `apt install python3-skyfield`
|
||||
2. Check TLE data is valid and recent
|
||||
3. Verify observer location is set correctly
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+18
-6
@@ -1,8 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" fill="#000"/>
|
||||
<path d="M50 5 L90 27.5 L90 72.5 L50 95 L10 72.5 L10 27.5 Z" stroke="#00d4ff" stroke-width="3" fill="none"/>
|
||||
<path d="M30 50 Q40 35, 50 50 Q60 65, 70 50" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
<path d="M35 50 Q42 40, 50 50 Q58 60, 65 50" stroke="#00ff88" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M40 50 Q45 45, 50 50 Q55 55, 60 50" stroke="#ffffff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="50" cy="50" r="4" fill="#00d4ff"/>
|
||||
<!-- Background -->
|
||||
<rect width="100" height="100" fill="#0a0a0f"/>
|
||||
|
||||
<!-- Signal brackets - left side -->
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Signal brackets - right side -->
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- The 'i' letter -->
|
||||
<circle cx="50" cy="22" r="7" fill="#00ff88"/>
|
||||
<rect x="43" y="35" width="14" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="36" y="35" width="28" height="5" rx="1" fill="#00d4ff"/>
|
||||
<rect x="36" y="75" width="28" height="5" rx="1" fill="#00d4ff"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 639 B After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
@@ -0,0 +1,898 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iNTERCEPT Promo</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--cyan: #00d4ff;
|
||||
--green: #00ff88;
|
||||
--red: #ff3366;
|
||||
--purple: #a855f7;
|
||||
--orange: #ff9500;
|
||||
--bg: #0a0a0f;
|
||||
--bg-secondary: #12121a;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Container maintains 9:16 aspect ratio and scales to fit */
|
||||
.video-frame {
|
||||
position: relative;
|
||||
width: min(100vw, calc(100vh * 9 / 16));
|
||||
height: min(100vh, calc(100vw * 16 / 9));
|
||||
max-width: 1080px;
|
||||
max-height: 1920px;
|
||||
background: var(--bg);
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
/* Scale font size based on container width */
|
||||
font-size: min(16px, calc(100vw * 16 / 1080));
|
||||
}
|
||||
|
||||
/* Animated background grid */
|
||||
.grid-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 30px 30px;
|
||||
animation: gridMove 20s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes gridMove {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(30px, 30px); }
|
||||
}
|
||||
|
||||
/* Scanning line effect */
|
||||
.scanline {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
|
||||
animation: scan 3s linear infinite;
|
||||
opacity: 0.7;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% { top: 0; }
|
||||
100% { top: 100%; }
|
||||
}
|
||||
|
||||
/* Glowing orbs background */
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(50px);
|
||||
opacity: 0.25;
|
||||
animation: orbFloat 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.orb-1 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: var(--cyan);
|
||||
top: 10%;
|
||||
left: -10%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.orb-2 {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
background: var(--purple);
|
||||
bottom: 20%;
|
||||
right: -5%;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.orb-3 {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: var(--green);
|
||||
bottom: 40%;
|
||||
left: 20%;
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
@keyframes orbFloat {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(30px, -30px) scale(1.1); }
|
||||
}
|
||||
|
||||
/* Main content container */
|
||||
.container {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Scene management */
|
||||
.scene {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.8s ease, visibility 0.8s ease;
|
||||
}
|
||||
|
||||
.scene.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Scene 1: Logo reveal */
|
||||
.logo-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo-svg {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
margin-bottom: 20px;
|
||||
filter: drop-shadow(0 0 40px rgba(0, 212, 255, 0.5));
|
||||
}
|
||||
|
||||
.logo-svg .signal-wave {
|
||||
opacity: 0;
|
||||
animation: signalReveal 0.5s ease forwards;
|
||||
}
|
||||
|
||||
.logo-svg .signal-wave-1 { animation-delay: 0.5s; }
|
||||
.logo-svg .signal-wave-2 { animation-delay: 0.7s; }
|
||||
.logo-svg .signal-wave-3 { animation-delay: 0.9s; }
|
||||
.logo-svg .signal-wave-4 { animation-delay: 0.5s; }
|
||||
.logo-svg .signal-wave-5 { animation-delay: 0.7s; }
|
||||
.logo-svg .signal-wave-6 { animation-delay: 0.9s; }
|
||||
|
||||
@keyframes signalReveal {
|
||||
0% { opacity: 0; transform: scale(0.8); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.logo-svg .logo-i {
|
||||
opacity: 0;
|
||||
animation: logoReveal 0.8s ease forwards 0.2s;
|
||||
}
|
||||
|
||||
@keyframes logoReveal {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.logo-svg .logo-dot {
|
||||
animation: dotPulse 1.5s ease-in-out infinite 1s;
|
||||
}
|
||||
|
||||
@keyframes dotPulse {
|
||||
0%, 100% { filter: drop-shadow(0 0 5px rgba(0, 255, 136, 0.5)); }
|
||||
50% { filter: drop-shadow(0 0 25px rgba(0, 255, 136, 1)); }
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0;
|
||||
animation: titleReveal 1s ease forwards 1.2s;
|
||||
}
|
||||
|
||||
@keyframes titleReveal {
|
||||
0% { opacity: 0; transform: translateY(20px); letter-spacing: 0.3em; }
|
||||
100% { opacity: 1; transform: translateY(0); letter-spacing: 0.15em; }
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 18px;
|
||||
color: var(--cyan);
|
||||
letter-spacing: 0.1em;
|
||||
opacity: 0;
|
||||
animation: taglineReveal 0.8s ease forwards 1.8s;
|
||||
}
|
||||
|
||||
@keyframes taglineReveal {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 15px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0;
|
||||
animation: subtitleReveal 0.8s ease forwards 2.2s;
|
||||
}
|
||||
|
||||
@keyframes subtitleReveal {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Scene 2: Features */
|
||||
.features-scene {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 24px;
|
||||
color: var(--cyan);
|
||||
margin-bottom: 30px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
animation: featureReveal 0.6s ease forwards;
|
||||
}
|
||||
|
||||
.feature-card:nth-child(1) { animation-delay: 0.2s; }
|
||||
.feature-card:nth-child(2) { animation-delay: 0.4s; }
|
||||
.feature-card:nth-child(3) { animation-delay: 0.6s; }
|
||||
.feature-card:nth-child(4) { animation-delay: 0.8s; }
|
||||
|
||||
@keyframes featureReveal {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.feature-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Scene 3: Modes showcase */
|
||||
.modes-scene {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mode-showcase {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mode-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-left: 3px solid var(--cyan);
|
||||
padding: 10px 15px;
|
||||
border-radius: 0 8px 8px 0;
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
animation: modeSlide 0.5s ease forwards;
|
||||
}
|
||||
|
||||
.mode-item:nth-child(1) { animation-delay: 0.1s; border-color: var(--cyan); }
|
||||
.mode-item:nth-child(2) { animation-delay: 0.2s; border-color: var(--green); }
|
||||
.mode-item:nth-child(3) { animation-delay: 0.3s; border-color: var(--purple); }
|
||||
.mode-item:nth-child(4) { animation-delay: 0.4s; border-color: var(--orange); }
|
||||
.mode-item:nth-child(5) { animation-delay: 0.5s; border-color: var(--red); }
|
||||
.mode-item:nth-child(6) { animation-delay: 0.6s; border-color: #00ffcc; }
|
||||
.mode-item:nth-child(7) { animation-delay: 0.7s; border-color: #ff66cc; }
|
||||
|
||||
@keyframes modeSlide {
|
||||
0% { opacity: 0; transform: translateX(-30px); }
|
||||
100% { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.mode-icon {
|
||||
font-size: 22px;
|
||||
width: 35px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mode-info {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mode-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.mode-desc {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Scene 4: UI Preview */
|
||||
.ui-scene {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui-preview {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 60px rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.ui-header {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 10px 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.ui-logo-small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.ui-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ui-body {
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ui-card {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.ui-card-header {
|
||||
font-size: 8px;
|
||||
color: var(--cyan);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.ui-stat {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.ui-stat.cyan { color: var(--cyan); }
|
||||
.ui-stat.orange { color: var(--orange); }
|
||||
|
||||
.ui-console {
|
||||
grid-column: span 3;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
text-align: left;
|
||||
border: 1px solid rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
|
||||
.console-line {
|
||||
margin-bottom: 4px;
|
||||
opacity: 0;
|
||||
animation: consoleLine 0.3s ease forwards;
|
||||
}
|
||||
|
||||
.console-line:nth-child(1) { animation-delay: 0.5s; }
|
||||
.console-line:nth-child(2) { animation-delay: 0.8s; }
|
||||
.console-line:nth-child(3) { animation-delay: 1.1s; }
|
||||
.console-line:nth-child(4) { animation-delay: 1.4s; }
|
||||
.console-line:nth-child(5) { animation-delay: 1.7s; }
|
||||
|
||||
@keyframes consoleLine {
|
||||
0% { opacity: 0; transform: translateX(-10px); }
|
||||
100% { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.console-time { color: #666; }
|
||||
.console-type { color: var(--cyan); }
|
||||
.console-msg { color: var(--green); }
|
||||
.console-freq { color: var(--orange); }
|
||||
|
||||
/* Scene 5: CTA */
|
||||
.cta-scene {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cta-logo {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin-bottom: 20px;
|
||||
animation: ctaLogoPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ctaLogoPulse {
|
||||
0%, 100% { filter: drop-shadow(0 0 20px rgba(0, 212, 255, 0.5)); transform: scale(1); }
|
||||
50% { filter: drop-shadow(0 0 40px rgba(0, 212, 255, 0.8)); transform: scale(1.05); }
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cta-tagline {
|
||||
font-size: 18px;
|
||||
color: var(--cyan);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.cta-btn {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
background: var(--cyan);
|
||||
border-radius: 30px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
animation: ctaBtnPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ctaBtnPulse {
|
||||
0%, 100% { box-shadow: 0 0 20px rgba(0, 212, 255, 0.5); }
|
||||
50% { box-shadow: 0 0 40px rgba(0, 212, 255, 0.8); }
|
||||
}
|
||||
|
||||
.cta-url {
|
||||
margin-top: 20px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Typing cursor effect */
|
||||
.typing-cursor {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 1em;
|
||||
background: var(--cyan);
|
||||
margin-left: 5px;
|
||||
animation: blink 0.8s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.progress-bar {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.progress-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-dot.active {
|
||||
background: var(--cyan);
|
||||
box-shadow: 0 0 10px var(--cyan);
|
||||
}
|
||||
|
||||
/* Decorative elements */
|
||||
.corner-decoration {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.corner-tl {
|
||||
top: 15px;
|
||||
left: 15px;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.corner-tr {
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
border-left: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.corner-bl {
|
||||
bottom: 50px;
|
||||
left: 15px;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.corner-br {
|
||||
bottom: 50px;
|
||||
right: 15px;
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="video-frame">
|
||||
<!-- Background elements -->
|
||||
<div class="grid-bg"></div>
|
||||
<div class="scanline"></div>
|
||||
<div class="orb orb-1"></div>
|
||||
<div class="orb orb-2"></div>
|
||||
<div class="orb orb-3"></div>
|
||||
|
||||
<!-- Corner decorations -->
|
||||
<div class="corner-decoration corner-tl"></div>
|
||||
<div class="corner-decoration corner-tr"></div>
|
||||
<div class="corner-decoration corner-bl"></div>
|
||||
<div class="corner-decoration corner-br"></div>
|
||||
|
||||
<!-- Scene 1: Logo Reveal -->
|
||||
<div class="scene active" id="scene1">
|
||||
<div class="logo-container">
|
||||
<svg class="logo-svg" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Signal brackets - left side -->
|
||||
<path class="signal-wave signal-wave-1" d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path class="signal-wave signal-wave-2" d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path class="signal-wave signal-wave-3" d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<!-- Signal brackets - right side -->
|
||||
<path class="signal-wave signal-wave-4" d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path class="signal-wave signal-wave-5" d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path class="signal-wave signal-wave-6" d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<!-- The 'i' letter -->
|
||||
<g class="logo-i">
|
||||
<circle class="logo-dot" cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</g>
|
||||
</svg>
|
||||
<h1 class="title">iNTERCEPT</h1>
|
||||
<p class="tagline">// See the Invisible</p>
|
||||
<p class="subtitle">Signal Intelligence & Counter Surveillance</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 2: Features Grid -->
|
||||
<div class="scene" id="scene2">
|
||||
<div class="features-scene">
|
||||
<h2 class="feature-title">Capabilities</h2>
|
||||
<div class="feature-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📡</div>
|
||||
<div class="feature-name">SDR Scanning</div>
|
||||
<div class="feature-desc">Multi-band reception</div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔐</div>
|
||||
<div class="feature-name">Decryption</div>
|
||||
<div class="feature-desc">Signal analysis</div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🛰️</div>
|
||||
<div class="feature-name">Tracking</div>
|
||||
<div class="feature-desc">Real-time monitoring</div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔍</div>
|
||||
<div class="feature-name">Detection</div>
|
||||
<div class="feature-desc">Counter surveillance</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 3: Modes List -->
|
||||
<div class="scene" id="scene3">
|
||||
<div class="modes-scene">
|
||||
<div class="mode-showcase">
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">📟</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">PAGER</div>
|
||||
<div class="mode-desc">POCSAG & FLEX decoding</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">✈️</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">ADS-B</div>
|
||||
<div class="mode-desc">Aircraft tracking</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">📻</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">LISTENING POST</div>
|
||||
<div class="mode-desc">RF monitoring & scanning</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">📶</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">WiFi</div>
|
||||
<div class="mode-desc">Network reconnaissance</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">🔵</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">BLUETOOTH</div>
|
||||
<div class="mode-desc">Device & tracker detection</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">🌡️</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">SENSORS</div>
|
||||
<div class="mode-desc">433MHz IoT decoding</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">🛰️</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">SATELLITE</div>
|
||||
<div class="mode-desc">Pass prediction & tracking</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 4: UI Preview -->
|
||||
<div class="scene" id="scene4">
|
||||
<div class="ui-scene">
|
||||
<div class="ui-preview">
|
||||
<div class="ui-header">
|
||||
<svg class="ui-logo-small" viewBox="0 0 100 100" fill="none">
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</svg>
|
||||
<span class="ui-title">iNTERCEPT</span>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div class="ui-card">
|
||||
<div class="ui-card-header">Messages</div>
|
||||
<div class="ui-stat">2,847</div>
|
||||
</div>
|
||||
<div class="ui-card">
|
||||
<div class="ui-card-header">Aircraft</div>
|
||||
<div class="ui-stat cyan">42</div>
|
||||
</div>
|
||||
<div class="ui-card">
|
||||
<div class="ui-card-header">Devices</div>
|
||||
<div class="ui-stat orange">156</div>
|
||||
</div>
|
||||
<div class="ui-console">
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:07]</span>
|
||||
<span class="console-type"> POCSAG </span>
|
||||
<span class="console-msg">Signal intercepted</span>
|
||||
<span class="console-freq"> 153.350 MHz</span>
|
||||
</div>
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:09]</span>
|
||||
<span class="console-type"> ADS-B </span>
|
||||
<span class="console-msg">Aircraft detected: BA284</span>
|
||||
<span class="console-freq"> FL350</span>
|
||||
</div>
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:11]</span>
|
||||
<span class="console-type"> BT </span>
|
||||
<span class="console-msg">AirTag detected nearby</span>
|
||||
<span class="console-freq"> -42 dBm</span>
|
||||
</div>
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:14]</span>
|
||||
<span class="console-type"> SENSOR </span>
|
||||
<span class="console-msg">Temperature: 22.4C</span>
|
||||
<span class="console-freq"> 433.92 MHz</span>
|
||||
</div>
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:16]</span>
|
||||
<span class="console-type"> SCAN </span>
|
||||
<span class="console-msg">Signal found</span>
|
||||
<span class="console-freq"> 145.500 MHz</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 5: CTA -->
|
||||
<div class="scene" id="scene5">
|
||||
<div class="cta-scene">
|
||||
<svg class="cta-logo" viewBox="0 0 100 100" fill="none">
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</svg>
|
||||
<h2 class="cta-title">iNTERCEPT</h2>
|
||||
<p class="cta-tagline">See the Invisible</p>
|
||||
<div class="cta-btn">Open Source</div>
|
||||
<p class="cta-url">github.com/yourrepo/intercept</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress dots -->
|
||||
<div class="progress-bar">
|
||||
<div class="progress-dot active" data-scene="1"></div>
|
||||
<div class="progress-dot" data-scene="2"></div>
|
||||
<div class="progress-dot" data-scene="3"></div>
|
||||
<div class="progress-dot" data-scene="4"></div>
|
||||
<div class="progress-dot" data-scene="5"></div>
|
||||
</div>
|
||||
</div><!-- end video-frame -->
|
||||
|
||||
<script>
|
||||
// Scene timing (in milliseconds)
|
||||
const sceneTiming = [
|
||||
{ scene: 1, duration: 4000 }, // Logo reveal
|
||||
{ scene: 2, duration: 4000 }, // Features
|
||||
{ scene: 3, duration: 5000 }, // Modes
|
||||
{ scene: 4, duration: 5000 }, // UI Preview
|
||||
{ scene: 5, duration: 4000 }, // CTA
|
||||
];
|
||||
|
||||
let currentScene = 0;
|
||||
|
||||
function showScene(index) {
|
||||
// Hide all scenes
|
||||
document.querySelectorAll('.scene').forEach(s => s.classList.remove('active'));
|
||||
document.querySelectorAll('.progress-dot').forEach(d => d.classList.remove('active'));
|
||||
|
||||
// Show current scene
|
||||
const scene = document.getElementById(`scene${index + 1}`);
|
||||
if (scene) {
|
||||
scene.classList.add('active');
|
||||
document.querySelector(`.progress-dot[data-scene="${index + 1}"]`).classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function nextScene() {
|
||||
currentScene++;
|
||||
if (currentScene >= sceneTiming.length) {
|
||||
currentScene = 0; // Loop back to start
|
||||
}
|
||||
showScene(currentScene);
|
||||
setTimeout(nextScene, sceneTiming[currentScene].duration);
|
||||
}
|
||||
|
||||
// Start the animation sequence
|
||||
setTimeout(nextScene, sceneTiming[0].duration);
|
||||
|
||||
// Keyboard controls for manual navigation
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowRight') {
|
||||
currentScene = (currentScene + 1) % sceneTiming.length;
|
||||
showScene(currentScene);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
currentScene = (currentScene - 1 + sceneTiming.length) % sceneTiming.length;
|
||||
showScene(currentScene);
|
||||
} else if (e.key === ' ') {
|
||||
// Spacebar to pause/resume could be added here
|
||||
}
|
||||
});
|
||||
|
||||
// Click on progress dots to jump to scene
|
||||
document.querySelectorAll('.progress-dot').forEach(dot => {
|
||||
dot.addEventListener('click', () => {
|
||||
currentScene = parseInt(dot.dataset.scene) - 1;
|
||||
showScene(currentScene);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+2
-1
@@ -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"
|
||||
@@ -40,6 +40,7 @@ Issues = "https://github.com/smittix/intercept/issues"
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"pytest-mock>=3.15.1",
|
||||
"ruff>=0.1.0",
|
||||
"black>=23.0.0",
|
||||
"mypy>=1.0.0",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
# Testing
|
||||
pytest>=7.0.0
|
||||
pytest-cov>=4.0.0
|
||||
pytest-mock>=3.15.1
|
||||
|
||||
# Code quality
|
||||
ruff>=0.1.0
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Core dependencies
|
||||
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
|
||||
@@ -13,3 +17,4 @@ pyserial>=3.5
|
||||
# ruff>=0.1.0
|
||||
# black>=23.0.0
|
||||
# mypy>=1.0.0
|
||||
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)
|
||||
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
"""ACARS aircraft messaging routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import pty
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import format_sse
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
)
|
||||
|
||||
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
||||
|
||||
# Default VHF ACARS frequencies (MHz) - common worldwide
|
||||
DEFAULT_ACARS_FREQUENCIES = [
|
||||
'131.550', # Primary worldwide
|
||||
'130.025', # Secondary USA/Canada
|
||||
'129.125', # USA
|
||||
'131.525', # Europe
|
||||
'131.725', # Europe secondary
|
||||
]
|
||||
|
||||
# Message counter for statistics
|
||||
acars_message_count = 0
|
||||
acars_last_message_time = None
|
||||
|
||||
|
||||
def find_acarsdec():
|
||||
"""Find acarsdec binary."""
|
||||
return shutil.which('acarsdec')
|
||||
|
||||
|
||||
def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
|
||||
"""Stream acarsdec JSON output to queue."""
|
||||
global acars_message_count, acars_last_message_time
|
||||
|
||||
try:
|
||||
app_module.acars_queue.put({'type': 'status', 'status': 'started'})
|
||||
|
||||
# Use appropriate sentinel based on mode (text mode for pty on macOS)
|
||||
sentinel = '' if is_text_mode else b''
|
||||
for line in iter(process.stdout.readline, sentinel):
|
||||
if is_text_mode:
|
||||
line = line.strip()
|
||||
else:
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
# acarsdec -o 4 outputs JSON, one message per line
|
||||
data = json.loads(line)
|
||||
|
||||
# Add our metadata
|
||||
data['type'] = 'acars'
|
||||
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
||||
|
||||
# Update stats
|
||||
acars_message_count += 1
|
||||
acars_last_message_time = time.time()
|
||||
|
||||
app_module.acars_queue.put(data)
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{ts} | ACARS | {json.dumps(data)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON - could be status message
|
||||
if line:
|
||||
logger.debug(f"acarsdec non-JSON: {line[:100]}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ACARS stream error: {e}")
|
||||
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
with app_module.acars_lock:
|
||||
app_module.acars_process = None
|
||||
|
||||
|
||||
@acars_bp.route('/tools')
|
||||
def check_acars_tools() -> Response:
|
||||
"""Check for ACARS decoding tools."""
|
||||
has_acarsdec = find_acarsdec() is not None
|
||||
|
||||
return jsonify({
|
||||
'acarsdec': has_acarsdec,
|
||||
'ready': has_acarsdec
|
||||
})
|
||||
|
||||
|
||||
@acars_bp.route('/status')
|
||||
def acars_status() -> Response:
|
||||
"""Get ACARS decoder status."""
|
||||
running = False
|
||||
if app_module.acars_process:
|
||||
running = app_module.acars_process.poll() is None
|
||||
|
||||
return jsonify({
|
||||
'running': running,
|
||||
'message_count': acars_message_count,
|
||||
'last_message_time': acars_last_message_time,
|
||||
'queue_size': app_module.acars_queue.qsize()
|
||||
})
|
||||
|
||||
|
||||
@acars_bp.route('/start', methods=['POST'])
|
||||
def start_acars() -> Response:
|
||||
"""Start ACARS decoder."""
|
||||
global acars_message_count, acars_last_message_time
|
||||
|
||||
with app_module.acars_lock:
|
||||
if app_module.acars_process and app_module.acars_process.poll() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'ACARS decoder already running'
|
||||
}), 409
|
||||
|
||||
# Check for acarsdec
|
||||
acarsdec_path = find_acarsdec()
|
||||
if not acarsdec_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'acarsdec not found. Install with: sudo apt install acarsdec'
|
||||
}), 400
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Get frequencies - use provided or defaults
|
||||
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
||||
if isinstance(frequencies, str):
|
||||
frequencies = [f.strip() for f in frequencies.split(',')]
|
||||
|
||||
# Clear queue
|
||||
while not app_module.acars_queue.empty():
|
||||
try:
|
||||
app_module.acars_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Reset stats
|
||||
acars_message_count = 0
|
||||
acars_last_message_time = None
|
||||
|
||||
# Build acarsdec command
|
||||
# acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||
# Note: -o 4 is JSON stdout, gain/ppm must come BEFORE -r
|
||||
cmd = [
|
||||
acarsdec_path,
|
||||
'-o', '4', # JSON output to stdout
|
||||
]
|
||||
|
||||
# Add gain if not auto (must be before -r)
|
||||
if gain and str(gain) != '0':
|
||||
cmd.extend(['-g', str(gain)])
|
||||
|
||||
# Add PPM correction if specified (must be before -r)
|
||||
if ppm and str(ppm) != '0':
|
||||
cmd.extend(['-p', str(ppm)])
|
||||
|
||||
# Add device and frequencies (-r takes device, remaining args are frequencies)
|
||||
cmd.extend(['-r', str(device)])
|
||||
cmd.extend(frequencies)
|
||||
|
||||
logger.info(f"Starting ACARS decoder: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
is_text_mode = False
|
||||
|
||||
# On macOS, use pty to avoid stdout buffering issues
|
||||
if platform.system() == 'Darwin':
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=slave_fd,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
os.close(slave_fd)
|
||||
# Wrap master_fd as a text file for line-buffered reading
|
||||
process.stdout = io.open(master_fd, 'r', buffering=1)
|
||||
is_text_mode = True
|
||||
else:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
# Wait briefly to check if process started
|
||||
time.sleep(PROCESS_START_WAIT)
|
||||
|
||||
if process.poll() is not None:
|
||||
# Process died
|
||||
stderr = ''
|
||||
if process.stderr:
|
||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||
error_msg = f'acarsdec failed to start'
|
||||
if stderr:
|
||||
error_msg += f': {stderr[:200]}'
|
||||
logger.error(error_msg)
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
app_module.acars_process = process
|
||||
|
||||
# Start output streaming thread
|
||||
thread = threading.Thread(
|
||||
target=stream_acars_output,
|
||||
args=(process, is_text_mode),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequencies': frequencies,
|
||||
'device': device,
|
||||
'gain': gain
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start ACARS decoder: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@acars_bp.route('/stop', methods=['POST'])
|
||||
def stop_acars() -> Response:
|
||||
"""Stop ACARS decoder."""
|
||||
with app_module.acars_lock:
|
||||
if not app_module.acars_process:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'ACARS decoder not running'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
app_module.acars_process.terminate()
|
||||
app_module.acars_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.acars_process.kill()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping ACARS: {e}")
|
||||
|
||||
app_module.acars_process = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@acars_bp.route('/stream')
|
||||
def stream_acars() -> Response:
|
||||
"""SSE stream for ACARS messages."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@acars_bp.route('/frequencies')
|
||||
def get_frequencies() -> Response:
|
||||
"""Get default ACARS frequencies."""
|
||||
return jsonify({
|
||||
'default': DEFAULT_ACARS_FREQUENCIES,
|
||||
'regions': {
|
||||
'north_america': ['129.125', '130.025', '130.450', '131.550'],
|
||||
'europe': ['131.525', '131.725', '131.550'],
|
||||
'asia_pacific': ['131.550', '131.450'],
|
||||
}
|
||||
})
|
||||
+162
-8
@@ -35,6 +35,7 @@ from utils.constants import (
|
||||
ADSB_UPDATE_INTERVAL,
|
||||
DUMP1090_START_WAIT,
|
||||
)
|
||||
from utils import aircraft_db
|
||||
|
||||
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
|
||||
|
||||
@@ -43,6 +44,14 @@ adsb_using_service = False
|
||||
adsb_connected = False
|
||||
adsb_messages_received = 0
|
||||
adsb_last_message_time = None
|
||||
adsb_bytes_received = 0
|
||||
adsb_lines_received = 0
|
||||
|
||||
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
|
||||
_looked_up_icaos: set[str] = set()
|
||||
|
||||
# Load aircraft database at module init
|
||||
aircraft_db.load_database()
|
||||
|
||||
# Common installation paths for dump1090 (when not in PATH)
|
||||
DUMP1090_PATHS = [
|
||||
@@ -91,7 +100,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
|
||||
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received
|
||||
|
||||
host, port = service_addr.split(':')
|
||||
port = int(port)
|
||||
@@ -111,12 +120,16 @@ def parse_sbs_stream(service_addr):
|
||||
buffer = ""
|
||||
last_update = time.time()
|
||||
pending_updates = set()
|
||||
adsb_bytes_received = 0
|
||||
adsb_lines_received = 0
|
||||
|
||||
while adsb_using_service:
|
||||
try:
|
||||
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
|
||||
if not data:
|
||||
logger.warning("SBS connection closed (no data)")
|
||||
break
|
||||
adsb_bytes_received += len(data)
|
||||
buffer += data
|
||||
|
||||
while '\n' in buffer:
|
||||
@@ -125,8 +138,15 @@ def parse_sbs_stream(service_addr):
|
||||
if not line:
|
||||
continue
|
||||
|
||||
adsb_lines_received += 1
|
||||
# Log first few lines for debugging
|
||||
if adsb_lines_received <= 3:
|
||||
logger.info(f"SBS line {adsb_lines_received}: {line[:100]}")
|
||||
|
||||
parts = line.split(',')
|
||||
if len(parts) < 11 or parts[0] != 'MSG':
|
||||
if adsb_lines_received <= 5:
|
||||
logger.debug(f"Skipping non-MSG line: {line[:50]}")
|
||||
continue
|
||||
|
||||
msg_type = parts[1]
|
||||
@@ -136,6 +156,18 @@ def parse_sbs_stream(service_addr):
|
||||
|
||||
aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao}
|
||||
|
||||
# Look up aircraft type from database (once per ICAO)
|
||||
if icao not in _looked_up_icaos:
|
||||
_looked_up_icaos.add(icao)
|
||||
db_info = aircraft_db.lookup(icao)
|
||||
if db_info:
|
||||
if db_info['registration']:
|
||||
aircraft['registration'] = db_info['registration']
|
||||
if db_info['type_code']:
|
||||
aircraft['type_code'] = db_info['type_code']
|
||||
if db_info['type_desc']:
|
||||
aircraft['type_desc'] = db_info['type_desc']
|
||||
|
||||
if msg_type == '1' and len(parts) > 10:
|
||||
callsign = parts[10].strip()
|
||||
if callsign:
|
||||
@@ -154,7 +186,7 @@ def parse_sbs_stream(service_addr):
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
elif msg_type == '4' and len(parts) > 13:
|
||||
elif msg_type == '4' and len(parts) > 16:
|
||||
if parts[12]:
|
||||
try:
|
||||
aircraft['speed'] = int(float(parts[12]))
|
||||
@@ -165,6 +197,11 @@ def parse_sbs_stream(service_addr):
|
||||
aircraft['heading'] = int(float(parts[13]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if parts[16]:
|
||||
try:
|
||||
aircraft['vertical_rate'] = int(float(parts[16]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
elif msg_type == '5' and len(parts) > 11:
|
||||
if parts[10]:
|
||||
@@ -213,25 +250,52 @@ def parse_sbs_stream(service_addr):
|
||||
|
||||
@adsb_bp.route('/tools')
|
||||
def check_adsb_tools():
|
||||
"""Check for ADS-B decoding tools."""
|
||||
"""Check for ADS-B decoding tools and hardware."""
|
||||
# Check available decoders
|
||||
has_dump1090 = find_dump1090() is not None
|
||||
has_readsb = shutil.which('readsb') is not None
|
||||
has_rtl_adsb = shutil.which('rtl_adsb') is not None
|
||||
|
||||
# Check what SDR hardware is detected
|
||||
devices = SDRFactory.detect_devices()
|
||||
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
|
||||
has_soapy_sdr = any(d.sdr_type in (SDRType.HACKRF, SDRType.LIME_SDR, SDRType.AIRSPY) for d in devices)
|
||||
soapy_types = [d.sdr_type.value for d in devices if d.sdr_type in (SDRType.HACKRF, SDRType.LIME_SDR, SDRType.AIRSPY)]
|
||||
|
||||
# Determine if readsb is needed but missing
|
||||
needs_readsb = has_soapy_sdr and not has_readsb
|
||||
|
||||
return jsonify({
|
||||
'dump1090': find_dump1090() is not None,
|
||||
'rtl_adsb': shutil.which('rtl_adsb') is not None
|
||||
'dump1090': has_dump1090,
|
||||
'readsb': has_readsb,
|
||||
'rtl_adsb': has_rtl_adsb,
|
||||
'has_rtlsdr': has_rtlsdr,
|
||||
'has_soapy_sdr': has_soapy_sdr,
|
||||
'soapy_types': soapy_types,
|
||||
'needs_readsb': needs_readsb
|
||||
})
|
||||
|
||||
|
||||
@adsb_bp.route('/status')
|
||||
def adsb_status():
|
||||
"""Get ADS-B tracking status for debugging."""
|
||||
# Check if dump1090 process is still running
|
||||
dump1090_running = False
|
||||
if app_module.adsb_process:
|
||||
dump1090_running = app_module.adsb_process.poll() is None
|
||||
|
||||
return jsonify({
|
||||
'tracking_active': adsb_using_service,
|
||||
'connected_to_sbs': adsb_connected,
|
||||
'messages_received': adsb_messages_received,
|
||||
'bytes_received': adsb_bytes_received,
|
||||
'lines_received': adsb_lines_received,
|
||||
'last_message_time': adsb_last_message_time,
|
||||
'aircraft_count': len(app_module.adsb_aircraft),
|
||||
'aircraft': dict(app_module.adsb_aircraft), # Full aircraft data
|
||||
'queue_size': app_module.adsb_queue.qsize(),
|
||||
'dump1090_path': find_dump1090(),
|
||||
'dump1090_running': dump1090_running,
|
||||
'port_30003_open': check_dump1090_service() is not None
|
||||
})
|
||||
|
||||
@@ -317,9 +381,11 @@ def start_adsb():
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
|
||||
# Build ADS-B decoder command
|
||||
bias_t = data.get('bias_t', False)
|
||||
cmd = builder.build_adsb_command(
|
||||
device=sdr_device,
|
||||
gain=float(gain)
|
||||
gain=float(gain),
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
# For RTL-SDR, ensure we use the found dump1090 path
|
||||
@@ -330,13 +396,29 @@ def start_adsb():
|
||||
app_module.adsb_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
time.sleep(DUMP1090_START_WAIT)
|
||||
|
||||
if app_module.adsb_process.poll() is not None:
|
||||
return jsonify({'status': 'error', 'message': 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'})
|
||||
# Process exited - try to get error message
|
||||
stderr_output = ''
|
||||
if app_module.adsb_process.stderr:
|
||||
try:
|
||||
stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||
except Exception:
|
||||
pass
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
error_msg = 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'
|
||||
if stderr_output:
|
||||
error_msg += f' Error: {stderr_output[:200]}'
|
||||
return jsonify({'status': 'error', 'message': error_msg})
|
||||
else:
|
||||
error_msg = f'ADS-B decoder failed to start for {sdr_type.value}. Ensure readsb is installed with SoapySDR support and the device is connected.'
|
||||
if stderr_output:
|
||||
error_msg += f' Error: {stderr_output[:200]}'
|
||||
return jsonify({'status': 'error', 'message': error_msg})
|
||||
|
||||
adsb_using_service = True
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
|
||||
@@ -363,6 +445,7 @@ def stop_adsb():
|
||||
adsb_using_service = False
|
||||
|
||||
app_module.adsb_aircraft.clear()
|
||||
_looked_up_icaos.clear()
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@@ -393,3 +476,74 @@ def stream_adsb():
|
||||
def adsb_dashboard():
|
||||
"""Popout ADS-B dashboard."""
|
||||
return render_template('adsb_dashboard.html')
|
||||
|
||||
|
||||
# ============================================
|
||||
# AIRCRAFT DATABASE MANAGEMENT
|
||||
# ============================================
|
||||
|
||||
@adsb_bp.route('/aircraft-db/status')
|
||||
def aircraft_db_status():
|
||||
"""Get aircraft database status."""
|
||||
return jsonify(aircraft_db.get_db_status())
|
||||
|
||||
|
||||
@adsb_bp.route('/aircraft-db/check-updates')
|
||||
def aircraft_db_check_updates():
|
||||
"""Check for aircraft database updates."""
|
||||
result = aircraft_db.check_for_updates()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@adsb_bp.route('/aircraft-db/download', methods=['POST'])
|
||||
def aircraft_db_download():
|
||||
"""Download/update aircraft database."""
|
||||
global _looked_up_icaos
|
||||
result = aircraft_db.download_database()
|
||||
if result.get('success'):
|
||||
# Clear lookup cache so new data is used
|
||||
_looked_up_icaos.clear()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@adsb_bp.route('/aircraft-db/delete', methods=['POST'])
|
||||
def aircraft_db_delete():
|
||||
"""Delete aircraft database."""
|
||||
result = aircraft_db.delete_database()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@adsb_bp.route('/aircraft-photo/<registration>')
|
||||
def aircraft_photo(registration: str):
|
||||
"""Fetch aircraft photo from Planespotters.net API."""
|
||||
import requests
|
||||
|
||||
# Validate registration format (alphanumeric with dashes)
|
||||
if not registration or not all(c.isalnum() or c == '-' for c in registration):
|
||||
return jsonify({'error': 'Invalid registration'}), 400
|
||||
|
||||
try:
|
||||
# Planespotters.net public API
|
||||
url = f'https://api.planespotters.net/pub/photos/reg/{registration}'
|
||||
resp = requests.get(url, timeout=5, headers={
|
||||
'User-Agent': 'INTERCEPT-ADS-B/1.0'
|
||||
})
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get('photos') and len(data['photos']) > 0:
|
||||
photo = data['photos'][0]
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'thumbnail': photo.get('thumbnail_large', {}).get('src'),
|
||||
'link': photo.get('link'),
|
||||
'photographer': photo.get('photographer')
|
||||
})
|
||||
|
||||
return jsonify({'success': False, 'error': 'No photo found'})
|
||||
|
||||
except requests.Timeout:
|
||||
return jsonify({'success': False, 'error': 'Request timeout'}), 504
|
||||
except Exception as e:
|
||||
logger.debug(f"Error fetching aircraft photo: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
+561
@@ -0,0 +1,561 @@
|
||||
"""APRS amateur radio position reporting routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator, Optional
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import format_sse
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
)
|
||||
|
||||
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
||||
|
||||
# APRS frequencies by region (MHz)
|
||||
APRS_FREQUENCIES = {
|
||||
'north_america': '144.390',
|
||||
'europe': '144.800',
|
||||
'australia': '145.175',
|
||||
'new_zealand': '144.575',
|
||||
'argentina': '144.930',
|
||||
'brazil': '145.570',
|
||||
'japan': '144.640',
|
||||
'china': '144.640',
|
||||
}
|
||||
|
||||
# Statistics
|
||||
aprs_packet_count = 0
|
||||
aprs_station_count = 0
|
||||
aprs_last_packet_time = None
|
||||
aprs_stations = {} # callsign -> station data
|
||||
|
||||
|
||||
def find_direwolf() -> Optional[str]:
|
||||
"""Find direwolf binary."""
|
||||
return shutil.which('direwolf')
|
||||
|
||||
|
||||
def find_multimon_ng() -> Optional[str]:
|
||||
"""Find multimon-ng binary."""
|
||||
return shutil.which('multimon-ng')
|
||||
|
||||
|
||||
def find_rtl_fm() -> Optional[str]:
|
||||
"""Find rtl_fm binary."""
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def parse_aprs_packet(raw_packet: str) -> Optional[dict]:
|
||||
"""Parse APRS packet into structured data."""
|
||||
try:
|
||||
# Basic APRS packet format: CALLSIGN>PATH:DATA
|
||||
# Example: N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077
|
||||
|
||||
match = re.match(r'^([A-Z0-9-]+)>([^:]+):(.+)$', raw_packet, re.IGNORECASE)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
callsign = match.group(1).upper()
|
||||
path = match.group(2)
|
||||
data = match.group(3)
|
||||
|
||||
packet = {
|
||||
'type': 'aprs',
|
||||
'callsign': callsign,
|
||||
'path': path,
|
||||
'raw': raw_packet,
|
||||
'timestamp': datetime.utcnow().isoformat() + 'Z',
|
||||
}
|
||||
|
||||
# Determine packet type and parse accordingly
|
||||
if data.startswith('!') or data.startswith('='):
|
||||
# Position without timestamp
|
||||
packet['packet_type'] = 'position'
|
||||
pos = parse_position(data[1:])
|
||||
if pos:
|
||||
packet.update(pos)
|
||||
|
||||
elif data.startswith('/') or data.startswith('@'):
|
||||
# Position with timestamp
|
||||
packet['packet_type'] = 'position'
|
||||
# Skip timestamp (7 chars) and parse position
|
||||
if len(data) > 8:
|
||||
pos = parse_position(data[8:])
|
||||
if pos:
|
||||
packet.update(pos)
|
||||
|
||||
elif data.startswith('>'):
|
||||
# Status message
|
||||
packet['packet_type'] = 'status'
|
||||
packet['status'] = data[1:]
|
||||
|
||||
elif data.startswith(':'):
|
||||
# Message
|
||||
packet['packet_type'] = 'message'
|
||||
msg_match = re.match(r'^:([A-Z0-9 -]{9}):(.*)$', data, re.IGNORECASE)
|
||||
if msg_match:
|
||||
packet['addressee'] = msg_match.group(1).strip()
|
||||
packet['message'] = msg_match.group(2)
|
||||
|
||||
elif data.startswith('_'):
|
||||
# Weather report (Positionless)
|
||||
packet['packet_type'] = 'weather'
|
||||
packet['weather'] = parse_weather(data)
|
||||
|
||||
elif data.startswith(';'):
|
||||
# Object
|
||||
packet['packet_type'] = 'object'
|
||||
|
||||
elif data.startswith(')'):
|
||||
# Item
|
||||
packet['packet_type'] = 'item'
|
||||
|
||||
elif data.startswith('T'):
|
||||
# Telemetry
|
||||
packet['packet_type'] = 'telemetry'
|
||||
|
||||
else:
|
||||
packet['packet_type'] = 'other'
|
||||
packet['data'] = data
|
||||
|
||||
return packet
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse APRS packet: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def parse_position(data: str) -> Optional[dict]:
|
||||
"""Parse APRS position data."""
|
||||
try:
|
||||
# Format: DDMM.mmN/DDDMM.mmW (or similar with symbols)
|
||||
# Example: 4903.50N/07201.75W
|
||||
|
||||
pos_match = re.match(
|
||||
r'^(\d{2})(\d{2}\.\d+)([NS])(.)(\d{3})(\d{2}\.\d+)([EW])(.)?',
|
||||
data
|
||||
)
|
||||
|
||||
if pos_match:
|
||||
lat_deg = int(pos_match.group(1))
|
||||
lat_min = float(pos_match.group(2))
|
||||
lat_dir = pos_match.group(3)
|
||||
symbol_table = pos_match.group(4)
|
||||
lon_deg = int(pos_match.group(5))
|
||||
lon_min = float(pos_match.group(6))
|
||||
lon_dir = pos_match.group(7)
|
||||
symbol_code = pos_match.group(8) or ''
|
||||
|
||||
lat = lat_deg + lat_min / 60.0
|
||||
if lat_dir == 'S':
|
||||
lat = -lat
|
||||
|
||||
lon = lon_deg + lon_min / 60.0
|
||||
if lon_dir == 'W':
|
||||
lon = -lon
|
||||
|
||||
result = {
|
||||
'lat': round(lat, 6),
|
||||
'lon': round(lon, 6),
|
||||
'symbol': symbol_table + symbol_code,
|
||||
}
|
||||
|
||||
# Parse additional data after position (course/speed, altitude, etc.)
|
||||
remaining = data[18:] if len(data) > 18 else ''
|
||||
|
||||
# Course/Speed: CCC/SSS
|
||||
cs_match = re.search(r'(\d{3})/(\d{3})', remaining)
|
||||
if cs_match:
|
||||
result['course'] = int(cs_match.group(1))
|
||||
result['speed'] = int(cs_match.group(2)) # knots
|
||||
|
||||
# Altitude: /A=NNNNNN
|
||||
alt_match = re.search(r'/A=(-?\d+)', remaining)
|
||||
if alt_match:
|
||||
result['altitude'] = int(alt_match.group(1)) # feet
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse position: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_weather(data: str) -> dict:
|
||||
"""Parse APRS weather data."""
|
||||
weather = {}
|
||||
|
||||
# Wind direction: cCCC
|
||||
match = re.search(r'c(\d{3})', data)
|
||||
if match:
|
||||
weather['wind_direction'] = int(match.group(1))
|
||||
|
||||
# Wind speed: sSSS (mph)
|
||||
match = re.search(r's(\d{3})', data)
|
||||
if match:
|
||||
weather['wind_speed'] = int(match.group(1))
|
||||
|
||||
# Wind gust: gGGG (mph)
|
||||
match = re.search(r'g(\d{3})', data)
|
||||
if match:
|
||||
weather['wind_gust'] = int(match.group(1))
|
||||
|
||||
# Temperature: tTTT (Fahrenheit)
|
||||
match = re.search(r't(-?\d{2,3})', data)
|
||||
if match:
|
||||
weather['temperature'] = int(match.group(1))
|
||||
|
||||
# Rain last hour: rRRR (hundredths of inch)
|
||||
match = re.search(r'r(\d{3})', data)
|
||||
if match:
|
||||
weather['rain_1h'] = int(match.group(1)) / 100.0
|
||||
|
||||
# Rain last 24h: pPPP
|
||||
match = re.search(r'p(\d{3})', data)
|
||||
if match:
|
||||
weather['rain_24h'] = int(match.group(1)) / 100.0
|
||||
|
||||
# Humidity: hHH (%)
|
||||
match = re.search(r'h(\d{2})', data)
|
||||
if match:
|
||||
h = int(match.group(1))
|
||||
weather['humidity'] = 100 if h == 0 else h
|
||||
|
||||
# Barometric pressure: bBBBBB (tenths of millibars)
|
||||
match = re.search(r'b(\d{5})', data)
|
||||
if match:
|
||||
weather['pressure'] = int(match.group(1)) / 10.0
|
||||
|
||||
return weather
|
||||
|
||||
|
||||
def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None:
|
||||
"""Stream decoded APRS packets to queue."""
|
||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||
|
||||
try:
|
||||
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
|
||||
|
||||
for line in iter(decoder_process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# direwolf outputs decoded packets, multimon-ng outputs "AFSK1200: ..."
|
||||
if line.startswith('AFSK1200:'):
|
||||
line = line[9:].strip()
|
||||
|
||||
# Skip non-packet lines
|
||||
if '>' not in line or ':' not in line:
|
||||
continue
|
||||
|
||||
packet = parse_aprs_packet(line)
|
||||
if packet:
|
||||
aprs_packet_count += 1
|
||||
aprs_last_packet_time = time.time()
|
||||
|
||||
# Track unique stations
|
||||
callsign = packet.get('callsign')
|
||||
if callsign and callsign not in aprs_stations:
|
||||
aprs_station_count += 1
|
||||
|
||||
# Update station data
|
||||
if callsign:
|
||||
aprs_stations[callsign] = {
|
||||
'callsign': callsign,
|
||||
'lat': packet.get('lat'),
|
||||
'lon': packet.get('lon'),
|
||||
'symbol': packet.get('symbol'),
|
||||
'last_seen': packet.get('timestamp'),
|
||||
'packet_type': packet.get('packet_type'),
|
||||
}
|
||||
|
||||
app_module.aprs_queue.put(packet)
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{ts} | APRS | {json.dumps(packet)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"APRS stream error: {e}")
|
||||
app_module.aprs_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
# Cleanup processes
|
||||
for proc in [rtl_process, decoder_process]:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@aprs_bp.route('/tools')
|
||||
def check_aprs_tools() -> Response:
|
||||
"""Check for APRS decoding tools."""
|
||||
has_rtl_fm = find_rtl_fm() is not None
|
||||
has_direwolf = find_direwolf() is not None
|
||||
has_multimon = find_multimon_ng() is not None
|
||||
|
||||
return jsonify({
|
||||
'rtl_fm': has_rtl_fm,
|
||||
'direwolf': has_direwolf,
|
||||
'multimon_ng': has_multimon,
|
||||
'ready': has_rtl_fm and (has_direwolf or has_multimon),
|
||||
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
|
||||
})
|
||||
|
||||
|
||||
@aprs_bp.route('/status')
|
||||
def aprs_status() -> Response:
|
||||
"""Get APRS decoder status."""
|
||||
running = False
|
||||
if app_module.aprs_process:
|
||||
running = app_module.aprs_process.poll() is None
|
||||
|
||||
return jsonify({
|
||||
'running': running,
|
||||
'packet_count': aprs_packet_count,
|
||||
'station_count': aprs_station_count,
|
||||
'last_packet_time': aprs_last_packet_time,
|
||||
'queue_size': app_module.aprs_queue.qsize()
|
||||
})
|
||||
|
||||
|
||||
@aprs_bp.route('/stations')
|
||||
def get_stations() -> Response:
|
||||
"""Get all tracked APRS stations."""
|
||||
return jsonify({
|
||||
'stations': list(aprs_stations.values()),
|
||||
'count': len(aprs_stations)
|
||||
})
|
||||
|
||||
|
||||
@aprs_bp.route('/start', methods=['POST'])
|
||||
def start_aprs() -> Response:
|
||||
"""Start APRS decoder."""
|
||||
global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations
|
||||
|
||||
with app_module.aprs_lock:
|
||||
if app_module.aprs_process and app_module.aprs_process.poll() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'APRS decoder already running'
|
||||
}), 409
|
||||
|
||||
# Check for required tools
|
||||
rtl_fm_path = find_rtl_fm()
|
||||
if not rtl_fm_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
||||
}), 400
|
||||
|
||||
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||
direwolf_path = find_direwolf()
|
||||
multimon_path = find_multimon_ng()
|
||||
|
||||
if not direwolf_path and not multimon_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
|
||||
}), 400
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Get frequency for region
|
||||
region = data.get('region', 'north_america')
|
||||
frequency = APRS_FREQUENCIES.get(region, '144.390')
|
||||
|
||||
# Allow custom frequency override
|
||||
if data.get('frequency'):
|
||||
frequency = data.get('frequency')
|
||||
|
||||
# Clear queue and reset stats
|
||||
while not app_module.aprs_queue.empty():
|
||||
try:
|
||||
app_module.aprs_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
aprs_packet_count = 0
|
||||
aprs_station_count = 0
|
||||
aprs_last_packet_time = None
|
||||
aprs_stations = {}
|
||||
|
||||
# Build rtl_fm command
|
||||
freq_hz = f"{float(frequency)}M"
|
||||
rtl_cmd = [
|
||||
rtl_fm_path,
|
||||
'-f', freq_hz,
|
||||
'-s', '22050', # Sample rate for AFSK1200
|
||||
'-d', str(device),
|
||||
]
|
||||
|
||||
if gain and str(gain) != '0':
|
||||
rtl_cmd.extend(['-g', str(gain)])
|
||||
if ppm and str(ppm) != '0':
|
||||
rtl_cmd.extend(['-p', str(ppm)])
|
||||
|
||||
# Build decoder command
|
||||
if direwolf_path:
|
||||
decoder_cmd = [direwolf_path, '-r', '22050', '-D', '1', '-']
|
||||
decoder_name = 'direwolf'
|
||||
else:
|
||||
decoder_cmd = [multimon_path, '-t', 'raw', '-a', 'AFSK1200', '-']
|
||||
decoder_name = 'multimon-ng'
|
||||
|
||||
logger.info(f"Starting APRS decoder: {' '.join(rtl_cmd)} | {' '.join(decoder_cmd)}")
|
||||
|
||||
try:
|
||||
# Start rtl_fm
|
||||
rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
# Start decoder with rtl_fm output
|
||||
decoder_process = subprocess.Popen(
|
||||
decoder_cmd,
|
||||
stdin=rtl_process.stdout,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
# Allow rtl_fm stdout to be consumed by decoder
|
||||
rtl_process.stdout.close()
|
||||
|
||||
# Wait briefly to check if processes started
|
||||
time.sleep(PROCESS_START_WAIT)
|
||||
|
||||
if rtl_process.poll() is not None:
|
||||
stderr = rtl_process.stderr.read().decode('utf-8', errors='replace') if rtl_process.stderr else ''
|
||||
error_msg = f'rtl_fm failed to start'
|
||||
if stderr:
|
||||
error_msg += f': {stderr[:200]}'
|
||||
logger.error(error_msg)
|
||||
decoder_process.kill()
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
# Store reference to decoder process (for status checks)
|
||||
app_module.aprs_process = decoder_process
|
||||
app_module.aprs_rtl_process = rtl_process
|
||||
|
||||
# Start output streaming thread
|
||||
thread = threading.Thread(
|
||||
target=stream_aprs_output,
|
||||
args=(rtl_process, decoder_process),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'region': region,
|
||||
'device': device,
|
||||
'decoder': decoder_name
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start APRS decoder: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@aprs_bp.route('/stop', methods=['POST'])
|
||||
def stop_aprs() -> Response:
|
||||
"""Stop APRS decoder."""
|
||||
with app_module.aprs_lock:
|
||||
processes_to_stop = []
|
||||
|
||||
if hasattr(app_module, 'aprs_rtl_process') and app_module.aprs_rtl_process:
|
||||
processes_to_stop.append(app_module.aprs_rtl_process)
|
||||
|
||||
if app_module.aprs_process:
|
||||
processes_to_stop.append(app_module.aprs_process)
|
||||
|
||||
if not processes_to_stop:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'APRS decoder not running'
|
||||
}), 400
|
||||
|
||||
for proc in processes_to_stop:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping APRS process: {e}")
|
||||
|
||||
app_module.aprs_process = None
|
||||
if hasattr(app_module, 'aprs_rtl_process'):
|
||||
app_module.aprs_rtl_process = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@aprs_bp.route('/stream')
|
||||
def stream_aprs() -> Response:
|
||||
"""SSE stream for APRS packets."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@aprs_bp.route('/frequencies')
|
||||
def get_frequencies() -> Response:
|
||||
"""Get APRS frequencies by region."""
|
||||
return jsonify(APRS_FREQUENCIES)
|
||||
@@ -0,0 +1,256 @@
|
||||
"""WebSocket-based audio streaming for SDR."""
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import shutil
|
||||
import json
|
||||
from flask import Flask
|
||||
|
||||
# Try to import flask-sock
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
WEBSOCKET_AVAILABLE = True
|
||||
except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
Sock = None
|
||||
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.audio_ws')
|
||||
|
||||
# Global state
|
||||
audio_process = None
|
||||
rtl_process = None
|
||||
process_lock = threading.Lock()
|
||||
current_config = {
|
||||
'frequency': 118.0,
|
||||
'modulation': 'am',
|
||||
'squelch': 0,
|
||||
'gain': 40,
|
||||
'device': 0
|
||||
}
|
||||
|
||||
|
||||
def find_rtl_fm():
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_ffmpeg():
|
||||
return shutil.which('ffmpeg')
|
||||
|
||||
|
||||
def kill_audio_processes():
|
||||
"""Kill any running audio processes."""
|
||||
global audio_process, rtl_process
|
||||
|
||||
if audio_process:
|
||||
try:
|
||||
audio_process.terminate()
|
||||
audio_process.wait(timeout=0.5)
|
||||
except:
|
||||
try:
|
||||
audio_process.kill()
|
||||
except:
|
||||
pass
|
||||
audio_process = None
|
||||
|
||||
if rtl_process:
|
||||
try:
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=0.5)
|
||||
except:
|
||||
try:
|
||||
rtl_process.kill()
|
||||
except:
|
||||
pass
|
||||
rtl_process = None
|
||||
|
||||
# Kill any orphaned processes
|
||||
try:
|
||||
subprocess.run(['pkill', '-9', '-f', 'rtl_fm'], capture_output=True, timeout=1)
|
||||
except:
|
||||
pass
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
|
||||
def start_audio_stream(config):
|
||||
"""Start rtl_fm + ffmpeg pipeline, return the ffmpeg process."""
|
||||
global audio_process, rtl_process, current_config
|
||||
|
||||
kill_audio_processes()
|
||||
|
||||
rtl_fm = find_rtl_fm()
|
||||
ffmpeg = find_ffmpeg()
|
||||
|
||||
if not rtl_fm or not ffmpeg:
|
||||
logger.error("rtl_fm or ffmpeg not found")
|
||||
return None
|
||||
|
||||
current_config.update(config)
|
||||
|
||||
freq = config.get('frequency', 118.0)
|
||||
mod = config.get('modulation', 'am')
|
||||
squelch = config.get('squelch', 0)
|
||||
gain = config.get('gain', 40)
|
||||
device = config.get('device', 0)
|
||||
|
||||
# Sample rates based on modulation
|
||||
if mod == 'wfm':
|
||||
sample_rate = 170000
|
||||
resample_rate = 32000
|
||||
elif mod in ['usb', 'lsb']:
|
||||
sample_rate = 12000
|
||||
resample_rate = 12000
|
||||
else:
|
||||
sample_rate = 24000
|
||||
resample_rate = 24000
|
||||
|
||||
freq_hz = int(freq * 1e6)
|
||||
|
||||
rtl_cmd = [
|
||||
rtl_fm,
|
||||
'-M', mod,
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(gain),
|
||||
'-d', str(device),
|
||||
'-l', str(squelch),
|
||||
]
|
||||
|
||||
# Encode to MP3 for browser compatibility
|
||||
ffmpeg_cmd = [
|
||||
ffmpeg,
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-f', 's16le',
|
||||
'-ar', str(resample_rate),
|
||||
'-ac', '1',
|
||||
'-i', 'pipe:0',
|
||||
'-acodec', 'libmp3lame',
|
||||
'-b:a', '128k',
|
||||
'-f', 'mp3',
|
||||
'-flush_packets', '1',
|
||||
'pipe:1'
|
||||
]
|
||||
|
||||
try:
|
||||
logger.info(f"Starting rtl_fm: {freq} MHz, {mod}")
|
||||
rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
audio_process = subprocess.Popen(
|
||||
ffmpeg_cmd,
|
||||
stdin=rtl_process.stdout,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
bufsize=0
|
||||
)
|
||||
|
||||
rtl_process.stdout.close()
|
||||
|
||||
# Check processes started
|
||||
time.sleep(0.2)
|
||||
if rtl_process.poll() is not None or audio_process.poll() is not None:
|
||||
logger.error("Audio process failed to start")
|
||||
kill_audio_processes()
|
||||
return None
|
||||
|
||||
return audio_process
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start audio: {e}")
|
||||
kill_audio_processes()
|
||||
return None
|
||||
|
||||
|
||||
def init_audio_websocket(app: Flask):
|
||||
"""Initialize WebSocket audio streaming."""
|
||||
if not WEBSOCKET_AVAILABLE:
|
||||
logger.warning("flask-sock not installed, WebSocket audio disabled")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route('/ws/audio')
|
||||
def audio_stream(ws):
|
||||
"""WebSocket endpoint for audio streaming."""
|
||||
logger.info("WebSocket audio client connected")
|
||||
|
||||
proc = None
|
||||
streaming = False
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Check for messages from client (non-blocking with timeout)
|
||||
try:
|
||||
msg = ws.receive(timeout=0.01)
|
||||
if msg:
|
||||
data = json.loads(msg)
|
||||
cmd = data.get('cmd')
|
||||
|
||||
if cmd == 'start':
|
||||
config = data.get('config', {})
|
||||
logger.info(f"Starting audio: {config}")
|
||||
with process_lock:
|
||||
proc = start_audio_stream(config)
|
||||
if proc:
|
||||
streaming = True
|
||||
ws.send(json.dumps({'status': 'started'}))
|
||||
else:
|
||||
ws.send(json.dumps({'status': 'error', 'message': 'Failed to start'}))
|
||||
|
||||
elif cmd == 'stop':
|
||||
logger.info("Stopping audio")
|
||||
streaming = False
|
||||
with process_lock:
|
||||
kill_audio_processes()
|
||||
proc = None
|
||||
ws.send(json.dumps({'status': 'stopped'}))
|
||||
|
||||
elif cmd == 'tune':
|
||||
# Change frequency/modulation - restart stream
|
||||
config = data.get('config', {})
|
||||
logger.info(f"Retuning: {config}")
|
||||
with process_lock:
|
||||
proc = start_audio_stream(config)
|
||||
if proc:
|
||||
streaming = True
|
||||
ws.send(json.dumps({'status': 'tuned'}))
|
||||
else:
|
||||
streaming = False
|
||||
ws.send(json.dumps({'status': 'error', 'message': 'Failed to tune'}))
|
||||
|
||||
except TimeoutError:
|
||||
pass
|
||||
except Exception as e:
|
||||
if "timed out" not in str(e).lower():
|
||||
logger.error(f"WebSocket receive error: {e}")
|
||||
|
||||
# Stream audio data if active
|
||||
if streaming and proc and proc.poll() is None:
|
||||
try:
|
||||
chunk = proc.stdout.read(4096)
|
||||
if chunk:
|
||||
ws.send(chunk)
|
||||
except Exception as e:
|
||||
logger.error(f"Audio read error: {e}")
|
||||
streaming = False
|
||||
elif streaming:
|
||||
# Process died
|
||||
streaming = False
|
||||
ws.send(json.dumps({'status': 'error', 'message': 'Audio process died'}))
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"WebSocket closed: {e}")
|
||||
finally:
|
||||
with process_lock:
|
||||
kill_audio_processes()
|
||||
logger.info("WebSocket audio client disconnected")
|
||||
+80
-10
@@ -21,6 +21,7 @@ import app as app_module
|
||||
from utils.dependencies import check_tool
|
||||
from utils.logging import bluetooth_logger as logger
|
||||
from utils.sse import format_sse
|
||||
from utils.validation import validate_bluetooth_interface
|
||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
||||
from utils.constants import (
|
||||
@@ -43,42 +44,76 @@ def classify_bt_device(name, device_class, services, manufacturer=None):
|
||||
name_lower = (name or '').lower()
|
||||
mfr_lower = (manufacturer or '').lower()
|
||||
|
||||
# Audio devices - check name patterns first
|
||||
audio_patterns = [
|
||||
'airpod', 'earbud', 'headphone', 'headset', 'speaker', 'audio', 'beats', 'bose',
|
||||
'jbl', 'sony wh', 'sony wf', 'sennheiser', 'jabra', 'soundcore', 'anker', 'buds',
|
||||
'earphone', 'pod', 'soundbar', 'skullcandy', 'marshall', 'b&o', 'bang', 'olufsen'
|
||||
'earphone', 'pod', 'soundbar', 'skullcandy', 'marshall', 'b&o', 'bang', 'olufsen',
|
||||
'powerbeats', 'soundlink', 'soundsport', 'quietcomfort', 'qc35', 'qc45', 'nc700',
|
||||
'wh-1000', 'wf-1000', 'linkbuds', 'freebuds', 'galaxy buds', 'pixel buds',
|
||||
'echo dot', 'homepod', 'sonos', 'ue boom', 'flip', 'charge', 'xtreme', 'pulse'
|
||||
]
|
||||
if any(x in name_lower for x in audio_patterns):
|
||||
return 'audio'
|
||||
|
||||
# Wearables
|
||||
wearable_patterns = [
|
||||
'watch', 'band', 'fitbit', 'garmin', 'mi band', 'miband', 'amazfit',
|
||||
'galaxy watch', 'gear', 'versa', 'sense', 'charge', 'inspire'
|
||||
'galaxy watch', 'gear', 'versa', 'sense', 'charge', 'inspire', 'fenix',
|
||||
'forerunner', 'venu', 'vivoactive', 'instinct', 'apple watch', 'gt 2', 'gt2'
|
||||
]
|
||||
if any(x in name_lower for x in wearable_patterns):
|
||||
return 'wearable'
|
||||
|
||||
# Phones - check name patterns
|
||||
phone_patterns = [
|
||||
'iphone', 'galaxy', 'pixel', 'phone', 'android', 'oneplus', 'huawei', 'xiaomi'
|
||||
'iphone', 'galaxy', 'pixel', 'phone', 'android', 'oneplus', 'huawei', 'xiaomi',
|
||||
'redmi', 'poco', 'realme', 'oppo', 'vivo', 'motorola', 'nokia', 'lg-', 'sm-',
|
||||
'moto g', 'moto e', 'note', 'ultra', 'pro max', 's21', 's22', 's23', 's24'
|
||||
]
|
||||
if any(x in name_lower for x in phone_patterns):
|
||||
return 'phone'
|
||||
|
||||
tracker_patterns = ['airtag', 'tile', 'smarttag', 'chipolo', 'find my']
|
||||
# Trackers
|
||||
tracker_patterns = ['airtag', 'tile', 'smarttag', 'chipolo', 'find my', 'findmy']
|
||||
if any(x in name_lower for x in tracker_patterns):
|
||||
return 'tracker'
|
||||
|
||||
input_patterns = ['keyboard', 'mouse', 'controller', 'gamepad', 'remote']
|
||||
# Input devices
|
||||
input_patterns = ['keyboard', 'mouse', 'controller', 'gamepad', 'remote', 'trackpad',
|
||||
'magic keyboard', 'magic mouse', 'magic trackpad', 'mx master', 'mx keys',
|
||||
'logitech k', 'logitech m', 'razer', 'dualshock', 'dualsense', 'xbox']
|
||||
if any(x in name_lower for x in input_patterns):
|
||||
return 'input'
|
||||
|
||||
if mfr_lower in ['bose', 'jbl', 'sony', 'sennheiser', 'jabra', 'beats']:
|
||||
# Computers/laptops
|
||||
computer_patterns = ['macbook', 'imac', 'mac pro', 'mac mini', 'dell', 'hp ', 'lenovo',
|
||||
'thinkpad', 'surface', 'chromebook', 'laptop', 'desktop', 'pc']
|
||||
if any(x in name_lower for x in computer_patterns):
|
||||
return 'computer'
|
||||
|
||||
# Check manufacturer for device type inference
|
||||
audio_manufacturers = ['bose', 'jbl', 'sony', 'sennheiser', 'jabra', 'beats',
|
||||
'bang & olufsen', 'audio-technica', 'skullcandy', 'anker', 'plantronics']
|
||||
if mfr_lower in audio_manufacturers:
|
||||
return 'audio'
|
||||
if mfr_lower in ['fitbit', 'garmin']:
|
||||
|
||||
wearable_manufacturers = ['fitbit', 'garmin']
|
||||
if mfr_lower in wearable_manufacturers:
|
||||
return 'wearable'
|
||||
|
||||
if mfr_lower == 'tile':
|
||||
return 'tracker'
|
||||
|
||||
phone_manufacturers = ['samsung', 'xiaomi', 'huawei', 'oneplus', 'google', 'oppo', 'vivo', 'realme']
|
||||
if mfr_lower in phone_manufacturers:
|
||||
return 'phone'
|
||||
|
||||
computer_manufacturers = ['dell', 'hp', 'lenovo', 'microsoft', 'intel']
|
||||
if mfr_lower in computer_manufacturers:
|
||||
return 'computer'
|
||||
|
||||
# Check device class if available
|
||||
if device_class:
|
||||
major_class = (device_class >> 8) & 0x1F
|
||||
if major_class == 1:
|
||||
@@ -218,18 +253,43 @@ def stream_bt_scan(process, scan_mode):
|
||||
line = re.sub(r'\r', '', line)
|
||||
|
||||
if 'Device' in line:
|
||||
# Check for RSSI update: [CHG] Device XX:XX:XX RSSI: -65
|
||||
rssi_match = re.search(r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}).*RSSI:\s*(-?\d+)', line)
|
||||
if rssi_match:
|
||||
mac = rssi_match.group(1).upper()
|
||||
rssi = int(rssi_match.group(2))
|
||||
if mac in app_module.bt_devices:
|
||||
app_module.bt_devices[mac]['rssi'] = rssi
|
||||
app_module.bt_devices[mac]['last_seen'] = time.time()
|
||||
# Send RSSI update
|
||||
app_module.bt_queue.put({
|
||||
**app_module.bt_devices[mac],
|
||||
'type': 'device',
|
||||
'device_type': app_module.bt_devices[mac].get('type', 'other'),
|
||||
'action': 'update',
|
||||
})
|
||||
continue
|
||||
|
||||
# Check for new device: [NEW] Device XX:XX:XX Name
|
||||
match = re.search(r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s*(.*)', line)
|
||||
if match:
|
||||
mac = match.group(1).upper()
|
||||
name = match.group(2).strip()
|
||||
|
||||
# Extract RSSI from name if present
|
||||
rssi_in_name = re.search(r'RSSI:\s*(-?\d+)', name)
|
||||
initial_rssi = int(rssi_in_name.group(1)) if rssi_in_name else None
|
||||
|
||||
# Remove "RSSI: -XX" from name
|
||||
name = re.sub(r'\s*RSSI:\s*-?\d+\s*', '', name).strip()
|
||||
|
||||
manufacturer = get_manufacturer(mac)
|
||||
device = {
|
||||
'mac': mac,
|
||||
'name': name or '[Unknown]',
|
||||
'manufacturer': manufacturer,
|
||||
'type': classify_bt_device(name, None, None, manufacturer),
|
||||
'rssi': None,
|
||||
'rssi': initial_rssi,
|
||||
'last_seen': time.time()
|
||||
}
|
||||
|
||||
@@ -304,9 +364,14 @@ def start_bt_scan():
|
||||
|
||||
data = request.json
|
||||
scan_mode = data.get('mode', 'hcitool')
|
||||
interface = data.get('interface', 'hci0')
|
||||
scan_ble = data.get('scan_ble', True)
|
||||
|
||||
# Validate Bluetooth interface name
|
||||
try:
|
||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
app_module.bt_interface = interface
|
||||
app_module.bt_devices = {}
|
||||
|
||||
@@ -388,7 +453,12 @@ def stop_bt_scan():
|
||||
def reset_bt_adapter():
|
||||
"""Reset Bluetooth adapter."""
|
||||
data = request.json
|
||||
interface = data.get('interface', 'hci0')
|
||||
|
||||
# Validate Bluetooth interface name
|
||||
try:
|
||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
with app_module.bt_lock:
|
||||
if app_module.bt_process:
|
||||
|
||||
+44
-154
@@ -1,9 +1,8 @@
|
||||
"""GPS dongle routes for USB GPS device support."""
|
||||
"""GPS routes for gpsd daemon support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import Generator
|
||||
|
||||
@@ -12,15 +11,11 @@ from flask import Blueprint, jsonify, request, Response
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.gps import (
|
||||
detect_gps_devices,
|
||||
is_serial_available,
|
||||
get_gps_reader,
|
||||
start_gps,
|
||||
start_gpsd,
|
||||
stop_gps,
|
||||
get_current_position,
|
||||
GPSPosition,
|
||||
GPSDClient,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.gps')
|
||||
@@ -44,93 +39,42 @@ def _position_callback(position: GPSPosition) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@gps_bp.route('/available')
|
||||
def check_gps_available():
|
||||
"""Check if GPS dongle support is available."""
|
||||
return jsonify({
|
||||
'available': is_serial_available(),
|
||||
'message': None if is_serial_available() else 'pyserial not installed - run: pip install pyserial'
|
||||
})
|
||||
@gps_bp.route('/auto-connect', methods=['POST'])
|
||||
def auto_connect_gps():
|
||||
"""
|
||||
Automatically connect to gpsd if available.
|
||||
|
||||
|
||||
@gps_bp.route('/gpsd/check')
|
||||
def check_gpsd_available():
|
||||
"""Check if gpsd is reachable."""
|
||||
Called on page load to seamlessly enable GPS if gpsd is running.
|
||||
Returns current status if already connected.
|
||||
"""
|
||||
import socket
|
||||
|
||||
host = request.args.get('host', 'localhost')
|
||||
port = int(request.args.get('port', 2947))
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
if reader and reader.is_running:
|
||||
position = reader.position
|
||||
return jsonify({
|
||||
'status': 'connected',
|
||||
'source': 'gpsd',
|
||||
'has_fix': position is not None,
|
||||
'position': position.to_dict() if position else None
|
||||
})
|
||||
|
||||
# Try to connect to gpsd on localhost:2947
|
||||
host = 'localhost'
|
||||
port = 2947
|
||||
|
||||
# First check if gpsd is reachable
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(2.0)
|
||||
sock.settimeout(1.0)
|
||||
sock.connect((host, port))
|
||||
sock.close()
|
||||
except Exception:
|
||||
return jsonify({
|
||||
'available': True,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'message': f'gpsd reachable at {host}:{port}'
|
||||
'status': 'unavailable',
|
||||
'message': 'gpsd not running'
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'available': False,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'message': f'Cannot connect to gpsd at {host}:{port}: {e}'
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/devices')
|
||||
def list_gps_devices():
|
||||
"""List available GPS serial devices."""
|
||||
if not is_serial_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'pyserial not installed'
|
||||
}), 503
|
||||
|
||||
devices = detect_gps_devices()
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'devices': devices
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/start', methods=['POST'])
|
||||
def start_gps_reader():
|
||||
"""Start GPS reader on specified device."""
|
||||
if not is_serial_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'pyserial not installed'
|
||||
}), 503
|
||||
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
if reader and reader.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'GPS reader already running'
|
||||
}), 409
|
||||
|
||||
data = request.json or {}
|
||||
device_path = data.get('device')
|
||||
baudrate = data.get('baudrate', 9600)
|
||||
|
||||
if not device_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Device path required'
|
||||
}), 400
|
||||
|
||||
# Validate baudrate
|
||||
valid_baudrates = [4800, 9600, 19200, 38400, 57600, 115200]
|
||||
if baudrate not in valid_baudrates:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid baudrate. Valid options: {valid_baudrates}'
|
||||
}), 400
|
||||
|
||||
# Clear the queue
|
||||
while not _gps_queue.empty():
|
||||
@@ -139,80 +83,26 @@ def start_gps_reader():
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Start the GPS reader with callback pre-registered (avoids race condition)
|
||||
success = start_gps(device_path, baudrate, callback=_position_callback)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'device': device_path,
|
||||
'baudrate': baudrate,
|
||||
'source': 'serial'
|
||||
})
|
||||
else:
|
||||
reader = get_gps_reader()
|
||||
error = reader.error if reader else 'Unknown error'
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Failed to start GPS reader: {error}'
|
||||
}), 500
|
||||
|
||||
|
||||
@gps_bp.route('/gpsd/start', methods=['POST'])
|
||||
def start_gpsd_client():
|
||||
"""Start GPS client connected to gpsd."""
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
if reader and reader.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'GPS reader already running'
|
||||
}), 409
|
||||
|
||||
data = request.json or {}
|
||||
host = data.get('host', 'localhost')
|
||||
port = data.get('port', 2947)
|
||||
|
||||
# Validate port
|
||||
try:
|
||||
port = int(port)
|
||||
if not (1 <= port <= 65535):
|
||||
raise ValueError("Port out of range")
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid port number'
|
||||
}), 400
|
||||
|
||||
# Clear the queue
|
||||
while not _gps_queue.empty():
|
||||
try:
|
||||
_gps_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Start the gpsd client with callback pre-registered
|
||||
# Start the gpsd client
|
||||
success = start_gpsd(host, port, callback=_position_callback)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'host': host,
|
||||
'port': port,
|
||||
'source': 'gpsd'
|
||||
'status': 'connected',
|
||||
'source': 'gpsd',
|
||||
'has_fix': False,
|
||||
'position': None
|
||||
})
|
||||
else:
|
||||
reader = get_gps_reader()
|
||||
error = reader.error if reader else 'Unknown error'
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Failed to connect to gpsd: {error}'
|
||||
}), 500
|
||||
'status': 'unavailable',
|
||||
'message': 'Failed to connect to gpsd'
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/stop', methods=['POST'])
|
||||
def stop_gps_reader():
|
||||
"""Stop GPS reader."""
|
||||
"""Stop GPS client."""
|
||||
reader = get_gps_reader()
|
||||
if reader:
|
||||
reader.remove_callback(_position_callback)
|
||||
@@ -224,7 +114,7 @@ def stop_gps_reader():
|
||||
|
||||
@gps_bp.route('/status')
|
||||
def get_gps_status():
|
||||
"""Get current GPS reader status."""
|
||||
"""Get current GPS client status."""
|
||||
reader = get_gps_reader()
|
||||
|
||||
if not reader:
|
||||
@@ -233,7 +123,7 @@ def get_gps_status():
|
||||
'device': None,
|
||||
'position': None,
|
||||
'error': None,
|
||||
'message': 'GPS reader not started'
|
||||
'message': 'GPS client not started'
|
||||
})
|
||||
|
||||
position = reader.position
|
||||
@@ -262,7 +152,7 @@ def get_position():
|
||||
if not reader or not reader.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'GPS reader not running'
|
||||
'message': 'GPS client not running'
|
||||
}), 400
|
||||
else:
|
||||
return jsonify({
|
||||
@@ -273,22 +163,22 @@ def get_position():
|
||||
|
||||
@gps_bp.route('/debug')
|
||||
def debug_gps():
|
||||
"""Debug endpoint showing GPS reader state."""
|
||||
"""Debug endpoint showing GPS client state."""
|
||||
reader = get_gps_reader()
|
||||
|
||||
if not reader:
|
||||
return jsonify({
|
||||
'reader': None,
|
||||
'message': 'No GPS reader initialized'
|
||||
'message': 'No GPS client initialized'
|
||||
})
|
||||
|
||||
position = reader.position
|
||||
source = 'gpsd' if isinstance(reader, GPSDClient) else 'serial'
|
||||
return jsonify({
|
||||
'running': reader.is_running,
|
||||
'source': source,
|
||||
'source': 'gpsd',
|
||||
'device': reader.device_path,
|
||||
'baudrate': reader.baudrate,
|
||||
'host': reader.host,
|
||||
'port': reader.port,
|
||||
'has_position': position is not None,
|
||||
'position': position.to_dict() if position else None,
|
||||
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
||||
|
||||
+231
-90
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import select
|
||||
import signal
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -21,6 +23,7 @@ from utils.constants import (
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
)
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
|
||||
logger = get_logger('intercept.listening_post')
|
||||
|
||||
@@ -54,6 +57,8 @@ scanner_config = {
|
||||
'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning)
|
||||
'device': 0,
|
||||
'gain': 40,
|
||||
'bias_t': False, # Bias-T power for external LNA
|
||||
'sdr_type': 'rtlsdr', # SDR type: rtlsdr, hackrf, airspy, limesdr, sdrplay
|
||||
}
|
||||
|
||||
# Activity log
|
||||
@@ -74,14 +79,16 @@ def find_rtl_fm() -> str | None:
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_rx_fm() -> str | None:
|
||||
"""Find rx_fm binary (SoapySDR FM demodulator for HackRF/Airspy/LimeSDR)."""
|
||||
return shutil.which('rx_fm')
|
||||
|
||||
|
||||
def find_ffmpeg() -> str | None:
|
||||
"""Find ffmpeg for audio encoding."""
|
||||
return shutil.which('ffmpeg')
|
||||
|
||||
|
||||
def find_sox() -> str | None:
|
||||
"""Find sox for audio encoding."""
|
||||
return shutil.which('sox')
|
||||
|
||||
|
||||
def add_activity_log(event_type: str, frequency: float, details: str = ''):
|
||||
@@ -133,9 +140,6 @@ def scanner_loop():
|
||||
last_signal_time = 0
|
||||
signal_detected = False
|
||||
|
||||
# Convert step from kHz to MHz
|
||||
step_mhz = scanner_config['step'] / 1000.0
|
||||
|
||||
try:
|
||||
while scanner_running:
|
||||
# Check if paused
|
||||
@@ -143,6 +147,13 @@ def scanner_loop():
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
# Read config values on each iteration (allows live updates)
|
||||
step_mhz = scanner_config['step'] / 1000.0
|
||||
squelch = scanner_config['squelch']
|
||||
mod = scanner_config['modulation']
|
||||
gain = scanner_config['gain']
|
||||
device = scanner_config['device']
|
||||
|
||||
scanner_current_freq = current_freq
|
||||
|
||||
# Notify clients of frequency change
|
||||
@@ -157,7 +168,6 @@ def scanner_loop():
|
||||
|
||||
# Start rtl_fm at this frequency
|
||||
freq_hz = int(current_freq * 1e6)
|
||||
mod = scanner_config['modulation']
|
||||
|
||||
# Sample rates
|
||||
if mod == 'wfm':
|
||||
@@ -177,9 +187,12 @@ def scanner_loop():
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(scanner_config['gain']),
|
||||
'-d', str(scanner_config['device']),
|
||||
'-g', str(gain),
|
||||
'-d', str(device),
|
||||
]
|
||||
# Add bias-t flag if enabled (for external LNA power)
|
||||
if scanner_config.get('bias_t', False):
|
||||
rtl_cmd.append('-T')
|
||||
|
||||
try:
|
||||
# Start rtl_fm
|
||||
@@ -212,21 +225,22 @@ def scanner_loop():
|
||||
# Analyze audio level
|
||||
audio_detected = False
|
||||
rms = 0
|
||||
threshold = 3000
|
||||
threshold = 500
|
||||
if len(audio_data) > 100:
|
||||
import struct
|
||||
samples = struct.unpack(f'{len(audio_data)//2}h', audio_data)
|
||||
# Calculate RMS level (root mean square)
|
||||
rms = (sum(s*s for s in samples) / len(samples)) ** 0.5
|
||||
|
||||
# WFM (broadcast FM) has much higher audio output - needs higher threshold
|
||||
# AM/NFM have lower output levels
|
||||
# Threshold based on squelch setting
|
||||
# Lower squelch = more sensitive (lower threshold)
|
||||
# squelch 0 = very sensitive, squelch 100 = only strong signals
|
||||
if mod == 'wfm':
|
||||
# WFM: threshold 4000-12000 based on squelch
|
||||
threshold = 4000 + (scanner_config['squelch'] * 80)
|
||||
# WFM: threshold 500-10000 based on squelch
|
||||
threshold = 500 + (squelch * 95)
|
||||
else:
|
||||
# AM/NFM: threshold 1500-8000 based on squelch
|
||||
threshold = 1500 + (scanner_config['squelch'] * 65)
|
||||
# AM/NFM: threshold 300-6500 based on squelch
|
||||
threshold = 300 + (squelch * 62)
|
||||
|
||||
audio_detected = rms > threshold
|
||||
|
||||
@@ -340,14 +354,19 @@ def _start_audio_stream(frequency: float, modulation: str):
|
||||
# Stop any existing stream
|
||||
_stop_audio_stream_internal()
|
||||
|
||||
rtl_fm_path = find_rtl_fm()
|
||||
ffmpeg_path = find_ffmpeg()
|
||||
|
||||
if not rtl_fm_path or not ffmpeg_path:
|
||||
if not ffmpeg_path:
|
||||
logger.error("ffmpeg not found")
|
||||
return
|
||||
|
||||
freq_hz = int(frequency * 1e6)
|
||||
# Determine SDR type and build appropriate command
|
||||
sdr_type_str = scanner_config.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
# Set sample rates based on modulation
|
||||
if modulation == 'wfm':
|
||||
sample_rate = 170000
|
||||
resample_rate = 32000
|
||||
@@ -358,48 +377,93 @@ def _start_audio_stream(frequency: float, modulation: str):
|
||||
sample_rate = 24000
|
||||
resample_rate = 24000
|
||||
|
||||
rtl_cmd = [
|
||||
rtl_fm_path,
|
||||
'-M', modulation,
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(scanner_config['gain']),
|
||||
'-d', str(scanner_config['device']),
|
||||
'-l', str(scanner_config['squelch']),
|
||||
]
|
||||
# Build the SDR command based on device type
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
# Use rtl_fm for RTL-SDR devices
|
||||
rtl_fm_path = find_rtl_fm()
|
||||
if not rtl_fm_path:
|
||||
logger.error("rtl_fm not found")
|
||||
return
|
||||
|
||||
freq_hz = int(frequency * 1e6)
|
||||
sdr_cmd = [
|
||||
rtl_fm_path,
|
||||
'-M', modulation,
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(scanner_config['gain']),
|
||||
'-d', str(scanner_config['device']),
|
||||
'-l', str(scanner_config['squelch']),
|
||||
]
|
||||
if scanner_config.get('bias_t', False):
|
||||
sdr_cmd.append('-T')
|
||||
else:
|
||||
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
|
||||
rx_fm_path = find_rx_fm()
|
||||
if not rx_fm_path:
|
||||
logger.error(f"rx_fm not found - required for {sdr_type.value}. Install SoapySDR utilities.")
|
||||
return
|
||||
|
||||
# Create device and get command builder
|
||||
device = SDRFactory.create_default_device(sdr_type, index=scanner_config['device'])
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
|
||||
# Build FM demod command
|
||||
sdr_cmd = builder.build_fm_demod_command(
|
||||
device=device,
|
||||
frequency_mhz=frequency,
|
||||
sample_rate=resample_rate,
|
||||
gain=float(scanner_config['gain']),
|
||||
modulation=modulation,
|
||||
squelch=scanner_config['squelch'],
|
||||
bias_t=scanner_config.get('bias_t', False)
|
||||
)
|
||||
# Ensure we use the found rx_fm path
|
||||
sdr_cmd[0] = rx_fm_path
|
||||
|
||||
encoder_cmd = [
|
||||
ffmpeg_path,
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-f', 's16le',
|
||||
'-ar', str(resample_rate),
|
||||
'-ac', '1',
|
||||
'-i', 'pipe:0',
|
||||
'-acodec', 'libmp3lame',
|
||||
'-b:a', '128k',
|
||||
'-ar', '44100',
|
||||
'-f', 'mp3',
|
||||
'-b:a', '64k',
|
||||
'-flush_packets', '1',
|
||||
'pipe:1'
|
||||
]
|
||||
|
||||
try:
|
||||
audio_rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
# 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}")
|
||||
|
||||
audio_rtl_process = None # Not used in shell mode
|
||||
audio_process = subprocess.Popen(
|
||||
encoder_cmd,
|
||||
stdin=audio_rtl_process.stdout,
|
||||
shell_cmd,
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
bufsize=0
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=0,
|
||||
start_new_session=True # Create new process group for clean shutdown
|
||||
)
|
||||
|
||||
audio_rtl_process.stdout.close()
|
||||
# Brief delay to check if process started successfully
|
||||
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}")
|
||||
return
|
||||
|
||||
audio_running = True
|
||||
audio_frequency = frequency
|
||||
audio_modulation = modulation
|
||||
logger.info(f"Audio stream started: {frequency} MHz ({modulation}) via {sdr_type.value}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start audio stream: {e}")
|
||||
@@ -415,31 +479,38 @@ def _stop_audio_stream_internal():
|
||||
"""Internal stop (must hold lock)."""
|
||||
global audio_process, audio_rtl_process, audio_running, audio_frequency
|
||||
|
||||
if audio_process:
|
||||
try:
|
||||
audio_process.terminate()
|
||||
audio_process.wait(timeout=1)
|
||||
except:
|
||||
try:
|
||||
audio_process.kill()
|
||||
except:
|
||||
pass
|
||||
audio_process = None
|
||||
|
||||
if audio_rtl_process:
|
||||
try:
|
||||
audio_rtl_process.terminate()
|
||||
audio_rtl_process.wait(timeout=1)
|
||||
except:
|
||||
try:
|
||||
audio_rtl_process.kill()
|
||||
except:
|
||||
pass
|
||||
audio_rtl_process = None
|
||||
|
||||
# Set flag first to stop any streaming
|
||||
audio_running = False
|
||||
audio_frequency = 0.0
|
||||
|
||||
# Kill the shell process and its children
|
||||
if audio_process:
|
||||
try:
|
||||
# Kill entire process group (rtl_fm, ffmpeg, shell)
|
||||
try:
|
||||
os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
audio_process.kill()
|
||||
audio_process.wait(timeout=0.5)
|
||||
except:
|
||||
pass
|
||||
|
||||
audio_process = None
|
||||
audio_rtl_process = None
|
||||
|
||||
# Kill any orphaned rtl_fm and ffmpeg processes
|
||||
try:
|
||||
subprocess.run(['pkill', '-9', 'rtl_fm'], capture_output=True, timeout=0.5)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
subprocess.run(['pkill', '-9', '-f', 'ffmpeg.*pipe:0'], capture_output=True, timeout=0.5)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Pause for SDR device to be released (important for frequency/modulation changes)
|
||||
time.sleep(0.7)
|
||||
|
||||
|
||||
# ============================================
|
||||
# API ENDPOINTS
|
||||
@@ -449,16 +520,23 @@ def _stop_audio_stream_internal():
|
||||
def check_tools() -> Response:
|
||||
"""Check for required tools."""
|
||||
rtl_fm = find_rtl_fm()
|
||||
rx_fm = find_rx_fm()
|
||||
ffmpeg = find_ffmpeg()
|
||||
sox = find_sox()
|
||||
can_stream = ffmpeg is not None or sox is not None
|
||||
|
||||
# Determine which SDR types are supported
|
||||
supported_sdr_types = []
|
||||
if rtl_fm:
|
||||
supported_sdr_types.append('rtlsdr')
|
||||
if rx_fm:
|
||||
# rx_fm from SoapySDR supports these types
|
||||
supported_sdr_types.extend(['hackrf', 'airspy', 'limesdr', 'sdrplay'])
|
||||
|
||||
return jsonify({
|
||||
'rtl_fm': rtl_fm is not None,
|
||||
'rx_fm': rx_fm is not None,
|
||||
'ffmpeg': ffmpeg is not None,
|
||||
'sox': sox is not None,
|
||||
'can_stream': can_stream,
|
||||
'available': rtl_fm is not None and can_stream
|
||||
'available': (rtl_fm is not None or rx_fm is not None) and ffmpeg is not None,
|
||||
'supported_sdr_types': supported_sdr_types
|
||||
})
|
||||
|
||||
|
||||
@@ -487,6 +565,8 @@ def start_scanner() -> Response:
|
||||
scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
|
||||
scanner_config['device'] = int(data.get('device', 0))
|
||||
scanner_config['gain'] = int(data.get('gain', 40))
|
||||
scanner_config['bias_t'] = bool(data.get('bias_t', False))
|
||||
scanner_config['sdr_type'] = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -500,12 +580,20 @@ def start_scanner() -> Response:
|
||||
'message': 'start_freq must be less than end_freq'
|
||||
}), 400
|
||||
|
||||
# Check tools
|
||||
if not find_rtl_fm():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install rtl-sdr tools.'
|
||||
}), 503
|
||||
# Check tools based on SDR type
|
||||
sdr_type = scanner_config['sdr_type']
|
||||
if sdr_type == 'rtlsdr':
|
||||
if not find_rtl_fm():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install rtl-sdr tools.'
|
||||
}), 503
|
||||
else:
|
||||
if not find_rx_fm():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
||||
}), 503
|
||||
|
||||
# Start scanner thread
|
||||
scanner_running = True
|
||||
@@ -571,6 +659,42 @@ def skip_signal() -> Response:
|
||||
})
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/config', methods=['POST'])
|
||||
def update_scanner_config() -> Response:
|
||||
"""Update scanner config while running (step, squelch, gain, dwell)."""
|
||||
data = request.json or {}
|
||||
|
||||
updated = []
|
||||
|
||||
if 'step' in data:
|
||||
scanner_config['step'] = float(data['step'])
|
||||
updated.append(f"step={data['step']}kHz")
|
||||
|
||||
if 'squelch' in data:
|
||||
scanner_config['squelch'] = int(data['squelch'])
|
||||
updated.append(f"squelch={data['squelch']}")
|
||||
|
||||
if 'gain' in data:
|
||||
scanner_config['gain'] = int(data['gain'])
|
||||
updated.append(f"gain={data['gain']}")
|
||||
|
||||
if 'dwell_time' in data:
|
||||
scanner_config['dwell_time'] = int(data['dwell_time'])
|
||||
updated.append(f"dwell={data['dwell_time']}s")
|
||||
|
||||
if 'modulation' in data:
|
||||
scanner_config['modulation'] = str(data['modulation']).lower()
|
||||
updated.append(f"mod={data['modulation']}")
|
||||
|
||||
if updated:
|
||||
logger.info(f"Scanner config updated: {', '.join(updated)}")
|
||||
|
||||
return jsonify({
|
||||
'status': 'updated',
|
||||
'config': scanner_config
|
||||
})
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/status')
|
||||
def scanner_status() -> Response:
|
||||
"""Get scanner status."""
|
||||
@@ -651,6 +775,8 @@ 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
|
||||
@@ -664,6 +790,7 @@ def start_audio() -> Response:
|
||||
squelch = int(data.get('squelch', 0))
|
||||
gain = int(data.get('gain', 40))
|
||||
device = int(data.get('device', 0))
|
||||
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -683,25 +810,31 @@ def start_audio() -> Response:
|
||||
'message': f'Invalid modulation. Use: {", ".join(valid_mods)}'
|
||||
}), 400
|
||||
|
||||
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay']
|
||||
if sdr_type not in valid_sdr_types:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
|
||||
}), 400
|
||||
|
||||
# Update config for audio
|
||||
scanner_config['squelch'] = squelch
|
||||
scanner_config['gain'] = gain
|
||||
scanner_config['device'] = device
|
||||
scanner_config['sdr_type'] = sdr_type
|
||||
|
||||
_start_audio_stream(frequency, modulation)
|
||||
|
||||
if audio_running:
|
||||
add_activity_log('manual_tune', frequency, f'Manual tune to {frequency} MHz ({modulation.upper()})')
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'modulation': modulation,
|
||||
'stream_url': '/listening/audio/stream'
|
||||
'modulation': modulation
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start audio'
|
||||
'message': 'Failed to start audio. Check SDR device.'
|
||||
}), 500
|
||||
|
||||
|
||||
@@ -725,22 +858,30 @@ def audio_status() -> Response:
|
||||
@listening_post_bp.route('/audio/stream')
|
||||
def stream_audio() -> Response:
|
||||
"""Stream MP3 audio."""
|
||||
# Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
|
||||
for _ in range(40):
|
||||
if audio_running and audio_process:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
if not audio_running or not audio_process:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Audio not running'
|
||||
}), 400
|
||||
return Response(b'', mimetype='audio/mpeg', status=204)
|
||||
|
||||
def generate():
|
||||
chunk_size = 4096
|
||||
try:
|
||||
while audio_running and audio_process and audio_process.poll() is None:
|
||||
chunk = audio_process.stdout.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
logger.error(f"Audio stream error: {e}")
|
||||
# Use select to avoid blocking forever
|
||||
ready, _, _ = select.select([audio_process.stdout], [], [], 2.0)
|
||||
if ready:
|
||||
chunk = audio_process.stdout.read(4096)
|
||||
if chunk:
|
||||
yield chunk
|
||||
else:
|
||||
break
|
||||
except GeneratorExit:
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
|
||||
+8
-2
@@ -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__)
|
||||
|
||||
@@ -233,6 +234,7 @@ def start_decoding() -> Response:
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build FM demodulation command
|
||||
bias_t = data.get('bias_t', False)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
@@ -240,10 +242,14 @@ def start_decoding() -> Response:
|
||||
gain=float(gain) if gain and gain != '0' else None,
|
||||
ppm=int(ppm) if ppm and ppm != '0' else None,
|
||||
modulation='fm',
|
||||
squelch=squelch if squelch and squelch != 0 else None
|
||||
squelch=squelch if squelch and squelch != 0 else None,
|
||||
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}")
|
||||
|
||||
+4
-3
@@ -114,11 +114,13 @@ def start_sensor() -> Response:
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build ISM band decoder command
|
||||
bias_t = data.get('bias_t', False)
|
||||
cmd = builder.build_ism_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
gain=float(gain) if gain and gain != 0 else None,
|
||||
ppm=int(ppm) if ppm and ppm != 0 else None
|
||||
ppm=int(ppm) if ppm and ppm != 0 else None,
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
full_cmd = ' '.join(cmd)
|
||||
@@ -128,8 +130,7 @@ def start_sensor() -> Response:
|
||||
app_module.sensor_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=1
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
# Start output thread
|
||||
|
||||
@@ -9,8 +9,6 @@ from utils.database import (
|
||||
set_setting,
|
||||
delete_setting,
|
||||
get_all_settings,
|
||||
get_signal_history,
|
||||
add_signal_reading,
|
||||
get_correlations,
|
||||
)
|
||||
from utils.logging import get_logger
|
||||
@@ -145,66 +143,6 @@ def delete_single_setting(key: str) -> Response:
|
||||
}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Signal History Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@settings_bp.route('/signal-history/<mode>/<device_id>', methods=['GET'])
|
||||
def get_device_signal_history(mode: str, device_id: str) -> Response:
|
||||
"""Get signal strength history for a device."""
|
||||
limit = request.args.get('limit', 100, type=int)
|
||||
since_minutes = request.args.get('since', 60, type=int)
|
||||
|
||||
# Validate mode
|
||||
valid_modes = ['wifi', 'bluetooth', 'adsb', 'pager', 'sensor']
|
||||
if mode not in valid_modes:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid mode. Valid modes: {valid_modes}'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
history = get_signal_history(mode, device_id, limit, since_minutes)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'mode': mode,
|
||||
'device_id': device_id,
|
||||
'history': history
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting signal history: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@settings_bp.route('/signal-history', methods=['POST'])
|
||||
def add_signal_history() -> Response:
|
||||
"""Add a signal strength reading (for internal use)."""
|
||||
data = request.json or {}
|
||||
|
||||
mode = data.get('mode')
|
||||
device_id = data.get('device_id')
|
||||
signal_strength = data.get('signal_strength')
|
||||
|
||||
if not all([mode, device_id, signal_strength is not None]):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'mode, device_id, and signal_strength are required'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
add_signal_reading(mode, device_id, signal_strength, data.get('metadata'))
|
||||
return jsonify({'status': 'success'})
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding signal reading: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Device Correlation Endpoints
|
||||
# =============================================================================
|
||||
|
||||
+2276
File diff suppressed because it is too large
Load Diff
+310
-31
@@ -16,10 +16,10 @@ from typing import Any, Generator
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.dependencies import check_tool
|
||||
from utils.dependencies import check_tool, get_tool_path
|
||||
from utils.logging import wifi_logger as logger
|
||||
from utils.process import is_valid_mac, is_valid_channel
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||
from utils.sse import format_sse
|
||||
from data.oui import get_manufacturer
|
||||
from utils.constants import (
|
||||
@@ -105,12 +105,18 @@ def detect_wifi_interfaces():
|
||||
current_iface = line.split()[1]
|
||||
elif current_iface and 'type' in line:
|
||||
iface_type = line.split()[-1]
|
||||
interfaces.append({
|
||||
iface_info = {
|
||||
'name': current_iface,
|
||||
'type': iface_type,
|
||||
'monitor_capable': True,
|
||||
'status': 'up'
|
||||
})
|
||||
'status': 'up',
|
||||
'driver': '',
|
||||
'chipset': '',
|
||||
'mac': ''
|
||||
}
|
||||
# Get additional interface details
|
||||
iface_info.update(_get_interface_details(current_iface))
|
||||
interfaces.append(iface_info)
|
||||
current_iface = None
|
||||
except FileNotFoundError:
|
||||
# Fall back to iwconfig if iw is not available
|
||||
@@ -119,12 +125,17 @@ def detect_wifi_interfaces():
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'IEEE 802.11' in line:
|
||||
iface = line.split()[0]
|
||||
interfaces.append({
|
||||
iface_info = {
|
||||
'name': iface,
|
||||
'type': 'managed',
|
||||
'monitor_capable': True,
|
||||
'status': 'up'
|
||||
})
|
||||
'status': 'up',
|
||||
'driver': '',
|
||||
'chipset': '',
|
||||
'mac': ''
|
||||
}
|
||||
iface_info.update(_get_interface_details(iface))
|
||||
interfaces.append(iface_info)
|
||||
except FileNotFoundError:
|
||||
logger.debug("Neither iw nor iwconfig found")
|
||||
except subprocess.SubprocessError as e:
|
||||
@@ -137,6 +148,101 @@ def detect_wifi_interfaces():
|
||||
return interfaces
|
||||
|
||||
|
||||
def _get_interface_details(iface_name):
|
||||
"""Get additional details about a WiFi interface (driver, chipset, MAC)."""
|
||||
import os
|
||||
details = {'driver': '', 'chipset': '', 'mac': ''}
|
||||
|
||||
# Get MAC address
|
||||
try:
|
||||
mac_path = f'/sys/class/net/{iface_name}/address'
|
||||
with open(mac_path, 'r') as f:
|
||||
details['mac'] = f.read().strip().upper()
|
||||
except (FileNotFoundError, IOError):
|
||||
pass
|
||||
|
||||
# Get driver name
|
||||
try:
|
||||
driver_link = f'/sys/class/net/{iface_name}/device/driver'
|
||||
if os.path.islink(driver_link):
|
||||
driver_path = os.readlink(driver_link)
|
||||
details['driver'] = os.path.basename(driver_path)
|
||||
except (FileNotFoundError, IOError, OSError):
|
||||
pass
|
||||
|
||||
# Try airmon-ng first for chipset info (most reliable for WiFi adapters)
|
||||
try:
|
||||
result = subprocess.run(['airmon-ng'], capture_output=True, text=True, timeout=5)
|
||||
for line in result.stdout.split('\n'):
|
||||
# airmon-ng output format: PHY Interface Driver Chipset
|
||||
parts = line.split('\t')
|
||||
if len(parts) >= 4:
|
||||
if parts[1].strip() == iface_name or parts[1].strip().startswith(iface_name):
|
||||
if parts[2].strip():
|
||||
details['driver'] = parts[2].strip()
|
||||
if parts[3].strip():
|
||||
details['chipset'] = parts[3].strip()
|
||||
break
|
||||
# Also try space-separated format
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
if parts[1] == iface_name or parts[1].startswith(iface_name):
|
||||
details['driver'] = parts[2]
|
||||
details['chipset'] = ' '.join(parts[3:])
|
||||
break
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
# Fallback: Get chipset info from USB or PCI sysfs
|
||||
if not details['chipset']:
|
||||
try:
|
||||
device_path = f'/sys/class/net/{iface_name}/device'
|
||||
if os.path.exists(device_path):
|
||||
# Try to get USB product name
|
||||
for usb_path in [f'{device_path}/product', f'{device_path}/../product']:
|
||||
try:
|
||||
with open(usb_path, 'r') as f:
|
||||
details['chipset'] = f.read().strip()
|
||||
break
|
||||
except (FileNotFoundError, IOError):
|
||||
pass
|
||||
|
||||
# If no USB product, try lsusb for USB devices
|
||||
if not details['chipset']:
|
||||
try:
|
||||
# Get USB bus/device info
|
||||
uevent_path = f'{device_path}/uevent'
|
||||
with open(uevent_path, 'r') as f:
|
||||
for line in f:
|
||||
if line.startswith('PRODUCT='):
|
||||
# PRODUCT format: vendor/product/bcdDevice
|
||||
product = line.split('=')[1].strip()
|
||||
parts = product.split('/')
|
||||
if len(parts) >= 2:
|
||||
vid = parts[0].zfill(4)
|
||||
pid = parts[1].zfill(4)
|
||||
# Try lsusb to get device name
|
||||
try:
|
||||
lsusb = subprocess.run(
|
||||
['lsusb', '-d', f'{vid}:{pid}'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if lsusb.stdout:
|
||||
# Format: Bus XXX Device YYY: ID vid:pid Name
|
||||
usb_parts = lsusb.stdout.split(f'{vid}:{pid}')
|
||||
if len(usb_parts) > 1:
|
||||
details['chipset'] = usb_parts[1].strip()
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
break
|
||||
except (FileNotFoundError, IOError):
|
||||
pass
|
||||
except (FileNotFoundError, IOError, OSError):
|
||||
pass
|
||||
|
||||
return details
|
||||
|
||||
|
||||
def parse_airodump_csv(csv_path):
|
||||
"""Parse airodump-ng CSV output file."""
|
||||
networks = {}
|
||||
@@ -253,6 +359,20 @@ def stream_airodump_output(process, csv_path):
|
||||
'action': 'new',
|
||||
**client
|
||||
})
|
||||
else:
|
||||
# Send update if probes changed or signal changed significantly
|
||||
old_client = app_module.wifi_clients[mac]
|
||||
old_probes = old_client.get('probes', '')
|
||||
new_probes = client.get('probes', '')
|
||||
old_power = int(old_client.get('power', -100) or -100)
|
||||
new_power = int(client.get('power', -100) or -100)
|
||||
|
||||
if new_probes != old_probes or abs(new_power - old_power) >= 5:
|
||||
app_module.wifi_queue.put({
|
||||
'type': 'client',
|
||||
'action': 'update',
|
||||
**client
|
||||
})
|
||||
|
||||
app_module.wifi_networks = networks
|
||||
app_module.wifi_clients = clients
|
||||
@@ -303,11 +423,13 @@ def get_wifi_interfaces():
|
||||
def toggle_monitor_mode():
|
||||
"""Enable or disable monitor mode on an interface."""
|
||||
data = request.json
|
||||
interface = data.get('interface')
|
||||
action = data.get('action', 'start')
|
||||
|
||||
if not interface:
|
||||
return jsonify({'status': 'error', 'message': 'No interface specified'})
|
||||
# Validate interface name to prevent command injection
|
||||
try:
|
||||
interface = validate_network_interface(data.get('interface'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
if action == 'start':
|
||||
if check_tool('airmon-ng'):
|
||||
@@ -345,10 +467,11 @@ def toggle_monitor_mode():
|
||||
interfaces_before = get_wireless_interfaces()
|
||||
|
||||
kill_processes = data.get('kill_processes', False)
|
||||
airmon_path = get_tool_path('airmon-ng')
|
||||
if kill_processes:
|
||||
subprocess.run(['airmon-ng', 'check', 'kill'], capture_output=True, timeout=10)
|
||||
subprocess.run([airmon_path, 'check', 'kill'], capture_output=True, timeout=10)
|
||||
|
||||
result = subprocess.run(['airmon-ng', 'start', interface],
|
||||
result = subprocess.run([airmon_path, 'start', interface],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
|
||||
output = result.stdout + result.stderr
|
||||
@@ -405,8 +528,35 @@ def toggle_monitor_mode():
|
||||
if not monitor_iface:
|
||||
monitor_iface = interface + 'mon'
|
||||
|
||||
# Verify the interface actually exists
|
||||
def interface_exists(iface_name):
|
||||
return os.path.exists(f'/sys/class/net/{iface_name}')
|
||||
|
||||
if not interface_exists(monitor_iface):
|
||||
# Try common naming patterns
|
||||
candidates = [
|
||||
interface + 'mon',
|
||||
interface.replace('wlan', 'wlan') + 'mon',
|
||||
'wlan0mon', 'wlan1mon',
|
||||
interface # Maybe it stayed the same but in monitor mode
|
||||
]
|
||||
for candidate in candidates:
|
||||
if interface_exists(candidate):
|
||||
monitor_iface = candidate
|
||||
break
|
||||
else:
|
||||
# List all wireless interfaces to help debug
|
||||
all_wireless = [f for f in os.listdir('/sys/class/net')
|
||||
if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')]
|
||||
logger.error(f"Monitor interface not found. Tried: {monitor_iface}. Available: {all_wireless}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}'
|
||||
})
|
||||
|
||||
app_module.wifi_monitor_interface = monitor_iface
|
||||
app_module.wifi_queue.put({'type': 'info', 'text': f'Monitor mode enabled on {app_module.wifi_monitor_interface}'})
|
||||
logger.info(f"Monitor mode enabled on {monitor_iface}")
|
||||
return jsonify({'status': 'success', 'monitor_interface': app_module.wifi_monitor_interface})
|
||||
|
||||
except Exception as e:
|
||||
@@ -429,7 +579,8 @@ def toggle_monitor_mode():
|
||||
else: # stop
|
||||
if check_tool('airmon-ng'):
|
||||
try:
|
||||
subprocess.run(['airmon-ng', 'stop', app_module.wifi_monitor_interface or interface],
|
||||
airmon_path = get_tool_path('airmon-ng')
|
||||
subprocess.run([airmon_path, 'stop', app_module.wifi_monitor_interface or interface],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
app_module.wifi_monitor_interface = None
|
||||
return jsonify({'status': 'success', 'message': 'Monitor mode disabled'})
|
||||
@@ -456,13 +607,31 @@ def start_wifi_scan():
|
||||
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
||||
|
||||
data = request.json
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
channel = data.get('channel')
|
||||
band = data.get('band', 'abg')
|
||||
|
||||
# Use provided interface or fall back to stored monitor interface
|
||||
interface = data.get('interface')
|
||||
if interface:
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not interface:
|
||||
return jsonify({'status': 'error', 'message': 'No monitor interface available.'})
|
||||
|
||||
# Verify interface exists
|
||||
if not os.path.exists(f'/sys/class/net/{interface}'):
|
||||
all_wireless = [f for f in os.listdir('/sys/class/net')
|
||||
if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')]
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Interface "{interface}" does not exist. Available: {all_wireless}'
|
||||
})
|
||||
|
||||
app_module.wifi_networks = {}
|
||||
app_module.wifi_clients = {}
|
||||
|
||||
@@ -480,8 +649,9 @@ def start_wifi_scan():
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
airodump_path = get_tool_path('airodump-ng')
|
||||
cmd = [
|
||||
'airodump-ng',
|
||||
airodump_path,
|
||||
'-w', csv_path,
|
||||
'--output-format', 'csv,pcap',
|
||||
'--band', band,
|
||||
@@ -512,11 +682,12 @@ def start_wifi_scan():
|
||||
error_msg = re.sub(r'\x1b\[[0-9;]*m', '', error_msg)
|
||||
|
||||
if 'No such device' in error_msg or 'No such interface' in error_msg:
|
||||
error_msg = f'Interface "{interface}" not found.'
|
||||
error_msg = f'Interface "{interface}" not found. Make sure monitor mode is enabled.'
|
||||
elif 'Operation not permitted' in error_msg:
|
||||
error_msg = 'Permission denied. Try running with sudo.'
|
||||
|
||||
return jsonify({'status': 'error', 'message': error_msg})
|
||||
logger.error(f"airodump-ng failed for interface '{interface}': {error_msg}")
|
||||
return jsonify({'status': 'error', 'message': error_msg, 'interface': interface})
|
||||
|
||||
thread = threading.Thread(target=stream_airodump_output, args=(app_module.wifi_process, csv_path))
|
||||
thread.daemon = True
|
||||
@@ -554,7 +725,16 @@ def send_deauth():
|
||||
target_bssid = data.get('bssid')
|
||||
target_client = data.get('client', 'FF:FF:FF:FF:FF:FF')
|
||||
count = data.get('count', 5)
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
|
||||
# Validate interface
|
||||
interface = data.get('interface')
|
||||
if interface:
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid:
|
||||
return jsonify({'status': 'error', 'message': 'Target BSSID required'})
|
||||
@@ -579,8 +759,9 @@ def send_deauth():
|
||||
return jsonify({'status': 'error', 'message': 'aireplay-ng not found'})
|
||||
|
||||
try:
|
||||
aireplay_path = get_tool_path('aireplay-ng')
|
||||
cmd = [
|
||||
'aireplay-ng',
|
||||
aireplay_path,
|
||||
'--deauth', str(count),
|
||||
'-a', target_bssid,
|
||||
'-c', target_client,
|
||||
@@ -608,7 +789,16 @@ def capture_handshake():
|
||||
data = request.json
|
||||
target_bssid = data.get('bssid')
|
||||
channel = data.get('channel')
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
|
||||
# Validate interface
|
||||
interface = data.get('interface')
|
||||
if interface:
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid or not channel:
|
||||
return jsonify({'status': 'error', 'message': 'BSSID and channel required'})
|
||||
@@ -625,8 +815,9 @@ def capture_handshake():
|
||||
|
||||
capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}'
|
||||
|
||||
airodump_path = get_tool_path('airodump-ng')
|
||||
cmd = [
|
||||
'airodump-ng',
|
||||
airodump_path,
|
||||
'-c', str(channel),
|
||||
'--bssid', target_bssid,
|
||||
'-w', capture_path,
|
||||
@@ -664,14 +855,16 @@ def check_handshake_status():
|
||||
|
||||
try:
|
||||
if target_bssid and is_valid_mac(target_bssid):
|
||||
result = subprocess.run(
|
||||
['aircrack-ng', '-a', '2', '-b', target_bssid, capture_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
|
||||
if '0 handshake' not in output:
|
||||
handshake_found = True
|
||||
aircrack_path = get_tool_path('aircrack-ng')
|
||||
if aircrack_path:
|
||||
result = subprocess.run(
|
||||
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
|
||||
if '0 handshake' not in output:
|
||||
handshake_found = True
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception as e:
|
||||
@@ -694,7 +887,16 @@ def capture_pmkid():
|
||||
data = request.json
|
||||
target_bssid = data.get('bssid')
|
||||
channel = data.get('channel')
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
|
||||
# Validate interface
|
||||
interface = data.get('interface')
|
||||
if interface:
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid:
|
||||
return jsonify({'status': 'error', 'message': 'BSSID required'})
|
||||
@@ -785,6 +987,83 @@ def stop_pmkid():
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@wifi_bp.route('/handshake/crack', methods=['POST'])
|
||||
def crack_handshake():
|
||||
"""Crack a captured handshake using aircrack-ng."""
|
||||
data = request.json
|
||||
capture_file = data.get('capture_file', '')
|
||||
target_bssid = data.get('bssid', '')
|
||||
wordlist = data.get('wordlist', '')
|
||||
|
||||
# Validate paths to prevent path traversal
|
||||
if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture file path'}), 400
|
||||
|
||||
if '..' in wordlist:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid wordlist path'}), 400
|
||||
|
||||
if not os.path.exists(capture_file):
|
||||
return jsonify({'status': 'error', 'message': 'Capture file not found'}), 404
|
||||
|
||||
if not os.path.exists(wordlist):
|
||||
return jsonify({'status': 'error', 'message': 'Wordlist file not found'}), 404
|
||||
|
||||
if target_bssid and not is_valid_mac(target_bssid):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'}), 400
|
||||
|
||||
aircrack_path = get_tool_path('aircrack-ng')
|
||||
if not aircrack_path:
|
||||
return jsonify({'status': 'error', 'message': 'aircrack-ng not found'}), 500
|
||||
|
||||
try:
|
||||
cmd = [aircrack_path, '-a', '2', '-w', wordlist]
|
||||
if target_bssid:
|
||||
cmd.extend(['-b', target_bssid])
|
||||
cmd.append(capture_file)
|
||||
|
||||
logger.info(f"Starting aircrack-ng: {' '.join(cmd)}")
|
||||
|
||||
# Run aircrack-ng with a timeout (this could take a while)
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300 # 5 minute timeout
|
||||
)
|
||||
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
# Check if password was found
|
||||
# Aircrack-ng outputs "KEY FOUND! [ password ]" when successful
|
||||
if 'KEY FOUND!' in output:
|
||||
# Extract the password
|
||||
import re
|
||||
match = re.search(r'KEY FOUND!\s*\[\s*(.+?)\s*\]', output)
|
||||
if match:
|
||||
password = match.group(1)
|
||||
logger.info(f"Password cracked for {target_bssid}: {password}")
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'password': password,
|
||||
'bssid': target_bssid
|
||||
})
|
||||
|
||||
# Password not found
|
||||
return jsonify({
|
||||
'status': 'not_found',
|
||||
'message': 'Password not in wordlist'
|
||||
})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({
|
||||
'status': 'timeout',
|
||||
'message': 'Cracking timed out after 5 minutes. Try a smaller wordlist or use hashcat.'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Crack error: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/networks')
|
||||
def get_wifi_networks():
|
||||
"""Get current list of discovered networks."""
|
||||
|
||||
+246
-28
@@ -185,13 +185,144 @@ body {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 340px;
|
||||
grid-template-columns: auto 1fr 300px;
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: 0;
|
||||
height: calc(100vh - 60px);
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
/* ACARS sidebar (left of map) - Collapsible */
|
||||
.acars-sidebar {
|
||||
background: var(--bg-panel);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.acars-collapse-btn {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
background: var(--bg-card);
|
||||
border: none;
|
||||
border-left: 1px solid var(--border-color);
|
||||
color: var(--accent-cyan);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.acars-collapse-btn:hover {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.acars-collapse-label {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.acars-sidebar.collapsed .acars-collapse-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.acars-sidebar:not(.collapsed) .acars-collapse-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#acarsCollapseIcon {
|
||||
font-size: 10px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.acars-sidebar.collapsed #acarsCollapseIcon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.acars-sidebar-content {
|
||||
width: 250px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: width 0.3s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.acars-sidebar.collapsed .acars-sidebar-content {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.acars-sidebar .panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.acars-sidebar .panel::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.acars-sidebar .acars-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.acars-sidebar .acars-btn {
|
||||
background: var(--accent-green);
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 6px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.acars-sidebar .acars-btn:hover {
|
||||
background: #1db954;
|
||||
box-shadow: 0 0 10px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.acars-sidebar .acars-btn.active {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
|
||||
.acars-sidebar .acars-btn.active:hover {
|
||||
background: #dc2626;
|
||||
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.acars-message-item {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 10px;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.acars-message-item:hover {
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Panels */
|
||||
.panel {
|
||||
background: var(--bg-panel);
|
||||
@@ -228,8 +359,14 @@ body {
|
||||
.panel-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--accent-cyan);
|
||||
background: var(--text-dim);
|
||||
border-radius: 50%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.panel-indicator.active {
|
||||
background: var(--accent-green);
|
||||
opacity: 1;
|
||||
animation: blink 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -259,7 +396,7 @@ body {
|
||||
|
||||
/* Main display container (map + radar scope) */
|
||||
.main-display {
|
||||
grid-column: 1;
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
position: relative;
|
||||
}
|
||||
@@ -299,7 +436,7 @@ body {
|
||||
|
||||
/* Right sidebar */
|
||||
.sidebar {
|
||||
grid-column: 2;
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -346,7 +483,7 @@ body {
|
||||
/* Selected aircraft panel */
|
||||
.selected-aircraft {
|
||||
flex-shrink: 0;
|
||||
max-height: 280px;
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -354,6 +491,18 @@ body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
#aircraftPhotoContainer {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
#aircraftPhotoContainer img {
|
||||
max-height: 140px;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.selected-callsign {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 20px;
|
||||
@@ -406,6 +555,7 @@ body {
|
||||
}
|
||||
|
||||
.aircraft-item {
|
||||
position: relative;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(74, 158, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
@@ -478,11 +628,28 @@ body {
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 20px;
|
||||
padding: 10px 20px;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
padding: 8px 15px;
|
||||
background: var(--bg-panel);
|
||||
border-top: 1px solid rgba(74, 158, 255, 0.3);
|
||||
font-size: 11px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.controls-bar label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.controls-bar select,
|
||||
.controls-bar input[type="text"],
|
||||
.controls-bar input[type="number"] {
|
||||
padding: 3px 5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
@@ -535,9 +702,9 @@ body {
|
||||
/* Start/stop button */
|
||||
.start-btn {
|
||||
padding: 8px 20px;
|
||||
border: 1px solid var(--accent-cyan);
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
color: var(--accent-cyan);
|
||||
border: none;
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -550,19 +717,18 @@ body {
|
||||
}
|
||||
|
||||
.start-btn:hover {
|
||||
background: var(--accent-cyan);
|
||||
color: var(--bg-dark);
|
||||
box-shadow: 0 0 20px rgba(74, 158, 255, 0.3);
|
||||
background: #1db954;
|
||||
box-shadow: 0 0 20px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.start-btn.active {
|
||||
background: var(--accent-red);
|
||||
border-color: var(--accent-red);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.start-btn.active:hover {
|
||||
box-shadow: 0 0 20px rgba(255, 68, 68, 0.3);
|
||||
background: #dc2626;
|
||||
box-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* GPS button */
|
||||
@@ -626,8 +792,20 @@ body {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1000px) {
|
||||
/* Responsive - medium screens (hide ACARS sidebar, keep main sidebar) */
|
||||
@media (max-width: 1200px) {
|
||||
.dashboard {
|
||||
grid-template-columns: 1fr 300px;
|
||||
grid-template-rows: 1fr auto;
|
||||
}
|
||||
|
||||
.acars-sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive - small screens (single column) */
|
||||
@media (max-width: 900px) {
|
||||
.dashboard {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr auto auto;
|
||||
@@ -637,6 +815,10 @@ body {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.acars-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
@@ -653,9 +835,11 @@ body {
|
||||
/* Airband Audio Controls */
|
||||
.airband-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border-color);
|
||||
margin: 0 10px;
|
||||
height: 20px;
|
||||
background: var(--accent-cyan);
|
||||
opacity: 0.4;
|
||||
margin: 0 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.airband-controls {
|
||||
@@ -667,9 +851,9 @@ body {
|
||||
|
||||
.airband-btn {
|
||||
padding: 6px 12px;
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border: 1px solid var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
background: var(--accent-green);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
@@ -684,13 +868,18 @@ body {
|
||||
}
|
||||
|
||||
.airband-btn:hover {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
background: #1db954;
|
||||
box-shadow: 0 0 10px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.airband-btn.active {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
background: var(--accent-red);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.airband-btn.active:hover {
|
||||
background: #dc2626;
|
||||
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.airband-btn:disabled {
|
||||
@@ -775,3 +964,32 @@ body {
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* GPS Indicator */
|
||||
.gps-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border: 1px solid #22c55e;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #22c55e;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.gps-indicator .gps-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #22c55e;
|
||||
border-radius: 50%;
|
||||
animation: gps-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes gps-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.8); }
|
||||
}
|
||||
|
||||
+1735
-56
File diff suppressed because it is too large
Load Diff
@@ -589,13 +589,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 */
|
||||
@@ -692,4 +693,36 @@ body {
|
||||
.controls-bar {
|
||||
grid-row: 4;
|
||||
}
|
||||
}
|
||||
|
||||
/* Embedded Mode Styles */
|
||||
body.embedded {
|
||||
background: transparent;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
body.embedded .header {
|
||||
background: rgba(10, 12, 16, 0.95);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
body.embedded .header .logo {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body.embedded .header .logo span {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
body.embedded .dashboard {
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
body.embedded .panel {
|
||||
background: rgba(15, 18, 24, 0.95);
|
||||
}
|
||||
|
||||
body.embedded .controls-bar {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512" height="512" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- iNTERCEPT Logo - Signal Intelligence Platform (Dark Background Version) -->
|
||||
|
||||
<!-- Dark background -->
|
||||
<rect width="100" height="100" fill="#0a0a0f"/>
|
||||
|
||||
<!-- Subtle grid pattern -->
|
||||
<defs>
|
||||
<pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse">
|
||||
<path d="M 10 0 L 0 0 0 10" fill="none" stroke="#1a1a2e" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100" height="100" fill="url(#grid)"/>
|
||||
|
||||
<!-- Outer glow effect -->
|
||||
<defs>
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<g filter="url(#glow)">
|
||||
<!-- Signal brackets - left side (signal waves emanating) -->
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Signal brackets - right side (signal waves emanating) -->
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- The 'i' letter - center element -->
|
||||
<!-- dot of i (green accent - represents active signal) -->
|
||||
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
|
||||
<!-- stem of i with styled terminals -->
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
|
||||
<!-- top terminal bar -->
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
|
||||
<!-- bottom terminal bar -->
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512" height="512" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- iNTERCEPT Logo - Signal Intelligence Platform -->
|
||||
|
||||
<!-- Signal brackets - left side (signal waves emanating) -->
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Signal brackets - right side (signal waves emanating) -->
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- The 'i' letter - center element -->
|
||||
<!-- dot of i (green accent - represents active signal) -->
|
||||
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
|
||||
<!-- stem of i with styled terminals -->
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
|
||||
<!-- top terminal bar -->
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
|
||||
<!-- bottom terminal bar -->
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Intercept - Radio Knob Component
|
||||
* Interactive rotary knob control with drag-to-rotate
|
||||
*/
|
||||
|
||||
class RadioKnob {
|
||||
constructor(element, options = {}) {
|
||||
this.element = element;
|
||||
this.value = parseFloat(element.dataset.value) || 0;
|
||||
this.min = parseFloat(element.dataset.min) || 0;
|
||||
this.max = parseFloat(element.dataset.max) || 100;
|
||||
this.step = parseFloat(element.dataset.step) || 1;
|
||||
this.rotation = this.valueToRotation(this.value);
|
||||
this.isDragging = false;
|
||||
this.startY = 0;
|
||||
this.startRotation = 0;
|
||||
this.sensitivity = options.sensitivity || 1.5;
|
||||
this.onChange = options.onChange || null;
|
||||
|
||||
this.bindEvents();
|
||||
this.updateVisual();
|
||||
}
|
||||
|
||||
valueToRotation(value) {
|
||||
const range = this.max - this.min;
|
||||
const normalized = (value - this.min) / range;
|
||||
return normalized * 270 - 135; // -135 to +135 degrees
|
||||
}
|
||||
|
||||
rotationToValue(rotation) {
|
||||
const normalized = (rotation + 135) / 270;
|
||||
let value = this.min + normalized * (this.max - this.min);
|
||||
|
||||
// Snap to step
|
||||
value = Math.round(value / this.step) * this.step;
|
||||
return Math.max(this.min, Math.min(this.max, value));
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Mouse events
|
||||
this.element.addEventListener('mousedown', (e) => this.startDrag(e));
|
||||
document.addEventListener('mousemove', (e) => this.drag(e));
|
||||
document.addEventListener('mouseup', () => this.endDrag());
|
||||
|
||||
// Touch support
|
||||
this.element.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
this.startDrag(e.touches[0]);
|
||||
}, { passive: false });
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
if (this.isDragging) {
|
||||
e.preventDefault();
|
||||
this.drag(e.touches[0]);
|
||||
}
|
||||
}, { passive: false });
|
||||
document.addEventListener('touchend', () => this.endDrag());
|
||||
|
||||
// Scroll wheel support
|
||||
this.element.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false });
|
||||
|
||||
// Double-click to reset
|
||||
this.element.addEventListener('dblclick', () => this.reset());
|
||||
}
|
||||
|
||||
startDrag(e) {
|
||||
this.isDragging = true;
|
||||
this.startY = e.clientY;
|
||||
this.startRotation = this.rotation;
|
||||
this.element.style.cursor = 'grabbing';
|
||||
this.element.classList.add('active');
|
||||
|
||||
// Play click sound if available
|
||||
if (typeof playClickSound === 'function') {
|
||||
playClickSound();
|
||||
}
|
||||
}
|
||||
|
||||
drag(e) {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
const deltaY = this.startY - e.clientY;
|
||||
let newRotation = this.startRotation + deltaY * this.sensitivity;
|
||||
|
||||
// Clamp rotation
|
||||
newRotation = Math.max(-135, Math.min(135, newRotation));
|
||||
|
||||
this.rotation = newRotation;
|
||||
this.value = this.rotationToValue(this.rotation);
|
||||
this.updateVisual();
|
||||
this.dispatchChange();
|
||||
}
|
||||
|
||||
endDrag() {
|
||||
if (!this.isDragging) return;
|
||||
this.isDragging = false;
|
||||
this.element.style.cursor = 'grab';
|
||||
this.element.classList.remove('active');
|
||||
}
|
||||
|
||||
handleWheel(e) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -this.step : this.step;
|
||||
const multiplier = e.shiftKey ? 5 : 1; // Faster with shift key
|
||||
this.setValue(this.value + delta * multiplier);
|
||||
|
||||
// Play click sound if available
|
||||
if (typeof playClickSound === 'function') {
|
||||
playClickSound();
|
||||
}
|
||||
}
|
||||
|
||||
setValue(value, silent = false) {
|
||||
this.value = Math.max(this.min, Math.min(this.max, value));
|
||||
this.rotation = this.valueToRotation(this.value);
|
||||
this.updateVisual();
|
||||
if (!silent) {
|
||||
this.dispatchChange();
|
||||
}
|
||||
}
|
||||
|
||||
getValue() {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
reset() {
|
||||
const defaultValue = parseFloat(this.element.dataset.default) ||
|
||||
(this.min + this.max) / 2;
|
||||
this.setValue(defaultValue);
|
||||
}
|
||||
|
||||
updateVisual() {
|
||||
this.element.style.transform = `rotate(${this.rotation}deg)`;
|
||||
|
||||
// Update associated value display
|
||||
const valueDisplayId = this.element.id.replace('Knob', 'Value');
|
||||
const valueDisplay = document.getElementById(valueDisplayId);
|
||||
if (valueDisplay) {
|
||||
valueDisplay.textContent = Math.round(this.value);
|
||||
}
|
||||
|
||||
// Update data attribute
|
||||
this.element.dataset.value = this.value;
|
||||
}
|
||||
|
||||
dispatchChange() {
|
||||
// Custom callback
|
||||
if (this.onChange) {
|
||||
this.onChange(this.value, this);
|
||||
}
|
||||
|
||||
// Custom event
|
||||
this.element.dispatchEvent(new CustomEvent('knobchange', {
|
||||
detail: { value: this.value, knob: this },
|
||||
bubbles: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tuning Dial - Larger rotary control for frequency tuning
|
||||
*/
|
||||
class TuningDial extends RadioKnob {
|
||||
constructor(element, options = {}) {
|
||||
super(element, {
|
||||
sensitivity: options.sensitivity || 0.8,
|
||||
...options
|
||||
});
|
||||
|
||||
this.fineStep = options.fineStep || 0.025;
|
||||
this.coarseStep = options.coarseStep || 0.2;
|
||||
}
|
||||
|
||||
handleWheel(e) {
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? this.fineStep : this.coarseStep;
|
||||
const delta = e.deltaY > 0 ? -step : step;
|
||||
this.setValue(this.value + delta);
|
||||
}
|
||||
|
||||
// Override to not round to step for smooth tuning
|
||||
rotationToValue(rotation) {
|
||||
const normalized = (rotation + 135) / 270;
|
||||
let value = this.min + normalized * (this.max - this.min);
|
||||
return Math.max(this.min, Math.min(this.max, value));
|
||||
}
|
||||
|
||||
updateVisual() {
|
||||
this.element.style.transform = `rotate(${this.rotation}deg)`;
|
||||
|
||||
// Update associated value display with decimals
|
||||
const valueDisplayId = this.element.id.replace('Dial', 'Value');
|
||||
const valueDisplay = document.getElementById(valueDisplayId);
|
||||
if (valueDisplay) {
|
||||
valueDisplay.textContent = this.value.toFixed(3);
|
||||
}
|
||||
|
||||
this.element.dataset.value = this.value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all radio knobs on the page
|
||||
*/
|
||||
function initRadioKnobs() {
|
||||
// Initialize standard knobs
|
||||
document.querySelectorAll('.radio-knob').forEach(element => {
|
||||
if (!element._knob) {
|
||||
element._knob = new RadioKnob(element);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize tuning dials
|
||||
document.querySelectorAll('.tuning-dial').forEach(element => {
|
||||
if (!element._dial) {
|
||||
element._dial = new TuningDial(element);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-initialize on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', initRadioKnobs);
|
||||
|
||||
// Export for use in modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { RadioKnob, TuningDial, initRadioKnobs };
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* Intercept - Core Application Logic
|
||||
* Global state, mode switching, and shared functionality
|
||||
*/
|
||||
|
||||
// ============== GLOBAL STATE ==============
|
||||
|
||||
// Mode state flags
|
||||
let eventSource = null;
|
||||
let isRunning = false;
|
||||
let isSensorRunning = false;
|
||||
let isAdsbRunning = false;
|
||||
let isWifiRunning = false;
|
||||
let isBtRunning = false;
|
||||
let currentMode = 'pager';
|
||||
|
||||
// Message counters
|
||||
let msgCount = 0;
|
||||
let pocsagCount = 0;
|
||||
let flexCount = 0;
|
||||
let sensorCount = 0;
|
||||
let filteredCount = 0;
|
||||
|
||||
// Device list (populated from server via Jinja2)
|
||||
let deviceList = [];
|
||||
|
||||
// Auto-scroll setting
|
||||
let autoScroll = localStorage.getItem('autoScroll') !== 'false';
|
||||
|
||||
// Mute setting
|
||||
let muted = localStorage.getItem('audioMuted') === 'true';
|
||||
|
||||
// Observer location (load from localStorage or default to London)
|
||||
let observerLocation = (function() {
|
||||
const saved = localStorage.getItem('observerLocation');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.lat && parsed.lon) return parsed;
|
||||
} catch (e) {}
|
||||
}
|
||||
return { lat: 51.5074, lon: -0.1278 };
|
||||
})();
|
||||
|
||||
// Message storage for export
|
||||
let allMessages = [];
|
||||
|
||||
// Track unique sensor devices
|
||||
let uniqueDevices = new Set();
|
||||
|
||||
// SDR device usage tracking
|
||||
let sdrDeviceUsage = {};
|
||||
|
||||
// ============== DISCLAIMER HANDLING ==============
|
||||
|
||||
function checkDisclaimer() {
|
||||
const accepted = localStorage.getItem('disclaimerAccepted');
|
||||
if (accepted === 'true') {
|
||||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function acceptDisclaimer() {
|
||||
localStorage.setItem('disclaimerAccepted', 'true');
|
||||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||||
}
|
||||
|
||||
function declineDisclaimer() {
|
||||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||||
document.getElementById('rejectionPage').classList.remove('disclaimer-hidden');
|
||||
}
|
||||
|
||||
// ============== HEADER CLOCK ==============
|
||||
|
||||
function updateHeaderClock() {
|
||||
const now = new Date();
|
||||
const utc = now.toISOString().substring(11, 19);
|
||||
document.getElementById('headerUtcTime').textContent = utc;
|
||||
}
|
||||
|
||||
// ============== HEADER STATS SYNC ==============
|
||||
|
||||
function syncHeaderStats() {
|
||||
// Pager stats
|
||||
document.getElementById('headerMsgCount').textContent = msgCount;
|
||||
document.getElementById('headerPocsagCount').textContent = pocsagCount;
|
||||
document.getElementById('headerFlexCount').textContent = flexCount;
|
||||
|
||||
// Sensor stats
|
||||
document.getElementById('headerSensorCount').textContent = document.getElementById('sensorCount')?.textContent || '0';
|
||||
document.getElementById('headerDeviceTypeCount').textContent = document.getElementById('deviceCount')?.textContent || '0';
|
||||
|
||||
// WiFi stats
|
||||
document.getElementById('headerApCount').textContent = document.getElementById('apCount')?.textContent || '0';
|
||||
document.getElementById('headerClientCount').textContent = document.getElementById('clientCount')?.textContent || '0';
|
||||
document.getElementById('headerHandshakeCount').textContent = document.getElementById('handshakeCount')?.textContent || '0';
|
||||
document.getElementById('headerDroneCount').textContent = document.getElementById('droneCount')?.textContent || '0';
|
||||
|
||||
// Bluetooth stats
|
||||
document.getElementById('headerBtDeviceCount').textContent = document.getElementById('btDeviceCount')?.textContent || '0';
|
||||
document.getElementById('headerBtBeaconCount').textContent = document.getElementById('btBeaconCount')?.textContent || '0';
|
||||
|
||||
// Aircraft stats
|
||||
document.getElementById('headerAircraftCount').textContent = document.getElementById('aircraftCount')?.textContent || '0';
|
||||
document.getElementById('headerAdsbMsgCount').textContent = document.getElementById('adsbMsgCount')?.textContent || '0';
|
||||
document.getElementById('headerIcaoCount').textContent = document.getElementById('icaoCount')?.textContent || '0';
|
||||
|
||||
// Satellite stats
|
||||
document.getElementById('headerPassCount').textContent = document.getElementById('passCount')?.textContent || '0';
|
||||
}
|
||||
|
||||
// ============== MODE SWITCHING ==============
|
||||
|
||||
function switchMode(mode) {
|
||||
// Stop any running scans when switching modes
|
||||
if (isRunning && typeof stopDecoding === 'function') stopDecoding();
|
||||
if (isSensorRunning && typeof stopSensorDecoding === 'function') stopSensorDecoding();
|
||||
if (isWifiRunning && typeof stopWifiScan === 'function') stopWifiScan();
|
||||
if (isBtRunning && typeof stopBtScan === 'function') stopBtScan();
|
||||
if (isAdsbRunning && typeof stopAdsbScan === 'function') stopAdsbScan();
|
||||
|
||||
currentMode = mode;
|
||||
|
||||
// Remove active from all nav buttons, then add to the correct one
|
||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => btn.classList.remove('active'));
|
||||
const modeMap = {
|
||||
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
|
||||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
||||
'listening': 'listening'
|
||||
};
|
||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||||
const label = btn.querySelector('.nav-label');
|
||||
if (label && label.textContent.toLowerCase().includes(modeMap[mode])) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle mode content visibility
|
||||
document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
|
||||
document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
|
||||
document.getElementById('aircraftMode').classList.toggle('active', mode === 'aircraft');
|
||||
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
|
||||
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
|
||||
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
|
||||
|
||||
// Toggle stats visibility
|
||||
document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
|
||||
document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none';
|
||||
document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none';
|
||||
document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none';
|
||||
document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
document.getElementById('btStats').style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
|
||||
// Hide signal meter - individual panels show signal strength where needed
|
||||
document.getElementById('signalMeter').style.display = 'none';
|
||||
|
||||
// Update header stats groups
|
||||
document.getElementById('headerPagerStats').classList.toggle('active', mode === 'pager');
|
||||
document.getElementById('headerSensorStats').classList.toggle('active', mode === 'sensor');
|
||||
document.getElementById('headerAircraftStats').classList.toggle('active', mode === 'aircraft');
|
||||
document.getElementById('headerSatelliteStats').classList.toggle('active', mode === 'satellite');
|
||||
document.getElementById('headerWifiStats').classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('headerBtStats').classList.toggle('active', mode === 'bluetooth');
|
||||
|
||||
// Show/hide dashboard buttons in nav bar
|
||||
document.getElementById('adsbDashboardBtn').style.display = mode === 'aircraft' ? 'inline-flex' : 'none';
|
||||
document.getElementById('satelliteDashboardBtn').style.display = mode === 'satellite' ? 'inline-flex' : 'none';
|
||||
|
||||
// Update active mode indicator
|
||||
const modeNames = {
|
||||
'pager': 'PAGER',
|
||||
'sensor': '433MHZ',
|
||||
'aircraft': 'AIRCRAFT',
|
||||
'satellite': 'SATELLITE',
|
||||
'wifi': 'WIFI',
|
||||
'bluetooth': 'BLUETOOTH',
|
||||
'listening': 'LISTENING POST'
|
||||
};
|
||||
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>' + modeNames[mode];
|
||||
|
||||
// Toggle layout containers
|
||||
document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
|
||||
// Respect the "Show Radar Display" checkbox for aircraft mode
|
||||
const showRadar = document.getElementById('adsbEnableMap')?.checked;
|
||||
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
|
||||
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
|
||||
document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
|
||||
|
||||
// Update output panel title based on mode
|
||||
const titles = {
|
||||
'pager': 'Pager Decoder',
|
||||
'sensor': '433MHz Sensor Monitor',
|
||||
'aircraft': 'ADS-B Aircraft Tracker',
|
||||
'satellite': 'Satellite Monitor',
|
||||
'wifi': 'WiFi Scanner',
|
||||
'bluetooth': 'Bluetooth Scanner',
|
||||
'listening': 'Listening Post'
|
||||
};
|
||||
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
||||
|
||||
// Show/hide Device Intelligence for modes that use it
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') {
|
||||
document.getElementById('reconPanel').style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
} else {
|
||||
if (reconBtn) reconBtn.style.display = 'inline-block';
|
||||
if (intelBtn) intelBtn.style.display = 'inline-block';
|
||||
if (typeof reconEnabled !== 'undefined' && reconEnabled) {
|
||||
document.getElementById('reconPanel').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Show RTL-SDR device section for modes that use it
|
||||
document.getElementById('rtlDeviceSection').style.display =
|
||||
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none';
|
||||
|
||||
// Toggle mode-specific tool status displays
|
||||
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
|
||||
document.getElementById('toolStatusSensor').style.display = (mode === 'sensor') ? 'grid' : 'none';
|
||||
document.getElementById('toolStatusAircraft').style.display = (mode === 'aircraft') ? 'grid' : 'none';
|
||||
|
||||
// Hide waterfall and output console for modes with their own visualizations
|
||||
document.querySelector('.waterfall-container').style.display =
|
||||
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
|
||||
document.getElementById('output').style.display =
|
||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
|
||||
document.querySelector('.status-bar').style.display = (mode === 'satellite') ? 'none' : 'flex';
|
||||
|
||||
// Load interfaces and initialize visualizations when switching modes
|
||||
if (mode === 'wifi') {
|
||||
if (typeof refreshWifiInterfaces === 'function') refreshWifiInterfaces();
|
||||
if (typeof initRadar === 'function') initRadar();
|
||||
if (typeof initWatchList === 'function') initWatchList();
|
||||
} else if (mode === 'bluetooth') {
|
||||
if (typeof refreshBtInterfaces === 'function') refreshBtInterfaces();
|
||||
if (typeof initBtRadar === 'function') initBtRadar();
|
||||
} else if (mode === 'aircraft') {
|
||||
if (typeof checkAdsbTools === 'function') checkAdsbTools();
|
||||
if (typeof initAircraftRadar === 'function') initAircraftRadar();
|
||||
} else if (mode === 'satellite') {
|
||||
if (typeof initPolarPlot === 'function') initPolarPlot();
|
||||
if (typeof initSatelliteList === 'function') initSatelliteList();
|
||||
} else if (mode === 'listening') {
|
||||
if (typeof checkScannerTools === 'function') checkScannerTools();
|
||||
if (typeof checkAudioTools === 'function') checkAudioTools();
|
||||
if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
|
||||
if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
|
||||
}
|
||||
}
|
||||
|
||||
// ============== SECTION COLLAPSE ==============
|
||||
|
||||
function toggleSection(el) {
|
||||
el.closest('.section').classList.toggle('collapsed');
|
||||
}
|
||||
|
||||
// ============== THEME MANAGEMENT ==============
|
||||
|
||||
function toggleTheme() {
|
||||
const html = document.documentElement;
|
||||
const currentTheme = html.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
html.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Update button text
|
||||
const btn = document.getElementById('themeToggle');
|
||||
if (btn) {
|
||||
btn.textContent = newTheme === 'light' ? '🌙' : '☀️';
|
||||
}
|
||||
}
|
||||
|
||||
function loadTheme() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
const btn = document.getElementById('themeToggle');
|
||||
if (btn) {
|
||||
btn.textContent = savedTheme === 'light' ? '🌙' : '☀️';
|
||||
}
|
||||
}
|
||||
|
||||
// ============== AUTO-SCROLL ==============
|
||||
|
||||
function toggleAutoScroll() {
|
||||
autoScroll = !autoScroll;
|
||||
localStorage.setItem('autoScroll', autoScroll);
|
||||
updateAutoScrollButton();
|
||||
}
|
||||
|
||||
function updateAutoScrollButton() {
|
||||
const btn = document.getElementById('autoScrollBtn');
|
||||
if (btn) {
|
||||
btn.innerHTML = autoScroll ? '⬇ AUTO-SCROLL ON' : '⬇ AUTO-SCROLL OFF';
|
||||
btn.classList.toggle('active', autoScroll);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== SDR DEVICE MANAGEMENT ==============
|
||||
|
||||
function getSelectedDevice() {
|
||||
return document.getElementById('deviceSelect').value;
|
||||
}
|
||||
|
||||
function getSelectedSDRType() {
|
||||
return document.getElementById('sdrTypeSelect').value;
|
||||
}
|
||||
|
||||
function reserveDevice(deviceIndex, modeId) {
|
||||
sdrDeviceUsage[modeId] = deviceIndex;
|
||||
}
|
||||
|
||||
function releaseDevice(modeId) {
|
||||
delete sdrDeviceUsage[modeId];
|
||||
}
|
||||
|
||||
function checkDeviceAvailability(requestingMode) {
|
||||
const selectedDevice = parseInt(getSelectedDevice());
|
||||
for (const [mode, device] of Object.entries(sdrDeviceUsage)) {
|
||||
if (mode !== requestingMode && device === selectedDevice) {
|
||||
alert(`Device ${selectedDevice} is currently in use by ${mode} mode. Please select a different device or stop the other scan first.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============== BIAS-T SETTINGS ==============
|
||||
|
||||
function saveBiasTSetting() {
|
||||
const enabled = document.getElementById('biasT')?.checked || false;
|
||||
localStorage.setItem('biasTEnabled', enabled);
|
||||
}
|
||||
|
||||
function getBiasTEnabled() {
|
||||
return document.getElementById('biasT')?.checked || false;
|
||||
}
|
||||
|
||||
function loadBiasTSetting() {
|
||||
const saved = localStorage.getItem('biasTEnabled');
|
||||
if (saved === 'true') {
|
||||
const checkbox = document.getElementById('biasT');
|
||||
if (checkbox) checkbox.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ============== REMOTE SDR ==============
|
||||
|
||||
function toggleRemoteSDR() {
|
||||
const useRemote = document.getElementById('useRemoteSDR').checked;
|
||||
const configDiv = document.getElementById('remoteSDRConfig');
|
||||
const localControls = document.querySelectorAll('#sdrTypeSelect, #deviceSelect');
|
||||
|
||||
if (useRemote) {
|
||||
configDiv.style.display = 'block';
|
||||
localControls.forEach(el => el.disabled = true);
|
||||
} else {
|
||||
configDiv.style.display = 'none';
|
||||
localControls.forEach(el => el.disabled = false);
|
||||
}
|
||||
}
|
||||
|
||||
function getRemoteSDRConfig() {
|
||||
const useRemote = document.getElementById('useRemoteSDR')?.checked;
|
||||
if (!useRemote) return null;
|
||||
|
||||
const host = document.getElementById('rtlTcpHost')?.value || 'localhost';
|
||||
const port = parseInt(document.getElementById('rtlTcpPort')?.value || '1234');
|
||||
|
||||
if (!host || isNaN(port)) {
|
||||
alert('Please enter valid rtl_tcp host and port');
|
||||
return false;
|
||||
}
|
||||
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
// ============== OUTPUT DISPLAY ==============
|
||||
|
||||
function showInfo(text) {
|
||||
const output = document.getElementById('output');
|
||||
if (!output) return;
|
||||
|
||||
const placeholder = output.querySelector('.placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
const infoEl = document.createElement('div');
|
||||
infoEl.className = 'info-msg';
|
||||
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
|
||||
infoEl.textContent = text;
|
||||
output.insertBefore(infoEl, output.firstChild);
|
||||
}
|
||||
|
||||
function showError(text) {
|
||||
const output = document.getElementById('output');
|
||||
if (!output) return;
|
||||
|
||||
const placeholder = output.querySelector('.placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
const errorEl = document.createElement('div');
|
||||
errorEl.className = 'error-msg';
|
||||
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;';
|
||||
errorEl.textContent = '⚠ ' + text;
|
||||
output.insertBefore(errorEl, output.firstChild);
|
||||
}
|
||||
|
||||
// ============== OBSERVER LOCATION ==============
|
||||
|
||||
function saveObserverLocation() {
|
||||
const lat = parseFloat(document.getElementById('adsbObsLat')?.value || document.getElementById('obsLat')?.value);
|
||||
const lon = parseFloat(document.getElementById('adsbObsLon')?.value || document.getElementById('obsLon')?.value);
|
||||
|
||||
if (!isNaN(lat) && !isNaN(lon)) {
|
||||
observerLocation = { lat, lon };
|
||||
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
|
||||
|
||||
// Sync both input sets
|
||||
const adsbLat = document.getElementById('adsbObsLat');
|
||||
const adsbLon = document.getElementById('adsbObsLon');
|
||||
const satLat = document.getElementById('obsLat');
|
||||
const satLon = document.getElementById('obsLon');
|
||||
|
||||
if (adsbLat) adsbLat.value = lat.toFixed(4);
|
||||
if (adsbLon) adsbLon.value = lon.toFixed(4);
|
||||
if (satLat) satLat.value = lat.toFixed(4);
|
||||
if (satLon) satLon.value = lon.toFixed(4);
|
||||
}
|
||||
}
|
||||
|
||||
function useGeolocation() {
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const lat = position.coords.latitude;
|
||||
const lon = position.coords.longitude;
|
||||
|
||||
observerLocation = { lat, lon };
|
||||
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
|
||||
|
||||
// Update all input fields
|
||||
const adsbLat = document.getElementById('adsbObsLat');
|
||||
const adsbLon = document.getElementById('adsbObsLon');
|
||||
const satLat = document.getElementById('obsLat');
|
||||
const satLon = document.getElementById('obsLon');
|
||||
|
||||
if (adsbLat) adsbLat.value = lat.toFixed(4);
|
||||
if (adsbLon) adsbLon.value = lon.toFixed(4);
|
||||
if (satLat) satLat.value = lat.toFixed(4);
|
||||
if (satLon) satLon.value = lon.toFixed(4);
|
||||
|
||||
showInfo(`Location set to ${lat.toFixed(4)}, ${lon.toFixed(4)}`);
|
||||
},
|
||||
(error) => {
|
||||
showError('Geolocation failed: ' + error.message);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
showError('Geolocation not supported by browser');
|
||||
}
|
||||
}
|
||||
|
||||
// ============== EXPORT FUNCTIONS ==============
|
||||
|
||||
function exportCSV() {
|
||||
if (allMessages.length === 0) {
|
||||
alert('No messages to export');
|
||||
return;
|
||||
}
|
||||
const headers = ['Timestamp', 'Protocol', 'Address', 'Function', 'Type', 'Message'];
|
||||
const csv = [headers.join(',')];
|
||||
allMessages.forEach(msg => {
|
||||
const row = [
|
||||
msg.timestamp || '',
|
||||
msg.protocol || '',
|
||||
msg.address || '',
|
||||
msg.function || '',
|
||||
msg.msg_type || '',
|
||||
'"' + (msg.message || '').replace(/"/g, '""') + '"'
|
||||
];
|
||||
csv.push(row.join(','));
|
||||
});
|
||||
downloadFile(csv.join('\n'), 'intercept_messages.csv', 'text/csv');
|
||||
}
|
||||
|
||||
function exportJSON() {
|
||||
if (allMessages.length === 0) {
|
||||
alert('No messages to export');
|
||||
return;
|
||||
}
|
||||
downloadFile(JSON.stringify(allMessages, null, 2), 'intercept_messages.json', 'application/json');
|
||||
}
|
||||
|
||||
// ============== INITIALIZATION ==============
|
||||
|
||||
function initApp() {
|
||||
// Check disclaimer
|
||||
checkDisclaimer();
|
||||
|
||||
// Load theme
|
||||
loadTheme();
|
||||
|
||||
// Start clock
|
||||
updateHeaderClock();
|
||||
setInterval(updateHeaderClock, 1000);
|
||||
|
||||
// Start stats sync
|
||||
setInterval(syncHeaderStats, 500);
|
||||
|
||||
// Load bias-T setting
|
||||
loadBiasTSetting();
|
||||
|
||||
// Initialize observer location inputs
|
||||
const adsbLatInput = document.getElementById('adsbObsLat');
|
||||
const adsbLonInput = document.getElementById('adsbObsLon');
|
||||
const obsLatInput = document.getElementById('obsLat');
|
||||
const obsLonInput = document.getElementById('obsLon');
|
||||
if (adsbLatInput) adsbLatInput.value = observerLocation.lat.toFixed(4);
|
||||
if (adsbLonInput) adsbLonInput.value = observerLocation.lon.toFixed(4);
|
||||
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
|
||||
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
|
||||
|
||||
// Update UI state
|
||||
updateAutoScrollButton();
|
||||
|
||||
// Make sections collapsible
|
||||
document.querySelectorAll('.section h3').forEach(h3 => {
|
||||
h3.addEventListener('click', function() {
|
||||
this.parentElement.classList.toggle('collapsed');
|
||||
});
|
||||
});
|
||||
|
||||
// Collapse all sections by default (except SDR Device which is first)
|
||||
document.querySelectorAll('.section').forEach((section, index) => {
|
||||
if (index > 0) {
|
||||
section.classList.add('collapsed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Run initialization when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', initApp);
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Intercept - Audio System
|
||||
* Web Audio API alerts, notifications, and sound effects
|
||||
*/
|
||||
|
||||
// ============== AUDIO STATE ==============
|
||||
|
||||
let audioContext = null;
|
||||
let audioMuted = localStorage.getItem('audioMuted') === 'true';
|
||||
let notificationsEnabled = false;
|
||||
|
||||
// ============== AUDIO CONTEXT ==============
|
||||
|
||||
/**
|
||||
* Initialize the Web Audio API context
|
||||
* Must be called after user interaction due to browser autoplay policies
|
||||
*/
|
||||
function initAudio() {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the audio context
|
||||
* @returns {AudioContext}
|
||||
*/
|
||||
function getAudioContext() {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
// ============== ALERT SOUNDS ==============
|
||||
|
||||
/**
|
||||
* Play a basic alert beep
|
||||
* Used for message received notifications
|
||||
*/
|
||||
function playAlert() {
|
||||
if (audioMuted || !audioContext) return;
|
||||
|
||||
try {
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
oscillator.frequency.value = 880;
|
||||
oscillator.type = 'sine';
|
||||
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.2);
|
||||
} catch (e) {
|
||||
console.warn('Audio alert failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play alert sound by type
|
||||
* @param {string} type - 'emergency', 'military', 'warning', 'info'
|
||||
*/
|
||||
function playAlertSound(type) {
|
||||
if (audioMuted) return;
|
||||
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
switch (type) {
|
||||
case 'emergency':
|
||||
// Urgent two-tone alert for emergencies
|
||||
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
|
||||
oscillator.frequency.setValueAtTime(660, ctx.currentTime + 0.15);
|
||||
oscillator.frequency.setValueAtTime(880, ctx.currentTime + 0.3);
|
||||
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.5);
|
||||
break;
|
||||
|
||||
case 'military':
|
||||
// Single tone for military aircraft detection
|
||||
oscillator.frequency.setValueAtTime(523, ctx.currentTime);
|
||||
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.3);
|
||||
break;
|
||||
|
||||
case 'warning':
|
||||
// Warning tone (descending)
|
||||
oscillator.frequency.setValueAtTime(660, ctx.currentTime);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.3);
|
||||
gainNode.gain.setValueAtTime(0.25, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.3);
|
||||
break;
|
||||
|
||||
case 'info':
|
||||
default:
|
||||
// Simple info tone
|
||||
oscillator.frequency.setValueAtTime(440, ctx.currentTime);
|
||||
gainNode.gain.setValueAtTime(0.15, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15);
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.15);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Audio alert failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play scanner signal detected sound
|
||||
* A distinctive ascending tone for radio scanner
|
||||
*/
|
||||
function playSignalDetectedSound() {
|
||||
if (audioMuted) return;
|
||||
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
// Ascending tone
|
||||
oscillator.frequency.setValueAtTime(400, ctx.currentTime);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(800, ctx.currentTime + 0.15);
|
||||
oscillator.type = 'sine';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
|
||||
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.2);
|
||||
} catch (e) {
|
||||
console.warn('Signal detected sound failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a click sound for UI feedback
|
||||
*/
|
||||
function playClickSound() {
|
||||
if (audioMuted) return;
|
||||
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
oscillator.frequency.value = 1000;
|
||||
oscillator.type = 'square';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.05);
|
||||
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.05);
|
||||
} catch (e) {
|
||||
console.warn('Click sound failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== MUTE CONTROL ==============
|
||||
|
||||
/**
|
||||
* Toggle mute state
|
||||
*/
|
||||
function toggleMute() {
|
||||
audioMuted = !audioMuted;
|
||||
localStorage.setItem('audioMuted', audioMuted);
|
||||
updateMuteButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mute state
|
||||
* @param {boolean} muted - Whether audio should be muted
|
||||
*/
|
||||
function setMuted(muted) {
|
||||
audioMuted = muted;
|
||||
localStorage.setItem('audioMuted', audioMuted);
|
||||
updateMuteButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current mute state
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isMuted() {
|
||||
return audioMuted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mute button UI
|
||||
*/
|
||||
function updateMuteButton() {
|
||||
const btn = document.getElementById('muteBtn');
|
||||
if (btn) {
|
||||
btn.innerHTML = audioMuted ? '🔇 UNMUTE' : '🔊 MUTE';
|
||||
btn.classList.toggle('muted', audioMuted);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== DESKTOP NOTIFICATIONS ==============
|
||||
|
||||
/**
|
||||
* Request notification permission from user
|
||||
*/
|
||||
function requestNotificationPermission() {
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission().then(permission => {
|
||||
notificationsEnabled = permission === 'granted';
|
||||
if (notificationsEnabled && typeof showInfo === 'function') {
|
||||
showInfo('🔔 Desktop notifications enabled');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a desktop notification
|
||||
* @param {string} title - Notification title
|
||||
* @param {string} body - Notification body
|
||||
*/
|
||||
function showNotification(title, body) {
|
||||
if (notificationsEnabled && document.hidden) {
|
||||
new Notification(title, {
|
||||
body: body,
|
||||
icon: '/favicon.ico',
|
||||
tag: 'intercept-' + Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============== INITIALIZATION ==============
|
||||
|
||||
/**
|
||||
* Initialize audio system
|
||||
* Should be called on first user interaction
|
||||
*/
|
||||
function initAudioSystem() {
|
||||
// Initialize audio context
|
||||
initAudio();
|
||||
|
||||
// Update mute button state
|
||||
updateMuteButton();
|
||||
|
||||
// Check notification permission
|
||||
if ('Notification' in window) {
|
||||
if (Notification.permission === 'granted') {
|
||||
notificationsEnabled = true;
|
||||
} else if (Notification.permission === 'default') {
|
||||
// Will request on first interaction
|
||||
document.addEventListener('click', function requestOnce() {
|
||||
requestNotificationPermission();
|
||||
document.removeEventListener('click', requestOnce);
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on first user interaction (required for Web Audio API)
|
||||
document.addEventListener('click', function initOnInteraction() {
|
||||
initAudio();
|
||||
document.removeEventListener('click', initOnInteraction);
|
||||
}, { once: true });
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Intercept - Core Utility Functions
|
||||
* Pure utility functions with no DOM dependencies
|
||||
*/
|
||||
|
||||
// ============== HTML ESCAPING ==============
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @param {string} text - Text to escape
|
||||
* @returns {string} Escaped HTML
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape text for use in HTML attributes (especially onclick handlers)
|
||||
* @param {string} text - Text to escape
|
||||
* @returns {string} Escaped attribute value
|
||||
*/
|
||||
function escapeAttr(text) {
|
||||
if (text === null || text === undefined) return '';
|
||||
var s = String(text);
|
||||
s = s.replace(/&/g, '&');
|
||||
s = s.replace(/'/g, ''');
|
||||
s = s.replace(/"/g, '"');
|
||||
s = s.replace(/</g, '<');
|
||||
s = s.replace(/>/g, '>');
|
||||
return s;
|
||||
}
|
||||
|
||||
// ============== VALIDATION ==============
|
||||
|
||||
/**
|
||||
* Validate MAC address format (XX:XX:XX:XX:XX:XX)
|
||||
* @param {string} mac - MAC address to validate
|
||||
* @returns {boolean} True if valid
|
||||
*/
|
||||
function isValidMac(mac) {
|
||||
return /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/.test(mac);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate WiFi channel (1-200 covers all bands)
|
||||
* @param {string|number} ch - Channel number
|
||||
* @returns {boolean} True if valid
|
||||
*/
|
||||
function isValidChannel(ch) {
|
||||
const num = parseInt(ch, 10);
|
||||
return !isNaN(num) && num >= 1 && num <= 200;
|
||||
}
|
||||
|
||||
// ============== TIME FORMATTING ==============
|
||||
|
||||
/**
|
||||
* Get relative time string from timestamp
|
||||
* @param {string} timestamp - Time string in HH:MM:SS format
|
||||
* @returns {string} Relative time like "5s ago", "2m ago"
|
||||
*/
|
||||
function getRelativeTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const now = new Date();
|
||||
const parts = timestamp.split(':');
|
||||
const msgTime = new Date();
|
||||
msgTime.setHours(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]));
|
||||
|
||||
const diff = Math.floor((now - msgTime) / 1000);
|
||||
if (diff < 5) return 'just now';
|
||||
if (diff < 60) return diff + 's ago';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format UTC time string
|
||||
* @param {Date} date - Date object
|
||||
* @returns {string} UTC time in HH:MM:SS format
|
||||
*/
|
||||
function formatUtcTime(date) {
|
||||
return date.toISOString().substring(11, 19);
|
||||
}
|
||||
|
||||
// ============== DISTANCE CALCULATIONS ==============
|
||||
|
||||
/**
|
||||
* Calculate distance between two points in nautical miles
|
||||
* Uses Haversine formula
|
||||
* @param {number} lat1 - Latitude of first point
|
||||
* @param {number} lon1 - Longitude of first point
|
||||
* @param {number} lat2 - Latitude of second point
|
||||
* @param {number} lon2 - Longitude of second point
|
||||
* @returns {number} Distance in nautical miles
|
||||
*/
|
||||
function calculateDistanceNm(lat1, lon1, lat2, lon2) {
|
||||
const R = 3440.065; // Earth radius in nautical miles
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon/2) * Math.sin(dLon/2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between two points in kilometers
|
||||
* @param {number} lat1 - Latitude of first point
|
||||
* @param {number} lon1 - Longitude of first point
|
||||
* @param {number} lat2 - Latitude of second point
|
||||
* @param {number} lon2 - Longitude of second point
|
||||
* @returns {number} Distance in kilometers
|
||||
*/
|
||||
function calculateDistanceKm(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371; // Earth radius in kilometers
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon/2) * Math.sin(dLon/2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
// ============== FILE OPERATIONS ==============
|
||||
|
||||
/**
|
||||
* Download content as a file
|
||||
* @param {string} content - File content
|
||||
* @param {string} filename - Name for the downloaded file
|
||||
* @param {string} type - MIME type
|
||||
*/
|
||||
function downloadFile(content, filename, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ============== FREQUENCY FORMATTING ==============
|
||||
|
||||
/**
|
||||
* Format frequency value with proper units
|
||||
* @param {number} freqMhz - Frequency in MHz
|
||||
* @param {number} decimals - Number of decimal places (default 3)
|
||||
* @returns {string} Formatted frequency string
|
||||
*/
|
||||
function formatFrequency(freqMhz, decimals = 3) {
|
||||
return freqMhz.toFixed(decimals) + ' MHz';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse frequency string to MHz
|
||||
* @param {string} freqStr - Frequency string (e.g., "118.0", "118.0 MHz")
|
||||
* @returns {number} Frequency in MHz
|
||||
*/
|
||||
function parseFrequency(freqStr) {
|
||||
return parseFloat(freqStr.replace(/[^\d.-]/g, ''));
|
||||
}
|
||||
|
||||
// ============== LOCAL STORAGE HELPERS ==============
|
||||
|
||||
/**
|
||||
* Get item from localStorage with JSON parsing
|
||||
* @param {string} key - Storage key
|
||||
* @param {*} defaultValue - Default value if key doesn't exist
|
||||
* @returns {*} Parsed value or default
|
||||
*/
|
||||
function getStorageItem(key, defaultValue = null) {
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved === null) return defaultValue;
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch (e) {
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set item in localStorage with JSON stringification
|
||||
* @param {string} key - Storage key
|
||||
* @param {*} value - Value to store
|
||||
*/
|
||||
function setStorageItem(key, value) {
|
||||
if (typeof value === 'object') {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} else {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== ARRAY/OBJECT UTILITIES ==============
|
||||
|
||||
/**
|
||||
* Debounce function execution
|
||||
* @param {Function} func - Function to debounce
|
||||
* @param {number} wait - Wait time in milliseconds
|
||||
* @returns {Function} Debounced function
|
||||
*/
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle function execution
|
||||
* @param {Function} func - Function to throttle
|
||||
* @param {number} limit - Time limit in milliseconds
|
||||
* @returns {Function} Throttled function
|
||||
*/
|
||||
function throttle(func, limit) {
|
||||
let inThrottle;
|
||||
return function executedFunction(...args) {
|
||||
if (!inThrottle) {
|
||||
func(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============== NUMBER FORMATTING ==============
|
||||
|
||||
/**
|
||||
* Format large numbers with K/M suffixes
|
||||
* @param {number} num - Number to format
|
||||
* @returns {string} Formatted string
|
||||
*/
|
||||
function formatNumber(num) {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a number between min and max
|
||||
* @param {number} num - Number to clamp
|
||||
* @param {number} min - Minimum value
|
||||
* @param {number} max - Maximum value
|
||||
* @returns {number} Clamped value
|
||||
*/
|
||||
function clamp(num, min, max) {
|
||||
return Math.min(Math.max(num, min), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a value from one range to another
|
||||
* @param {number} value - Value to map
|
||||
* @param {number} inMin - Input range minimum
|
||||
* @param {number} inMax - Input range maximum
|
||||
* @param {number} outMin - Output range minimum
|
||||
* @param {number} outMax - Output range maximum
|
||||
* @returns {number} Mapped value
|
||||
*/
|
||||
function mapRange(value, inMin, inMax, outMin, outMax) {
|
||||
return (value - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1227
-278
File diff suppressed because it is too large
Load Diff
+4937
-2659
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SATELLITE COMMAND // INTERCEPT</title>
|
||||
<title>SATELLITE COMMAND // iNTERCEPT - See the Invisible</title>
|
||||
<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>
|
||||
@@ -16,7 +16,7 @@
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
SATELLITE COMMAND
|
||||
<span>// INTERCEPT</span>
|
||||
<span>// iNTERCEPT - See the Invisible</span>
|
||||
</div>
|
||||
<div class="stats-badges">
|
||||
<div class="stat-badge">
|
||||
@@ -183,6 +183,10 @@
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Check if embedded mode
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isEmbedded = urlParams.get('embedded') === 'true';
|
||||
|
||||
// Dashboard state
|
||||
let passes = [];
|
||||
let selectedPass = null;
|
||||
@@ -223,7 +227,29 @@
|
||||
calculatePasses();
|
||||
}
|
||||
|
||||
function setupEmbeddedMode() {
|
||||
if (isEmbedded) {
|
||||
// Hide back link when embedded
|
||||
const backLink = document.querySelector('.back-link');
|
||||
if (backLink) backLink.style.display = 'none';
|
||||
|
||||
// Add embedded class to body for CSS adjustments
|
||||
document.body.classList.add('embedded');
|
||||
|
||||
// Compact the header slightly
|
||||
const header = document.querySelector('.header');
|
||||
if (header) header.style.padding = '10px 20px';
|
||||
|
||||
// Hide decorative elements
|
||||
const gridBg = document.querySelector('.grid-bg');
|
||||
const scanline = document.querySelector('.scanline');
|
||||
if (gridBg) gridBg.style.display = 'none';
|
||||
if (scanline) scanline.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setupEmbeddedMode();
|
||||
initGroundMap();
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
@@ -361,7 +387,7 @@
|
||||
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const radius = Math.min(cx, cy) - 40;
|
||||
const radius = Math.max(10, Math.min(cx, cy) - 40);
|
||||
|
||||
ctx.fillStyle = '#0a0a0f';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
@@ -720,7 +746,7 @@
|
||||
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const radius = Math.min(cx, cy) - 40;
|
||||
const radius = Math.max(10, Math.min(cx, cy) - 40);
|
||||
|
||||
ctx.fillStyle = '#0a0a0f';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
@@ -818,7 +844,7 @@
|
||||
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const radius = Math.min(cx, cy) - 40;
|
||||
const radius = Math.max(10, Math.min(cx, cy) - 40);
|
||||
|
||||
if (el > -5) {
|
||||
const posEl = Math.max(0, el);
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import pytest
|
||||
import json
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
from flask import Flask
|
||||
from routes.bluetooth import bluetooth_bp, classify_bt_device, detect_tracker
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_app_module(mocker):
|
||||
mock_app = mocker.patch("routes.bluetooth.app_module")
|
||||
mock_app.bt_devices = {}
|
||||
mock_app.bt_beacons = {}
|
||||
mock_app.bt_services = {}
|
||||
mock_app.bt_queue = MagicMock()
|
||||
mock_app.bt_lock = MagicMock()
|
||||
mock_app.bt_process = None
|
||||
mock_app.bt_interface = "hci0"
|
||||
return mock_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(bluetooth_bp)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def test_classify_bt_device_by_name():
|
||||
"""Test classification based on common naming patterns."""
|
||||
assert classify_bt_device("Sony WH-1000XM4", None, None) == "audio"
|
||||
assert classify_bt_device("iPhone 15", None, None) == "phone"
|
||||
assert classify_bt_device("Garmin Fenix", None, None) == "wearable"
|
||||
assert classify_bt_device("Microsoft Mouse", None, None) == "input"
|
||||
assert classify_bt_device("AirTag", None, None) == "tracker"
|
||||
assert classify_bt_device("Generic Device", None, None) == "other"
|
||||
|
||||
|
||||
def test_classify_bt_device_by_class():
|
||||
"""Test classification based on Bluetooth Class of Device (CoD)."""
|
||||
assert classify_bt_device(None, 0x0100, None) == "computer"
|
||||
assert classify_bt_device(None, 0x0200, None) == "phone"
|
||||
assert classify_bt_device(None, 0x0400, None) == "audio"
|
||||
|
||||
|
||||
def test_detect_tracker_by_mac():
|
||||
"""Test tracker detection using MAC OUI prefixes."""
|
||||
# Assuming 'FF:FF:FF' is a mock prefix in patterns for testing
|
||||
with patch("routes.bluetooth.TILE_PREFIXES", ["FF:FF"]):
|
||||
result = detect_tracker("FF:FF:00:11:22:33", "Unknown")
|
||||
assert result["type"] == "tile"
|
||||
|
||||
|
||||
def test_detect_tracker_by_name():
|
||||
"""Test tracker detection using name strings."""
|
||||
result = detect_tracker("00:11:22:33:44:55", "My AirTag")
|
||||
assert result["type"] == "airtag"
|
||||
assert result["risk"] == "high"
|
||||
|
||||
|
||||
# --- Route Tests ---
|
||||
|
||||
|
||||
def test_get_interfaces_route(client, mocker):
|
||||
"""Test the /interfaces endpoint with mocked system output."""
|
||||
mock_run = mocker.patch("subprocess.run")
|
||||
# Mocking hciconfig output for a Linux system
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="hci0:\tType: Primary Bus: USB\n\tBD Address: 00:11:22:33:44:55 ACL MTU: 1021:8 SCO MTU: 64:1\n\tUP RUNNING\n"
|
||||
)
|
||||
mocker.patch("platform.system", return_value="Linux")
|
||||
mocker.patch("routes.bluetooth.check_tool", return_value=True)
|
||||
|
||||
response = client.get("/bt/interfaces")
|
||||
data = response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert data["interfaces"][0]["name"] == "hci0"
|
||||
assert data["interfaces"][0]["status"] == "up"
|
||||
assert data["tools"]["hcitool"] is True
|
||||
|
||||
|
||||
def test_stop_scan_route(client, mock_app_module):
|
||||
"""Test stopping a running scan process."""
|
||||
mock_process = MagicMock()
|
||||
mock_app_module.bt_process = mock_process
|
||||
|
||||
response = client.post("/bt/scan/stop")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["status"] == "stopped"
|
||||
mock_process.terminate.assert_called_once()
|
||||
|
||||
|
||||
def test_enum_services_error_no_mac(client):
|
||||
"""Test service enumeration validation."""
|
||||
response = client.post("/bt/enum", json={})
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["status"] == "error"
|
||||
|
||||
|
||||
def test_get_devices_route(client, mock_app_module):
|
||||
"""Test retrieving the current device list from memory."""
|
||||
mock_app_module.bt_devices = {"00:11:22:33:44:55": {"mac": "00:11:22:33:44:55", "name": "Test Device"}}
|
||||
|
||||
response = client.get("/bt/devices")
|
||||
data = response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(data["devices"]) == 1
|
||||
assert data["devices"][0]["name"] == "Test Device"
|
||||
|
||||
|
||||
def test_reload_oui_route(client, mocker):
|
||||
"""Test the OUI database reload functionality."""
|
||||
mocker.patch("routes.bluetooth.load_oui_database", return_value={"001122": "Test Corp"})
|
||||
|
||||
response = client.post("/bt/reload-oui")
|
||||
data = response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert data["status"] == "success"
|
||||
assert data["entries"] > 0
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
from flask import Flask
|
||||
from routes.satellite import satellite_bp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(satellite_bp)
|
||||
app.config['TESTING'] = True
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
def test_predict_passes_invalid_coords(client):
|
||||
"""Verify that invalid coordinates return a 400 error."""
|
||||
payload = {
|
||||
"latitude": 150.0, # Invalid (>90)
|
||||
"longitude": -0.1278
|
||||
}
|
||||
response = client.post('/satellite/predict', json=payload)
|
||||
assert response.status_code == 400
|
||||
assert response.json['status'] == 'error'
|
||||
|
||||
def test_fetch_celestrak_invalid_category(client):
|
||||
"""Verify that an unauthorized category is rejected."""
|
||||
response = client.get('/satellite/celestrak/category_fake')
|
||||
# The code returns 200 but includes an error message in the JSON body
|
||||
assert response.status_code == 200
|
||||
assert response.json['status'] == 'error'
|
||||
assert 'Invalid category' in response.json['message']
|
||||
|
||||
# Mocking Tests (External Calls and Skyfield)
|
||||
@patch('urllib.request.urlopen')
|
||||
def test_update_tle_success(mock_urlopen, client):
|
||||
"""Simulate a successful response from CelesTrak."""
|
||||
mock_content = (
|
||||
"ISS (ZARYA)\n"
|
||||
"1 25544U 98067A 23321.52083333 .00016717 00000-0 30171-3 0 9992\n"
|
||||
"2 25544 51.6416 20.4567 0004561 45.3212 67.8912 15.49876543123456\n"
|
||||
).encode('utf-8')
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = mock_content
|
||||
mock_response.__enter__.return_value = mock_response
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
response = client.post('/satellite/update-tle')
|
||||
assert response.status_code == 200
|
||||
assert response.json['status'] == 'success'
|
||||
assert 'ISS' in response.json['updated']
|
||||
|
||||
@patch('skyfield.api.load')
|
||||
def test_get_satellite_position_skyfield_error(mock_load, client):
|
||||
"""Test behavior when Skyfield fails or data is missing."""
|
||||
# Force the timescale load to fail
|
||||
mock_load.side_effect = Exception("Skyfield error")
|
||||
|
||||
payload = {
|
||||
"latitude": 51.5,
|
||||
"longitude": -0.1,
|
||||
"satellites": ["ISS"]
|
||||
}
|
||||
response = client.post('/satellite/position', json=payload)
|
||||
# Should return success but an empty positions list due to internal try-except
|
||||
assert response.status_code == 200
|
||||
assert response.json['positions'] == []
|
||||
|
||||
# Logic Integration Test (Simulating prediction)
|
||||
def test_predict_passes_empty_cache(client):
|
||||
"""Verify that if the satellite is not in cache, no passes are returned."""
|
||||
payload = {
|
||||
"latitude": 51.5,
|
||||
"longitude": -0.1,
|
||||
"satellites": ["SATELLITE_NON_EXISTENT"]
|
||||
}
|
||||
response = client.post('/satellite/predict', json=payload)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json['passes']) == 0
|
||||
@@ -0,0 +1,221 @@
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch, mock_open
|
||||
from flask import Flask
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
from routes.wifi import wifi_bp, parse_airodump_csv
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app_module(mocker):
|
||||
"""Mock the app_module imported inside routes.wifi."""
|
||||
mock = mocker.patch("routes.wifi.app_module")
|
||||
mock.wifi_lock = MagicMock()
|
||||
mock.wifi_process = None
|
||||
mock.wifi_monitor_interface = None
|
||||
mock.wifi_queue = MagicMock()
|
||||
mock.wifi_networks = {}
|
||||
mock_app_module.wifi_clients = {}
|
||||
return mock
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(wifi_bp)
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
def test_parse_airodump_csv(mocker):
|
||||
"""Test parsing logic for airodump CSV format."""
|
||||
csv_content = (
|
||||
"BSSID, First time seen, Last time seen, channel, Speed, Privacy, Cipher, Authentication, Power, # beacons, # IV, LAN IP, ID-length, ESSID, Key\n"
|
||||
"AA:BB:CC:DD:EE:FF, 2023-01-01, 2023-01-01, 6, 54, WPA2, CCMP, PSK, -50, 10, 5, 0.0.0.0, 7, MyWiFi, \n"
|
||||
"\n"
|
||||
"Station MAC, First time seen, Last time seen, Power, # packets, BSSID, Probes\n"
|
||||
"11:22:33:44:55:66, 2023-01-01, 2023-01-01, -60, 20, AA:BB:CC:DD:EE:FF, MyWiFi\n"
|
||||
)
|
||||
|
||||
with patch("builtins.open", mock_open(read_data=csv_content)):
|
||||
mocker.patch("routes.wifi.get_manufacturer", return_value="Apple")
|
||||
networks, clients = parse_airodump_csv("dummy.csv")
|
||||
|
||||
assert "AA:BB:CC:DD:EE:FF" in networks
|
||||
assert networks["AA:BB:CC:DD:EE:FF"]["essid"] == "MyWiFi"
|
||||
assert "11:22:33:44:55:66" in clients
|
||||
assert clients["11:22:33:44:55:66"]["vendor"] == "Apple"
|
||||
|
||||
### --- ROUTE TESTS --- ###
|
||||
|
||||
def test_get_interfaces(client, mocker):
|
||||
"""Test the /interfaces endpoint."""
|
||||
mocker.patch("routes.wifi.detect_wifi_interfaces", return_value=[{'name': 'wlan0', 'type': 'managed'}])
|
||||
mocker.patch("routes.wifi.check_tool", return_value=True)
|
||||
|
||||
response = client.get('/wifi/interfaces')
|
||||
data = response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(data['interfaces']) == 1
|
||||
assert data['tools']['airmon'] is True
|
||||
|
||||
def test_toggle_monitor_start_success(client, mocker):
|
||||
"""Test enabling monitor mode via airmon-ng."""
|
||||
mocker.patch("routes.wifi.validate_network_interface", return_value="wlan0")
|
||||
mocker.patch("routes.wifi.check_tool", return_value=True)
|
||||
mock_run = mocker.patch("routes.wifi.subprocess.run")
|
||||
mock_run.return_value = MagicMock(stdout="enabled on [phy0]wlan0mon", stderr="", returncode=0)
|
||||
|
||||
with patch("os.path.exists", return_value=True):
|
||||
response = client.post('/wifi/monitor', json={'action': 'start', 'interface': 'wlan0'})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['status'] == 'success'
|
||||
assert response.get_json()['monitor_interface'] == 'wlan0mon'
|
||||
|
||||
def test_start_scan_already_running(client, mock_app_module):
|
||||
"""Test that we can't start a scan if one is already active."""
|
||||
mock_app_module.wifi_process = MagicMock()
|
||||
|
||||
response = client.post('/wifi/scan/start', json={'interface': 'wlan0mon'})
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'error'
|
||||
assert 'already running' in data['message']
|
||||
|
||||
def test_start_scan_execution(client, mock_app_module, mocker):
|
||||
"""Test the full command construction of airodump-ng."""
|
||||
mock_app_module.wifi_process = None
|
||||
mocker.patch("os.path.exists", return_value=True)
|
||||
mocker.patch("routes.wifi.get_tool_path", return_value="/usr/bin/airodump-ng")
|
||||
|
||||
mock_popen = mocker.patch("routes.wifi.subprocess.Popen")
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
mock_popen.return_value = mock_proc
|
||||
|
||||
payload = {'interface': 'wlan0mon', 'channel': 6, 'band': 'g'}
|
||||
response = client.post('/wifi/scan/start', json=payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['status'] == 'started'
|
||||
|
||||
args, _ = mock_popen.call_args
|
||||
cmd = args[0]
|
||||
assert "-c" in cmd and "6" in cmd
|
||||
assert "wlan0mon" in cmd
|
||||
|
||||
def test_stop_scan(client, mock_app_module):
|
||||
"""Test terminating the scanning process."""
|
||||
mock_proc = MagicMock()
|
||||
mock_app_module.wifi_process = mock_proc
|
||||
|
||||
response = client.post('/wifi/scan/stop')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['status'] == 'stopped'
|
||||
mock_proc.terminate.assert_called_once()
|
||||
|
||||
def test_send_deauth_success(client, mock_app_module, mocker):
|
||||
"""Verify deauth command construction and execution."""
|
||||
mocker.patch("routes.wifi.check_tool", return_value=True)
|
||||
mocker.patch("routes.wifi.get_tool_path", return_value="/usr/bin/aireplay-ng")
|
||||
mock_run = mocker.patch("routes.wifi.subprocess.run")
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
payload = {
|
||||
'bssid': 'AA:BB:CC:DD:EE:FF',
|
||||
'count': 10,
|
||||
'interface': 'wlan0mon'
|
||||
}
|
||||
response = client.post('/wifi/deauth', json=payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
args, _ = mock_run.call_args
|
||||
cmd = args[0]
|
||||
assert "--deauth" in cmd
|
||||
assert "10" in cmd
|
||||
assert "AA:BB:CC:DD:EE:FF" in cmd
|
||||
|
||||
### --- HANDSHAKE TESTS --- ###
|
||||
|
||||
def test_capture_handshake_start(client, mock_app_module, mocker):
|
||||
"""Test starting airodump-ng for handshake capture."""
|
||||
mock_app_module.wifi_process = None
|
||||
mocker.patch("routes.wifi.get_tool_path", return_value="/usr/bin/airodump-ng")
|
||||
mock_popen = mocker.patch("routes.wifi.subprocess.Popen")
|
||||
|
||||
payload = {'bssid': 'AA:BB:CC:DD:EE:FF', 'channel': '6', 'interface': 'wlan0mon'}
|
||||
response = client.post('/wifi/handshake/capture', json=payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'capture_file' in response.get_json()
|
||||
assert mock_popen.called
|
||||
|
||||
def test_check_handshake_status_found(client, mocker):
|
||||
"""Verify detection of 'KEY FOUND' in aircrack output."""
|
||||
mocker.patch("os.path.exists", return_value=True)
|
||||
mocker.patch("os.path.getsize", return_value=1024)
|
||||
mocker.patch("routes.wifi.get_tool_path", return_value="aircrack-ng")
|
||||
|
||||
mock_run = mocker.patch("routes.wifi.subprocess.run")
|
||||
mock_run.return_value = MagicMock(stdout="WPA (1 handshake)", stderr="", returncode=0)
|
||||
|
||||
payload = {'file': '/tmp/intercept_handshake_test.cap', 'bssid': 'AA:BB:CC:DD:EE:FF'}
|
||||
response = client.post('/wifi/handshake/status', json=payload)
|
||||
|
||||
assert response.get_json()['handshake_found'] is True
|
||||
|
||||
### --- PMKID TESTS --- ###
|
||||
|
||||
def test_capture_pmkid_path_traversal_prevention(client):
|
||||
"""Ensure the status check rejects invalid paths."""
|
||||
payload = {'file': '/etc/passwd'} # Malicious path
|
||||
response = client.post('/wifi/pmkid/status', json=payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['status'] == 'error'
|
||||
assert 'Invalid capture file path' in response.get_json()['message']
|
||||
|
||||
### --- CRACKING TESTS --- ###
|
||||
|
||||
def test_crack_handshake_success(client, mocker):
|
||||
"""Test successful password extraction using Regex."""
|
||||
mocker.patch("os.path.exists", return_value=True)
|
||||
mocker.patch("routes.wifi.get_tool_path", return_value="aircrack-ng")
|
||||
|
||||
mock_run = mocker.patch("routes.wifi.subprocess.run")
|
||||
# Simulate the actual aircrack-ng success output
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="KEY FOUND! [ secret123 ]",
|
||||
stderr="",
|
||||
returncode=0
|
||||
)
|
||||
|
||||
payload = {
|
||||
'capture_file': '/tmp/intercept_handshake_test.cap',
|
||||
'wordlist': '/home/user/passwords.txt',
|
||||
'bssid': 'AA:BB:CC:DD:EE:FF'
|
||||
}
|
||||
response = client.post('/wifi/handshake/crack', json=payload)
|
||||
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'success'
|
||||
assert data['password'] == 'secret123'
|
||||
|
||||
### --- DATA FETCHING TESTS --- ###
|
||||
|
||||
def test_get_wifi_networks(client, mock_app_module):
|
||||
"""Test that the networks endpoint correctly formats internal data."""
|
||||
mock_app_module.wifi_networks = {
|
||||
'AA:BB:CC:DD:EE:FF': {'essid': 'Home-WiFi', 'bssid': 'AA:BB:CC:DD:EE:FF'}
|
||||
}
|
||||
mock_app_module.wifi_handshakes = ['AA:BB:CC:DD:EE:FF']
|
||||
|
||||
response = client.get('/wifi/networks')
|
||||
data = response.get_json()
|
||||
|
||||
assert len(data['networks']) == 1
|
||||
assert data['networks'][0]['essid'] == 'Home-WiFi'
|
||||
assert 'AA:BB:CC:DD:EE:FF' in data['handshakes']
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Aircraft database for ICAO hex to type/registration lookup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError
|
||||
|
||||
logger = logging.getLogger('intercept.aircraft_db')
|
||||
|
||||
# Database file location (project root)
|
||||
DB_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DB_FILE = os.path.join(DB_DIR, 'aircraft_db.json')
|
||||
DB_META_FILE = os.path.join(DB_DIR, 'aircraft_db_meta.json')
|
||||
|
||||
# Mictronics database URLs (raw GitHub)
|
||||
AIRCRAFT_DB_URL = 'https://raw.githubusercontent.com/Mictronics/readsb-protobuf/dev/webapp/src/db/aircrafts.json'
|
||||
TYPES_DB_URL = 'https://raw.githubusercontent.com/Mictronics/readsb-protobuf/dev/webapp/src/db/types.json'
|
||||
GITHUB_API_URL = 'https://api.github.com/repos/Mictronics/readsb-protobuf/commits?path=webapp/src/db/aircrafts.json&per_page=1'
|
||||
|
||||
# In-memory cache
|
||||
_aircraft_cache: dict[str, dict[str, str]] = {}
|
||||
_types_cache: dict[str, str] = {}
|
||||
_cache_lock = threading.Lock()
|
||||
_db_loaded = False
|
||||
_db_version: str | None = None
|
||||
_update_available: bool = False
|
||||
_latest_version: str | None = None
|
||||
|
||||
|
||||
def get_db_status() -> dict[str, Any]:
|
||||
"""Get current database status."""
|
||||
exists = os.path.exists(DB_FILE)
|
||||
meta = _load_meta()
|
||||
|
||||
return {
|
||||
'installed': exists,
|
||||
'version': meta.get('version') if meta else None,
|
||||
'downloaded': meta.get('downloaded') if meta else None,
|
||||
'aircraft_count': len(_aircraft_cache) if _db_loaded else 0,
|
||||
'update_available': _update_available,
|
||||
'latest_version': _latest_version,
|
||||
}
|
||||
|
||||
|
||||
def _load_meta() -> dict[str, Any] | None:
|
||||
"""Load database metadata."""
|
||||
try:
|
||||
if os.path.exists(DB_META_FILE):
|
||||
with open(DB_META_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error loading aircraft db meta: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _save_meta(version: str) -> None:
|
||||
"""Save database metadata."""
|
||||
try:
|
||||
meta = {
|
||||
'version': version,
|
||||
'downloaded': datetime.utcnow().isoformat() + 'Z',
|
||||
}
|
||||
with open(DB_META_FILE, 'w') as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error saving aircraft db meta: {e}")
|
||||
|
||||
|
||||
def load_database() -> bool:
|
||||
"""Load aircraft database into memory. Returns True if successful."""
|
||||
global _aircraft_cache, _types_cache, _db_loaded, _db_version
|
||||
|
||||
if not os.path.exists(DB_FILE):
|
||||
logger.info("Aircraft database not installed")
|
||||
return False
|
||||
|
||||
try:
|
||||
with _cache_lock:
|
||||
with open(DB_FILE, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
_aircraft_cache = data.get('aircraft', {})
|
||||
_types_cache = data.get('types', {})
|
||||
_db_loaded = True
|
||||
|
||||
meta = _load_meta()
|
||||
_db_version = meta.get('version') if meta else 'unknown'
|
||||
|
||||
logger.info(f"Loaded aircraft database: {len(_aircraft_cache)} aircraft, {len(_types_cache)} types")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading aircraft database: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def lookup(icao: str) -> dict[str, str] | None:
|
||||
"""
|
||||
Look up aircraft by ICAO hex code.
|
||||
|
||||
Returns dict with keys: registration, type_code, type_desc
|
||||
Or None if not found.
|
||||
"""
|
||||
if not _db_loaded:
|
||||
return None
|
||||
|
||||
icao_upper = icao.upper()
|
||||
|
||||
with _cache_lock:
|
||||
aircraft = _aircraft_cache.get(icao_upper)
|
||||
if not aircraft:
|
||||
return None
|
||||
|
||||
# Database format is array: [registration, type_code, flags, ...]
|
||||
# Handle both list format (from Mictronics) and dict format (legacy)
|
||||
if isinstance(aircraft, list):
|
||||
reg = aircraft[0] if len(aircraft) > 0 else ''
|
||||
type_code = aircraft[1] if len(aircraft) > 1 else ''
|
||||
else:
|
||||
# Dict format fallback
|
||||
reg = aircraft.get('r', '')
|
||||
type_code = aircraft.get('t', '')
|
||||
|
||||
# Look up type description
|
||||
type_desc = ''
|
||||
if type_code and type_code in _types_cache:
|
||||
type_desc = _types_cache[type_code]
|
||||
|
||||
return {
|
||||
'registration': reg,
|
||||
'type_code': type_code,
|
||||
'type_desc': type_desc,
|
||||
}
|
||||
|
||||
|
||||
def check_for_updates() -> dict[str, Any]:
|
||||
"""
|
||||
Check GitHub for database updates.
|
||||
Returns status dict with update_available flag.
|
||||
"""
|
||||
global _update_available, _latest_version
|
||||
|
||||
try:
|
||||
req = Request(GITHUB_API_URL, headers={'User-Agent': 'Intercept-SIGINT'})
|
||||
with urlopen(req, timeout=10) as response:
|
||||
commits = json.loads(response.read().decode('utf-8'))
|
||||
|
||||
if commits and len(commits) > 0:
|
||||
latest_sha = commits[0]['sha'][:8]
|
||||
latest_date = commits[0]['commit']['committer']['date']
|
||||
_latest_version = f"{latest_date[:10]}_{latest_sha}"
|
||||
|
||||
meta = _load_meta()
|
||||
current_version = meta.get('version') if meta else None
|
||||
|
||||
_update_available = current_version != _latest_version
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'current_version': current_version,
|
||||
'latest_version': _latest_version,
|
||||
'update_available': _update_available,
|
||||
}
|
||||
except URLError as e:
|
||||
logger.warning(f"Failed to check for updates: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking for updates: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
return {'success': False, 'error': 'Unknown error'}
|
||||
|
||||
|
||||
def download_database(progress_callback=None) -> dict[str, Any]:
|
||||
"""
|
||||
Download latest aircraft database from Mictronics repo.
|
||||
Returns status dict.
|
||||
"""
|
||||
global _update_available
|
||||
|
||||
try:
|
||||
if progress_callback:
|
||||
progress_callback('Downloading aircraft database...')
|
||||
|
||||
# Download aircraft database
|
||||
req = Request(AIRCRAFT_DB_URL, headers={'User-Agent': 'Intercept-SIGINT'})
|
||||
with urlopen(req, timeout=60) as response:
|
||||
aircraft_data = json.loads(response.read().decode('utf-8'))
|
||||
|
||||
if progress_callback:
|
||||
progress_callback('Downloading type codes...')
|
||||
|
||||
# Download types database
|
||||
req = Request(TYPES_DB_URL, headers={'User-Agent': 'Intercept-SIGINT'})
|
||||
with urlopen(req, timeout=30) as response:
|
||||
types_data = json.loads(response.read().decode('utf-8'))
|
||||
|
||||
if progress_callback:
|
||||
progress_callback('Processing database...')
|
||||
|
||||
# Combine into single file
|
||||
combined = {
|
||||
'aircraft': aircraft_data,
|
||||
'types': types_data,
|
||||
}
|
||||
|
||||
# Save to file
|
||||
with open(DB_FILE, 'w') as f:
|
||||
json.dump(combined, f, separators=(',', ':')) # Compact JSON
|
||||
|
||||
# Get version from GitHub
|
||||
version = datetime.utcnow().strftime('%Y-%m-%d')
|
||||
try:
|
||||
req = Request(GITHUB_API_URL, headers={'User-Agent': 'Intercept-SIGINT'})
|
||||
with urlopen(req, timeout=10) as response:
|
||||
commits = json.loads(response.read().decode('utf-8'))
|
||||
if commits:
|
||||
sha = commits[0]['sha'][:8]
|
||||
date = commits[0]['commit']['committer']['date'][:10]
|
||||
version = f"{date}_{sha}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_save_meta(version)
|
||||
_update_available = False
|
||||
|
||||
# Reload into memory
|
||||
load_database()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'Downloaded {len(aircraft_data)} aircraft, {len(types_data)} types',
|
||||
'version': version,
|
||||
}
|
||||
|
||||
except URLError as e:
|
||||
logger.error(f"Download failed: {e}")
|
||||
return {'success': False, 'error': f'Download failed: {e}'}
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading database: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
|
||||
def delete_database() -> dict[str, Any]:
|
||||
"""Delete local database files."""
|
||||
global _aircraft_cache, _types_cache, _db_loaded, _db_version
|
||||
|
||||
try:
|
||||
with _cache_lock:
|
||||
_aircraft_cache = {}
|
||||
_types_cache = {}
|
||||
_db_loaded = False
|
||||
_db_version = None
|
||||
|
||||
if os.path.exists(DB_FILE):
|
||||
os.remove(DB_FILE)
|
||||
if os.path.exists(DB_META_FILE):
|
||||
os.remove(DB_META_FILE)
|
||||
|
||||
return {'success': True, 'message': 'Database deleted'}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
@@ -99,6 +99,23 @@ class DataStore:
|
||||
with self._lock:
|
||||
return key in self.data
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""Get an entry using subscript notation."""
|
||||
with self._lock:
|
||||
return self.data[key]
|
||||
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
"""Set an entry using subscript notation."""
|
||||
with self._lock:
|
||||
self.data[key] = value
|
||||
self.timestamps[key] = time.time()
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
"""Delete an entry using subscript notation."""
|
||||
with self._lock:
|
||||
del self.data[key]
|
||||
del self.timestamps[key]
|
||||
|
||||
def cleanup(self) -> int:
|
||||
"""
|
||||
Remove entries older than max_age.
|
||||
|
||||
@@ -100,6 +100,100 @@ def init_db() -> None:
|
||||
)
|
||||
''')
|
||||
|
||||
# =====================================================================
|
||||
# TSCM (Technical Surveillance Countermeasures) Tables
|
||||
# =====================================================================
|
||||
|
||||
# TSCM Baselines - Environment snapshots for comparison
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tscm_baselines (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
location TEXT,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
wifi_networks TEXT,
|
||||
bt_devices TEXT,
|
||||
rf_frequencies TEXT,
|
||||
gps_coords TEXT,
|
||||
is_active BOOLEAN DEFAULT 0
|
||||
)
|
||||
''')
|
||||
|
||||
# TSCM Sweeps - Individual sweep sessions
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tscm_sweeps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
baseline_id INTEGER,
|
||||
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
status TEXT DEFAULT 'running',
|
||||
sweep_type TEXT,
|
||||
wifi_enabled BOOLEAN DEFAULT 1,
|
||||
bt_enabled BOOLEAN DEFAULT 1,
|
||||
rf_enabled BOOLEAN DEFAULT 1,
|
||||
results TEXT,
|
||||
anomalies TEXT,
|
||||
threats_found INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (baseline_id) REFERENCES tscm_baselines(id)
|
||||
)
|
||||
''')
|
||||
|
||||
# TSCM Threats - Detected threats/anomalies
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tscm_threats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sweep_id INTEGER,
|
||||
detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
threat_type TEXT NOT NULL,
|
||||
severity TEXT DEFAULT 'medium',
|
||||
source TEXT,
|
||||
identifier TEXT,
|
||||
name TEXT,
|
||||
signal_strength INTEGER,
|
||||
frequency REAL,
|
||||
details TEXT,
|
||||
acknowledged BOOLEAN DEFAULT 0,
|
||||
notes TEXT,
|
||||
gps_coords TEXT,
|
||||
FOREIGN KEY (sweep_id) REFERENCES tscm_sweeps(id)
|
||||
)
|
||||
''')
|
||||
|
||||
# TSCM Scheduled Sweeps
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tscm_schedules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
baseline_id INTEGER,
|
||||
zone_name TEXT,
|
||||
cron_expression TEXT,
|
||||
sweep_type TEXT DEFAULT 'standard',
|
||||
enabled BOOLEAN DEFAULT 1,
|
||||
last_run TIMESTAMP,
|
||||
next_run TIMESTAMP,
|
||||
notify_on_threat BOOLEAN DEFAULT 1,
|
||||
notify_email TEXT,
|
||||
FOREIGN KEY (baseline_id) REFERENCES tscm_baselines(id)
|
||||
)
|
||||
''')
|
||||
|
||||
# TSCM indexes for performance
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_tscm_threats_sweep
|
||||
ON tscm_threats(sweep_id)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_tscm_threats_severity
|
||||
ON tscm_threats(severity, detected_at)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_tscm_sweeps_baseline
|
||||
ON tscm_sweeps(baseline_id)
|
||||
''')
|
||||
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
|
||||
@@ -349,3 +443,353 @@ def get_correlations(min_confidence: float = 0.5) -> list[dict]:
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TSCM Functions
|
||||
# =============================================================================
|
||||
|
||||
def create_tscm_baseline(
|
||||
name: str,
|
||||
location: str | None = None,
|
||||
description: str | None = None,
|
||||
wifi_networks: list | None = None,
|
||||
bt_devices: list | None = None,
|
||||
rf_frequencies: list | None = None,
|
||||
gps_coords: dict | None = None
|
||||
) -> int:
|
||||
"""
|
||||
Create a new TSCM baseline.
|
||||
|
||||
Returns:
|
||||
The ID of the created baseline
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO tscm_baselines
|
||||
(name, location, description, wifi_networks, bt_devices, rf_frequencies, gps_coords)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
name,
|
||||
location,
|
||||
description,
|
||||
json.dumps(wifi_networks) if wifi_networks else None,
|
||||
json.dumps(bt_devices) if bt_devices else None,
|
||||
json.dumps(rf_frequencies) if rf_frequencies else None,
|
||||
json.dumps(gps_coords) if gps_coords else None
|
||||
))
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def get_tscm_baseline(baseline_id: int) -> dict | None:
|
||||
"""Get a specific TSCM baseline by ID."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
SELECT * FROM tscm_baselines WHERE id = ?
|
||||
''', (baseline_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
'id': row['id'],
|
||||
'name': row['name'],
|
||||
'location': row['location'],
|
||||
'description': row['description'],
|
||||
'created_at': row['created_at'],
|
||||
'wifi_networks': json.loads(row['wifi_networks']) if row['wifi_networks'] else [],
|
||||
'bt_devices': json.loads(row['bt_devices']) if row['bt_devices'] else [],
|
||||
'rf_frequencies': json.loads(row['rf_frequencies']) if row['rf_frequencies'] else [],
|
||||
'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None,
|
||||
'is_active': bool(row['is_active'])
|
||||
}
|
||||
|
||||
|
||||
def get_all_tscm_baselines() -> list[dict]:
|
||||
"""Get all TSCM baselines."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
SELECT id, name, location, description, created_at, is_active
|
||||
FROM tscm_baselines
|
||||
ORDER BY created_at DESC
|
||||
''')
|
||||
|
||||
return [dict(row) for row in cursor]
|
||||
|
||||
|
||||
def get_active_tscm_baseline() -> dict | None:
|
||||
"""Get the currently active TSCM baseline."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
SELECT * FROM tscm_baselines WHERE is_active = 1 LIMIT 1
|
||||
''')
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
return get_tscm_baseline(row['id'])
|
||||
|
||||
|
||||
def set_active_tscm_baseline(baseline_id: int) -> bool:
|
||||
"""Set a baseline as active (deactivates others)."""
|
||||
with get_db() as conn:
|
||||
# Deactivate all
|
||||
conn.execute('UPDATE tscm_baselines SET is_active = 0')
|
||||
# Activate selected
|
||||
cursor = conn.execute(
|
||||
'UPDATE tscm_baselines SET is_active = 1 WHERE id = ?',
|
||||
(baseline_id,)
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def update_tscm_baseline(
|
||||
baseline_id: int,
|
||||
wifi_networks: list | None = None,
|
||||
bt_devices: list | None = None,
|
||||
rf_frequencies: list | None = None
|
||||
) -> bool:
|
||||
"""Update baseline device lists."""
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if wifi_networks is not None:
|
||||
updates.append('wifi_networks = ?')
|
||||
params.append(json.dumps(wifi_networks))
|
||||
if bt_devices is not None:
|
||||
updates.append('bt_devices = ?')
|
||||
params.append(json.dumps(bt_devices))
|
||||
if rf_frequencies is not None:
|
||||
updates.append('rf_frequencies = ?')
|
||||
params.append(json.dumps(rf_frequencies))
|
||||
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
params.append(baseline_id)
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
f'UPDATE tscm_baselines SET {", ".join(updates)} WHERE id = ?',
|
||||
params
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def delete_tscm_baseline(baseline_id: int) -> bool:
|
||||
"""Delete a TSCM baseline."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
'DELETE FROM tscm_baselines WHERE id = ?',
|
||||
(baseline_id,)
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def create_tscm_sweep(
|
||||
sweep_type: str,
|
||||
baseline_id: int | None = None,
|
||||
wifi_enabled: bool = True,
|
||||
bt_enabled: bool = True,
|
||||
rf_enabled: bool = True
|
||||
) -> int:
|
||||
"""
|
||||
Create a new TSCM sweep session.
|
||||
|
||||
Returns:
|
||||
The ID of the created sweep
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO tscm_sweeps
|
||||
(baseline_id, sweep_type, wifi_enabled, bt_enabled, rf_enabled)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (baseline_id, sweep_type, wifi_enabled, bt_enabled, rf_enabled))
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def update_tscm_sweep(
|
||||
sweep_id: int,
|
||||
status: str | None = None,
|
||||
results: dict | None = None,
|
||||
anomalies: list | None = None,
|
||||
threats_found: int | None = None,
|
||||
completed: bool = False
|
||||
) -> bool:
|
||||
"""Update a TSCM sweep."""
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if status is not None:
|
||||
updates.append('status = ?')
|
||||
params.append(status)
|
||||
if results is not None:
|
||||
updates.append('results = ?')
|
||||
params.append(json.dumps(results))
|
||||
if anomalies is not None:
|
||||
updates.append('anomalies = ?')
|
||||
params.append(json.dumps(anomalies))
|
||||
if threats_found is not None:
|
||||
updates.append('threats_found = ?')
|
||||
params.append(threats_found)
|
||||
if completed:
|
||||
updates.append('completed_at = CURRENT_TIMESTAMP')
|
||||
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
params.append(sweep_id)
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
f'UPDATE tscm_sweeps SET {", ".join(updates)} WHERE id = ?',
|
||||
params
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def get_tscm_sweep(sweep_id: int) -> dict | None:
|
||||
"""Get a specific TSCM sweep by ID."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('SELECT * FROM tscm_sweeps WHERE id = ?', (sweep_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
'id': row['id'],
|
||||
'baseline_id': row['baseline_id'],
|
||||
'started_at': row['started_at'],
|
||||
'completed_at': row['completed_at'],
|
||||
'status': row['status'],
|
||||
'sweep_type': row['sweep_type'],
|
||||
'wifi_enabled': bool(row['wifi_enabled']),
|
||||
'bt_enabled': bool(row['bt_enabled']),
|
||||
'rf_enabled': bool(row['rf_enabled']),
|
||||
'results': json.loads(row['results']) if row['results'] else None,
|
||||
'anomalies': json.loads(row['anomalies']) if row['anomalies'] else [],
|
||||
'threats_found': row['threats_found']
|
||||
}
|
||||
|
||||
|
||||
def add_tscm_threat(
|
||||
sweep_id: int,
|
||||
threat_type: str,
|
||||
severity: str,
|
||||
source: str,
|
||||
identifier: str,
|
||||
name: str | None = None,
|
||||
signal_strength: int | None = None,
|
||||
frequency: float | None = None,
|
||||
details: dict | None = None,
|
||||
gps_coords: dict | None = None
|
||||
) -> int:
|
||||
"""
|
||||
Add a detected threat to a TSCM sweep.
|
||||
|
||||
Returns:
|
||||
The ID of the created threat
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO tscm_threats
|
||||
(sweep_id, threat_type, severity, source, identifier, name,
|
||||
signal_strength, frequency, details, gps_coords)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
sweep_id, threat_type, severity, source, identifier, name,
|
||||
signal_strength, frequency,
|
||||
json.dumps(details) if details else None,
|
||||
json.dumps(gps_coords) if gps_coords else None
|
||||
))
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def get_tscm_threats(
|
||||
sweep_id: int | None = None,
|
||||
severity: str | None = None,
|
||||
acknowledged: bool | None = None,
|
||||
limit: int = 100
|
||||
) -> list[dict]:
|
||||
"""Get TSCM threats with optional filters."""
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if sweep_id is not None:
|
||||
conditions.append('sweep_id = ?')
|
||||
params.append(sweep_id)
|
||||
if severity is not None:
|
||||
conditions.append('severity = ?')
|
||||
params.append(severity)
|
||||
if acknowledged is not None:
|
||||
conditions.append('acknowledged = ?')
|
||||
params.append(1 if acknowledged else 0)
|
||||
|
||||
where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else ''
|
||||
params.append(limit)
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(f'''
|
||||
SELECT * FROM tscm_threats
|
||||
{where_clause}
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT ?
|
||||
''', params)
|
||||
|
||||
results = []
|
||||
for row in cursor:
|
||||
results.append({
|
||||
'id': row['id'],
|
||||
'sweep_id': row['sweep_id'],
|
||||
'detected_at': row['detected_at'],
|
||||
'threat_type': row['threat_type'],
|
||||
'severity': row['severity'],
|
||||
'source': row['source'],
|
||||
'identifier': row['identifier'],
|
||||
'name': row['name'],
|
||||
'signal_strength': row['signal_strength'],
|
||||
'frequency': row['frequency'],
|
||||
'details': json.loads(row['details']) if row['details'] else None,
|
||||
'acknowledged': bool(row['acknowledged']),
|
||||
'notes': row['notes'],
|
||||
'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def acknowledge_tscm_threat(threat_id: int, notes: str | None = None) -> bool:
|
||||
"""Acknowledge a TSCM threat."""
|
||||
with get_db() as conn:
|
||||
if notes:
|
||||
cursor = conn.execute(
|
||||
'UPDATE tscm_threats SET acknowledged = 1, notes = ? WHERE id = ?',
|
||||
(notes, threat_id)
|
||||
)
|
||||
else:
|
||||
cursor = conn.execute(
|
||||
'UPDATE tscm_threats SET acknowledged = 1 WHERE id = ?',
|
||||
(threat_id,)
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def get_tscm_threat_summary() -> dict:
|
||||
"""Get summary counts of threats by severity."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
SELECT severity, COUNT(*) as count
|
||||
FROM tscm_threats
|
||||
WHERE acknowledged = 0
|
||||
GROUP BY severity
|
||||
''')
|
||||
|
||||
summary = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0, 'total': 0}
|
||||
for row in cursor:
|
||||
summary[row['severity']] = row['count']
|
||||
summary['total'] += row['count']
|
||||
|
||||
return summary
|
||||
|
||||
+109
-2
@@ -1,15 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger('intercept.dependencies')
|
||||
|
||||
# Additional paths to search for tools (e.g., /usr/sbin on Debian)
|
||||
EXTRA_TOOL_PATHS = ['/usr/sbin', '/sbin']
|
||||
|
||||
|
||||
def check_tool(name: str) -> bool:
|
||||
"""Check if a tool is installed."""
|
||||
return shutil.which(name) is not None
|
||||
return get_tool_path(name) is not None
|
||||
|
||||
|
||||
def get_tool_path(name: str) -> str | None:
|
||||
"""Get the full path to a tool, checking standard PATH and extra locations."""
|
||||
# First check standard PATH
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return path
|
||||
|
||||
# Check additional paths (e.g., /usr/sbin for aircrack-ng on Debian)
|
||||
for extra_path in EXTRA_TOOL_PATHS:
|
||||
full_path = os.path.join(extra_path, name)
|
||||
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
|
||||
return full_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Comprehensive tool dependency definitions
|
||||
@@ -32,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': {
|
||||
@@ -175,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': {
|
||||
@@ -254,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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+28
-483
@@ -1,32 +1,20 @@
|
||||
"""
|
||||
GPS dongle support for INTERCEPT.
|
||||
GPS support for INTERCEPT via gpsd daemon.
|
||||
|
||||
Provides detection and reading of USB GPS dongles via serial port.
|
||||
Parses NMEA sentences to extract location data.
|
||||
Provides GPS location data by connecting to the gpsd daemon.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import glob
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional, Callable, Union
|
||||
from typing import Optional, Callable
|
||||
|
||||
logger = logging.getLogger('intercept.gps')
|
||||
|
||||
# Try to import serial, but don't fail if not available
|
||||
try:
|
||||
import serial
|
||||
SERIAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
SERIAL_AVAILABLE = False
|
||||
logger.warning("pyserial not installed - GPS dongle support disabled")
|
||||
|
||||
|
||||
@dataclass
|
||||
class GPSPosition:
|
||||
@@ -34,10 +22,10 @@ class GPSPosition:
|
||||
latitude: float
|
||||
longitude: float
|
||||
altitude: Optional[float] = None
|
||||
speed: Optional[float] = None # knots
|
||||
speed: Optional[float] = None # m/s
|
||||
heading: Optional[float] = None # degrees
|
||||
satellites: Optional[int] = None
|
||||
fix_quality: int = 0 # 0=invalid, 1=GPS, 2=DGPS
|
||||
fix_quality: int = 0 # 0=unknown, 1=no fix, 2=2D fix, 3=3D fix
|
||||
timestamp: Optional[datetime] = None
|
||||
device: Optional[str] = None
|
||||
|
||||
@@ -56,407 +44,6 @@ class GPSPosition:
|
||||
}
|
||||
|
||||
|
||||
def detect_gps_devices() -> list[dict]:
|
||||
"""
|
||||
Detect potential GPS serial devices.
|
||||
|
||||
Returns a list of device info dictionaries.
|
||||
"""
|
||||
devices = []
|
||||
|
||||
# Common GPS device patterns by platform
|
||||
patterns = []
|
||||
|
||||
if os.name == 'posix':
|
||||
# Linux
|
||||
patterns.extend([
|
||||
'/dev/ttyUSB*', # USB serial adapters
|
||||
'/dev/ttyACM*', # USB CDC ACM devices (many GPS)
|
||||
'/dev/gps*', # gpsd symlinks
|
||||
])
|
||||
# macOS
|
||||
patterns.extend([
|
||||
'/dev/tty.usbserial*',
|
||||
'/dev/tty.usbmodem*',
|
||||
'/dev/cu.usbserial*',
|
||||
'/dev/cu.usbmodem*',
|
||||
])
|
||||
|
||||
for pattern in patterns:
|
||||
for path in glob.glob(pattern):
|
||||
# Try to get device info
|
||||
device_info = {
|
||||
'path': path,
|
||||
'name': os.path.basename(path),
|
||||
'type': 'serial',
|
||||
}
|
||||
|
||||
# Check if it's readable
|
||||
if os.access(path, os.R_OK):
|
||||
device_info['accessible'] = True
|
||||
else:
|
||||
device_info['accessible'] = False
|
||||
device_info['error'] = 'Permission denied'
|
||||
|
||||
devices.append(device_info)
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def parse_nmea_coordinate(coord: str, direction: str) -> Optional[float]:
|
||||
"""
|
||||
Parse NMEA coordinate format to decimal degrees.
|
||||
|
||||
NMEA format: DDDMM.MMMM or DDMM.MMMM
|
||||
"""
|
||||
if not coord or not direction:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Find the decimal point
|
||||
dot_pos = coord.index('.')
|
||||
|
||||
# Degrees are everything before the last 2 digits before decimal
|
||||
degrees = int(coord[:dot_pos - 2])
|
||||
minutes = float(coord[dot_pos - 2:])
|
||||
|
||||
result = degrees + (minutes / 60.0)
|
||||
|
||||
# Apply direction
|
||||
if direction in ('S', 'W'):
|
||||
result = -result
|
||||
|
||||
return result
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_gga(parts: list[str]) -> Optional[GPSPosition]:
|
||||
"""
|
||||
Parse GPGGA/GNGGA sentence (Global Positioning System Fix Data).
|
||||
|
||||
Format: $GPGGA,time,lat,N/S,lon,E/W,quality,satellites,hdop,altitude,M,...
|
||||
"""
|
||||
if len(parts) < 10:
|
||||
return None
|
||||
|
||||
try:
|
||||
fix_quality = int(parts[6]) if parts[6] else 0
|
||||
|
||||
# No fix
|
||||
if fix_quality == 0:
|
||||
return None
|
||||
|
||||
lat = parse_nmea_coordinate(parts[2], parts[3])
|
||||
lon = parse_nmea_coordinate(parts[4], parts[5])
|
||||
|
||||
if lat is None or lon is None:
|
||||
return None
|
||||
|
||||
# Parse optional fields
|
||||
satellites = int(parts[7]) if parts[7] else None
|
||||
altitude = float(parts[9]) if parts[9] else None
|
||||
|
||||
# Parse time (HHMMSS.sss)
|
||||
timestamp = None
|
||||
if parts[1]:
|
||||
try:
|
||||
time_str = parts[1].split('.')[0]
|
||||
if len(time_str) >= 6:
|
||||
now = datetime.utcnow()
|
||||
timestamp = now.replace(
|
||||
hour=int(time_str[0:2]),
|
||||
minute=int(time_str[2:4]),
|
||||
second=int(time_str[4:6]),
|
||||
microsecond=0
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
return GPSPosition(
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
altitude=altitude,
|
||||
satellites=satellites,
|
||||
fix_quality=fix_quality,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.debug(f"GGA parse error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def parse_rmc(parts: list[str]) -> Optional[GPSPosition]:
|
||||
"""
|
||||
Parse GPRMC/GNRMC sentence (Recommended Minimum).
|
||||
|
||||
Format: $GPRMC,time,status,lat,N/S,lon,E/W,speed,heading,date,...
|
||||
"""
|
||||
if len(parts) < 8:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Check status (A=active/valid, V=void/invalid)
|
||||
if parts[2] != 'A':
|
||||
return None
|
||||
|
||||
lat = parse_nmea_coordinate(parts[3], parts[4])
|
||||
lon = parse_nmea_coordinate(parts[5], parts[6])
|
||||
|
||||
if lat is None or lon is None:
|
||||
return None
|
||||
|
||||
# Parse optional fields
|
||||
speed = float(parts[7]) if parts[7] else None # knots
|
||||
heading = float(parts[8]) if len(parts) > 8 and parts[8] else None
|
||||
|
||||
# Parse timestamp
|
||||
timestamp = None
|
||||
if parts[1] and len(parts) > 9 and parts[9]:
|
||||
try:
|
||||
time_str = parts[1].split('.')[0]
|
||||
date_str = parts[9]
|
||||
if len(time_str) >= 6 and len(date_str) >= 6:
|
||||
timestamp = datetime(
|
||||
year=2000 + int(date_str[4:6]),
|
||||
month=int(date_str[2:4]),
|
||||
day=int(date_str[0:2]),
|
||||
hour=int(time_str[0:2]),
|
||||
minute=int(time_str[2:4]),
|
||||
second=int(time_str[4:6]),
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
return GPSPosition(
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
speed=speed,
|
||||
heading=heading,
|
||||
timestamp=timestamp,
|
||||
fix_quality=1, # RMC with A status means valid fix
|
||||
)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.debug(f"RMC parse error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def parse_nmea_sentence(sentence: str) -> Optional[GPSPosition]:
|
||||
"""
|
||||
Parse an NMEA sentence and extract position data.
|
||||
|
||||
Supports: GGA, RMC sentences (with GP, GN, GL prefixes)
|
||||
"""
|
||||
sentence = sentence.strip()
|
||||
|
||||
# Validate checksum if present
|
||||
if '*' in sentence:
|
||||
data, checksum = sentence.rsplit('*', 1)
|
||||
if data.startswith('$'):
|
||||
data = data[1:]
|
||||
|
||||
# Calculate checksum
|
||||
calc_checksum = 0
|
||||
for char in data:
|
||||
calc_checksum ^= ord(char)
|
||||
|
||||
try:
|
||||
if int(checksum, 16) != calc_checksum:
|
||||
logger.debug(f"Checksum mismatch: {sentence}")
|
||||
return None
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Remove $ prefix if present
|
||||
if sentence.startswith('$'):
|
||||
sentence = sentence[1:]
|
||||
|
||||
# Remove checksum for parsing
|
||||
if '*' in sentence:
|
||||
sentence = sentence.split('*')[0]
|
||||
|
||||
parts = sentence.split(',')
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
msg_type = parts[0]
|
||||
|
||||
# Handle various NMEA talker IDs (GP=GPS, GN=GNSS, GL=GLONASS, GA=Galileo)
|
||||
if msg_type.endswith('GGA'):
|
||||
return parse_gga(parts)
|
||||
elif msg_type.endswith('RMC'):
|
||||
return parse_rmc(parts)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class GPSReader:
|
||||
"""
|
||||
Reads GPS data from a serial device.
|
||||
|
||||
Runs in a background thread and maintains current position.
|
||||
"""
|
||||
|
||||
def __init__(self, device_path: str, baudrate: int = 9600):
|
||||
self.device_path = device_path
|
||||
self.baudrate = baudrate
|
||||
self._position: Optional[GPSPosition] = None
|
||||
self._lock = threading.Lock()
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._serial: Optional['serial.Serial'] = None
|
||||
self._last_update: Optional[datetime] = None
|
||||
self._error: Optional[str] = None
|
||||
self._callbacks: list[Callable[[GPSPosition], None]] = []
|
||||
|
||||
@property
|
||||
def position(self) -> Optional[GPSPosition]:
|
||||
"""Get the current GPS position."""
|
||||
with self._lock:
|
||||
return self._position
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the reader is running."""
|
||||
return self._running
|
||||
|
||||
@property
|
||||
def last_update(self) -> Optional[datetime]:
|
||||
"""Get the time of the last position update."""
|
||||
with self._lock:
|
||||
return self._last_update
|
||||
|
||||
@property
|
||||
def error(self) -> Optional[str]:
|
||||
"""Get any error message."""
|
||||
with self._lock:
|
||||
return self._error
|
||||
|
||||
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
|
||||
"""Add a callback to be called on position updates."""
|
||||
self._callbacks.append(callback)
|
||||
|
||||
def remove_callback(self, callback: Callable[[GPSPosition], None]) -> None:
|
||||
"""Remove a position update callback."""
|
||||
if callback in self._callbacks:
|
||||
self._callbacks.remove(callback)
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start reading GPS data in a background thread."""
|
||||
if not SERIAL_AVAILABLE:
|
||||
self._error = "pyserial not installed"
|
||||
return False
|
||||
|
||||
if self._running:
|
||||
return True
|
||||
|
||||
try:
|
||||
self._serial = serial.Serial(
|
||||
self.device_path,
|
||||
baudrate=self.baudrate,
|
||||
timeout=1.0
|
||||
)
|
||||
self._running = True
|
||||
self._error = None
|
||||
|
||||
self._thread = threading.Thread(target=self._read_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
logger.info(f"Started GPS reader on {self.device_path}")
|
||||
return True
|
||||
|
||||
except serial.SerialException as e:
|
||||
self._error = str(e)
|
||||
logger.error(f"Failed to open GPS device {self.device_path}: {e}")
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop reading GPS data."""
|
||||
self._running = False
|
||||
|
||||
if self._serial:
|
||||
try:
|
||||
self._serial.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._serial = None
|
||||
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
|
||||
logger.info(f"Stopped GPS reader on {self.device_path}")
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
"""Background thread loop for reading GPS data."""
|
||||
buffer = ""
|
||||
sentence_count = 0
|
||||
bytes_read = 0
|
||||
|
||||
print(f"[GPS] Read loop started on {self.device_path} at {self.baudrate} baud", flush=True)
|
||||
|
||||
while self._running and self._serial:
|
||||
try:
|
||||
# Read available data
|
||||
waiting = self._serial.in_waiting
|
||||
if waiting:
|
||||
data = self._serial.read(waiting)
|
||||
bytes_read += len(data)
|
||||
if bytes_read <= 500 or bytes_read % 1000 == 0:
|
||||
print(f"[GPS] Read {len(data)} bytes (total: {bytes_read})", flush=True)
|
||||
buffer += data.decode('ascii', errors='ignore')
|
||||
|
||||
# Process complete lines
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
|
||||
if line.startswith('$'):
|
||||
sentence_count += 1
|
||||
# Log first few sentences and periodically after that
|
||||
if sentence_count <= 10 or sentence_count % 50 == 0:
|
||||
print(f"[GPS] NMEA [{sentence_count}]: {line[:70]}", flush=True)
|
||||
|
||||
position = parse_nmea_sentence(line)
|
||||
if position:
|
||||
print(f"[GPS] FIX: {position.latitude:.6f}, {position.longitude:.6f} (sats: {position.satellites}, quality: {position.fix_quality})", flush=True)
|
||||
position.device = self.device_path
|
||||
self._update_position(position)
|
||||
else:
|
||||
time.sleep(0.1)
|
||||
|
||||
except serial.SerialException as e:
|
||||
logger.error(f"GPS read error: {e}")
|
||||
with self._lock:
|
||||
self._error = str(e)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug(f"GPS parse error: {e}")
|
||||
|
||||
def _update_position(self, position: GPSPosition) -> None:
|
||||
"""Update the current position and notify callbacks."""
|
||||
with self._lock:
|
||||
# Merge data from different sentence types
|
||||
if self._position:
|
||||
# Keep altitude from GGA if RMC doesn't have it
|
||||
if position.altitude is None and self._position.altitude:
|
||||
position.altitude = self._position.altitude
|
||||
# Keep satellites from GGA
|
||||
if position.satellites is None and self._position.satellites:
|
||||
position.satellites = self._position.satellites
|
||||
|
||||
self._position = position
|
||||
self._last_update = datetime.utcnow()
|
||||
self._error = None
|
||||
|
||||
# Notify callbacks
|
||||
for callback in self._callbacks:
|
||||
try:
|
||||
callback(position)
|
||||
except Exception as e:
|
||||
logger.error(f"GPS callback error: {e}")
|
||||
|
||||
|
||||
class GPSDClient:
|
||||
"""
|
||||
Connects to gpsd daemon for GPS data.
|
||||
@@ -506,14 +93,9 @@ class GPSDClient:
|
||||
|
||||
@property
|
||||
def device_path(self) -> str:
|
||||
"""Return gpsd connection info (for compatibility with GPSReader)."""
|
||||
"""Return gpsd connection info."""
|
||||
return f"gpsd://{self.host}:{self.port}"
|
||||
|
||||
@property
|
||||
def baudrate(self) -> int:
|
||||
"""Return 0 for gpsd (for compatibility with GPSReader)."""
|
||||
return 0
|
||||
|
||||
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
|
||||
"""Add a callback to be called on position updates."""
|
||||
self._callbacks.append(callback)
|
||||
@@ -667,7 +249,7 @@ class GPSDClient:
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
altitude=msg.get('alt'),
|
||||
speed=msg.get('speed'), # m/s in gpsd (not knots)
|
||||
speed=msg.get('speed'), # m/s in gpsd
|
||||
heading=msg.get('track'),
|
||||
fix_quality=mode,
|
||||
timestamp=timestamp,
|
||||
@@ -692,47 +274,15 @@ class GPSDClient:
|
||||
logger.error(f"GPS callback error: {e}")
|
||||
|
||||
|
||||
# Type alias for GPS source (either serial reader or gpsd client)
|
||||
GPSSource = Union[GPSReader, GPSDClient]
|
||||
|
||||
# Global GPS reader instance
|
||||
_gps_reader: Optional[GPSSource] = None
|
||||
# Global GPS client instance
|
||||
_gps_client: Optional[GPSDClient] = None
|
||||
_gps_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_gps_reader() -> Optional[GPSSource]:
|
||||
"""Get the global GPS reader/client instance."""
|
||||
def get_gps_reader() -> Optional[GPSDClient]:
|
||||
"""Get the global GPS client instance."""
|
||||
with _gps_lock:
|
||||
return _gps_reader
|
||||
|
||||
|
||||
def start_gps(device_path: str, baudrate: int = 9600,
|
||||
callback: Optional[Callable[[GPSPosition], None]] = None) -> bool:
|
||||
"""
|
||||
Start the global GPS reader.
|
||||
|
||||
Args:
|
||||
device_path: Path to the GPS serial device
|
||||
baudrate: Serial baudrate (default 9600)
|
||||
callback: Optional callback for position updates (registered before start to avoid race condition)
|
||||
|
||||
Returns:
|
||||
True if started successfully
|
||||
"""
|
||||
global _gps_reader
|
||||
|
||||
with _gps_lock:
|
||||
# Stop existing reader if any
|
||||
if _gps_reader:
|
||||
_gps_reader.stop()
|
||||
|
||||
_gps_reader = GPSReader(device_path, baudrate)
|
||||
|
||||
# Register callback BEFORE starting to avoid race condition
|
||||
if callback:
|
||||
_gps_reader.add_callback(callback)
|
||||
|
||||
return _gps_reader.start()
|
||||
return _gps_client
|
||||
|
||||
|
||||
def start_gpsd(host: str = 'localhost', port: int = 2947,
|
||||
@@ -748,40 +298,35 @@ def start_gpsd(host: str = 'localhost', port: int = 2947,
|
||||
Returns:
|
||||
True if started successfully
|
||||
"""
|
||||
global _gps_reader
|
||||
global _gps_client
|
||||
|
||||
with _gps_lock:
|
||||
# Stop existing reader if any
|
||||
if _gps_reader:
|
||||
_gps_reader.stop()
|
||||
# Stop existing client if any
|
||||
if _gps_client:
|
||||
_gps_client.stop()
|
||||
|
||||
_gps_reader = GPSDClient(host, port)
|
||||
_gps_client = GPSDClient(host, port)
|
||||
|
||||
# Register callback BEFORE starting to avoid race condition
|
||||
if callback:
|
||||
_gps_reader.add_callback(callback)
|
||||
_gps_client.add_callback(callback)
|
||||
|
||||
return _gps_reader.start()
|
||||
return _gps_client.start()
|
||||
|
||||
|
||||
def stop_gps() -> None:
|
||||
"""Stop the global GPS reader/client."""
|
||||
global _gps_reader
|
||||
"""Stop the global GPS client."""
|
||||
global _gps_client
|
||||
|
||||
with _gps_lock:
|
||||
if _gps_reader:
|
||||
_gps_reader.stop()
|
||||
_gps_reader = None
|
||||
if _gps_client:
|
||||
_gps_client.stop()
|
||||
_gps_client = None
|
||||
|
||||
|
||||
def get_current_position() -> Optional[GPSPosition]:
|
||||
"""Get the current GPS position from the global reader."""
|
||||
reader = get_gps_reader()
|
||||
if reader:
|
||||
return reader.position
|
||||
"""Get the current GPS position from the global client."""
|
||||
client = get_gps_reader()
|
||||
if client:
|
||||
return client.position
|
||||
return None
|
||||
|
||||
|
||||
def is_serial_available() -> bool:
|
||||
"""Check if pyserial is available."""
|
||||
return SERIAL_AVAILABLE
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
Process health monitoring and auto-restart functionality.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Callable, Dict, Optional, Any
|
||||
|
||||
logger = logging.getLogger('intercept.process_monitor')
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessInfo:
|
||||
"""Information about a monitored process."""
|
||||
name: str
|
||||
process: Any # subprocess.Popen
|
||||
started_at: datetime = field(default_factory=datetime.now)
|
||||
restart_count: int = 0
|
||||
last_restart: Optional[datetime] = None
|
||||
restart_callback: Optional[Callable] = None
|
||||
max_restarts: int = 3
|
||||
backoff_seconds: float = 5.0
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ProcessMonitor:
|
||||
"""
|
||||
Monitor and auto-restart processes.
|
||||
|
||||
Usage:
|
||||
monitor = ProcessMonitor()
|
||||
monitor.register('pager', process, restart_callback=start_pager)
|
||||
monitor.start()
|
||||
"""
|
||||
|
||||
def __init__(self, check_interval: float = 5.0):
|
||||
self.processes: Dict[str, ProcessInfo] = {}
|
||||
self.check_interval = check_interval
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
process: Any,
|
||||
restart_callback: Optional[Callable] = None,
|
||||
max_restarts: int = 3,
|
||||
backoff_seconds: float = 5.0
|
||||
) -> None:
|
||||
"""
|
||||
Register a process for monitoring.
|
||||
|
||||
Args:
|
||||
name: Unique name for the process
|
||||
process: The subprocess.Popen object
|
||||
restart_callback: Function to call to restart the process
|
||||
max_restarts: Maximum number of automatic restarts
|
||||
backoff_seconds: Base backoff time between restarts
|
||||
"""
|
||||
with self._lock:
|
||||
self.processes[name] = ProcessInfo(
|
||||
name=name,
|
||||
process=process,
|
||||
restart_callback=restart_callback,
|
||||
max_restarts=max_restarts,
|
||||
backoff_seconds=backoff_seconds
|
||||
)
|
||||
logger.info(f"Registered process for monitoring: {name}")
|
||||
|
||||
def unregister(self, name: str) -> None:
|
||||
"""Remove a process from monitoring."""
|
||||
with self._lock:
|
||||
if name in self.processes:
|
||||
del self.processes[name]
|
||||
logger.info(f"Unregistered process: {name}")
|
||||
|
||||
def update_process(self, name: str, process: Any) -> None:
|
||||
"""Update the process object for a registered name."""
|
||||
with self._lock:
|
||||
if name in self.processes:
|
||||
self.processes[name].process = process
|
||||
self.processes[name].started_at = datetime.now()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the monitoring thread."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||||
self._thread.start()
|
||||
logger.info("Process monitor started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the monitoring thread."""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=self.check_interval + 1)
|
||||
logger.info("Process monitor stopped")
|
||||
|
||||
def _monitor_loop(self) -> None:
|
||||
"""Main monitoring loop."""
|
||||
while self._running:
|
||||
self._check_all_processes()
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
def _check_all_processes(self) -> None:
|
||||
"""Check health of all registered processes."""
|
||||
with self._lock:
|
||||
for name, info in list(self.processes.items()):
|
||||
if not info.enabled:
|
||||
continue
|
||||
|
||||
if info.process is None:
|
||||
continue
|
||||
|
||||
# Check if process has terminated
|
||||
return_code = info.process.poll()
|
||||
if return_code is not None:
|
||||
logger.warning(
|
||||
f"Process '{name}' terminated with code {return_code}"
|
||||
)
|
||||
self._handle_crash(name, info)
|
||||
|
||||
def _handle_crash(self, name: str, info: ProcessInfo) -> None:
|
||||
"""Handle a crashed process."""
|
||||
if info.restart_callback is None:
|
||||
logger.info(f"No restart callback for '{name}', skipping auto-restart")
|
||||
return
|
||||
|
||||
if info.restart_count >= info.max_restarts:
|
||||
logger.error(
|
||||
f"Process '{name}' exceeded max restarts ({info.max_restarts}), "
|
||||
"disabling auto-restart"
|
||||
)
|
||||
info.enabled = False
|
||||
return
|
||||
|
||||
# Calculate backoff with exponential increase
|
||||
backoff = info.backoff_seconds * (2 ** info.restart_count)
|
||||
logger.info(
|
||||
f"Attempting to restart '{name}' in {backoff:.1f}s "
|
||||
f"(attempt {info.restart_count + 1}/{info.max_restarts})"
|
||||
)
|
||||
|
||||
# Wait for backoff period
|
||||
time.sleep(backoff)
|
||||
|
||||
# Attempt restart
|
||||
try:
|
||||
info.restart_callback()
|
||||
info.restart_count += 1
|
||||
info.last_restart = datetime.now()
|
||||
logger.info(f"Successfully restarted '{name}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to restart '{name}': {e}")
|
||||
info.restart_count += 1
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get status of all monitored processes.
|
||||
|
||||
Returns:
|
||||
Dict with process status information
|
||||
"""
|
||||
with self._lock:
|
||||
status = {}
|
||||
for name, info in self.processes.items():
|
||||
is_running = (
|
||||
info.process is not None and
|
||||
info.process.poll() is None
|
||||
)
|
||||
status[name] = {
|
||||
'running': is_running,
|
||||
'started_at': info.started_at.isoformat() if info.started_at else None,
|
||||
'restart_count': info.restart_count,
|
||||
'last_restart': info.last_restart.isoformat() if info.last_restart else None,
|
||||
'auto_restart_enabled': info.enabled,
|
||||
'return_code': info.process.poll() if info.process else None
|
||||
}
|
||||
return status
|
||||
|
||||
def reset_restart_count(self, name: str) -> None:
|
||||
"""Reset the restart count for a process (e.g., after manual restart)."""
|
||||
with self._lock:
|
||||
if name in self.processes:
|
||||
self.processes[name].restart_count = 0
|
||||
self.processes[name].enabled = True
|
||||
|
||||
def is_healthy(self) -> bool:
|
||||
"""Check if all processes are healthy."""
|
||||
with self._lock:
|
||||
for info in self.processes.values():
|
||||
if info.process is not None and info.process.poll() is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# Global monitor instance
|
||||
process_monitor = ProcessMonitor()
|
||||
@@ -31,6 +31,7 @@ from .rtlsdr import RTLSDRCommandBuilder
|
||||
from .limesdr import LimeSDRCommandBuilder
|
||||
from .hackrf import HackRFCommandBuilder
|
||||
from .airspy import AirspyCommandBuilder
|
||||
from .sdrplay import SDRPlayCommandBuilder
|
||||
from .validation import (
|
||||
SDRValidationError,
|
||||
validate_frequency,
|
||||
@@ -51,6 +52,7 @@ class SDRFactory:
|
||||
SDRType.LIME_SDR: LimeSDRCommandBuilder,
|
||||
SDRType.HACKRF: HackRFCommandBuilder,
|
||||
SDRType.AIRSPY: AirspyCommandBuilder,
|
||||
SDRType.SDRPLAY: SDRPlayCommandBuilder,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -217,6 +219,7 @@ __all__ = [
|
||||
'LimeSDRCommandBuilder',
|
||||
'HackRFCommandBuilder',
|
||||
'AirspyCommandBuilder',
|
||||
'SDRPlayCommandBuilder',
|
||||
# Validation
|
||||
'SDRValidationError',
|
||||
'validate_frequency',
|
||||
|
||||
+15
-3
@@ -64,7 +64,8 @@ class AirspyCommandBuilder(CommandBuilder):
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
squelch: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build SoapySDR rx_fm command for FM demodulation.
|
||||
@@ -87,6 +88,9 @@ class AirspyCommandBuilder(CommandBuilder):
|
||||
if squelch is not None and squelch > 0:
|
||||
cmd.extend(['-l', str(squelch)])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
# Output to stdout
|
||||
cmd.append('-')
|
||||
|
||||
@@ -95,7 +99,8 @@ class AirspyCommandBuilder(CommandBuilder):
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
gain: Optional[float] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090/readsb command with SoapySDR support for ADS-B decoding.
|
||||
@@ -115,6 +120,9 @@ class AirspyCommandBuilder(CommandBuilder):
|
||||
if gain is not None:
|
||||
cmd.extend(['--gain', str(int(gain))])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['--enable-bias-t'])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_ism_command(
|
||||
@@ -122,7 +130,8 @@ class AirspyCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
ppm: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_433 command with SoapySDR support for ISM band decoding.
|
||||
@@ -141,6 +150,9 @@ class AirspyCommandBuilder(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
-3
@@ -19,6 +19,7 @@ class SDRType(Enum):
|
||||
LIME_SDR = "limesdr"
|
||||
HACKRF = "hackrf"
|
||||
AIRSPY = "airspy"
|
||||
SDRPLAY = "sdrplay"
|
||||
# Future support
|
||||
# USRP = "usrp"
|
||||
# BLADE_RF = "bladerf"
|
||||
@@ -93,7 +94,8 @@ class CommandBuilder(ABC):
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
squelch: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build FM demodulation command (for pager decoding).
|
||||
@@ -106,6 +108,7 @@ class CommandBuilder(ABC):
|
||||
ppm: PPM frequency correction
|
||||
modulation: Modulation type (fm, am, etc.)
|
||||
squelch: Squelch level
|
||||
bias_t: Enable bias-T power (for active antennas)
|
||||
|
||||
Returns:
|
||||
Command as list of strings for subprocess
|
||||
@@ -116,7 +119,8 @@ class CommandBuilder(ABC):
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
gain: Optional[float] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build ADS-B decoder command.
|
||||
@@ -124,6 +128,7 @@ class CommandBuilder(ABC):
|
||||
Args:
|
||||
device: The SDR device to use
|
||||
gain: Gain in dB (None for auto)
|
||||
bias_t: Enable bias-T power (for active antennas)
|
||||
|
||||
Returns:
|
||||
Command as list of strings for subprocess
|
||||
@@ -136,7 +141,8 @@ class CommandBuilder(ABC):
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
ppm: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build ISM band decoder command (433MHz sensors).
|
||||
@@ -146,6 +152,7 @@ class CommandBuilder(ABC):
|
||||
frequency_mhz: Center frequency in MHz (default 433.92)
|
||||
gain: Gain in dB (None for auto)
|
||||
ppm: PPM frequency correction
|
||||
bias_t: Enable bias-T power (for active antennas)
|
||||
|
||||
Returns:
|
||||
Command as list of strings for subprocess
|
||||
|
||||
+16
-3
@@ -29,12 +29,14 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
|
||||
from .limesdr import LimeSDRCommandBuilder
|
||||
from .hackrf import HackRFCommandBuilder
|
||||
from .airspy import AirspyCommandBuilder
|
||||
from .sdrplay import SDRPlayCommandBuilder
|
||||
|
||||
builders = {
|
||||
SDRType.RTL_SDR: RTLSDRCommandBuilder,
|
||||
SDRType.LIME_SDR: LimeSDRCommandBuilder,
|
||||
SDRType.HACKRF: HackRFCommandBuilder,
|
||||
SDRType.AIRSPY: AirspyCommandBuilder,
|
||||
SDRType.SDRPLAY: SDRPlayCommandBuilder,
|
||||
}
|
||||
|
||||
builder_class = builders.get(sdr_type)
|
||||
@@ -64,6 +66,7 @@ def _driver_to_sdr_type(driver: str) -> Optional[SDRType]:
|
||||
'hackrf': SDRType.HACKRF,
|
||||
'airspy': SDRType.AIRSPY,
|
||||
'airspyhf': SDRType.AIRSPY, # Airspy HF+ uses same builder
|
||||
'sdrplay': SDRType.SDRPLAY,
|
||||
# Future support
|
||||
# 'uhd': SDRType.USRP,
|
||||
# 'bladerf': SDRType.BLADE_RF,
|
||||
@@ -144,6 +147,15 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
||||
return devices
|
||||
|
||||
|
||||
def _find_soapy_util() -> str | None:
|
||||
"""Find SoapySDR utility command (name varies by distribution)."""
|
||||
# Try different command names used across distributions
|
||||
for cmd in ['SoapySDRUtil', 'soapy_sdr_util', 'soapysdr-util']:
|
||||
if _check_tool(cmd):
|
||||
return cmd
|
||||
return None
|
||||
|
||||
|
||||
def detect_soapy_devices(skip_types: Optional[set[SDRType]] = None) -> list[SDRDevice]:
|
||||
"""
|
||||
Detect SDR devices via SoapySDR.
|
||||
@@ -156,13 +168,14 @@ def detect_soapy_devices(skip_types: Optional[set[SDRType]] = None) -> list[SDRD
|
||||
devices: list[SDRDevice] = []
|
||||
skip_types = skip_types or set()
|
||||
|
||||
if not _check_tool('SoapySDRUtil'):
|
||||
logger.debug("SoapySDRUtil not found, skipping SoapySDR detection")
|
||||
soapy_cmd = _find_soapy_util()
|
||||
if not soapy_cmd:
|
||||
logger.debug("SoapySDR utility not found, skipping SoapySDR detection")
|
||||
return devices
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['SoapySDRUtil', '--find'],
|
||||
[soapy_cmd, '--find'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
|
||||
+18
-3
@@ -60,7 +60,8 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
squelch: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build SoapySDR rx_fm command for FM demodulation.
|
||||
@@ -84,6 +85,9 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
if squelch is not None and squelch > 0:
|
||||
cmd.extend(['-l', str(squelch)])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
# Output to stdout
|
||||
cmd.append('-')
|
||||
|
||||
@@ -92,7 +96,8 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
gain: Optional[float] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090/readsb command with SoapySDR support for ADS-B decoding.
|
||||
@@ -112,6 +117,9 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
if gain is not None:
|
||||
cmd.extend(['--gain', str(int(gain))])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['--enable-bias-t'])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_ism_command(
|
||||
@@ -119,14 +127,21 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
ppm: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
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',
|
||||
|
||||
@@ -41,12 +41,14 @@ class LimeSDRCommandBuilder(CommandBuilder):
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
squelch: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build SoapySDR rx_fm command for FM demodulation.
|
||||
|
||||
For pager decoding with LimeSDR.
|
||||
Note: LimeSDR does not support bias-T, parameter is ignored.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
@@ -73,13 +75,15 @@ class LimeSDRCommandBuilder(CommandBuilder):
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
gain: Optional[float] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090 command with SoapySDR support for ADS-B decoding.
|
||||
|
||||
Uses dump1090 compiled with SoapySDR support, or readsb as alternative.
|
||||
Note: Requires dump1090 with SoapySDR support or readsb.
|
||||
Note: LimeSDR does not support bias-T, parameter is ignored.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
@@ -102,12 +106,14 @@ class LimeSDRCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
ppm: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_433 command with SoapySDR support for ISM band decoding.
|
||||
|
||||
rtl_433 has native SoapySDR support via -d flag.
|
||||
Note: LimeSDR does not support bias-T, parameter is ignored.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
|
||||
+31
-7
@@ -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):
|
||||
@@ -45,15 +46,17 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
squelch: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_fm command for FM demodulation.
|
||||
|
||||
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,
|
||||
@@ -69,6 +72,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
if squelch is not None and squelch > 0:
|
||||
cmd.extend(['-l', str(squelch)])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
# Output to stdout for piping
|
||||
cmd.append('-')
|
||||
|
||||
@@ -77,7 +83,8 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
gain: Optional[float] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090 command for ADS-B decoding.
|
||||
@@ -94,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'
|
||||
@@ -104,6 +112,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
if gain is not None:
|
||||
cmd.extend(['--gain', str(int(gain))])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['--enable-bias-t'])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_ism_command(
|
||||
@@ -111,16 +122,29 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
ppm: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
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'
|
||||
]
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
SDRPlay command builder implementation.
|
||||
|
||||
Uses SoapySDR-based tools for FM demodulation and signal capture.
|
||||
SDRPlay RSP devices support 1 kHz to 2 GHz frequency range.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||
|
||||
|
||||
class SDRPlayCommandBuilder(CommandBuilder):
|
||||
"""SDRPlay command builder using SoapySDR tools."""
|
||||
|
||||
# SDRPlay RSP capabilities (RSPdx, RSP1A, RSPduo, etc.)
|
||||
CAPABILITIES = SDRCapabilities(
|
||||
sdr_type=SDRType.SDRPLAY,
|
||||
freq_min_mhz=0.001, # 1 kHz
|
||||
freq_max_mhz=2000.0, # 2 GHz
|
||||
gain_min=0.0,
|
||||
gain_max=59.0, # IFGR range
|
||||
sample_rates=[62500, 96000, 125000, 192000, 250000, 384000, 500000, 1000000, 2000000],
|
||||
supports_bias_t=True,
|
||||
supports_ppm=False, # SDRPlay has TCXO, no PPM needed
|
||||
tx_capable=False
|
||||
)
|
||||
|
||||
def _build_device_string(self, device: SDRDevice) -> str:
|
||||
"""Build SoapySDR device string for SDRPlay."""
|
||||
if device.serial and device.serial != 'N/A':
|
||||
return f'driver=sdrplay,serial={device.serial}'
|
||||
return 'driver=sdrplay'
|
||||
|
||||
def build_fm_demod_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float,
|
||||
sample_rate: int = 22050,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build SoapySDR rx_fm command for FM demodulation.
|
||||
|
||||
For pager decoding with SDRPlay.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'rx_fm',
|
||||
'-d', device_str,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-M', modulation,
|
||||
'-s', str(sample_rate),
|
||||
]
|
||||
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-g', f'IFGR={int(gain)}'])
|
||||
|
||||
if squelch is not None and squelch > 0:
|
||||
cmd.extend(['-l', str(squelch)])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
# Output to stdout
|
||||
cmd.append('-')
|
||||
|
||||
return cmd
|
||||
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090/readsb command with SoapySDR support for ADS-B decoding.
|
||||
|
||||
Uses readsb which has better SoapySDR support.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'readsb',
|
||||
'--net',
|
||||
'--device-type', 'soapysdr',
|
||||
'--device', device_str,
|
||||
'--quiet'
|
||||
]
|
||||
|
||||
if gain is not None:
|
||||
cmd.extend(['--gain', str(int(gain))])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['--enable-bias-t'])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_ism_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_433 command with SoapySDR support for ISM band decoding.
|
||||
|
||||
rtl_433 has native SoapySDR support via -d flag.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'rtl_433',
|
||||
'-d', device_str,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-F', 'json'
|
||||
]
|
||||
|
||||
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:
|
||||
"""Return SDRPlay capabilities."""
|
||||
return self.CAPABILITIES
|
||||
|
||||
@classmethod
|
||||
def get_sdr_type(cls) -> SDRType:
|
||||
"""Return SDR type."""
|
||||
return SDRType.SDRPLAY
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
TSCM (Technical Surveillance Countermeasures) Utilities Package
|
||||
|
||||
Provides baseline recording, threat detection, correlation analysis,
|
||||
BLE scanning, and MAC-randomization resistant device identity tools
|
||||
for counter-surveillance operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ['detector', 'baseline', 'correlation', 'ble_scanner', 'device_identity']
|
||||
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
TSCM Baseline Recording and Comparison
|
||||
|
||||
Records environment "fingerprints" and compares current scans
|
||||
against baselines to detect new or anomalous devices.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from utils.database import (
|
||||
create_tscm_baseline,
|
||||
get_active_tscm_baseline,
|
||||
get_tscm_baseline,
|
||||
update_tscm_baseline,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('intercept.tscm.baseline')
|
||||
|
||||
|
||||
class BaselineRecorder:
|
||||
"""
|
||||
Records and manages TSCM environment baselines.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.recording = False
|
||||
self.current_baseline_id: int | None = None
|
||||
self.wifi_networks: dict[str, dict] = {} # BSSID -> network info
|
||||
self.bt_devices: dict[str, dict] = {} # MAC -> device info
|
||||
self.rf_frequencies: dict[float, dict] = {} # Frequency -> signal info
|
||||
|
||||
def start_recording(
|
||||
self,
|
||||
name: str,
|
||||
location: str | None = None,
|
||||
description: str | None = None
|
||||
) -> int:
|
||||
"""
|
||||
Start recording a new baseline.
|
||||
|
||||
Args:
|
||||
name: Baseline name
|
||||
location: Optional location description
|
||||
description: Optional description
|
||||
|
||||
Returns:
|
||||
Baseline ID
|
||||
"""
|
||||
self.recording = True
|
||||
self.wifi_networks = {}
|
||||
self.bt_devices = {}
|
||||
self.rf_frequencies = {}
|
||||
|
||||
# Create baseline in database
|
||||
self.current_baseline_id = create_tscm_baseline(
|
||||
name=name,
|
||||
location=location,
|
||||
description=description
|
||||
)
|
||||
|
||||
logger.info(f"Started baseline recording: {name} (ID: {self.current_baseline_id})")
|
||||
return self.current_baseline_id
|
||||
|
||||
def stop_recording(self) -> dict:
|
||||
"""
|
||||
Stop recording and finalize baseline.
|
||||
|
||||
Returns:
|
||||
Final baseline summary
|
||||
"""
|
||||
if not self.recording or not self.current_baseline_id:
|
||||
return {'error': 'Not recording'}
|
||||
|
||||
self.recording = False
|
||||
|
||||
# Convert to lists for storage
|
||||
wifi_list = list(self.wifi_networks.values())
|
||||
bt_list = list(self.bt_devices.values())
|
||||
rf_list = list(self.rf_frequencies.values())
|
||||
|
||||
# Update database
|
||||
update_tscm_baseline(
|
||||
self.current_baseline_id,
|
||||
wifi_networks=wifi_list,
|
||||
bt_devices=bt_list,
|
||||
rf_frequencies=rf_list
|
||||
)
|
||||
|
||||
summary = {
|
||||
'baseline_id': self.current_baseline_id,
|
||||
'wifi_count': len(wifi_list),
|
||||
'bt_count': len(bt_list),
|
||||
'rf_count': len(rf_list),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Baseline recording complete: {summary['wifi_count']} WiFi, "
|
||||
f"{summary['bt_count']} BT, {summary['rf_count']} RF"
|
||||
)
|
||||
|
||||
baseline_id = self.current_baseline_id
|
||||
self.current_baseline_id = None
|
||||
|
||||
return summary
|
||||
|
||||
def add_wifi_device(self, device: dict) -> None:
|
||||
"""Add a WiFi device to the current baseline."""
|
||||
if not self.recording:
|
||||
return
|
||||
|
||||
mac = device.get('bssid', device.get('mac', '')).upper()
|
||||
if not mac:
|
||||
return
|
||||
|
||||
# Update or add device
|
||||
if mac in self.wifi_networks:
|
||||
# Update with latest info
|
||||
self.wifi_networks[mac].update({
|
||||
'last_seen': datetime.now().isoformat(),
|
||||
'power': device.get('power', self.wifi_networks[mac].get('power')),
|
||||
})
|
||||
else:
|
||||
self.wifi_networks[mac] = {
|
||||
'bssid': mac,
|
||||
'essid': device.get('essid', device.get('ssid', '')),
|
||||
'channel': device.get('channel'),
|
||||
'power': device.get('power', device.get('signal')),
|
||||
'vendor': device.get('vendor', ''),
|
||||
'encryption': device.get('privacy', device.get('encryption', '')),
|
||||
'first_seen': datetime.now().isoformat(),
|
||||
'last_seen': datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
def add_bt_device(self, device: dict) -> None:
|
||||
"""Add a Bluetooth device to the current baseline."""
|
||||
if not self.recording:
|
||||
return
|
||||
|
||||
mac = device.get('mac', device.get('address', '')).upper()
|
||||
if not mac:
|
||||
return
|
||||
|
||||
if mac in self.bt_devices:
|
||||
self.bt_devices[mac].update({
|
||||
'last_seen': datetime.now().isoformat(),
|
||||
'rssi': device.get('rssi', self.bt_devices[mac].get('rssi')),
|
||||
})
|
||||
else:
|
||||
self.bt_devices[mac] = {
|
||||
'mac': mac,
|
||||
'name': device.get('name', ''),
|
||||
'rssi': device.get('rssi', device.get('signal')),
|
||||
'manufacturer': device.get('manufacturer', ''),
|
||||
'type': device.get('type', ''),
|
||||
'first_seen': datetime.now().isoformat(),
|
||||
'last_seen': datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
def add_rf_signal(self, signal: dict) -> None:
|
||||
"""Add an RF signal to the current baseline."""
|
||||
if not self.recording:
|
||||
return
|
||||
|
||||
frequency = signal.get('frequency')
|
||||
if not frequency:
|
||||
return
|
||||
|
||||
# Round to 0.1 MHz for grouping
|
||||
freq_key = round(frequency, 1)
|
||||
|
||||
if freq_key in self.rf_frequencies:
|
||||
existing = self.rf_frequencies[freq_key]
|
||||
existing['last_seen'] = datetime.now().isoformat()
|
||||
existing['hit_count'] = existing.get('hit_count', 1) + 1
|
||||
# Update max signal level
|
||||
new_level = signal.get('level', signal.get('power', -100))
|
||||
if new_level > existing.get('max_level', -100):
|
||||
existing['max_level'] = new_level
|
||||
else:
|
||||
self.rf_frequencies[freq_key] = {
|
||||
'frequency': freq_key,
|
||||
'level': signal.get('level', signal.get('power')),
|
||||
'max_level': signal.get('level', signal.get('power', -100)),
|
||||
'modulation': signal.get('modulation', ''),
|
||||
'first_seen': datetime.now().isoformat(),
|
||||
'last_seen': datetime.now().isoformat(),
|
||||
'hit_count': 1,
|
||||
}
|
||||
|
||||
def get_recording_status(self) -> dict:
|
||||
"""Get current recording status and counts."""
|
||||
return {
|
||||
'recording': self.recording,
|
||||
'baseline_id': self.current_baseline_id,
|
||||
'wifi_count': len(self.wifi_networks),
|
||||
'bt_count': len(self.bt_devices),
|
||||
'rf_count': len(self.rf_frequencies),
|
||||
}
|
||||
|
||||
|
||||
class BaselineComparator:
|
||||
"""
|
||||
Compares current scan results against a baseline.
|
||||
"""
|
||||
|
||||
def __init__(self, baseline: dict):
|
||||
"""
|
||||
Initialize comparator with a baseline.
|
||||
|
||||
Args:
|
||||
baseline: Baseline dict from database
|
||||
"""
|
||||
self.baseline = baseline
|
||||
self.baseline_wifi = {
|
||||
d.get('bssid', d.get('mac', '')).upper(): d
|
||||
for d in baseline.get('wifi_networks', [])
|
||||
if d.get('bssid') or d.get('mac')
|
||||
}
|
||||
self.baseline_bt = {
|
||||
d.get('mac', d.get('address', '')).upper(): d
|
||||
for d in baseline.get('bt_devices', [])
|
||||
if d.get('mac') or d.get('address')
|
||||
}
|
||||
self.baseline_rf = {
|
||||
round(d.get('frequency', 0), 1): d
|
||||
for d in baseline.get('rf_frequencies', [])
|
||||
if d.get('frequency')
|
||||
}
|
||||
|
||||
def compare_wifi(self, current_devices: list[dict]) -> dict:
|
||||
"""
|
||||
Compare current WiFi devices against baseline.
|
||||
|
||||
Returns:
|
||||
Dict with new, missing, and matching devices
|
||||
"""
|
||||
current_macs = {
|
||||
d.get('bssid', d.get('mac', '')).upper(): d
|
||||
for d in current_devices
|
||||
if d.get('bssid') or d.get('mac')
|
||||
}
|
||||
|
||||
new_devices = []
|
||||
missing_devices = []
|
||||
matching_devices = []
|
||||
|
||||
# Find new devices
|
||||
for mac, device in current_macs.items():
|
||||
if mac not in self.baseline_wifi:
|
||||
new_devices.append(device)
|
||||
else:
|
||||
matching_devices.append(device)
|
||||
|
||||
# Find missing devices
|
||||
for mac, device in self.baseline_wifi.items():
|
||||
if mac not in current_macs:
|
||||
missing_devices.append(device)
|
||||
|
||||
return {
|
||||
'new': new_devices,
|
||||
'missing': missing_devices,
|
||||
'matching': matching_devices,
|
||||
'new_count': len(new_devices),
|
||||
'missing_count': len(missing_devices),
|
||||
'matching_count': len(matching_devices),
|
||||
}
|
||||
|
||||
def compare_bluetooth(self, current_devices: list[dict]) -> dict:
|
||||
"""Compare current Bluetooth devices against baseline."""
|
||||
current_macs = {
|
||||
d.get('mac', d.get('address', '')).upper(): d
|
||||
for d in current_devices
|
||||
if d.get('mac') or d.get('address')
|
||||
}
|
||||
|
||||
new_devices = []
|
||||
missing_devices = []
|
||||
matching_devices = []
|
||||
|
||||
for mac, device in current_macs.items():
|
||||
if mac not in self.baseline_bt:
|
||||
new_devices.append(device)
|
||||
else:
|
||||
matching_devices.append(device)
|
||||
|
||||
for mac, device in self.baseline_bt.items():
|
||||
if mac not in current_macs:
|
||||
missing_devices.append(device)
|
||||
|
||||
return {
|
||||
'new': new_devices,
|
||||
'missing': missing_devices,
|
||||
'matching': matching_devices,
|
||||
'new_count': len(new_devices),
|
||||
'missing_count': len(missing_devices),
|
||||
'matching_count': len(matching_devices),
|
||||
}
|
||||
|
||||
def compare_rf(self, current_signals: list[dict]) -> dict:
|
||||
"""Compare current RF signals against baseline."""
|
||||
current_freqs = {
|
||||
round(s.get('frequency', 0), 1): s
|
||||
for s in current_signals
|
||||
if s.get('frequency')
|
||||
}
|
||||
|
||||
new_signals = []
|
||||
missing_signals = []
|
||||
matching_signals = []
|
||||
|
||||
for freq, signal in current_freqs.items():
|
||||
if freq not in self.baseline_rf:
|
||||
new_signals.append(signal)
|
||||
else:
|
||||
matching_signals.append(signal)
|
||||
|
||||
for freq, signal in self.baseline_rf.items():
|
||||
if freq not in current_freqs:
|
||||
missing_signals.append(signal)
|
||||
|
||||
return {
|
||||
'new': new_signals,
|
||||
'missing': missing_signals,
|
||||
'matching': matching_signals,
|
||||
'new_count': len(new_signals),
|
||||
'missing_count': len(missing_signals),
|
||||
'matching_count': len(matching_signals),
|
||||
}
|
||||
|
||||
def compare_all(
|
||||
self,
|
||||
wifi_devices: list[dict] | None = None,
|
||||
bt_devices: list[dict] | None = None,
|
||||
rf_signals: list[dict] | None = None
|
||||
) -> dict:
|
||||
"""
|
||||
Compare all current data against baseline.
|
||||
|
||||
Returns:
|
||||
Dict with comparison results for each category
|
||||
"""
|
||||
results = {
|
||||
'wifi': None,
|
||||
'bluetooth': None,
|
||||
'rf': None,
|
||||
'total_new': 0,
|
||||
'total_missing': 0,
|
||||
}
|
||||
|
||||
if wifi_devices is not None:
|
||||
results['wifi'] = self.compare_wifi(wifi_devices)
|
||||
results['total_new'] += results['wifi']['new_count']
|
||||
results['total_missing'] += results['wifi']['missing_count']
|
||||
|
||||
if bt_devices is not None:
|
||||
results['bluetooth'] = self.compare_bluetooth(bt_devices)
|
||||
results['total_new'] += results['bluetooth']['new_count']
|
||||
results['total_missing'] += results['bluetooth']['missing_count']
|
||||
|
||||
if rf_signals is not None:
|
||||
results['rf'] = self.compare_rf(rf_signals)
|
||||
results['total_new'] += results['rf']['new_count']
|
||||
results['total_missing'] += results['rf']['missing_count']
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_comparison_for_active_baseline(
|
||||
wifi_devices: list[dict] | None = None,
|
||||
bt_devices: list[dict] | None = None,
|
||||
rf_signals: list[dict] | None = None
|
||||
) -> dict | None:
|
||||
"""
|
||||
Convenience function to compare against the active baseline.
|
||||
|
||||
Returns:
|
||||
Comparison results or None if no active baseline
|
||||
"""
|
||||
baseline = get_active_tscm_baseline()
|
||||
if not baseline:
|
||||
return None
|
||||
|
||||
comparator = BaselineComparator(baseline)
|
||||
return comparator.compare_all(wifi_devices, bt_devices, rf_signals)
|
||||
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
BLE Scanner for TSCM
|
||||
|
||||
Cross-platform BLE scanning with manufacturer data detection.
|
||||
Supports macOS and Linux using the bleak library with fallback to system tools.
|
||||
|
||||
Detects:
|
||||
- Apple AirTags (company ID 0x004C)
|
||||
- Tile trackers
|
||||
- Samsung SmartTags
|
||||
- ESP32/ESP8266 devices (Espressif, company ID 0x02E5)
|
||||
- Generic BLE devices with suspicious characteristics
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger('intercept.tscm.ble')
|
||||
|
||||
# Manufacturer company IDs (Bluetooth SIG assigned)
|
||||
COMPANY_IDS = {
|
||||
0x004C: 'Apple',
|
||||
0x02E5: 'Espressif',
|
||||
0x0059: 'Nordic Semiconductor',
|
||||
0x000D: 'Texas Instruments',
|
||||
0x0075: 'Samsung',
|
||||
0x00E0: 'Google',
|
||||
0x0006: 'Microsoft',
|
||||
0x01DA: 'Tile',
|
||||
}
|
||||
|
||||
# Known tracker signatures
|
||||
TRACKER_SIGNATURES = {
|
||||
# Apple AirTag detection patterns
|
||||
'airtag': {
|
||||
'company_id': 0x004C,
|
||||
'data_patterns': [
|
||||
b'\x12\x19', # AirTag/Find My advertisement prefix
|
||||
b'\x07\x19', # Offline Finding
|
||||
],
|
||||
'name_patterns': ['airtag', 'findmy', 'find my'],
|
||||
},
|
||||
# Tile tracker
|
||||
'tile': {
|
||||
'company_id': 0x01DA,
|
||||
'name_patterns': ['tile'],
|
||||
},
|
||||
# Samsung SmartTag
|
||||
'smarttag': {
|
||||
'company_id': 0x0075,
|
||||
'name_patterns': ['smarttag', 'smart tag', 'galaxy smart'],
|
||||
},
|
||||
# ESP32/ESP8266
|
||||
'espressif': {
|
||||
'company_id': 0x02E5,
|
||||
'name_patterns': ['esp32', 'esp8266', 'espressif'],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BLEDevice:
|
||||
"""Represents a detected BLE device with full advertisement data."""
|
||||
mac: str
|
||||
name: Optional[str] = None
|
||||
rssi: Optional[int] = None
|
||||
manufacturer_id: Optional[int] = None
|
||||
manufacturer_name: Optional[str] = None
|
||||
manufacturer_data: bytes = field(default_factory=bytes)
|
||||
service_uuids: list = field(default_factory=list)
|
||||
tx_power: Optional[int] = None
|
||||
is_connectable: bool = True
|
||||
|
||||
# Detection flags
|
||||
is_airtag: bool = False
|
||||
is_tile: bool = False
|
||||
is_smarttag: bool = False
|
||||
is_espressif: bool = False
|
||||
is_tracker: bool = False
|
||||
tracker_type: Optional[str] = None
|
||||
|
||||
first_seen: datetime = field(default_factory=datetime.now)
|
||||
last_seen: datetime = field(default_factory=datetime.now)
|
||||
detection_count: int = 1
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'mac': self.mac,
|
||||
'name': self.name or 'Unknown',
|
||||
'rssi': self.rssi,
|
||||
'manufacturer_id': self.manufacturer_id,
|
||||
'manufacturer_name': self.manufacturer_name,
|
||||
'service_uuids': self.service_uuids,
|
||||
'tx_power': self.tx_power,
|
||||
'is_connectable': self.is_connectable,
|
||||
'is_airtag': self.is_airtag,
|
||||
'is_tile': self.is_tile,
|
||||
'is_smarttag': self.is_smarttag,
|
||||
'is_espressif': self.is_espressif,
|
||||
'is_tracker': self.is_tracker,
|
||||
'tracker_type': self.tracker_type,
|
||||
'detection_count': self.detection_count,
|
||||
'type': 'ble',
|
||||
}
|
||||
|
||||
|
||||
class BLEScanner:
|
||||
"""
|
||||
Cross-platform BLE scanner with manufacturer data detection.
|
||||
|
||||
Uses bleak library for proper BLE scanning, with fallback to
|
||||
system tools (hcitool/btmgmt on Linux, system_profiler on macOS).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.devices: dict[str, BLEDevice] = {}
|
||||
self._bleak_available = self._check_bleak()
|
||||
self._scanning = False
|
||||
|
||||
def _check_bleak(self) -> bool:
|
||||
"""Check if bleak library is available."""
|
||||
try:
|
||||
import bleak
|
||||
return True
|
||||
except ImportError:
|
||||
logger.warning("bleak library not available - using fallback scanning")
|
||||
return False
|
||||
|
||||
async def scan_async(self, duration: int = 10) -> list[BLEDevice]:
|
||||
"""
|
||||
Perform async BLE scan using bleak.
|
||||
|
||||
Args:
|
||||
duration: Scan duration in seconds
|
||||
|
||||
Returns:
|
||||
List of detected BLE devices
|
||||
"""
|
||||
if not self._bleak_available:
|
||||
# Use synchronous fallback
|
||||
return self._scan_fallback(duration)
|
||||
|
||||
try:
|
||||
from bleak import BleakScanner
|
||||
from bleak.backends.device import BLEDevice as BleakDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
|
||||
detected = {}
|
||||
|
||||
def detection_callback(device: BleakDevice, adv_data: AdvertisementData):
|
||||
"""Callback for each detected device."""
|
||||
mac = device.address.upper()
|
||||
|
||||
if mac in detected:
|
||||
# Update existing device
|
||||
detected[mac].rssi = adv_data.rssi
|
||||
detected[mac].last_seen = datetime.now()
|
||||
detected[mac].detection_count += 1
|
||||
else:
|
||||
# Create new device entry
|
||||
ble_device = BLEDevice(
|
||||
mac=mac,
|
||||
name=adv_data.local_name or device.name,
|
||||
rssi=adv_data.rssi,
|
||||
service_uuids=list(adv_data.service_uuids) if adv_data.service_uuids else [],
|
||||
tx_power=adv_data.tx_power,
|
||||
)
|
||||
|
||||
# Parse manufacturer data
|
||||
if adv_data.manufacturer_data:
|
||||
for company_id, data in adv_data.manufacturer_data.items():
|
||||
ble_device.manufacturer_id = company_id
|
||||
ble_device.manufacturer_name = COMPANY_IDS.get(company_id, f'Unknown ({hex(company_id)})')
|
||||
ble_device.manufacturer_data = bytes(data)
|
||||
|
||||
# Check for known trackers
|
||||
self._identify_tracker(ble_device, company_id, data)
|
||||
|
||||
# Also check name patterns
|
||||
self._check_name_patterns(ble_device)
|
||||
|
||||
detected[mac] = ble_device
|
||||
|
||||
logger.info(f"Starting BLE scan with bleak (duration={duration}s)")
|
||||
|
||||
scanner = BleakScanner(detection_callback=detection_callback)
|
||||
await scanner.start()
|
||||
await asyncio.sleep(duration)
|
||||
await scanner.stop()
|
||||
|
||||
# Update internal device list
|
||||
for mac, device in detected.items():
|
||||
if mac in self.devices:
|
||||
self.devices[mac].rssi = device.rssi
|
||||
self.devices[mac].last_seen = device.last_seen
|
||||
self.devices[mac].detection_count += 1
|
||||
else:
|
||||
self.devices[mac] = device
|
||||
|
||||
logger.info(f"BLE scan complete: {len(detected)} devices found")
|
||||
return list(detected.values())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Bleak scan failed: {e}")
|
||||
return self._scan_fallback(duration)
|
||||
|
||||
def scan(self, duration: int = 10) -> list[BLEDevice]:
|
||||
"""
|
||||
Synchronous wrapper for BLE scanning.
|
||||
|
||||
Args:
|
||||
duration: Scan duration in seconds
|
||||
|
||||
Returns:
|
||||
List of detected BLE devices
|
||||
"""
|
||||
if self._bleak_available:
|
||||
try:
|
||||
# Try to get existing event loop
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# We're in an async context, can't use run()
|
||||
future = asyncio.ensure_future(self.scan_async(duration))
|
||||
return asyncio.get_event_loop().run_until_complete(future)
|
||||
except RuntimeError:
|
||||
# No running loop, create one
|
||||
return asyncio.run(self.scan_async(duration))
|
||||
except Exception as e:
|
||||
logger.error(f"Async scan failed: {e}")
|
||||
return self._scan_fallback(duration)
|
||||
else:
|
||||
return self._scan_fallback(duration)
|
||||
|
||||
def _identify_tracker(self, device: BLEDevice, company_id: int, data: bytes):
|
||||
"""Identify if device is a known tracker type."""
|
||||
|
||||
# Apple AirTag detection
|
||||
if company_id == 0x004C: # Apple
|
||||
# Check for Find My / AirTag advertisement patterns
|
||||
if len(data) >= 2:
|
||||
# AirTag advertisements have specific byte patterns
|
||||
if data[0] == 0x12 and data[1] == 0x19:
|
||||
device.is_airtag = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'AirTag'
|
||||
logger.info(f"AirTag detected: {device.mac}")
|
||||
elif data[0] == 0x07: # Offline Finding
|
||||
device.is_airtag = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'AirTag (Offline)'
|
||||
logger.info(f"AirTag (offline mode) detected: {device.mac}")
|
||||
|
||||
# Tile tracker
|
||||
elif company_id == 0x01DA: # Tile
|
||||
device.is_tile = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'Tile'
|
||||
logger.info(f"Tile tracker detected: {device.mac}")
|
||||
|
||||
# Samsung SmartTag
|
||||
elif company_id == 0x0075: # Samsung
|
||||
# Check if it's specifically a SmartTag
|
||||
device.is_smarttag = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'SmartTag'
|
||||
logger.info(f"Samsung SmartTag detected: {device.mac}")
|
||||
|
||||
# Espressif (ESP32/ESP8266)
|
||||
elif company_id == 0x02E5: # Espressif
|
||||
device.is_espressif = True
|
||||
device.tracker_type = 'ESP32/ESP8266'
|
||||
logger.info(f"ESP32/ESP8266 device detected: {device.mac}")
|
||||
|
||||
def _check_name_patterns(self, device: BLEDevice):
|
||||
"""Check device name for tracker patterns."""
|
||||
if not device.name:
|
||||
return
|
||||
|
||||
name_lower = device.name.lower()
|
||||
|
||||
# Check each tracker type
|
||||
for tracker_type, sig in TRACKER_SIGNATURES.items():
|
||||
patterns = sig.get('name_patterns', [])
|
||||
for pattern in patterns:
|
||||
if pattern in name_lower:
|
||||
if tracker_type == 'airtag':
|
||||
device.is_airtag = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'AirTag'
|
||||
elif tracker_type == 'tile':
|
||||
device.is_tile = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'Tile'
|
||||
elif tracker_type == 'smarttag':
|
||||
device.is_smarttag = True
|
||||
device.is_tracker = True
|
||||
device.tracker_type = 'SmartTag'
|
||||
elif tracker_type == 'espressif':
|
||||
device.is_espressif = True
|
||||
device.tracker_type = 'ESP32/ESP8266'
|
||||
|
||||
logger.info(f"Tracker identified by name: {device.name} -> {tracker_type}")
|
||||
return
|
||||
|
||||
def _scan_fallback(self, duration: int = 10) -> list[BLEDevice]:
|
||||
"""
|
||||
Fallback scanning using system tools when bleak is unavailable.
|
||||
Works on both macOS and Linux.
|
||||
"""
|
||||
system = platform.system()
|
||||
|
||||
if system == 'Darwin':
|
||||
return self._scan_macos(duration)
|
||||
else:
|
||||
return self._scan_linux(duration)
|
||||
|
||||
def _scan_macos(self, duration: int = 10) -> list[BLEDevice]:
|
||||
"""Fallback BLE scanning on macOS using system_profiler."""
|
||||
devices = []
|
||||
|
||||
try:
|
||||
import json
|
||||
result = subprocess.run(
|
||||
['system_profiler', 'SPBluetoothDataType', '-json'],
|
||||
capture_output=True, text=True, timeout=15
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
bt_data = data.get('SPBluetoothDataType', [{}])[0]
|
||||
|
||||
# Get connected/paired devices
|
||||
for section in ['device_connected', 'device_title']:
|
||||
section_data = bt_data.get(section, {})
|
||||
if isinstance(section_data, dict):
|
||||
for name, info in section_data.items():
|
||||
if isinstance(info, dict):
|
||||
mac = info.get('device_address', '').upper()
|
||||
if mac:
|
||||
device = BLEDevice(
|
||||
mac=mac,
|
||||
name=name,
|
||||
)
|
||||
# Check name patterns
|
||||
self._check_name_patterns(device)
|
||||
devices.append(device)
|
||||
|
||||
logger.info(f"macOS fallback scan found {len(devices)} devices")
|
||||
except Exception as e:
|
||||
logger.error(f"macOS fallback scan failed: {e}")
|
||||
|
||||
return devices
|
||||
|
||||
def _scan_linux(self, duration: int = 10) -> list[BLEDevice]:
|
||||
"""Fallback BLE scanning on Linux using bluetoothctl/btmgmt."""
|
||||
import shutil
|
||||
|
||||
devices = []
|
||||
seen_macs = set()
|
||||
|
||||
# Method 1: Try btmgmt for BLE devices
|
||||
if shutil.which('btmgmt'):
|
||||
try:
|
||||
logger.info("Trying btmgmt find...")
|
||||
result = subprocess.run(
|
||||
['btmgmt', 'find'],
|
||||
capture_output=True, text=True, timeout=duration + 5
|
||||
)
|
||||
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'dev_found' in line.lower() or ('type' in line.lower() and ':' in line):
|
||||
mac_match = re.search(
|
||||
r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:'
|
||||
r'[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})',
|
||||
line
|
||||
)
|
||||
if mac_match:
|
||||
mac = mac_match.group(1).upper()
|
||||
if mac not in seen_macs:
|
||||
seen_macs.add(mac)
|
||||
name_match = re.search(r'name\s+(.+?)(?:\s|$)', line, re.I)
|
||||
name = name_match.group(1) if name_match else None
|
||||
|
||||
device = BLEDevice(mac=mac, name=name)
|
||||
self._check_name_patterns(device)
|
||||
devices.append(device)
|
||||
|
||||
logger.info(f"btmgmt found {len(devices)} devices")
|
||||
except Exception as e:
|
||||
logger.warning(f"btmgmt failed: {e}")
|
||||
|
||||
# Method 2: Try hcitool lescan
|
||||
if not devices and shutil.which('hcitool'):
|
||||
try:
|
||||
logger.info("Trying hcitool lescan...")
|
||||
# Start lescan in background
|
||||
process = subprocess.Popen(
|
||||
['hcitool', 'lescan', '--duplicates'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
|
||||
import time
|
||||
time.sleep(duration)
|
||||
process.terminate()
|
||||
|
||||
stdout, _ = process.communicate(timeout=2)
|
||||
|
||||
for line in stdout.split('\n'):
|
||||
mac_match = re.search(
|
||||
r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:'
|
||||
r'[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})',
|
||||
line
|
||||
)
|
||||
if mac_match:
|
||||
mac = mac_match.group(1).upper()
|
||||
if mac not in seen_macs:
|
||||
seen_macs.add(mac)
|
||||
# Extract name (comes after MAC)
|
||||
parts = line.strip().split()
|
||||
name = ' '.join(parts[1:]) if len(parts) > 1 else None
|
||||
|
||||
device = BLEDevice(mac=mac, name=name if name != '(unknown)' else None)
|
||||
self._check_name_patterns(device)
|
||||
devices.append(device)
|
||||
|
||||
logger.info(f"hcitool lescan found {len(devices)} devices")
|
||||
except Exception as e:
|
||||
logger.warning(f"hcitool lescan failed: {e}")
|
||||
|
||||
return devices
|
||||
|
||||
def get_trackers(self) -> list[BLEDevice]:
|
||||
"""Get all detected tracker devices."""
|
||||
return [d for d in self.devices.values() if d.is_tracker]
|
||||
|
||||
def get_espressif_devices(self) -> list[BLEDevice]:
|
||||
"""Get all detected ESP32/ESP8266 devices."""
|
||||
return [d for d in self.devices.values() if d.is_espressif]
|
||||
|
||||
def clear(self):
|
||||
"""Clear all detected devices."""
|
||||
self.devices.clear()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_scanner: Optional[BLEScanner] = None
|
||||
|
||||
|
||||
def get_ble_scanner() -> BLEScanner:
|
||||
"""Get the global BLE scanner instance."""
|
||||
global _scanner
|
||||
if _scanner is None:
|
||||
_scanner = BLEScanner()
|
||||
return _scanner
|
||||
|
||||
|
||||
def scan_ble_devices(duration: int = 10) -> list[dict]:
|
||||
"""
|
||||
Convenience function to scan for BLE devices.
|
||||
|
||||
Args:
|
||||
duration: Scan duration in seconds
|
||||
|
||||
Returns:
|
||||
List of device dictionaries
|
||||
"""
|
||||
scanner = get_ble_scanner()
|
||||
devices = scanner.scan(duration)
|
||||
return [d.to_dict() for d in devices]
|
||||
@@ -0,0 +1,959 @@
|
||||
"""
|
||||
TSCM Cross-Protocol Correlation Engine
|
||||
|
||||
Correlates Bluetooth, Wi-Fi, and RF indicators to detect potential surveillance activity.
|
||||
Implements scoring model for risk assessment and provides actionable intelligence.
|
||||
|
||||
DISCLAIMER: This system performs wireless and RF surveillance screening.
|
||||
Findings indicate anomalies and indicators, not confirmed surveillance devices.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger('intercept.tscm.correlation')
|
||||
|
||||
|
||||
class RiskLevel(Enum):
|
||||
"""Risk classification levels."""
|
||||
INFORMATIONAL = 'informational' # Score 0-2
|
||||
NEEDS_REVIEW = 'review' # Score 3-5
|
||||
HIGH_INTEREST = 'high_interest' # Score 6+
|
||||
|
||||
|
||||
class IndicatorType(Enum):
|
||||
"""Types of risk indicators."""
|
||||
UNKNOWN_DEVICE = 'unknown_device'
|
||||
AUDIO_CAPABLE = 'audio_capable'
|
||||
PERSISTENT = 'persistent'
|
||||
MEETING_CORRELATED = 'meeting_correlated'
|
||||
CROSS_PROTOCOL = 'cross_protocol'
|
||||
HIDDEN_IDENTITY = 'hidden_identity'
|
||||
ROGUE_AP = 'rogue_ap'
|
||||
BURST_TRANSMISSION = 'burst_transmission'
|
||||
STABLE_RSSI = 'stable_rssi'
|
||||
HIGH_FREQ_ADVERTISING = 'high_freq_advertising'
|
||||
MAC_ROTATION = 'mac_rotation'
|
||||
NARROWBAND_SIGNAL = 'narrowband_signal'
|
||||
ALWAYS_ON_CARRIER = 'always_on_carrier'
|
||||
# Tracker-specific indicators
|
||||
KNOWN_TRACKER = 'known_tracker'
|
||||
AIRTAG_DETECTED = 'airtag_detected'
|
||||
TILE_DETECTED = 'tile_detected'
|
||||
SMARTTAG_DETECTED = 'smarttag_detected'
|
||||
ESP32_DEVICE = 'esp32_device'
|
||||
GENERIC_CHIPSET = 'generic_chipset'
|
||||
|
||||
|
||||
# Scoring weights for each indicator
|
||||
INDICATOR_SCORES = {
|
||||
IndicatorType.UNKNOWN_DEVICE: 1,
|
||||
IndicatorType.AUDIO_CAPABLE: 2,
|
||||
IndicatorType.PERSISTENT: 2,
|
||||
IndicatorType.MEETING_CORRELATED: 2,
|
||||
IndicatorType.CROSS_PROTOCOL: 3,
|
||||
IndicatorType.HIDDEN_IDENTITY: 2,
|
||||
IndicatorType.ROGUE_AP: 3,
|
||||
IndicatorType.BURST_TRANSMISSION: 2,
|
||||
IndicatorType.STABLE_RSSI: 1,
|
||||
IndicatorType.HIGH_FREQ_ADVERTISING: 1,
|
||||
IndicatorType.MAC_ROTATION: 1,
|
||||
IndicatorType.NARROWBAND_SIGNAL: 2,
|
||||
IndicatorType.ALWAYS_ON_CARRIER: 2,
|
||||
# Tracker scores - higher for covert tracking devices
|
||||
IndicatorType.KNOWN_TRACKER: 3,
|
||||
IndicatorType.AIRTAG_DETECTED: 3,
|
||||
IndicatorType.TILE_DETECTED: 2,
|
||||
IndicatorType.SMARTTAG_DETECTED: 2,
|
||||
IndicatorType.ESP32_DEVICE: 2,
|
||||
IndicatorType.GENERIC_CHIPSET: 1,
|
||||
}
|
||||
|
||||
|
||||
# Known tracker device signatures
|
||||
TRACKER_SIGNATURES = {
|
||||
# Apple AirTag - OUI prefixes
|
||||
'airtag_oui': ['4C:E6:76', '7C:04:D0', 'DC:A4:CA', 'F0:B3:EC'],
|
||||
# Tile trackers
|
||||
'tile_oui': ['D0:03:DF', 'EC:2E:4E'],
|
||||
# Samsung SmartTag
|
||||
'smarttag_oui': ['8C:71:F8', 'CC:2D:83', 'F0:5C:D5'],
|
||||
# ESP32/ESP8266 Espressif chipsets
|
||||
'espressif_oui': ['24:0A:C4', '24:6F:28', '24:62:AB', '30:AE:A4',
|
||||
'3C:61:05', '3C:71:BF', '40:F5:20', '48:3F:DA',
|
||||
'4C:11:AE', '54:43:B2', '58:BF:25', '5C:CF:7F',
|
||||
'60:01:94', '68:C6:3A', '7C:9E:BD', '84:0D:8E',
|
||||
'84:CC:A8', '84:F3:EB', '8C:AA:B5', '90:38:0C',
|
||||
'94:B5:55', '98:CD:AC', 'A4:7B:9D', 'A4:CF:12',
|
||||
'AC:67:B2', 'B4:E6:2D', 'BC:DD:C2', 'C4:4F:33',
|
||||
'C8:2B:96', 'CC:50:E3', 'D8:A0:1D', 'DC:4F:22',
|
||||
'E0:98:06', 'E8:68:E7', 'EC:FA:BC', 'F4:CF:A2'],
|
||||
# Generic/suspicious chipset vendors (potential covert devices)
|
||||
'generic_chipset_oui': [
|
||||
'00:1A:7D', # cyber-blue(HK)
|
||||
'00:25:00', # Apple (but generic BLE)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Indicator:
|
||||
"""A single risk indicator."""
|
||||
type: IndicatorType
|
||||
description: str
|
||||
score: int
|
||||
details: dict = field(default_factory=dict)
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceProfile:
|
||||
"""Complete profile for a detected device."""
|
||||
# Identity
|
||||
identifier: str # MAC, BSSID, or frequency
|
||||
protocol: str # 'bluetooth', 'wifi', 'rf'
|
||||
|
||||
# Device info
|
||||
name: Optional[str] = None
|
||||
manufacturer: Optional[str] = None
|
||||
device_type: Optional[str] = None
|
||||
|
||||
# Bluetooth-specific
|
||||
services: list[str] = field(default_factory=list)
|
||||
company_id: Optional[int] = None
|
||||
advertising_interval: Optional[int] = None
|
||||
|
||||
# Wi-Fi-specific
|
||||
ssid: Optional[str] = None
|
||||
channel: Optional[int] = None
|
||||
encryption: Optional[str] = None
|
||||
beacon_interval: Optional[int] = None
|
||||
is_hidden: bool = False
|
||||
|
||||
# RF-specific
|
||||
frequency: Optional[float] = None
|
||||
bandwidth: Optional[float] = None
|
||||
modulation: Optional[str] = None
|
||||
|
||||
# Common measurements
|
||||
rssi_samples: list[tuple[datetime, int]] = field(default_factory=list)
|
||||
first_seen: Optional[datetime] = None
|
||||
last_seen: Optional[datetime] = None
|
||||
detection_count: int = 0
|
||||
|
||||
# Behavioral analysis
|
||||
indicators: list[Indicator] = field(default_factory=list)
|
||||
total_score: int = 0
|
||||
risk_level: RiskLevel = RiskLevel.INFORMATIONAL
|
||||
|
||||
# Correlation
|
||||
correlated_devices: list[str] = field(default_factory=list)
|
||||
|
||||
# Output
|
||||
confidence: float = 0.0
|
||||
recommended_action: str = 'monitor'
|
||||
|
||||
def add_rssi_sample(self, rssi: int) -> None:
|
||||
"""Add an RSSI sample with timestamp."""
|
||||
self.rssi_samples.append((datetime.now(), rssi))
|
||||
# Keep last 100 samples
|
||||
if len(self.rssi_samples) > 100:
|
||||
self.rssi_samples = self.rssi_samples[-100:]
|
||||
|
||||
def get_rssi_stability(self) -> float:
|
||||
"""Calculate RSSI stability (0-1, higher = more stable)."""
|
||||
if len(self.rssi_samples) < 3:
|
||||
return 0.0
|
||||
values = [r for _, r in self.rssi_samples[-20:]]
|
||||
if not values:
|
||||
return 0.0
|
||||
avg = sum(values) / len(values)
|
||||
variance = sum((v - avg) ** 2 for v in values) / len(values)
|
||||
# Convert variance to stability score (lower variance = higher stability)
|
||||
# Variance of ~0 = 1.0, variance of 100+ = ~0
|
||||
return max(0, 1 - (variance / 100))
|
||||
|
||||
def add_indicator(self, indicator_type: IndicatorType, description: str,
|
||||
details: dict = None) -> None:
|
||||
"""Add a risk indicator and update score."""
|
||||
score = INDICATOR_SCORES.get(indicator_type, 1)
|
||||
self.indicators.append(Indicator(
|
||||
type=indicator_type,
|
||||
description=description,
|
||||
score=score,
|
||||
details=details or {}
|
||||
))
|
||||
self._recalculate_score()
|
||||
|
||||
def _recalculate_score(self) -> None:
|
||||
"""Recalculate total score and risk level."""
|
||||
self.total_score = sum(i.score for i in self.indicators)
|
||||
|
||||
if self.total_score >= 6:
|
||||
self.risk_level = RiskLevel.HIGH_INTEREST
|
||||
self.recommended_action = 'investigate'
|
||||
elif self.total_score >= 3:
|
||||
self.risk_level = RiskLevel.NEEDS_REVIEW
|
||||
self.recommended_action = 'review'
|
||||
else:
|
||||
self.risk_level = RiskLevel.INFORMATIONAL
|
||||
self.recommended_action = 'monitor'
|
||||
|
||||
# Calculate confidence based on number and quality of indicators
|
||||
indicator_count = len(self.indicators)
|
||||
self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05))
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'identifier': self.identifier,
|
||||
'protocol': self.protocol,
|
||||
'name': self.name,
|
||||
'manufacturer': self.manufacturer,
|
||||
'device_type': self.device_type,
|
||||
'ssid': self.ssid,
|
||||
'frequency': self.frequency,
|
||||
'first_seen': self.first_seen.isoformat() if self.first_seen else None,
|
||||
'last_seen': self.last_seen.isoformat() if self.last_seen else None,
|
||||
'detection_count': self.detection_count,
|
||||
'rssi_current': self.rssi_samples[-1][1] if self.rssi_samples else None,
|
||||
'rssi_stability': self.get_rssi_stability(),
|
||||
'indicators': [
|
||||
{
|
||||
'type': i.type.value,
|
||||
'description': i.description,
|
||||
'score': i.score,
|
||||
}
|
||||
for i in self.indicators
|
||||
],
|
||||
'total_score': self.total_score,
|
||||
'risk_level': self.risk_level.value,
|
||||
'confidence': round(self.confidence, 2),
|
||||
'recommended_action': self.recommended_action,
|
||||
'correlated_devices': self.correlated_devices,
|
||||
}
|
||||
|
||||
|
||||
# Known audio-capable BLE service UUIDs
|
||||
AUDIO_SERVICE_UUIDS = [
|
||||
'0000110b-0000-1000-8000-00805f9b34fb', # A2DP Sink
|
||||
'0000110a-0000-1000-8000-00805f9b34fb', # A2DP Source
|
||||
'0000111e-0000-1000-8000-00805f9b34fb', # Handsfree
|
||||
'0000111f-0000-1000-8000-00805f9b34fb', # Handsfree Audio Gateway
|
||||
'00001108-0000-1000-8000-00805f9b34fb', # Headset
|
||||
'00001203-0000-1000-8000-00805f9b34fb', # Generic Audio
|
||||
]
|
||||
|
||||
# Generic chipset vendors (often used in covert devices)
|
||||
GENERIC_CHIPSET_VENDORS = [
|
||||
'espressif',
|
||||
'nordic',
|
||||
'texas instruments',
|
||||
'silicon labs',
|
||||
'realtek',
|
||||
'mediatek',
|
||||
'qualcomm',
|
||||
'broadcom',
|
||||
'cypress',
|
||||
'dialog',
|
||||
]
|
||||
|
||||
# Suspicious frequency ranges for RF
|
||||
SUSPICIOUS_RF_BANDS = [
|
||||
{'start': 136, 'end': 174, 'name': 'VHF', 'risk': 'high'},
|
||||
{'start': 400, 'end': 470, 'name': 'UHF', 'risk': 'high'},
|
||||
{'start': 315, 'end': 316, 'name': '315 MHz ISM', 'risk': 'medium'},
|
||||
{'start': 433, 'end': 435, 'name': '433 MHz ISM', 'risk': 'medium'},
|
||||
{'start': 868, 'end': 870, 'name': '868 MHz ISM', 'risk': 'medium'},
|
||||
{'start': 902, 'end': 928, 'name': '915 MHz ISM', 'risk': 'medium'},
|
||||
]
|
||||
|
||||
|
||||
class CorrelationEngine:
|
||||
"""
|
||||
Cross-protocol correlation engine for TSCM analysis.
|
||||
|
||||
Correlates Bluetooth, Wi-Fi, and RF indicators to identify
|
||||
potential surveillance activity patterns.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.device_profiles: dict[str, DeviceProfile] = {}
|
||||
self.meeting_windows: list[tuple[datetime, datetime]] = []
|
||||
self.correlation_window = timedelta(minutes=5)
|
||||
|
||||
def start_meeting_window(self) -> None:
|
||||
"""Mark the start of a sensitive period (meeting)."""
|
||||
self.meeting_windows.append((datetime.now(), None))
|
||||
logger.info("Meeting window started")
|
||||
|
||||
def end_meeting_window(self) -> None:
|
||||
"""Mark the end of a sensitive period."""
|
||||
if self.meeting_windows and self.meeting_windows[-1][1] is None:
|
||||
start = self.meeting_windows[-1][0]
|
||||
self.meeting_windows[-1] = (start, datetime.now())
|
||||
logger.info("Meeting window ended")
|
||||
|
||||
def is_during_meeting(self, timestamp: datetime = None) -> bool:
|
||||
"""Check if timestamp falls within a meeting window."""
|
||||
ts = timestamp or datetime.now()
|
||||
for start, end in self.meeting_windows:
|
||||
if end is None:
|
||||
if ts >= start:
|
||||
return True
|
||||
elif start <= ts <= end:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_or_create_profile(self, identifier: str, protocol: str) -> DeviceProfile:
|
||||
"""Get existing profile or create new one."""
|
||||
key = f"{protocol}:{identifier}"
|
||||
if key not in self.device_profiles:
|
||||
self.device_profiles[key] = DeviceProfile(
|
||||
identifier=identifier,
|
||||
protocol=protocol,
|
||||
first_seen=datetime.now()
|
||||
)
|
||||
profile = self.device_profiles[key]
|
||||
profile.last_seen = datetime.now()
|
||||
profile.detection_count += 1
|
||||
return profile
|
||||
|
||||
def analyze_bluetooth_device(self, device: dict) -> DeviceProfile:
|
||||
"""
|
||||
Analyze a Bluetooth device for suspicious indicators.
|
||||
|
||||
Args:
|
||||
device: Dict with mac, name, rssi, services, manufacturer, etc.
|
||||
|
||||
Returns:
|
||||
DeviceProfile with risk assessment
|
||||
"""
|
||||
mac = device.get('mac', device.get('address', '')).upper()
|
||||
profile = self.get_or_create_profile(mac, 'bluetooth')
|
||||
|
||||
# Update profile data
|
||||
profile.name = device.get('name') or profile.name
|
||||
profile.manufacturer = device.get('manufacturer') or profile.manufacturer
|
||||
profile.device_type = device.get('type') or profile.device_type
|
||||
profile.services = device.get('services', []) or profile.services
|
||||
profile.company_id = device.get('company_id') or profile.company_id
|
||||
profile.advertising_interval = device.get('advertising_interval') or profile.advertising_interval
|
||||
|
||||
# Add RSSI sample
|
||||
rssi = device.get('rssi', device.get('signal'))
|
||||
if rssi:
|
||||
try:
|
||||
profile.add_rssi_sample(int(rssi))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Clear previous indicators for fresh analysis
|
||||
profile.indicators = []
|
||||
|
||||
# === Detection Logic ===
|
||||
|
||||
# 1. Unknown manufacturer or generic chipset
|
||||
if not profile.manufacturer:
|
||||
profile.add_indicator(
|
||||
IndicatorType.UNKNOWN_DEVICE,
|
||||
'Unknown manufacturer',
|
||||
{'manufacturer': None}
|
||||
)
|
||||
elif any(v in profile.manufacturer.lower() for v in GENERIC_CHIPSET_VENDORS):
|
||||
profile.add_indicator(
|
||||
IndicatorType.UNKNOWN_DEVICE,
|
||||
f'Generic chipset vendor: {profile.manufacturer}',
|
||||
{'manufacturer': profile.manufacturer}
|
||||
)
|
||||
|
||||
# 2. No human-readable name
|
||||
if not profile.name or profile.name in ['Unknown', '', 'N/A']:
|
||||
profile.add_indicator(
|
||||
IndicatorType.HIDDEN_IDENTITY,
|
||||
'No device name advertised',
|
||||
{'name': profile.name}
|
||||
)
|
||||
|
||||
# 3. Audio-capable services
|
||||
if profile.services:
|
||||
audio_services = [s for s in profile.services
|
||||
if s.lower() in [u.lower() for u in AUDIO_SERVICE_UUIDS]]
|
||||
if audio_services:
|
||||
profile.add_indicator(
|
||||
IndicatorType.AUDIO_CAPABLE,
|
||||
'Audio-capable BLE services detected',
|
||||
{'services': audio_services}
|
||||
)
|
||||
|
||||
# Check name for audio keywords
|
||||
if profile.name:
|
||||
audio_keywords = ['headphone', 'headset', 'earphone', 'speaker',
|
||||
'mic', 'audio', 'airpod', 'buds', 'jabra', 'bose']
|
||||
if any(k in profile.name.lower() for k in audio_keywords):
|
||||
profile.add_indicator(
|
||||
IndicatorType.AUDIO_CAPABLE,
|
||||
f'Audio device name: {profile.name}',
|
||||
{'name': profile.name}
|
||||
)
|
||||
|
||||
# 4. High-frequency advertising (< 100ms interval is suspicious)
|
||||
if profile.advertising_interval and profile.advertising_interval < 100:
|
||||
profile.add_indicator(
|
||||
IndicatorType.HIGH_FREQ_ADVERTISING,
|
||||
f'High advertising frequency: {profile.advertising_interval}ms',
|
||||
{'interval': profile.advertising_interval}
|
||||
)
|
||||
|
||||
# 5. Persistent presence
|
||||
if profile.detection_count >= 3:
|
||||
profile.add_indicator(
|
||||
IndicatorType.PERSISTENT,
|
||||
f'Persistent device ({profile.detection_count} detections)',
|
||||
{'count': profile.detection_count}
|
||||
)
|
||||
|
||||
# 6. Stable RSSI (suggests fixed placement)
|
||||
rssi_stability = profile.get_rssi_stability()
|
||||
if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5:
|
||||
profile.add_indicator(
|
||||
IndicatorType.STABLE_RSSI,
|
||||
f'Stable signal strength (stability: {rssi_stability:.0%})',
|
||||
{'stability': rssi_stability}
|
||||
)
|
||||
|
||||
# 7. Meeting correlation
|
||||
if self.is_during_meeting():
|
||||
profile.add_indicator(
|
||||
IndicatorType.MEETING_CORRELATED,
|
||||
'Detected during sensitive period',
|
||||
{'during_meeting': True}
|
||||
)
|
||||
|
||||
# 8. MAC rotation pattern (random MAC prefix)
|
||||
if mac and mac[1] in ['2', '6', 'A', 'E', 'a', 'e']:
|
||||
profile.add_indicator(
|
||||
IndicatorType.MAC_ROTATION,
|
||||
'Random/rotating MAC address detected',
|
||||
{'mac': mac}
|
||||
)
|
||||
|
||||
# 9. Known tracker detection (AirTag, Tile, SmartTag, ESP32)
|
||||
mac_prefix = mac[:8] if len(mac) >= 8 else ''
|
||||
tracker_detected = False
|
||||
|
||||
# Check for tracker flags from BLE scanner (manufacturer ID detection)
|
||||
if device.get('is_airtag'):
|
||||
profile.add_indicator(
|
||||
IndicatorType.AIRTAG_DETECTED,
|
||||
'Apple AirTag detected via manufacturer data',
|
||||
{'mac': mac, 'tracker_type': 'AirTag'}
|
||||
)
|
||||
profile.device_type = device.get('tracker_type', 'AirTag')
|
||||
tracker_detected = True
|
||||
|
||||
if device.get('is_tile'):
|
||||
profile.add_indicator(
|
||||
IndicatorType.TILE_DETECTED,
|
||||
'Tile tracker detected via manufacturer data',
|
||||
{'mac': mac, 'tracker_type': 'Tile'}
|
||||
)
|
||||
profile.device_type = 'Tile Tracker'
|
||||
tracker_detected = True
|
||||
|
||||
if device.get('is_smarttag'):
|
||||
profile.add_indicator(
|
||||
IndicatorType.SMARTTAG_DETECTED,
|
||||
'Samsung SmartTag detected via manufacturer data',
|
||||
{'mac': mac, 'tracker_type': 'SmartTag'}
|
||||
)
|
||||
profile.device_type = 'Samsung SmartTag'
|
||||
tracker_detected = True
|
||||
|
||||
if device.get('is_espressif'):
|
||||
profile.add_indicator(
|
||||
IndicatorType.ESP32_DEVICE,
|
||||
'ESP32/ESP8266 detected via Espressif manufacturer ID',
|
||||
{'mac': mac, 'chipset': 'Espressif'}
|
||||
)
|
||||
profile.manufacturer = 'Espressif'
|
||||
profile.device_type = device.get('tracker_type', 'ESP32/ESP8266')
|
||||
tracker_detected = True
|
||||
|
||||
# Check manufacturer_id directly
|
||||
mfg_id = device.get('manufacturer_id')
|
||||
if mfg_id:
|
||||
if mfg_id == 0x004C and not device.get('is_airtag'):
|
||||
# Apple device - could be AirTag
|
||||
profile.manufacturer = 'Apple'
|
||||
elif mfg_id == 0x02E5 and not device.get('is_espressif'):
|
||||
# Espressif device
|
||||
profile.add_indicator(
|
||||
IndicatorType.ESP32_DEVICE,
|
||||
'ESP32/ESP8266 detected via manufacturer ID',
|
||||
{'mac': mac, 'manufacturer_id': mfg_id}
|
||||
)
|
||||
profile.manufacturer = 'Espressif'
|
||||
tracker_detected = True
|
||||
|
||||
# Fallback: Check for Apple AirTag by OUI
|
||||
if not tracker_detected and mac_prefix in TRACKER_SIGNATURES.get('airtag_oui', []):
|
||||
profile.add_indicator(
|
||||
IndicatorType.AIRTAG_DETECTED,
|
||||
'Apple AirTag detected - potential tracking device',
|
||||
{'mac': mac, 'tracker_type': 'AirTag'}
|
||||
)
|
||||
profile.device_type = 'AirTag'
|
||||
tracker_detected = True
|
||||
|
||||
# Check for Tile tracker
|
||||
if mac_prefix in TRACKER_SIGNATURES.get('tile_oui', []):
|
||||
profile.add_indicator(
|
||||
IndicatorType.TILE_DETECTED,
|
||||
'Tile tracker detected',
|
||||
{'mac': mac, 'tracker_type': 'Tile'}
|
||||
)
|
||||
profile.device_type = 'Tile Tracker'
|
||||
tracker_detected = True
|
||||
|
||||
# Check for Samsung SmartTag
|
||||
if mac_prefix in TRACKER_SIGNATURES.get('smarttag_oui', []):
|
||||
profile.add_indicator(
|
||||
IndicatorType.SMARTTAG_DETECTED,
|
||||
'Samsung SmartTag detected',
|
||||
{'mac': mac, 'tracker_type': 'SmartTag'}
|
||||
)
|
||||
profile.device_type = 'Samsung SmartTag'
|
||||
tracker_detected = True
|
||||
|
||||
# Check for ESP32/ESP8266 devices
|
||||
if mac_prefix in TRACKER_SIGNATURES.get('espressif_oui', []):
|
||||
profile.add_indicator(
|
||||
IndicatorType.ESP32_DEVICE,
|
||||
'ESP32/ESP8266 device detected - programmable hardware',
|
||||
{'mac': mac, 'chipset': 'Espressif'}
|
||||
)
|
||||
profile.manufacturer = 'Espressif'
|
||||
tracker_detected = True
|
||||
|
||||
# Check for generic/suspicious chipsets
|
||||
if mac_prefix in TRACKER_SIGNATURES.get('generic_chipset_oui', []):
|
||||
profile.add_indicator(
|
||||
IndicatorType.GENERIC_CHIPSET,
|
||||
'Generic chipset vendor - often used in covert devices',
|
||||
{'mac': mac}
|
||||
)
|
||||
tracker_detected = True
|
||||
|
||||
# If any tracker detected, add general tracker indicator
|
||||
if tracker_detected:
|
||||
profile.add_indicator(
|
||||
IndicatorType.KNOWN_TRACKER,
|
||||
'Known tracking device signature detected',
|
||||
{'mac': mac}
|
||||
)
|
||||
|
||||
# Also check name for tracker keywords
|
||||
if profile.name:
|
||||
name_lower = profile.name.lower()
|
||||
if 'airtag' in name_lower or 'findmy' in name_lower:
|
||||
profile.add_indicator(
|
||||
IndicatorType.AIRTAG_DETECTED,
|
||||
f'AirTag identified by name: {profile.name}',
|
||||
{'name': profile.name}
|
||||
)
|
||||
profile.device_type = 'AirTag'
|
||||
elif 'tile' in name_lower:
|
||||
profile.add_indicator(
|
||||
IndicatorType.TILE_DETECTED,
|
||||
f'Tile tracker identified by name: {profile.name}',
|
||||
{'name': profile.name}
|
||||
)
|
||||
profile.device_type = 'Tile Tracker'
|
||||
elif 'smarttag' in name_lower:
|
||||
profile.add_indicator(
|
||||
IndicatorType.SMARTTAG_DETECTED,
|
||||
f'SmartTag identified by name: {profile.name}',
|
||||
{'name': profile.name}
|
||||
)
|
||||
profile.device_type = 'Samsung SmartTag'
|
||||
|
||||
return profile
|
||||
|
||||
def analyze_wifi_device(self, device: dict) -> DeviceProfile:
|
||||
"""
|
||||
Analyze a Wi-Fi device/AP for suspicious indicators.
|
||||
|
||||
Args:
|
||||
device: Dict with bssid, ssid, channel, rssi, encryption, etc.
|
||||
|
||||
Returns:
|
||||
DeviceProfile with risk assessment
|
||||
"""
|
||||
bssid = device.get('bssid', device.get('mac', '')).upper()
|
||||
profile = self.get_or_create_profile(bssid, 'wifi')
|
||||
|
||||
# Update profile data
|
||||
ssid = device.get('ssid', device.get('essid', ''))
|
||||
profile.ssid = ssid if ssid else profile.ssid
|
||||
profile.name = ssid or f'Hidden Network ({bssid[-8:]})'
|
||||
profile.channel = device.get('channel') or profile.channel
|
||||
profile.encryption = device.get('encryption', device.get('privacy')) or profile.encryption
|
||||
profile.beacon_interval = device.get('beacon_interval') or profile.beacon_interval
|
||||
profile.is_hidden = not ssid or ssid in ['', 'Hidden', '[Hidden]']
|
||||
|
||||
# Extract manufacturer from OUI
|
||||
if bssid and len(bssid) >= 8:
|
||||
profile.manufacturer = device.get('vendor') or profile.manufacturer
|
||||
|
||||
# Add RSSI sample
|
||||
rssi = device.get('rssi', device.get('power', device.get('signal')))
|
||||
if rssi:
|
||||
try:
|
||||
profile.add_rssi_sample(int(rssi))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Clear previous indicators
|
||||
profile.indicators = []
|
||||
|
||||
# === Detection Logic ===
|
||||
|
||||
# 1. Hidden or unnamed SSID
|
||||
if profile.is_hidden:
|
||||
profile.add_indicator(
|
||||
IndicatorType.HIDDEN_IDENTITY,
|
||||
'Hidden or empty SSID',
|
||||
{'ssid': ssid}
|
||||
)
|
||||
|
||||
# 2. BSSID not in authorized list (would need baseline)
|
||||
# For now, mark as unknown if no manufacturer
|
||||
if not profile.manufacturer:
|
||||
profile.add_indicator(
|
||||
IndicatorType.UNKNOWN_DEVICE,
|
||||
'Unknown AP manufacturer',
|
||||
{'bssid': bssid}
|
||||
)
|
||||
|
||||
# 3. Consumer device OUI in restricted environment
|
||||
consumer_ouis = ['tp-link', 'netgear', 'd-link', 'linksys', 'asus']
|
||||
if profile.manufacturer and any(c in profile.manufacturer.lower() for c in consumer_ouis):
|
||||
profile.add_indicator(
|
||||
IndicatorType.ROGUE_AP,
|
||||
f'Consumer-grade AP detected: {profile.manufacturer}',
|
||||
{'manufacturer': profile.manufacturer}
|
||||
)
|
||||
|
||||
# 4. Camera device patterns
|
||||
camera_keywords = ['cam', 'camera', 'ipcam', 'dvr', 'nvr', 'wyze',
|
||||
'ring', 'arlo', 'nest', 'blink', 'eufy', 'yi']
|
||||
if ssid and any(k in ssid.lower() for k in camera_keywords):
|
||||
profile.add_indicator(
|
||||
IndicatorType.AUDIO_CAPABLE, # Cameras often have mics
|
||||
f'Potential camera device: {ssid}',
|
||||
{'ssid': ssid}
|
||||
)
|
||||
|
||||
# 5. Persistent presence
|
||||
if profile.detection_count >= 3:
|
||||
profile.add_indicator(
|
||||
IndicatorType.PERSISTENT,
|
||||
f'Persistent AP ({profile.detection_count} detections)',
|
||||
{'count': profile.detection_count}
|
||||
)
|
||||
|
||||
# 6. Stable RSSI (fixed placement)
|
||||
rssi_stability = profile.get_rssi_stability()
|
||||
if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5:
|
||||
profile.add_indicator(
|
||||
IndicatorType.STABLE_RSSI,
|
||||
f'Stable signal (stability: {rssi_stability:.0%})',
|
||||
{'stability': rssi_stability}
|
||||
)
|
||||
|
||||
# 7. Meeting correlation
|
||||
if self.is_during_meeting():
|
||||
profile.add_indicator(
|
||||
IndicatorType.MEETING_CORRELATED,
|
||||
'Detected during sensitive period',
|
||||
{'during_meeting': True}
|
||||
)
|
||||
|
||||
# 8. Strong hidden AP (very suspicious)
|
||||
if profile.is_hidden and profile.rssi_samples:
|
||||
latest_rssi = profile.rssi_samples[-1][1]
|
||||
if latest_rssi > -50:
|
||||
profile.add_indicator(
|
||||
IndicatorType.ROGUE_AP,
|
||||
f'Strong hidden AP (RSSI: {latest_rssi} dBm)',
|
||||
{'rssi': latest_rssi}
|
||||
)
|
||||
|
||||
return profile
|
||||
|
||||
def analyze_rf_signal(self, signal: dict) -> DeviceProfile:
|
||||
"""
|
||||
Analyze an RF signal for suspicious indicators.
|
||||
|
||||
Args:
|
||||
signal: Dict with frequency, power, bandwidth, modulation, etc.
|
||||
|
||||
Returns:
|
||||
DeviceProfile with risk assessment
|
||||
"""
|
||||
frequency = signal.get('frequency', 0)
|
||||
freq_key = f"{frequency:.3f}"
|
||||
profile = self.get_or_create_profile(freq_key, 'rf')
|
||||
|
||||
# Update profile data
|
||||
profile.frequency = frequency
|
||||
profile.name = f'{frequency:.3f} MHz'
|
||||
profile.bandwidth = signal.get('bandwidth') or profile.bandwidth
|
||||
profile.modulation = signal.get('modulation') or profile.modulation
|
||||
|
||||
# Add power sample
|
||||
power = signal.get('power', signal.get('level'))
|
||||
if power:
|
||||
try:
|
||||
profile.add_rssi_sample(int(float(power)))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Clear previous indicators
|
||||
profile.indicators = []
|
||||
|
||||
# === Detection Logic ===
|
||||
|
||||
# 1. Determine frequency band risk
|
||||
band_info = None
|
||||
for band in SUSPICIOUS_RF_BANDS:
|
||||
if band['start'] <= frequency <= band['end']:
|
||||
band_info = band
|
||||
break
|
||||
|
||||
if band_info:
|
||||
if band_info['risk'] == 'high':
|
||||
profile.add_indicator(
|
||||
IndicatorType.NARROWBAND_SIGNAL,
|
||||
f"Signal in high-risk band: {band_info['name']}",
|
||||
{'band': band_info['name'], 'frequency': frequency}
|
||||
)
|
||||
else:
|
||||
profile.add_indicator(
|
||||
IndicatorType.UNKNOWN_DEVICE,
|
||||
f"Signal in ISM band: {band_info['name']}",
|
||||
{'band': band_info['name'], 'frequency': frequency}
|
||||
)
|
||||
|
||||
# 2. Narrowband FM/AM (potential bug)
|
||||
if profile.modulation and profile.modulation.lower() in ['fm', 'nfm', 'am']:
|
||||
profile.add_indicator(
|
||||
IndicatorType.NARROWBAND_SIGNAL,
|
||||
f'Narrowband {profile.modulation.upper()} signal',
|
||||
{'modulation': profile.modulation}
|
||||
)
|
||||
|
||||
# 3. Persistent/always-on carrier
|
||||
if profile.detection_count >= 2:
|
||||
profile.add_indicator(
|
||||
IndicatorType.ALWAYS_ON_CARRIER,
|
||||
f'Persistent carrier ({profile.detection_count} detections)',
|
||||
{'count': profile.detection_count}
|
||||
)
|
||||
|
||||
# 4. Strong signal (close proximity)
|
||||
if profile.rssi_samples:
|
||||
latest_power = profile.rssi_samples[-1][1]
|
||||
if latest_power > -40:
|
||||
profile.add_indicator(
|
||||
IndicatorType.STABLE_RSSI,
|
||||
f'Strong signal suggesting close proximity ({latest_power} dBm)',
|
||||
{'power': latest_power}
|
||||
)
|
||||
|
||||
# 5. Meeting correlation
|
||||
if self.is_during_meeting():
|
||||
profile.add_indicator(
|
||||
IndicatorType.MEETING_CORRELATED,
|
||||
'Signal detected during sensitive period',
|
||||
{'during_meeting': True}
|
||||
)
|
||||
|
||||
return profile
|
||||
|
||||
def correlate_devices(self) -> list[dict]:
|
||||
"""
|
||||
Perform cross-protocol correlation analysis.
|
||||
|
||||
Identifies devices across protocols that may be related.
|
||||
|
||||
Returns:
|
||||
List of correlation findings
|
||||
"""
|
||||
correlations = []
|
||||
now = datetime.now()
|
||||
|
||||
# Get recent devices by protocol
|
||||
bt_devices = [p for p in self.device_profiles.values()
|
||||
if p.protocol == 'bluetooth' and
|
||||
p.last_seen and (now - p.last_seen) < self.correlation_window]
|
||||
wifi_devices = [p for p in self.device_profiles.values()
|
||||
if p.protocol == 'wifi' and
|
||||
p.last_seen and (now - p.last_seen) < self.correlation_window]
|
||||
rf_signals = [p for p in self.device_profiles.values()
|
||||
if p.protocol == 'rf' and
|
||||
p.last_seen and (now - p.last_seen) < self.correlation_window]
|
||||
|
||||
# Correlation 1: BLE audio device + RF narrowband signal
|
||||
audio_bt = [p for p in bt_devices
|
||||
if any(i.type == IndicatorType.AUDIO_CAPABLE for i in p.indicators)]
|
||||
narrowband_rf = [p for p in rf_signals
|
||||
if any(i.type == IndicatorType.NARROWBAND_SIGNAL for i in p.indicators)]
|
||||
|
||||
for bt in audio_bt:
|
||||
for rf in narrowband_rf:
|
||||
correlation = {
|
||||
'type': 'bt_audio_rf_narrowband',
|
||||
'description': 'Audio-capable BLE device detected alongside narrowband RF signal',
|
||||
'devices': [bt.identifier, rf.identifier],
|
||||
'protocols': ['bluetooth', 'rf'],
|
||||
'score_boost': 3,
|
||||
'significance': 'high',
|
||||
}
|
||||
correlations.append(correlation)
|
||||
|
||||
# Add cross-protocol indicator to both
|
||||
bt.add_indicator(
|
||||
IndicatorType.CROSS_PROTOCOL,
|
||||
f'Correlated with RF signal at {rf.frequency:.3f} MHz',
|
||||
{'correlated_device': rf.identifier}
|
||||
)
|
||||
rf.add_indicator(
|
||||
IndicatorType.CROSS_PROTOCOL,
|
||||
f'Correlated with BLE device {bt.identifier}',
|
||||
{'correlated_device': bt.identifier}
|
||||
)
|
||||
bt.correlated_devices.append(rf.identifier)
|
||||
rf.correlated_devices.append(bt.identifier)
|
||||
|
||||
# Correlation 2: Rogue WiFi AP + RF burst activity
|
||||
rogue_aps = [p for p in wifi_devices
|
||||
if any(i.type == IndicatorType.ROGUE_AP for i in p.indicators)]
|
||||
rf_bursts = [p for p in rf_signals
|
||||
if any(i.type in [IndicatorType.BURST_TRANSMISSION,
|
||||
IndicatorType.ALWAYS_ON_CARRIER] for i in p.indicators)]
|
||||
|
||||
for ap in rogue_aps:
|
||||
for rf in rf_bursts:
|
||||
correlation = {
|
||||
'type': 'rogue_ap_rf_burst',
|
||||
'description': 'Rogue AP detected alongside RF transmission',
|
||||
'devices': [ap.identifier, rf.identifier],
|
||||
'protocols': ['wifi', 'rf'],
|
||||
'score_boost': 3,
|
||||
'significance': 'high',
|
||||
}
|
||||
correlations.append(correlation)
|
||||
|
||||
ap.add_indicator(
|
||||
IndicatorType.CROSS_PROTOCOL,
|
||||
f'Correlated with RF at {rf.frequency:.3f} MHz',
|
||||
{'correlated_device': rf.identifier}
|
||||
)
|
||||
rf.add_indicator(
|
||||
IndicatorType.CROSS_PROTOCOL,
|
||||
f'Correlated with AP {ap.ssid or ap.identifier}',
|
||||
{'correlated_device': ap.identifier}
|
||||
)
|
||||
|
||||
# Correlation 3: Same vendor BLE + WiFi
|
||||
for bt in bt_devices:
|
||||
if bt.manufacturer:
|
||||
for wifi in wifi_devices:
|
||||
if wifi.manufacturer and bt.manufacturer.lower() in wifi.manufacturer.lower():
|
||||
correlation = {
|
||||
'type': 'same_vendor_bt_wifi',
|
||||
'description': f'Same vendor ({bt.manufacturer}) on BLE and WiFi',
|
||||
'devices': [bt.identifier, wifi.identifier],
|
||||
'protocols': ['bluetooth', 'wifi'],
|
||||
'score_boost': 2,
|
||||
'significance': 'medium',
|
||||
}
|
||||
correlations.append(correlation)
|
||||
|
||||
return correlations
|
||||
|
||||
def get_high_interest_devices(self) -> list[DeviceProfile]:
|
||||
"""Get all devices classified as high interest."""
|
||||
return [p for p in self.device_profiles.values()
|
||||
if p.risk_level == RiskLevel.HIGH_INTEREST]
|
||||
|
||||
def get_all_findings(self) -> dict:
|
||||
"""
|
||||
Get comprehensive findings report.
|
||||
|
||||
Returns:
|
||||
Dict with all device profiles, correlations, and summary
|
||||
"""
|
||||
correlations = self.correlate_devices()
|
||||
|
||||
devices_by_risk = {
|
||||
'high_interest': [],
|
||||
'needs_review': [],
|
||||
'informational': [],
|
||||
}
|
||||
|
||||
for profile in self.device_profiles.values():
|
||||
devices_by_risk[profile.risk_level.value].append(profile.to_dict())
|
||||
|
||||
return {
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'summary': {
|
||||
'total_devices': len(self.device_profiles),
|
||||
'high_interest': len(devices_by_risk['high_interest']),
|
||||
'needs_review': len(devices_by_risk['needs_review']),
|
||||
'informational': len(devices_by_risk['informational']),
|
||||
'correlations_found': len(correlations),
|
||||
},
|
||||
'devices': devices_by_risk,
|
||||
'correlations': correlations,
|
||||
'disclaimer': (
|
||||
"This system performs wireless and RF surveillance screening. "
|
||||
"Findings indicate anomalies and indicators, not confirmed surveillance devices."
|
||||
),
|
||||
}
|
||||
|
||||
def clear_old_profiles(self, max_age_hours: int = 24) -> int:
|
||||
"""Remove profiles older than specified age."""
|
||||
cutoff = datetime.now() - timedelta(hours=max_age_hours)
|
||||
old_keys = [
|
||||
k for k, v in self.device_profiles.items()
|
||||
if v.last_seen and v.last_seen < cutoff
|
||||
]
|
||||
for key in old_keys:
|
||||
del self.device_profiles[key]
|
||||
return len(old_keys)
|
||||
|
||||
|
||||
# Global correlation engine instance
|
||||
_correlation_engine: CorrelationEngine | None = None
|
||||
|
||||
|
||||
def get_correlation_engine() -> CorrelationEngine:
|
||||
"""Get or create the global correlation engine."""
|
||||
global _correlation_engine
|
||||
if _correlation_engine is None:
|
||||
_correlation_engine = CorrelationEngine()
|
||||
return _correlation_engine
|
||||
|
||||
|
||||
def reset_correlation_engine() -> None:
|
||||
"""Reset the global correlation engine."""
|
||||
global _correlation_engine
|
||||
_correlation_engine = CorrelationEngine()
|
||||
@@ -0,0 +1,564 @@
|
||||
"""
|
||||
TSCM Threat Detection Engine
|
||||
|
||||
Analyzes WiFi, Bluetooth, and RF data to identify potential surveillance devices
|
||||
and classify threats based on known patterns and baseline comparison.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from data.tscm_frequencies import (
|
||||
BLE_TRACKER_SIGNATURES,
|
||||
THREAT_TYPES,
|
||||
WIFI_CAMERA_PATTERNS,
|
||||
get_frequency_risk,
|
||||
get_threat_severity,
|
||||
is_known_tracker,
|
||||
is_potential_camera,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('intercept.tscm.detector')
|
||||
|
||||
# Classification levels for TSCM devices
|
||||
CLASSIFICATION_LEVELS = {
|
||||
'informational': {
|
||||
'color': '#00cc00', # Green
|
||||
'label': 'Informational',
|
||||
'description': 'Known device, expected infrastructure, or background noise',
|
||||
},
|
||||
'review': {
|
||||
'color': '#ffcc00', # Yellow
|
||||
'label': 'Needs Review',
|
||||
'description': 'Unknown device requiring investigation',
|
||||
},
|
||||
'high_interest': {
|
||||
'color': '#ff3333', # Red
|
||||
'label': 'High Interest',
|
||||
'description': 'Suspicious device requiring immediate attention',
|
||||
},
|
||||
}
|
||||
|
||||
# BLE device types that can transmit audio (potential bugs)
|
||||
AUDIO_CAPABLE_BLE_NAMES = [
|
||||
'headphone', 'headset', 'earphone', 'earbud', 'speaker',
|
||||
'audio', 'mic', 'microphone', 'airpod', 'buds',
|
||||
'jabra', 'bose', 'sony wf', 'sony wh', 'beats',
|
||||
'jbl', 'soundcore', 'anker', 'skullcandy',
|
||||
]
|
||||
|
||||
# Device history for tracking repeat detections across scans
|
||||
_device_history: dict[str, list[datetime]] = {}
|
||||
_history_window_hours = 24 # Consider detections within 24 hours
|
||||
|
||||
|
||||
def _record_device_seen(identifier: str) -> int:
|
||||
"""Record a device sighting and return count of times seen."""
|
||||
now = datetime.now()
|
||||
if identifier not in _device_history:
|
||||
_device_history[identifier] = []
|
||||
|
||||
# Clean old entries
|
||||
cutoff = now.timestamp() - (_history_window_hours * 3600)
|
||||
_device_history[identifier] = [
|
||||
dt for dt in _device_history[identifier]
|
||||
if dt.timestamp() > cutoff
|
||||
]
|
||||
|
||||
_device_history[identifier].append(now)
|
||||
return len(_device_history[identifier])
|
||||
|
||||
|
||||
def _is_audio_capable_ble(name: str | None, device_type: str | None = None) -> bool:
|
||||
"""Check if a BLE device might be audio-capable."""
|
||||
if name:
|
||||
name_lower = name.lower()
|
||||
for pattern in AUDIO_CAPABLE_BLE_NAMES:
|
||||
if pattern in name_lower:
|
||||
return True
|
||||
if device_type:
|
||||
type_lower = device_type.lower()
|
||||
if any(t in type_lower for t in ['audio', 'headset', 'headphone', 'speaker']):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class ThreatDetector:
|
||||
"""
|
||||
Analyzes scan results to detect potential surveillance threats.
|
||||
"""
|
||||
|
||||
def __init__(self, baseline: dict | None = None):
|
||||
"""
|
||||
Initialize the threat detector.
|
||||
|
||||
Args:
|
||||
baseline: Optional baseline dict containing expected devices
|
||||
"""
|
||||
self.baseline = baseline
|
||||
self.baseline_wifi_macs = set()
|
||||
self.baseline_bt_macs = set()
|
||||
self.baseline_rf_freqs = set()
|
||||
|
||||
if baseline:
|
||||
self._load_baseline(baseline)
|
||||
|
||||
def _load_baseline(self, baseline: dict) -> None:
|
||||
"""Load baseline device identifiers for comparison."""
|
||||
# WiFi networks and clients
|
||||
for network in baseline.get('wifi_networks', []):
|
||||
if 'bssid' in network:
|
||||
self.baseline_wifi_macs.add(network['bssid'].upper())
|
||||
if 'clients' in network:
|
||||
for client in network['clients']:
|
||||
if 'mac' in client:
|
||||
self.baseline_wifi_macs.add(client['mac'].upper())
|
||||
|
||||
# Bluetooth devices
|
||||
for device in baseline.get('bt_devices', []):
|
||||
if 'mac' in device:
|
||||
self.baseline_bt_macs.add(device['mac'].upper())
|
||||
|
||||
# RF frequencies (rounded to nearest 0.1 MHz)
|
||||
for freq in baseline.get('rf_frequencies', []):
|
||||
if isinstance(freq, dict):
|
||||
self.baseline_rf_freqs.add(round(freq.get('frequency', 0), 1))
|
||||
else:
|
||||
self.baseline_rf_freqs.add(round(freq, 1))
|
||||
|
||||
logger.info(
|
||||
f"Loaded baseline: {len(self.baseline_wifi_macs)} WiFi, "
|
||||
f"{len(self.baseline_bt_macs)} BT, {len(self.baseline_rf_freqs)} RF"
|
||||
)
|
||||
|
||||
def classify_wifi_device(self, device: dict) -> dict:
|
||||
"""
|
||||
Classify a WiFi device into informational/review/high_interest.
|
||||
|
||||
Returns:
|
||||
Dict with 'classification', 'reasons', and metadata
|
||||
"""
|
||||
mac = device.get('bssid', device.get('mac', '')).upper()
|
||||
ssid = device.get('essid', device.get('ssid', ''))
|
||||
signal = device.get('power', device.get('signal', -100))
|
||||
|
||||
reasons = []
|
||||
classification = 'informational'
|
||||
|
||||
# Track repeat detections
|
||||
times_seen = _record_device_seen(f'wifi:{mac}') if mac else 1
|
||||
|
||||
# Check if in baseline (known device)
|
||||
in_baseline = mac in self.baseline_wifi_macs if self.baseline else False
|
||||
|
||||
if in_baseline:
|
||||
reasons.append('Known device in baseline')
|
||||
classification = 'informational'
|
||||
else:
|
||||
# New/unknown device
|
||||
reasons.append('New WiFi access point')
|
||||
classification = 'review'
|
||||
|
||||
# Check for suspicious patterns -> high interest
|
||||
if is_potential_camera(ssid=ssid, mac=mac):
|
||||
reasons.append('Matches camera device patterns')
|
||||
classification = 'high_interest'
|
||||
|
||||
if not ssid and signal and int(signal) > -60:
|
||||
reasons.append('Hidden SSID with strong signal')
|
||||
classification = 'high_interest'
|
||||
|
||||
# Repeat detections across scans
|
||||
if times_seen >= 3:
|
||||
reasons.append(f'Repeat detection ({times_seen} times)')
|
||||
if classification != 'high_interest':
|
||||
classification = 'high_interest'
|
||||
|
||||
return {
|
||||
'classification': classification,
|
||||
'reasons': reasons,
|
||||
'in_baseline': in_baseline,
|
||||
'times_seen': times_seen,
|
||||
}
|
||||
|
||||
def classify_bt_device(self, device: dict) -> dict:
|
||||
"""
|
||||
Classify a Bluetooth device into informational/review/high_interest.
|
||||
|
||||
Returns:
|
||||
Dict with 'classification', 'reasons', and metadata
|
||||
"""
|
||||
mac = device.get('mac', device.get('address', '')).upper()
|
||||
name = device.get('name', '')
|
||||
rssi = device.get('rssi', device.get('signal', -100))
|
||||
device_type = device.get('type', '')
|
||||
manufacturer_data = device.get('manufacturer_data')
|
||||
|
||||
reasons = []
|
||||
classification = 'informational'
|
||||
tracker_info = None
|
||||
|
||||
# Track repeat detections
|
||||
times_seen = _record_device_seen(f'bt:{mac}') if mac else 1
|
||||
|
||||
# Check if in baseline (known device)
|
||||
in_baseline = mac in self.baseline_bt_macs if self.baseline else False
|
||||
|
||||
# Check for trackers (do this early for all devices)
|
||||
tracker_info = is_known_tracker(name, manufacturer_data)
|
||||
|
||||
if in_baseline:
|
||||
reasons.append('Known device in baseline')
|
||||
classification = 'informational'
|
||||
else:
|
||||
# New/unknown BLE device
|
||||
if not name or name == 'Unknown':
|
||||
reasons.append('Unknown BLE device')
|
||||
classification = 'review'
|
||||
else:
|
||||
reasons.append('New Bluetooth device')
|
||||
classification = 'review'
|
||||
|
||||
# Check for trackers -> high interest
|
||||
if tracker_info:
|
||||
reasons.append(f"Known tracker: {tracker_info.get('name', 'Unknown')}")
|
||||
classification = 'high_interest'
|
||||
|
||||
# Check for audio-capable devices -> high interest
|
||||
if _is_audio_capable_ble(name, device_type):
|
||||
reasons.append('Audio-capable BLE device')
|
||||
classification = 'high_interest'
|
||||
|
||||
# Strong signal from unknown device
|
||||
if rssi and int(rssi) > -50 and not name:
|
||||
reasons.append('Strong signal from unnamed device')
|
||||
classification = 'high_interest'
|
||||
|
||||
# Repeat detections across scans
|
||||
if times_seen >= 3:
|
||||
reasons.append(f'Repeat detection ({times_seen} times)')
|
||||
if classification != 'high_interest':
|
||||
classification = 'high_interest'
|
||||
|
||||
return {
|
||||
'classification': classification,
|
||||
'reasons': reasons,
|
||||
'in_baseline': in_baseline,
|
||||
'times_seen': times_seen,
|
||||
'is_tracker': tracker_info is not None,
|
||||
'is_audio_capable': _is_audio_capable_ble(name, device_type),
|
||||
}
|
||||
|
||||
def classify_rf_signal(self, signal: dict) -> dict:
|
||||
"""
|
||||
Classify an RF signal into informational/review/high_interest.
|
||||
|
||||
Returns:
|
||||
Dict with 'classification', 'reasons', and metadata
|
||||
"""
|
||||
frequency = signal.get('frequency', 0)
|
||||
power = signal.get('power', signal.get('level', -100))
|
||||
band = signal.get('band', '')
|
||||
|
||||
reasons = []
|
||||
classification = 'informational'
|
||||
freq_rounded = round(frequency, 1)
|
||||
|
||||
# Track repeat detections
|
||||
times_seen = _record_device_seen(f'rf:{freq_rounded}')
|
||||
|
||||
# Check if in baseline (known frequency)
|
||||
in_baseline = freq_rounded in self.baseline_rf_freqs if self.baseline else False
|
||||
|
||||
# Get frequency risk info
|
||||
risk, band_name = get_frequency_risk(frequency)
|
||||
|
||||
if in_baseline:
|
||||
reasons.append('Known frequency in baseline')
|
||||
classification = 'informational'
|
||||
else:
|
||||
# New/unidentified RF carrier
|
||||
reasons.append(f'Unidentified RF carrier in {band_name}')
|
||||
|
||||
if risk == 'low':
|
||||
reasons.append('Background RF noise band')
|
||||
classification = 'review'
|
||||
elif risk == 'medium':
|
||||
reasons.append('ISM band signal')
|
||||
classification = 'review'
|
||||
elif risk in ['high', 'critical']:
|
||||
reasons.append(f'High-risk surveillance band: {band_name}')
|
||||
classification = 'high_interest'
|
||||
|
||||
# Strong persistent signal
|
||||
if power and float(power) > -40:
|
||||
reasons.append('Strong persistent transmitter')
|
||||
classification = 'high_interest'
|
||||
|
||||
# Repeat detections (persistent transmitter)
|
||||
if times_seen >= 2:
|
||||
reasons.append(f'Persistent transmitter ({times_seen} detections)')
|
||||
classification = 'high_interest'
|
||||
|
||||
return {
|
||||
'classification': classification,
|
||||
'reasons': reasons,
|
||||
'in_baseline': in_baseline,
|
||||
'times_seen': times_seen,
|
||||
'risk_level': risk,
|
||||
'band_name': band_name,
|
||||
}
|
||||
|
||||
def analyze_wifi_device(self, device: dict) -> dict | None:
|
||||
"""
|
||||
Analyze a WiFi device for threats.
|
||||
|
||||
Args:
|
||||
device: WiFi device dict with bssid, essid, etc.
|
||||
|
||||
Returns:
|
||||
Threat dict if threat detected, None otherwise
|
||||
"""
|
||||
mac = device.get('bssid', device.get('mac', '')).upper()
|
||||
ssid = device.get('essid', device.get('ssid', ''))
|
||||
vendor = device.get('vendor', '')
|
||||
signal = device.get('power', device.get('signal', -100))
|
||||
|
||||
threats = []
|
||||
|
||||
# Check if new device (not in baseline)
|
||||
if self.baseline and mac and mac not in self.baseline_wifi_macs:
|
||||
threats.append({
|
||||
'type': 'new_device',
|
||||
'severity': get_threat_severity('new_device', {'signal_strength': signal}),
|
||||
'reason': 'Device not present in baseline',
|
||||
})
|
||||
|
||||
# Check for hidden camera patterns
|
||||
if is_potential_camera(ssid=ssid, mac=mac, vendor=vendor):
|
||||
threats.append({
|
||||
'type': 'hidden_camera',
|
||||
'severity': get_threat_severity('hidden_camera', {'signal_strength': signal}),
|
||||
'reason': 'Device matches WiFi camera patterns',
|
||||
})
|
||||
|
||||
# Check for hidden SSID with strong signal
|
||||
if not ssid and signal and signal > -60:
|
||||
threats.append({
|
||||
'type': 'anomaly',
|
||||
'severity': 'medium',
|
||||
'reason': 'Hidden SSID with strong signal',
|
||||
})
|
||||
|
||||
if not threats:
|
||||
return None
|
||||
|
||||
# Return highest severity threat
|
||||
threats.sort(key=lambda t: ['low', 'medium', 'high', 'critical'].index(t['severity']), reverse=True)
|
||||
|
||||
return {
|
||||
'threat_type': threats[0]['type'],
|
||||
'severity': threats[0]['severity'],
|
||||
'source': 'wifi',
|
||||
'identifier': mac,
|
||||
'name': ssid or 'Hidden Network',
|
||||
'signal_strength': signal,
|
||||
'details': {
|
||||
'all_threats': threats,
|
||||
'vendor': vendor,
|
||||
'ssid': ssid,
|
||||
}
|
||||
}
|
||||
|
||||
def analyze_bt_device(self, device: dict) -> dict | None:
|
||||
"""
|
||||
Analyze a Bluetooth device for threats.
|
||||
|
||||
Args:
|
||||
device: BT device dict with mac, name, rssi, etc.
|
||||
|
||||
Returns:
|
||||
Threat dict if threat detected, None otherwise
|
||||
"""
|
||||
mac = device.get('mac', device.get('address', '')).upper()
|
||||
name = device.get('name', '')
|
||||
rssi = device.get('rssi', device.get('signal', -100))
|
||||
manufacturer = device.get('manufacturer', '')
|
||||
device_type = device.get('type', '')
|
||||
manufacturer_data = device.get('manufacturer_data')
|
||||
|
||||
threats = []
|
||||
|
||||
# Check if new device (not in baseline)
|
||||
if self.baseline and mac and mac not in self.baseline_bt_macs:
|
||||
threats.append({
|
||||
'type': 'new_device',
|
||||
'severity': get_threat_severity('new_device', {'signal_strength': rssi}),
|
||||
'reason': 'Device not present in baseline',
|
||||
})
|
||||
|
||||
# Check for known trackers
|
||||
tracker_info = is_known_tracker(name, manufacturer_data)
|
||||
if tracker_info:
|
||||
threats.append({
|
||||
'type': 'tracker',
|
||||
'severity': tracker_info.get('risk', 'high'),
|
||||
'reason': f"Known tracker detected: {tracker_info.get('name', 'Unknown')}",
|
||||
'tracker_type': tracker_info.get('name'),
|
||||
})
|
||||
|
||||
# Check for suspicious BLE beacons (unnamed, persistent)
|
||||
if not name and rssi and rssi > -70:
|
||||
threats.append({
|
||||
'type': 'anomaly',
|
||||
'severity': 'medium',
|
||||
'reason': 'Unnamed BLE device with strong signal',
|
||||
})
|
||||
|
||||
if not threats:
|
||||
return None
|
||||
|
||||
# Return highest severity threat
|
||||
threats.sort(key=lambda t: ['low', 'medium', 'high', 'critical'].index(t['severity']), reverse=True)
|
||||
|
||||
return {
|
||||
'threat_type': threats[0]['type'],
|
||||
'severity': threats[0]['severity'],
|
||||
'source': 'bluetooth',
|
||||
'identifier': mac,
|
||||
'name': name or 'Unknown BLE Device',
|
||||
'signal_strength': rssi,
|
||||
'details': {
|
||||
'all_threats': threats,
|
||||
'manufacturer': manufacturer,
|
||||
'device_type': device_type,
|
||||
}
|
||||
}
|
||||
|
||||
def analyze_rf_signal(self, signal: dict) -> dict | None:
|
||||
"""
|
||||
Analyze an RF signal for threats.
|
||||
|
||||
Args:
|
||||
signal: RF signal dict with frequency, level, etc.
|
||||
|
||||
Returns:
|
||||
Threat dict if threat detected, None otherwise
|
||||
"""
|
||||
frequency = signal.get('frequency', 0)
|
||||
level = signal.get('level', signal.get('power', -100))
|
||||
modulation = signal.get('modulation', '')
|
||||
|
||||
if not frequency:
|
||||
return None
|
||||
|
||||
threats = []
|
||||
freq_rounded = round(frequency, 1)
|
||||
|
||||
# Check if new frequency (not in baseline)
|
||||
if self.baseline and freq_rounded not in self.baseline_rf_freqs:
|
||||
risk, band_name = get_frequency_risk(frequency)
|
||||
threats.append({
|
||||
'type': 'unknown_signal',
|
||||
'severity': risk,
|
||||
'reason': f'New signal in {band_name}',
|
||||
})
|
||||
|
||||
# Check frequency risk even without baseline
|
||||
risk, band_name = get_frequency_risk(frequency)
|
||||
if risk in ['high', 'critical']:
|
||||
threats.append({
|
||||
'type': 'unknown_signal',
|
||||
'severity': risk,
|
||||
'reason': f'Signal in high-risk band: {band_name}',
|
||||
})
|
||||
|
||||
if not threats:
|
||||
return None
|
||||
|
||||
# Return highest severity threat
|
||||
threats.sort(key=lambda t: ['low', 'medium', 'high', 'critical'].index(t['severity']), reverse=True)
|
||||
|
||||
return {
|
||||
'threat_type': threats[0]['type'],
|
||||
'severity': threats[0]['severity'],
|
||||
'source': 'rf',
|
||||
'identifier': f'{frequency:.3f} MHz',
|
||||
'name': f'RF Signal @ {frequency:.3f} MHz',
|
||||
'signal_strength': level,
|
||||
'frequency': frequency,
|
||||
'details': {
|
||||
'all_threats': threats,
|
||||
'modulation': modulation,
|
||||
'band_name': band_name,
|
||||
}
|
||||
}
|
||||
|
||||
def analyze_all(
|
||||
self,
|
||||
wifi_devices: list[dict] | None = None,
|
||||
bt_devices: list[dict] | None = None,
|
||||
rf_signals: list[dict] | None = None
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Analyze all provided devices and signals for threats.
|
||||
|
||||
Returns:
|
||||
List of detected threats sorted by severity
|
||||
"""
|
||||
threats = []
|
||||
|
||||
if wifi_devices:
|
||||
for device in wifi_devices:
|
||||
threat = self.analyze_wifi_device(device)
|
||||
if threat:
|
||||
threats.append(threat)
|
||||
|
||||
if bt_devices:
|
||||
for device in bt_devices:
|
||||
threat = self.analyze_bt_device(device)
|
||||
if threat:
|
||||
threats.append(threat)
|
||||
|
||||
if rf_signals:
|
||||
for signal in rf_signals:
|
||||
threat = self.analyze_rf_signal(signal)
|
||||
if threat:
|
||||
threats.append(threat)
|
||||
|
||||
# Sort by severity (critical first)
|
||||
severity_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3}
|
||||
threats.sort(key=lambda t: severity_order.get(t.get('severity', 'low'), 3))
|
||||
|
||||
return threats
|
||||
|
||||
|
||||
def classify_device_threat(
|
||||
source: str,
|
||||
device: dict,
|
||||
baseline: dict | None = None
|
||||
) -> dict | None:
|
||||
"""
|
||||
Convenience function to classify a single device.
|
||||
|
||||
Args:
|
||||
source: Device source ('wifi', 'bluetooth', 'rf')
|
||||
device: Device data dict
|
||||
baseline: Optional baseline for comparison
|
||||
|
||||
Returns:
|
||||
Threat dict if threat detected, None otherwise
|
||||
"""
|
||||
detector = ThreatDetector(baseline)
|
||||
|
||||
if source == 'wifi':
|
||||
return detector.analyze_wifi_device(device)
|
||||
elif source == 'bluetooth':
|
||||
return detector.analyze_bt_device(device)
|
||||
elif source == 'rf':
|
||||
return detector.analyze_rf_signal(device)
|
||||
|
||||
return None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -195,3 +195,64 @@ def sanitize_device_name(name: str | None) -> str:
|
||||
return ''
|
||||
# Escape HTML and limit length
|
||||
return escape_html(str(name)[:64])
|
||||
|
||||
|
||||
def validate_network_interface(name: Any) -> str:
|
||||
"""
|
||||
Validate network interface name to prevent command injection.
|
||||
|
||||
Interface names must:
|
||||
- Start with a letter
|
||||
- Contain only alphanumeric, underscore, or hyphen
|
||||
- Be 1-15 characters long (Linux IFNAMSIZ limit)
|
||||
|
||||
Args:
|
||||
name: Interface name to validate
|
||||
|
||||
Returns:
|
||||
Validated interface name
|
||||
|
||||
Raises:
|
||||
ValueError: If interface name is invalid
|
||||
"""
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("Interface name is required")
|
||||
|
||||
name = name.strip()
|
||||
|
||||
if not name:
|
||||
raise ValueError("Interface name cannot be empty")
|
||||
|
||||
if len(name) > 15:
|
||||
raise ValueError(f"Interface name too long (max 15 chars): {name}")
|
||||
|
||||
# Must start with letter, contain only alphanumeric/underscore/hyphen
|
||||
if not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', name):
|
||||
raise ValueError(f"Invalid interface name: {name}")
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def validate_bluetooth_interface(name: Any) -> str:
|
||||
"""
|
||||
Validate Bluetooth interface name (hciX format).
|
||||
|
||||
Args:
|
||||
name: Interface name to validate
|
||||
|
||||
Returns:
|
||||
Validated interface name
|
||||
|
||||
Raises:
|
||||
ValueError: If interface name is invalid
|
||||
"""
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("Bluetooth interface name is required")
|
||||
|
||||
name = name.strip()
|
||||
|
||||
# Must be hciX format where X is a number 0-255
|
||||
if not re.match(r'^hci([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$', name):
|
||||
raise ValueError(f"Invalid Bluetooth interface name (expected hciX): {name}")
|
||||
|
||||
return name
|
||||
|
||||
Reference in New Issue
Block a user