Compare commits

..

164 Commits

Author SHA1 Message Date
Smittix 0eed4a2649 Bump version to 2.9.5
- Update VERSION in config.py
- Add changelog entry for v2.9.5 highlights
- Update CHANGELOG.md with detailed release notes
- Update pyproject.toml version

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:00:44 +00:00
Smittix 7b49c95967 Add welcome page with changelog and mode selection
- Replace simple splash screen with comprehensive welcome page
- Show version number and latest changelog entries
- Add 9-button mode selection grid for direct navigation
- User can now choose which mode to start with
- Responsive layout adapts to mobile screens
- Flow: Welcome → Disclaimer (if needed) → Selected mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 17:26:23 +00:00
Smittix 30126b1709 Add dropdown navigation menus and fix TSCM baseline recording
- Convert flat mode nav buttons into dropdown menus by category (SDR/RF, Wireless, Security)
- Add CSS styles for dropdown animations and active state highlighting
- Fix baseline recording by feeding device data to recorder endpoints
- Remove redundant threat summary section from TSCM sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 16:51:45 +00:00
Smittix 66c7db73e2 Integrate device identity engine into TSCM sweep
- Import device identity functions (get_identity_engine, ingest_ble_dict, etc.)
- Initialize and clear identity engine at sweep start
- Feed BLE observations to identity engine during Bluetooth scan
- Feed WiFi observations to identity engine during WiFi scan
- Finalize sessions and emit identity_clusters event at sweep completion
- Include identity cluster statistics in sweep results

The device identity engine provides MAC-randomization resistant detection
by clustering observations using fingerprinting, timing patterns, and
RSSI trajectory analysis.
2026-01-14 16:34:49 +00:00
Smittix 07af3acb84 Fix rtl_433 bias-t flag and add TSCM enhancements
- Fix bias-t option in rtl_433 for RTL-SDR and HackRF:
  - rtl_433's -T flag is for timeout, not bias-t
  - RTL-SDR: Use :biast=1 suffix on device string
  - HackRF: Use bias_t=1 in SoapySDR device string
- Add "Listen (FM/AM)" buttons to TSCM RF signal details
  - Switches to Listening Post mode and tunes to frequency
- Fix device detail header padding to prevent protocol badge
  overlapping with close button
2026-01-14 16:27:07 +00:00
Smittix b2feccdb90 Improve TSCM modal close button visibility
- Add circular background to close button
- Use visible border and solid background color
- Increase z-index to ensure it's above content
- Add hover effect with red background
- Better positioning and sizing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:53:23 +00:00
Smittix db2f46b46e Add root privilege check and warning display
- Add startup check in app.py for root/sudo privileges
- Show warning in terminal if not running as root
- Add running_as_root flag to TSCM devices API response
- Display privilege warning in TSCM UI when not running as root
- Show command to run with sudo in the warning
- Add CSS styling for privilege warning banner

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:37:56 +00:00
Smittix ff7c768287 Fix RF scanning and add status feedback
- Remove requirement for sdr_device to be set before RF scanning
- Add RTL-SDR device detection check with rtl_test before scanning
- Lower signal detection threshold from -50dBm to -70dBm
- Lower noise floor threshold from 15dB to 10dB above noise
- Add rf_status event for frontend feedback when RF unavailable
- Show status message in RF panel explaining why scanning isn't working
- Add CSS styling for status messages
- Reset RF status message when sweep starts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:35:47 +00:00
Smittix 236fbf061c Fix TSCM modal readability issues
- Fix transparent modal background: use --bg-card instead of undefined --panel-bg
- Add box-shadow to modal for better visibility
- Fix reason text color: use --text-secondary instead of hard-to-read --text-muted
- Fix device details section headings and table labels
- Fix indicator tags, disclaimer text, and reasons list colors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:25:53 +00:00
Smittix 21b0a153e8 Add MAC-randomization resistant device detection for TSCM
- New device_identity.py: Clusters BLE/WiFi observations into probable
  physical devices using passive fingerprinting (not MAC addresses)
- Fingerprinting based on manufacturer data, service UUIDs, capabilities,
  timing patterns, and RSSI trajectories
- Session tracking with automatic gap detection
- Risk indicators: stable RSSI, MAC rotation, ESP32 chipsets, audio-capable
- Full audit trail for all clustering decisions

- New ble_scanner.py: Cross-platform BLE scanning with bleak library
- Detects AirTags, Tile, SmartTags, ESP32 by manufacturer ID
- Fallback to system tools (btmgmt, hcitool, system_profiler)

- Added API endpoints for device identity clustering (/tscm/identity/*)
- Updated setup.sh with bleak dependency
- Updated documentation with TSCM features and hardware requirements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:19:20 +00:00
Smittix 35ca3f3a07 Add clickable score cards and fix findings panel
Features:
- Score cards (High Interest, Needs Review, etc.) are now clickable
- Clicking a card shows all devices in that category in a modal
- Can click through to see individual device details
- Correlations card shows cross-protocol matches

Fixes:
- Findings panel now shows devices with score >= 3 (was 6)
- Panel items color-coded by score (critical/high/medium)
- Sorted by score descending
- Fixed empty state message

UI:
- Added hover effects on clickable cards
- Added CSS for category device list
- Added protocol badges and mini indicators

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:58:28 +00:00
Smittix 87f72db8ad Add click-to-expand device details and fix score card updates
Features:
- Click any device to see detailed breakdown of why it was scored
- Modal shows score circle, risk level, recommended action
- Lists all indicators that contributed to the score
- Shows device-specific information (MAC, RSSI, etc.)
- Includes disclaimer about findings

Fixes:
- Score cards (High Interest, Needs Review, etc.) now update in real-time
- High-interest devices (score 6+) populate the Detected Threats panel
- Added updateTscmThreatCounts() calls when devices are added

UI:
- Device items now have cursor:pointer to indicate clickability
- Added CSS for modal, score circle, indicator list, etc.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:39:39 +00:00
Smittix 93b763865b Update TSCM with improved WiFi scanning, new scoring UI, and tracker detection
WiFi Scanning:
- Add 'iw' scan method as primary (sometimes works without root)
- Auto-detect wireless interface from /sys/class/net
- Better error logging for permission issues
- Fall back to iwlist if iw fails

UI Updates:
- Replace Critical/High/Medium/Low cards with new scoring model
- Now shows: High Interest (6+), Needs Review (3-5), Informational (0-2)
- Add Correlations count card
- Update counts based on device classification scores

Tracker Detection:
- Add detection for Apple AirTag (by OUI and name)
- Add detection for Tile trackers
- Add detection for Samsung SmartTag
- Add detection for ESP32/ESP8266 devices (Espressif chipset)
- Add generic chipset vendor detection
- New indicator types with appropriate scoring weights

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:28:54 +00:00
Smittix b15b5ad9ba Fix Bluetooth event type being overwritten by device type
The bt_device event was including 'type': device.get('type') which
overwrote the SSE event type 'bt_device' with 'ble', causing the
frontend to not recognize the events.

- Rename device type field from 'type' to 'device_type' in bt_device events
- Update frontend to use device_type for display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:20:41 +00:00
Smittix 364600e545 Fix Linux device detection with more fallback methods
- Restore airodump-ng check for WiFi tools
- Add /sys/class/net/*/wireless fallback for WiFi detection
- Add /sys/class/bluetooth/hci* fallback for Bluetooth detection
- Add hciconfig to Bluetooth tool checks
- Add SubprocessError to exception handling
- Multiple fallback layers ensure detection works even with partial tools

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:10:04 +00:00
Smittix 23b2a2a0c0 Fix TSCM device detection for macOS
- Add macOS-specific WiFi detection using airport utility
- Add macOS-specific Bluetooth detection using system_profiler
- Add fallback to 'iw' command on Linux when iwconfig unavailable
- Properly handle platform differences for device availability checks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:07:29 +00:00
Smittix ef6eec3cf8 Integrate TSCM correlation engine with sweep and add comprehensive reporting
- Integrate correlation engine into sweep loop for real-time device profiling
- Add API endpoints for findings (/tscm/findings, /tscm/findings/high-interest,
  /tscm/findings/correlations, /tscm/findings/device/<id>)
- Add meeting window endpoints (/tscm/meeting/start, /tscm/meeting/end, /tscm/meeting/status)
- Add comprehensive report generation endpoint (/tscm/report)
- Update frontend to display scores, indicators, and recommended actions
- Add correlation findings display and cross-protocol analysis
- Show sweep summary with assessment on completion
- Add client-safe legal disclaimers throughout UI and API responses
- Sort devices by score (highest first) for prioritized review

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:04:22 +00:00
Smittix 94f4682f2f Implement TSCM correlation engine and fix scanning issues
Correlation Engine (utils/tscm/correlation.py):
- Device profiles with comprehensive tracking
- Scoring model: 0-2 Informational, 3-5 Review, 6+ High Interest
- Cross-protocol correlation (BLE+RF, WiFi+RF, same vendor)
- Meeting window tracking for time correlation
- Device history for persistence detection
- Indicator types: unknown, audio-capable, persistent, cross-protocol, etc.

Bluetooth Scanning Fixes:
- Added multiple scan methods for Linux (hcitool, btmgmt, bluetoothctl)
- Fixed indentation issues in bluetoothctl scan
- Added comprehensive logging for debugging

RF Scanning Fixes:
- Added logging for each frequency band scan
- Better error reporting from rtl_power
- Increased timeout for reliability

Classification Updates:
- Green/Yellow/Red color coding with reasons
- Audio-capable device detection (microphone badge)
- Proper CSS styling for classification levels
2026-01-14 13:57:56 +00:00
Smittix f407a3cb54 Add TSCM device classification system
Classification levels:
- Green (Informational): Known devices in baseline, expected infrastructure
- Yellow (Needs Review): Unknown BLE devices, new WiFi APs, unidentified RF
- Red (High Interest): Persistent transmitters, audio-capable BLE, trackers,
  devices with repeat detections across scans

Features:
- Device history tracking for repeat detection (24-hour window)
- Audio-capable BLE detection (headphones, mics, speakers)
- Classification reasons shown under each device
- Color-coded indicators with visual styling
- Microphone badge for audio-capable BLE devices
2026-01-14 13:52:28 +00:00
Smittix c11c1200e2 Stream devices to dashboard in real-time during TSCM sweep
- Emit wifi_device, bt_device, rf_signal events as devices are found
- Add frontend handlers to populate device lists in real-time
- Add RF Signals panel to TSCM dashboard
- Dashboard now updates during sweep, not just at the end
2026-01-14 13:43:19 +00:00
Smittix 0acbf87dde Add RF scanning and improve TSCM device naming
- Add _scan_rf_signals() function using rtl_power to scan:
  - FM broadcast band (88-108 MHz) for potential bugs
  - 315/433/868/915 MHz ISM bands
  - 1.2 GHz video transmitter band
  - 2.4 GHz ISM band
- Integrate RF scanning into sweep with 60-second interval
- Add display_name field for all devices with friendly names
- Update frontend to use display_name in dropdowns
- Improve scan status display: '14 WiFi | 20 BT | 3 RF' instead of '14w 20b'
- Auto-select first SDR device when available
2026-01-14 13:40:13 +00:00
Smittix 153336d757 Fix TSCM SDR device detection
SDRFactory.detect_devices() returns SDRDevice dataclass objects,
not dictionaries. Fixed to access attributes directly instead of
using .get() method.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:28:07 +00:00
Smittix 570710c556 Implement TSCM device selection and actual scanning
- Add /tscm/devices endpoint to list available WiFi interfaces,
  Bluetooth adapters, and SDR devices
- Add _scan_wifi_networks() for actual WiFi scanning (macOS/Linux)
- Add _scan_bluetooth_devices() for actual Bluetooth scanning
- Update _run_sweep() to perform real scans with selected interfaces
- Add severity_counts tracking in progress events
- Fix frontend to correctly access device and severity data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:23:26 +00:00
Smittix de13d5ea74 Restore lost features and unify button styling
- Restore APRS dynamic device selection and status bar
- Add ACARS status indicator with listening/receiving states
- Fix acars.py: use -o 4 for JSON, correct command order, add macOS pty fix
- Unify all start buttons (green) and stop buttons (red) across app
- Update help documentation with all modes (APRS, ACARS, Listening Post, TSCM)
- Add TSCM Alpha badge to sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:20:52 +00:00
Smittix f36e528086 Add TSCM counter-surveillance mode (Phase 1)
Features:
- New TSCM mode under Security navigation group
- Sweep presets: Quick, Standard, Full, Wireless Cameras, Body-Worn, GPS Trackers
- Device detection with warnings when WiFi/BT/SDR unavailable
- Baseline recording to capture environment "fingerprint"
- Threat detection for known trackers (AirTag, Tile, SmartTag, Chipolo)
- WiFi camera pattern detection
- Real-time SSE streaming for sweep progress
- Futuristic circular scanner progress visualization
- Unified threat dashboard with severity classification

New files:
- routes/tscm.py - TSCM Blueprint with REST API endpoints
- data/tscm_frequencies.py - Surveillance frequency database
- utils/tscm/baseline.py - BaselineRecorder and BaselineComparator
- utils/tscm/detector.py - ThreatDetector for WiFi, BT, RF analysis

Database:
- tscm_baselines, tscm_sweeps, tscm_threats, tscm_schedules tables

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:35:13 +00:00
Smittix 52ce930c31 Fix listening post frequency display - move MHz beside frequency
- Changed layout from stacked to inline using flexbox
- MHz now appears beside the frequency number (118.000 MHz)
- Uses align-items: baseline for proper text alignment
- Modulation badge (AM/FM) remains below on its own row
- Increased MHz font size slightly for better visibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:35:13 +00:00
Smittix bb694c9926 Revamp APRS layout and restore satellite modal
APRS Layout:
- Redesigned visualization panel with flexbox layout
- Map panel now takes 2/3 width with station list on right (1/3)
- Station list has proper min/max width (280-350px)
- Packet log at bottom with max height
- Better use of space for all screen sizes

Satellite Features:
- Restored satellite modal (was missing HTML, only JS existed)
- Add Satellite (TLE) button for manual TLE input
- Update from Celestrak button with category selection
- Categories: Space Stations, Weather, NOAA, GOES, Amateur,
  CubeSats, Starlink, OneWeb, Iridium NEXT, Visual, Geo, Resources
- Tracked satellites list in sidebar
- Modal tabs for TLE input vs Celestrak fetch

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:35:13 +00:00
Smittix a8c77c8db3 Fix APRS mode integration and remove orphaned signalMeter references
- Add APRS to switchMode modeMap, modeNames, and titles
- Add aprsMode classList toggle
- Add aprsVisuals display toggle
- Add APRS to recon panel hide condition
- Add APRS to RTL-SDR device section visibility
- Stop APRS scan when switching modes
- Remove aprsMode inline style (let CSS class handle visibility)
- Remove signalMeter reference (element was previously deleted)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:35:13 +00:00
Smittix 3263638c57 Add APRS amateur radio tracking feature
- Create routes/aprs.py with start/stop/stream endpoints for APRS decoding
- Support multiple regional frequencies (North America, Europe, Australia, etc.)
- Use direwolf (preferred) or multimon-ng as AFSK1200 decoder
- Parse APRS packets for position, weather, messages, and telemetry
- Add APRS visualization panel with Leaflet map and station tracking
- Include station list with callsigns, distance, and last heard time
- Add packet log with raw APRS data display
- Register APRS blueprint and add global state management
- Add direwolf and multimon-ng to dependency definitions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:35:13 +00:00
Smittix c30e5800df Remove unused signal meter div from output header
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:32:23 +00:00
Smittix 161e0d8ea8 Make dependency guidance consistent across all documentation
- Update acarsdec install instruction to point to ./setup.sh
- Add ACARS Messaging to README features list
- Add acarsdec to README acknowledgments section
- All sources now consistently recommend ./setup.sh for acarsdec

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:32:23 +00:00
Smittix 93f68aa29d Fix inaccurate dependency information
- Fix multimon-ng GitHub URL typo (EliasOewornal -> EliasOenal)
- Fix acarsdec install info (not in apt repos, must build from source)
- Add hcxdumptool to quick install command
- Add note about acarsdec requiring source build with link to setup.sh

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:32:23 +00:00
Smittix c5ce35ff13 Fix right sidebar grid-column to 3
With ACARS in column 1 and map in column 2, the right sidebar
needs to be in column 3.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 7069c8b636 Fix map display by updating grid-column to 2
ACARS is now in column 1, so main-display (map) needs to be in column 2.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 6149427753 Move ACARS panel to left of map in adsb/dashboard
- Reorder HTML: ACARS sidebar now comes before main-display
- Update grid: auto 1fr 300px (ACARS, Map, Sidebar)
- Swap borders: right border on sidebar, left border on button
- Button now on right side of ACARS panel (bordering map)
- Icon changed to left arrow (collapse direction)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 536b762f97 Fix ACARS toggle button visibility by using flexbox layout
The parent container has overflow:auto which clipped the absolutely
positioned button. Changed to simple flexbox approach where:
- Button is a normal flex child (always visible)
- Content div collapses to width:0 when collapsed
- Map expands to fill available space via flex

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix b423dcedf7 Fix ACARS toggle button icon direction and positioning
- Fix checkAcarsTools error by removing orphaned function call
- Change toggle icon from left arrow to right arrow (indicates collapse direction)
- Fix button positioning to use left edge instead of right edge
- Button now correctly appears on left side of ACARS panel (bordering map)
- Both index.html and adsb_dashboard now behave consistently

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 16cd1fef2d Remove ACARS section from left sidebar menu
ACARS controls are now in the collapsible sidebar next to the map,
so the redundant section in the left settings panel is no longer needed.

- Remove ACARS Messaging section from aircraft mode settings
- Remove unused JS functions (toggleAcarsPanel, setAcarsRegion, etc.)
- Keep addAcarsToOutput helper used by main sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix c94d0a642d Fix ACARS sidebar to collapse outward and expand map
- Change collapsed width from 32px to 0 so map expands fully
- Position collapse button as absolute overlay on map edge
- Button slides to edge of map when collapsed (right: 100%)
- Content fades out smoothly instead of abrupt hide
- Applied same fix to both adsb_dashboard.css and index.html

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 135390788d Add ACARS aircraft messaging feature with collapsible sidebar
- Add routes/acars.py with start/stop/stream endpoints for ACARS decoding
- Build acarsdec from source in Dockerfile (not available in Debian slim)
- Add acarsdec installation script to setup.sh for native installs
- Add ACARS to dependency checker in utils/dependencies.py
- Add collapsible ACARS sidebar next to map in aircraft tracking tab
- Add collapsible ACARS panel in ADS-B dashboard with same layout
- Include guidance about needing two SDRs for simultaneous ADS-B + ACARS
- Support regional frequency presets (N.America, Europe, Asia-Pacific)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 98e4e38809 Fix install hang on Linux Mint/Ubuntu with newer kernels
The soapysdr-tools package pulls in xtrx-dkms, which fails to compile
its kernel module on Kernel 6.14+ and causes apt to hang. Explicitly
exclude xtrx-dkms since most users don't have XTRX hardware.

Fixes #56

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 6d5a12a21f Fix dump1090 not found in Docker by building from source
The dump1090 packages are not available in Debian slim repos, causing
the Docker build to silently skip installation. This builds dump1090-fa
from FlightAware's source repository instead.

Fixes #46

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
James Smith fe3b3b536c bug fixes and feature updates 2026-01-14 10:30:24 +00:00
James Smith aa8a6baac4 Fix macOS setup: build multimon-ng from source and improve UX
- Add install_multimon_ng_from_source_macos() since multimon-ng is not
  available in Homebrew core
- Fix brew_install() to properly check return code before printing success
- Show startup instructions before tool check so users see them on macOS
- Make missing Bluetooth tools a warning on macOS instead of hard failure
  (bluetoothctl/hcitool/hciconfig are Linux-only BlueZ utilities)
2026-01-12 13:15:59 +00:00
Smittix b0982249c3 Add device debug endpoint and fix RTL-SDR detection issues
- Add /devices/debug endpoint for detailed SDR detection diagnostics
- Add kernel driver blacklisting to setup.sh for Debian/Ubuntu
- Blacklists dvb_usb_rtl28xxu, rtl2832, rtl2830, r820t modules
- Update docs to use correct venv command: sudo -E venv/bin/python

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:14:36 +00:00
Smittix b3a8a69244 Merge pull request #52 from zielu92/patch-1
Update README.md with project details and features
2026-01-11 20:33:33 +00:00
Michał Zieliński 8cd1ecffc4 Update README.md with project details and features
Fixed typo in Docker Compose
2026-01-11 12:54:14 +01:00
Smittix 7967b71405 Merge pull request #49 from armegia/armegia
In Ubuntu Desktop 25.10, dump1090 is dump1090-mutability. Because of this, cmd_exists dump1090 fails even after successful apt install. Added code to create a symbolic link from /usr/local/sbin/dump1090 to the dump1090-mutability if it exists
2026-01-10 19:22:12 +00:00
Antonio M cd0d5971e2 In Ubuntu Desktop 25.10, dump1090 is dump1090-mutability. Because of this, cmd_exists dump1090 fails even after successful apt install. Added code to create a symbolic link from /usr/local/sbin/dump1090 to the dump1090-mutability if it exists 2026-01-10 18:54:56 +01:00
Smittix b52b4db989 Merge pull request #48 from JonanOribe/main
Add satellite route tests and update .gitignore
2026-01-10 15:04:16 +00:00
Jon Ander Oribe ef5cfb4908 Add satellite route tests and update .gitignore
Added tests for satellite-related routes, including validation, error handling, and mocking of external dependencies. Updated .gitignore to exclude database files (*.db, *.sqlite3) in addition to lock files.
2026-01-10 14:03:49 +01:00
Smittix ee7781ee67 Merge pull request #47 from JonanOribe/main
Testing for Wifi
2026-01-10 11:11:07 +00:00
Jon Ander Oribe 8c5bb32ec6 Testing for Wifi 2026-01-10 07:35:28 +01:00
Smittix 007400d2a7 Release v2.9.0 - iNTERCEPT rebrand and UI overhaul
- Rebrand from INTERCEPT to iNTERCEPT
- New logo design with 'i' and signal wave brackets
- Add animated landing page with "See the Invisible" tagline
- Fix tuning dial audio issues with debouncing and restart prevention
- Fix Listening Post scanner with proper signal hit logging
- Update setup script for apt-based Python package installation
- Add Instagram promo video template
- Add full-size logo assets for external use

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 01:00:17 +00:00
Smittix 1f60e64217 Improve apt_install error handling in setup script
Show actual error output when apt-get fails instead of silently
failing. Now displays which packages failed, the last 10 lines of
apt output, and suggests a manual fix command.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 12:53:02 +00:00
Smittix 69de7e4afd Add HackRF/Airspy troubleshooting guide for ADS-B and Listening Post
Document how to use non-RTL-SDR devices:
- ADS-B via readsb with SoapySDR (Remote mode or build from source)
- Listening Post via rx_fm from soapysdr-tools

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:22:26 +00:00
Smittix 29025059af Add HackRF/Airspy/LimeSDR/SDRPlay support to Listening Post
The Listening Post module now uses the SDR abstraction layer to support
non-RTL-SDR devices via rx_fm (SoapySDR). Previously only rtl_fm worked.

- Add sdr_type parameter to /audio/start and /scanner/start endpoints
- Use appropriate command builder based on SDR type
- Update /tools endpoint to report rx_fm and supported SDR types

Fixes compatibility issue reported by DragonOS users with HackRF/Airspy.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:20:26 +00:00
Smittix 6229c25872 Add aircraft watchlist feature to ADS-B dashboard
- Add/remove callsigns, registrations, or ICAO codes to watch
- Alert notification and sound when watched aircraft detected
- Filter view to show only watched aircraft
- Visual highlighting with cyan border and star icon
- Watchlist persisted to localStorage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:13:49 +00:00
Smittix 73ac74a9d6 Add clickable squawk code reference on aircraft dashboard
Click any aircraft's squawk code to see its meaning and a full
reference table of common codes. Emergency codes highlighted in red.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:03:42 +00:00
Smittix ebb1e233d8 Add tagline 'See the Invisible' to branding
Update browser titles and headers across all pages with the new
tagline. Add 'Signal Intelligence Platform' as subtitle.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:55:41 +00:00
Smittix e719e32c73 Add SDRPlay device support via SoapySDR
Adds support for SDRPlay RSP devices (RSPdx, RSP1A, RSPduo, etc.)
through the SoapySDR interface. Closes #44.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:55:41 +00:00
Smittix 46ab5fe78d Update CHANGELOG for version 2.0.0 2026-01-08 20:15:31 +00:00
Smittix dc467aef91 Update README with new features and installation steps 2026-01-08 17:44:08 +00:00
Smittix 0bc915fe1f Add files via upload 2026-01-08 17:43:49 +00:00
Smittix b7f9ad786a Delete static/images/screenshots/logo-banner.png 2026-01-08 17:43:35 +00:00
Smittix 6c80521cf8 Add files via upload 2026-01-08 17:39:51 +00:00
Smittix a174884269 Remove LoRa/ISM mode (redundant with 433MHz)
The LoRa mode was removed because:
- rtl_433 cannot decode actual LoRa (CSS modulation)
- The 433MHz mode already handles ISM band devices
- True LoRa decoding requires specialized tools like gr-lora

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:07:36 +00:00
Smittix f3b1865a79 Add LoRa/ISM band monitoring mode
- Add new LoRa backend route (routes/lora.py) with:
  - Frequency band definitions (EU868, US915, AU915, AS923, IN865, ISM433)
  - Start/stop/stream/status endpoints using rtl_433
  - Device pattern matching for LoRa/LPWAN devices
  - Signal quality calculation from RSSI

- Add LoRa frontend UI with:
  - Navigation button in SDR/RF group
  - Band selector with channel presets
  - Visualization layout (radar, device types, signal quality, activity log)
  - Device card list with selection details
  - Header stats for devices and signals

- Fix Bias-T toggle visibility for Listening Post and LoRa modes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:56:21 +00:00
Smittix 6c99651ac9 Move Bias-T toggle to prominent location in sidebar
Moved from bottom of sidebar to right after device capabilities,
before the Refresh Devices button. Now has orange gradient
background and is immediately visible without scrolling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:43:53 +00:00
Smittix 0aaf888dd1 Make Bias-T toggle more prominent in sidebar
- Changed to orange styling to stand out
- Added "Power Settings" header with lightning icon
- Larger checkbox
- Added description text explaining purpose

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:40:30 +00:00
Smittix d947ce17a3 Hide waterfall and output console for Bluetooth mode
Bluetooth mode now has its own dedicated layout with device cards,
so the generic waterfall and output panels are hidden.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:57:26 +00:00
Smittix 97c957b70f Fix Bluetooth RSSI capture from initial device discovery
When a new device is discovered with bluetoothctl, extract and
capture the RSSI value from the discovery line instead of
discarding it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:56:55 +00:00
Smittix 82830c86ac Fix listening post error, hide signal meter, improve BT detection
- Fix scannerStatus -> scannerStatusText element reference
- Hide global signal meter (individual panels show signal)
- Expand Bluetooth device classification patterns
- Add more audio, phone, wearable, computer patterns
- Add manufacturer-based device type inference

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:55:51 +00:00
Smittix d8e4189100 Fix listening post tuneToFrequency null element error
The code referenced 'scannerStatus' but the element ID is
'scannerStatusText'. Fixed all instances to use the correct ID.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:44:53 +00:00
Smittix 6bcde56525 Remove debug console.log statements
Clean up temporary debug logging from channel recommendation
and WiFi client card debugging.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:43:11 +00:00
Smittix 88ebe3c337 Redesign Bluetooth page to match WiFi layout
HTML:
- Create bt-layout-container with flex layout
- Left side: visualizations (radar, selected device, device types,
  tracker detection, signal distribution, FindMy detection)
- Right side: scrollable device card list

CSS:
- Add bt-layout-container styles matching wifi-layout-container
- Add bt-device-card styles with purple accent
- Add device type overview styles
- Add signal distribution bar styles
- Add responsive breakpoints

JavaScript:
- Update addBtDeviceCard to create cards in new device list
- Add selectBtDevice for device selection
- Add updateBtStatsPanels for device type and signal stats
- Add updateBtFindMyList for FindMy device tracking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:41:56 +00:00
Smittix 5f4d1b05a8 Add debug logging for channel recommendation
Temporary console.log statements to diagnose why channel
recommendation always shows 1 and 36.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:37:33 +00:00
Smittix 370c46bddb Add CSS styling for WiFi client cards
- Add .wifi-client-card styles matching network card layout
- Purple border and subtle background for visual distinction
- Consistent font sizing with network cards

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:35:17 +00:00
Smittix 47b5e03bbb Add debug logging for WiFi client card issues
Temporary console.log statements to diagnose why clients
are not appearing in the device list.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:32:41 +00:00
Smittix 556ca59a99 Fix WiFi client updates, rogue AP detection, and channel recommendation
Backend:
- Send client updates when probes or signal change significantly
- Previously only new clients were reported, updates were ignored

Frontend:
- Add client cards to device list (was only showing networks)
- Fix rogue AP detection to check OUI - excludes legitimate mesh systems
- Improve channel recommendation with detailed usage breakdown
- Show per-channel interference counts for 2.4GHz
- Show unused channel count for 5GHz

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:27:15 +00:00
Smittix 81c5af474d Add visual rogue AP indicator for suspected evil twin detection
- Add rogueBssids Set to track all BSSIDs flagged as rogues
- Display red banner with pulsing animation on rogue network cards
- Apply red border and background tint to rogue AP cards
- Show prominent warning in selected device panel for rogue APs
- Change SSID color to red when viewing rogue AP details

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 14:18:53 +00:00
Smittix cdaee3f62f Fix hidden SSID, probe analysis, and device correlation
Hidden SSID fixes:
- Only track networks that are originally hidden (empty/Hidden ESSID)
- Reveal hidden SSIDs when network ESSID changes or client probes match
- Show count of hidden networks being monitored
- Show revealed SSIDs with checkmark

Probe analysis fixes:
- Call scheduleProbeAnalysisUpdate when client has probes
- Add periodic updateProbeAnalysis call every 2 seconds
- Properly trigger probe analysis from client handler

Other fixes:
- Remove drawNetworkGraph call (topology panel was removed)
- Add updateHiddenSsidDisplay to periodic updates
- Improve panel messages when no data available

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:56:41 +00:00
Smittix aab4288f67 Reorganize WiFi panels, remove PMKID, add aircrack integration
Layout changes:
- Security overview now next to network radar
- Channel utilization (2.4 GHz and 5 GHz) side by side
- Removed network topology panel
- Removed PMKID capture panel and functionality

Handshake improvements:
- Added "Crack with Aircrack-ng" button when handshake is captured
- Added /wifi/handshake/crack backend route
- Prompts for wordlist path with common defaults
- Shows password when found with notification
- 5 minute timeout with helpful error message

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:50:14 +00:00
Smittix bab49e4442 Improve WiFi layout and fix capture functionality
Layout changes:
- Move device list to right column beside visualizations
- Add dedicated WiFi device list panel with header and count
- Hide waterfall and generic output for WiFi mode
- Add responsive styles for smaller screens

Capture fixes:
- Fix handshake capture: add interface param, stop existing scan first
- Fix PMKID capture: add interface param, stop existing scan first
- Add proper error handling with try/catch for both capture functions
- Use monitorInterface variable when available

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:43:11 +00:00
Smittix 7608aca681 Hide waterfall canvas in WiFi mode
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:35:34 +00:00
Smittix 58907bdc4d Fix WiFi layout and remove redundant Target Signal panel
- Add min-height: 0 to output-panel to fix grid overflow scrolling
- Add min-height: 200px to output-content for device cards visibility
- Add max-height: 50vh to wifi-visuals to leave room for device list
- Make wifi-visuals scrollable when content exceeds max-height
- Remove Target Signal panel (redundant with Selected Device panel)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:33:12 +00:00
Smittix 8dfd92082c Move Selected Device panel to top of WiFi visuals
Moves the Selected Device panel to be the first element in the
WiFi visualizations grid so it's visible without scrolling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:29:15 +00:00
Smittix e39304da90 Add aircraft image panel to ADS-B tab
- Add new panel to display aircraft photos when selected
- Fetch photos from planespotters API via /adsb/aircraft-photo endpoint
- Cache photos to avoid repeated API calls
- Show loading, no photo, and placeholder states appropriately
- Reduced map panel to span 2 to accommodate new image panel

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:26:47 +00:00
Smittix 31fd3f3f63 Add WiFi device selection and fix signal strength display
- Add Selected Device panel showing detailed info for networks/clients
- Make network cards clickable to view details
- Make probe analysis client entries clickable
- Fix signal strength to show "N/A" when airodump returns -1
- Add visual signal meter and action buttons to selected device panel

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:23:27 +00:00
Smittix e1ab24b36b Improve WiFi monitor mode and scan interface detection
- Verify monitor interface actually exists before returning success
- Check for common interface naming patterns (wlan0mon, wlan1mon, etc.)
- Add interface existence check before starting scan
- Show available interfaces in error messages for debugging
- Better logging of monitor mode and scan failures

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:11:23 +00:00
Smittix f5b92ddcf9 Fix ADS-B tab and improve WiFi scanning feedback
- Hide waterfall and output panels in aircraft mode (use dedicated visualization)
- Add aircraft list panel with clickable aircraft selection
- Add selected aircraft info panel showing altitude, speed, heading, squawk
- Add "Open Full Dashboard" button linking to dedicated aircraft radar
- Add debugging console logs and alert messages to WiFi scan function
- Better error feedback when WiFi interface not selected or scan fails

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:08:36 +00:00
Smittix d9ee87d4b4 Improve WiFi device selection visibility and error handling
- Added "Select Device" label to WiFi adapter dropdown
- Better error handling with user notifications when interfaces fail to load
- Shows loading state while detecting interfaces
- Clearer notification messages for found/missing interfaces

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:01:47 +00:00
Smittix 5e83db54ac Fix WiFi section scrolling to show channel recommendation and correlation
- Changed output-panel overflow from hidden to auto
- Allows scrolling to see all content including bottom panels

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:59:30 +00:00
Smittix de7b12a759 Streamline WiFi scanning with auto monitor mode and better device detection
- Auto-enable monitor mode when clicking Start Scanning (no manual step needed)
- Improved WiFi interface detection using airmon-ng for chipset info
- Added lsusb fallback for USB adapter identification
- Fixed interface display format in enableMonitorMode callback
- Better error handling and status notifications during scan startup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:57:09 +00:00
Smittix 1236011174 Improve WiFi device identification, remove signal history, fix Listen button
- WiFi interfaces now show driver, chipset, and MAC address for easier identification
- Remove signal history feature from WiFi and Bluetooth sections (HTML, JS, CSS, API)
- Fix Listen button in Listening Post signal hits to properly tune to frequency
- Make stopAudio() async and improve tuneToFrequency() with proper awaits
- Fix Device Intelligence panel auto-expand and manufacturer display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:53:38 +00:00
Smittix b60f2cdf81 Parse Bluetooth RSSI from bluetoothctl output
- Capture RSSI from [CHG] Device XX:XX RSSI: -XX lines
- Update device RSSI in real-time as bluetoothctl reports changes
- Auto-refresh selected device panel when RSSI updates
- Preserve RSSI when merging device updates

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:40:49 +00:00
Smittix 0c310ab068 Add Bluetooth selected device details panel
- New panel shows full device details when clicked
- Displays name, type, RSSI with signal bars, MAC, manufacturer
- Shows tracker/FindMy badges for detected trackers
- Buttons to enumerate services and copy MAC address
- Reorganized BT visualization layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:37:03 +00:00
Smittix a87f66cc0c Enlarge Selected Target panel for aircraft photos
- Increased max-height from 280px to 480px
- Added styling for photo container with cyan border
- Photo limited to 140px height with cover fit

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:34:12 +00:00
Smittix c05756357f Add requests to requirements.txt
Required for aircraft photo API calls.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:30:45 +00:00
Smittix f4b4b5febd Add Bluetooth device list and tracker panels
- Added clickable device list panel sorted by signal strength
- Added dedicated tracker detection panel for AirTags/Tiles
- Clicking a device selects it for signal tracking and targeting
- Devices show name, MAC, RSSI with color-coded signal strength

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:29:25 +00:00
Smittix 805290b17f Fix Listening Post audio issues
- Add auto-reconnect on audio player errors/stalls
- Fix tuneToFrequency to properly wait between stop/start
- Improve ffmpeg encoding: 96k bitrate, 44.1kHz output, low latency flags
- Increase stream chunk size for smoother playback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:26:55 +00:00
Smittix fecc2237b8 Add aircraft photos from Planespotters.net
- Backend route to proxy photo requests from Planespotters API
- Frontend displays photo in Selected Target panel when available
- Photos are cached to avoid repeated API calls
- Clicking photo links to full image on Planespotters.net

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:22:29 +00:00
Smittix 471cc1ee94 Add bias_t parameter to build_adsb_command()
Completes bias-T support across all SDR command builder methods.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:17:02 +00:00
Smittix 41ebf59964 Remove bufsize=1 from sensor subprocess
Line buffering only works with text mode, not binary pipes.
Fixes RuntimeWarning about line buffering.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:15:14 +00:00
Smittix a5e9a3e1ce Add bias_t parameter to build_ism_command()
Same fix as build_fm_demod_command() - the parameter was being
passed but not defined in the method signatures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:08:38 +00:00
Smittix 23689d9fe1 Add bias_t parameter to SDR command builders
The bias_t parameter was being passed to build_fm_demod_command()
but wasn't defined in the method signatures, causing an unexpected
keyword argument error.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:05:11 +00:00
Smittix 601d432fbf Add python3-venv to Debian package installs
Required for creating Python virtual environments on Ubuntu/Debian.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:00:23 +00:00
Smittix a21e9c508e Fix unbound variable error in dump1090 build
Wrap build in subshell to isolate EXIT trap, preventing
orig_dir unbound variable error at script exit.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:58:14 +00:00
Smittix 55b0c0509d Add progress bar to setup script
Shows step counter with visual progress bar during installation:
[3/15] ██████░░░░░░░░░░░░░░ 20% - Installing ffmpeg

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:55:17 +00:00
Smittix 563c6b79fa Suppress needrestart prompts on Ubuntu Server
Set DEBIAN_FRONTEND and NEEDRESTART_MODE to prevent the
"scanning processes/candidates/microcode" messages during apt installs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:48:10 +00:00
Smittix 8d9e5f9d56 Add security hardening and bias-t support
Security improvements:
- Add interface name validation to prevent command injection
- Fix XSS vulnerability in pager message display
- Add security headers (X-Content-Type-Options, X-Frame-Options, etc.)
- Disable Werkzeug debug PIN
- Add security documentation

Features:
- Add bias-t power support for SDR dongles across all modes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:29:24 +00:00
Smittix c0f6ccaf2a Merge pull request #36 from JonanOribe/main
Thanks for the contribution! The Bluetooth tests look great and will help ensure stability as we continue development.
2026-01-08 10:38:04 +00:00
Jon Ander Oribe 9b3e4ec7fb Merge branch 'main' into main 2026-01-08 06:57:33 +01:00
Jon Ander Oribe 9d45eb21a4 Update .gitignore 2026-01-08 06:51:08 +01:00
Jon Ander Oribe bcf8fe59f5 Delete uv.lock 2026-01-08 06:47:15 +01:00
Jon Ander Oribe 5b411456c7 Update 2026-01-08 06:46:52 +01:00
Smittix 4432816934 Update README.md with project details and features 2026-01-07 21:45:09 +00:00
Smittix 5277537445 Update troubleshooting guide with new issues and solutions
Added additional troubleshooting steps for ADS-B mode and installation issues.
2026-01-07 21:44:22 +00:00
Smittix e73ce8cd8f Update README with new features and installation details 2026-01-07 21:38:54 +00:00
Smittix 120015d133 Update troubleshooting guide for clarity and details 2026-01-07 21:30:21 +00:00
Smittix f85cf61019 Revise hardware setup and installation instructions
Updated hardware setup documentation with new installation instructions and tool references.
2026-01-07 21:26:56 +00:00
Smittix 41226d173a Revise README for better clarity and organization
Updated README to improve clarity and structure, including installation instructions and troubleshooting.
2026-01-07 21:23:51 +00:00
Smittix 83244c85fe Add uv.lock to .gitignore
Prevent accidental commits of uv lock files since we use
requirements.txt for dependency management.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:46:14 +00:00
Smittix 27dd868d97 Add pager message filtering (closes #40)
Add ability to filter out unwanted pager messages from display:
- Hide "Tone Only" messages by default (toggle in UI)
- Custom keyword filter (comma-separated list)
- Filtered messages are still logged and counted, just hidden from view
- Filter settings persist in localStorage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:14:46 +00:00
Smittix 45b35ea5b0 Consolidate setup scripts into single setup.sh
Merge the improved setup-dev.sh logic into setup.sh and remove the
separate dev script. The consolidated script includes:
- Stricter bash error handling (set -Eeuo pipefail)
- Cleaner output with info/ok/warn/fail helpers
- gpsd installation for GPS daemon support
- Required tools verification with hard fail
- Source build fallback for dump1090

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:07:40 +00:00
Smittix ac8b9f82cd Add gpsd installation to setup-dev.sh
Include gpsd daemon in the setup script for both macOS (via Homebrew)
and Debian/Ubuntu (via apt with gpsd-clients). Also add gpsd to the
required tools check.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:04:57 +00:00
Smittix 9d0e417f2a Simplify GPS to gpsd-only and streamline UI controls
Remove direct serial GPS dongle support in favor of gpsd daemon connectivity.
The UI now auto-connects to gpsd on page load and shows a GPS indicator when connected.
Simplify ADS-B dashboard controls bar for a cleaner, more compact layout.
Add setup-dev.sh for streamlined development environment setup.

- Remove GPSReader class and NMEA parsing (utils/gps.py)
- Consolidate to GPSDClient only with auto-connect endpoint
- Add GPS indicator with pulsing dot animation
- Compact controls bar with smaller fonts and tighter spacing
- Add aircraft database download banner/functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 19:49:58 +00:00
Smittix 40369ccb7b Support /usr/sbin paths for aircrack-ng on Debian and add hcxtools
- Add get_tool_path() to check /usr/sbin and /sbin for tools
- Update wifi.py to use full paths for airmon-ng, airodump-ng, aireplay-ng, aircrack-ng
- Add hcxdumptool and hcxtools to setup.sh for Debian and macOS
- Update check_cmd() in setup.sh to also check /usr/sbin and /sbin

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 18:20:39 +00:00
Smittix 61ef3f7bdd Fix ADS-B tracking and aircraft database lookup
- Add debug stats (bytes_received, lines_received) to diagnose connection issues
- Capture stderr from dump1090 to show actual error messages on failure
- Add dump1090_running status to /adsb/status endpoint
- Fix aircraft_db.lookup() to handle Mictronics array format [reg, type, flags]
  instead of expecting dict format {r: reg, t: type}
- Add logging for first few SBS lines to help debug parsing issues

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:42:17 +00:00
Smittix bcb1a825d3 Prevent duplicate signal hits in Listening Post
Added duplicate detection to addSignalHit():
- Tracks recent signals by frequency in a Map
- Ignores same frequency within 5 seconds
- Auto-cleans entries older than 30 seconds

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:33:38 +00:00
Smittix 1f7a3fe664 Fix SDRType.LIMESDR -> SDRType.LIME_SDR typo
The enum is LIME_SDR (with underscore) but the code used LIMESDR,
causing an AttributeError on /adsb/tools endpoint.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:24:06 +00:00
Smittix dcd855896e Add pauses after each installation step for readability
Each package install now pauses for 1 second after completion
so the user can see what happened before it scrolls away.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:13:22 +00:00
Smittix 4778134ab6 Fix setup.sh - remove set -e, improve dump1090 build
- Changed set -e to set +e - handle errors explicitly instead
- set -e was causing silent early exits on any failure
- Improved dump1090 build with more dependencies
- Added BLADERF=no to skip optional BladeRF dependency
- Falls back to simpler antirez/dump1090 if FlightAware fails
- Better success/failure output for each package

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:03:12 +00:00
Smittix 300b19d1d6 Simplify Debian tool installation - always install everything
- Remove conditional MISSING_* checks - just install all tools
- apt-get will skip already-installed packages anyway
- Add verification step at end showing what actually got installed
- Better output showing success/failure for each package
- This fixes issues where flag-based logic was failing silently

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:00:27 +00:00
Smittix 945ae33361 Fix setup.sh to install system tools before Python deps
- Reorder: check tools -> install tools -> Python deps
- This ensures system tools install even if pip fails
- Make pip failures non-fatal (continue with warning)
- Auto-install python3-venv if needed on Debian
- Don't exit on venv creation failure, continue with tools

The previous order meant if pip failed (common on fresh installs),
the script would exit before installing rtl-sdr, multimon-ng, etc.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:57:57 +00:00
Smittix dbbcb6c5cc Add dump1090 build from source when not in repositories
On newer Debian versions (like Trixie), dump1090 isn't available
in the package repositories. Now automatically builds from the
FlightAware GitHub repo as a fallback:

- Installs build dependencies (build-essential, librtlsdr-dev, etc.)
- Clones from github.com/flightaware/dump1090
- Compiles and installs to /usr/local/bin

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:53:38 +00:00
Smittix 016959ad7c Make setup.sh fully automatic - no prompts required
- Remove all Y/n prompts for tool installation
- Install tools automatically when missing
- Show clear progress for each package being installed
- Show warnings if individual packages fail instead of silent failure
- Changed apt to apt-get for better script compatibility
- Auto-setup udev rules on Linux without prompting
- Auto-install Homebrew on macOS if missing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:52:19 +00:00
Smittix 7a9599786c Add error handling to checkStatus() to prevent console errors
The periodic /status check was throwing uncaught promise errors
when the server was unavailable or restarting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:48:42 +00:00
Smittix fa537390c5 Fix audio stream errors and improve error handling
- Fix "no supported source" error by returning empty audio response
  instead of JSON when audio not running (browser can't parse JSON)
- Add wait loop in stream endpoint to handle race condition with start
- Add logging for rtl_fm and ffmpeg commands
- Capture stderr to log actual process errors
- Check if processes exit immediately and log reason
- Improved error messages for users

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:42:55 +00:00
Smittix bb24bdb06c Fix aircraft dashboard audio endpoints (404 error)
Changed /spectrum/audio/* to /listening/audio/* to match the
actual listening_post blueprint URL prefix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:37:36 +00:00
Smittix b55100d5c3 Fix misleading 'No RTL-SDR devices found' warning in Listening Post
The warning was triggered when ffmpeg was missing, but the message
incorrectly said "No RTL-SDR devices found". Now properly shows
"ffmpeg not found" with installation instructions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:17:49 +00:00
Smittix 02cb9c751a Fix audio dependency to use ffmpeg instead of sox
The Listening Post actually uses ffmpeg for audio encoding, not sox.
Updated all documentation, setup scripts, and code to reflect this:

- Removed unused find_sox() function from listening_post.py
- Simplified tools endpoint to only check for ffmpeg
- Updated CHANGELOG, README, HARDWARE.md, Dockerfile
- Fixed setup.sh to check for ffmpeg
- Updated frontend warnings to mention ffmpeg

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:53:54 +00:00
Smittix 8555938f52 Add readsb warning for HackRF/LimeSDR ADS-B tracking
- Enhanced /adsb/tools endpoint to detect SoapySDR hardware and check for readsb
- Added UI warning in aircraft dashboard when HackRF/LimeSDR is detected but readsb is missing
- Warning includes expandable installation instructions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:46:36 +00:00
Smittix a2a3ea62f1 Fix SoapySDR detection and HackRF ADS-B error message
- Try multiple SoapySDR utility names: SoapySDRUtil, soapy_sdr_util, soapysdr-util
- Improve error message for HackRF/LimeSDR ADS-B to mention readsb requirement

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:38:35 +00:00
Smittix 0d5310eb4b Fix DataStore subscript access for ADS-B tracking
Add __getitem__, __setitem__, and __delitem__ methods to DataStore
class to support dict-style subscript notation (store[key]).

Fixes TypeError: 'DataStore' object is not subscriptable

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:35:51 +00:00
Smittix 5c6bd5d65a Release v2.0.0
- Add Listening Post mode with frequency scanner
- Add device correlation and settings system
- Overhaul documentation and setup script
- Update Dockerfile with all dependencies
- Add comprehensive test suite

See CHANGELOG.md for full details.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:55:08 +00:00
Smittix dcb1b4e3a6 Merge remote changes, keep Acknowledgments section 2026-01-06 17:35:22 +00:00
Smittix b5547d3fa9 Add Listening Post, improve setup and documentation
- Add Listening Post mode with frequency scanner and audio monitoring
- Add dependency warning for aircraft dashboard listen feature
- Auto-restart audio when switching frequencies
- Fix toolbar overflow on aircraft dashboard custom frequency
- Update setup script with full macOS/Debian support
- Clean up README and documentation for clarity
- Add sox and dump1090 to Dockerfile
- Add comprehensive tool reference to HARDWARE.md
- Add correlation, settings, and database utilities
- Add new test files for routes, validation, correlation, database

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 17:34:53 +00:00
Jon Ander Oribe a5a2692a5f Testing for Bluetooth & Black without running 2026-01-06 08:20:33 +01:00
Smittix 7a112c84be Update FEATURES.md 2026-01-05 23:29:13 +00:00
Smittix b3b3566a27 Update README.md 2026-01-05 23:22:51 +00:00
Smittix f77c501db6 Add files via upload 2026-01-05 23:17:43 +00:00
Smittix 68e179bfd2 Fix SoapySDR device detection for RTL-SDR and HackRF
Previously, RTL-SDR devices from SoapySDR were unconditionally skipped,
even if native rtl_test wasn't available. Now:

- Native detection runs first for RTL-SDR and HackRF
- SoapySDR only skips device types that were already found natively
- If native tools aren't available, SoapySDR detection is used as fallback

This fixes the issue where users with only SoapySDR installed couldn't
see their RTL-SDR or HackRF devices.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:30:08 +00:00
Smittix 20d9178159 Bump version to 1.2.0
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:21:33 +00:00
Smittix b2c32173e1 Add Airspy SDR support and persist GPS coordinates
Airspy support:
- Add AIRSPY to SDRType enum and driver mappings
- Create AirspyCommandBuilder using SoapySDR tools (rx_fm, readsb, rtl_433)
- Register in SDRFactory and add to hardware type dropdown
- Supports Airspy R2/Mini (24MHz-1.8GHz) and HF+ devices

GPS coordinate persistence:
- Save observer location to localStorage when manually entered or via geolocation
- Restore saved coordinates on page load in both index.html and adsb_dashboard.html
- Coordinates are shared between both pages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:04:43 +00:00
Smittix 82a2883f82 Update HARDWARE.md 2026-01-05 17:33:10 +00:00
Smittix 1807d736b1 Update HARDWARE.md 2026-01-05 17:23:25 +00:00
Smittix f2b1839fdc Check dump1090 availability per Debian version
- Try dump1090-fa, dump1090-mutability, dump1090 variants
- Use pkg_available() to check actual installability
- Skip gracefully on Debian Trixie and other versions without it
- Only show manual install message if not already installed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:54:48 +00:00
Smittix 564ef3706f Skip dump1090 apt install - not in Debian repos
- dump1090 is not available in standard Debian repositories
- Show manual installation instructions with FlightAware link
- Add pkg_available() helper for reliable package checks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:42:51 +00:00
Smittix 417fa280c3 Handle missing dump1090 and rtl-433 packages gracefully
- Check apt-cache for package availability before installing
- Try dump1090-fa, dump1090-mutability, then dump1090 variants
- Try rtl-433 and rtl433 package names
- Show helpful message with FlightAware install link if dump1090 unavailable
- Update manual instructions with correct package info

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:39:05 +00:00
Smittix 5077e56d76 Handle running as root without sudo on Debian
- Add setup_sudo() to detect root user or check for sudo availability
- Use $SUDO variable instead of hardcoded sudo commands
- Show appropriate commands in error messages based on privilege level
- Exit with helpful message if not root and sudo unavailable

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:35:40 +00:00
Smittix 3a7c429c4b Add automatic tool installation for Debian/Ubuntu
- Automatically install missing SDR, WiFi, and Bluetooth tools on Debian
- Prompt user before installing (can decline for manual instructions)
- Auto-setup RTL-SDR udev rules with user confirmation
- Track missing tools by category (core, wifi, bluetooth)
- Keep manual instructions for macOS, Arch, and other distros

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:31:04 +00:00
Smittix f7ccd56ec0 Fix setup.sh venv detection on fresh Debian installs
- Check for venv/bin/activate file instead of just venv directory
- Add error handling when python3-venv package is not installed
- Clean up incomplete venv directories from failed attempts
- Provide helpful instructions to install python3-venv

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:24:49 +00:00
Smittix 1f2a7ee523 Add uv installation instructions and update pyproject.toml
- Add uv quick start option to README (collapsible section)
- Add Python Dependencies section to HARDWARE.md with multiple options:
  - setup.sh (recommended)
  - pip with requirements.txt
  - uv with uv sync
  - pip with pyproject.toml
- Update pyproject.toml:
  - Bump version to 1.1.0
  - Add pyserial to dependencies
  - Add project URLs
2026-01-05 10:12:03 +00:00
84 changed files with 29599 additions and 2902 deletions
+6
View File
@@ -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
+137
View File
@@ -0,0 +1,137 @@
# Changelog
All notable changes to iNTERCEPT will be documented in this file.
## [2.9.5] - 2026-01-14
### Added
- **MAC-Randomization Resistant Detection** - TSCM now identifies devices using randomized MAC addresses
- **Clickable Score Cards** - Click on threat scores to see detailed findings
- **Device Detail Expansion** - Click-to-expand device details in TSCM results
- **Root Privilege Check** - Warning display when running without required privileges
- **Real-time Device Streaming** - Devices stream to dashboard during TSCM sweep
### Changed
- **TSCM Correlation Engine** - Improved device correlation with comprehensive reporting
- **Device Classification System** - Enhanced threat classification and scoring
- **WiFi Scanning** - Improved scanning reliability and device naming
### Fixed
- **RF Scanning** - Fixed scanning issues with improved status feedback
- **TSCM Modal Readability** - Improved modal styling and close button visibility
- **Linux Device Detection** - Added more fallback methods for device detection
- **macOS Device Detection** - Fixed TSCM device detection on macOS
- **Bluetooth Event Type** - Fixed device type being overwritten
- **rtl_433 Bias-T Flag** - Corrected bias-t flag handling
---
## [2.9.0] - 2026-01-10
### Added
- **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 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 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
- **Settings System** - SQLite-based persistent settings storage
- **Comprehensive Test Suite** - Added tests for routes, validation, correlation, database
### Changed
- **Documentation Overhaul**
- Simplified README with clear macOS and Debian installation steps
- Added Docker installation option
- Complete tool reference table in HARDWARE.md
- Removed redundant/confusing content
- **Setup Script Rewrite**
- Full macOS support with Homebrew auto-installation
- Improved Debian/Ubuntu package detection
- Added ffmpeg to tool checks
- Better error messages with platform-specific install commands
- **Dockerfile Updated**
- Added ffmpeg for Listening Post audio encoding
- Added dump1090 with fallback for different package names
### Fixed
- SoapySDR device detection for RTL-SDR and HackRF
- Aircraft dashboard toolbar layout when using custom frequency input
- Frequency switching now properly stops/restarts audio
### Technical
- Added `utils/constants.py` for centralized configuration values
- Added `utils/database.py` for SQLite settings storage
- Added `utils/correlation.py` for device correlation logic
- Added `routes/listening_post.py` for scanner endpoints
- Added `routes/settings.py` for settings API
- Added `routes/correlation.py` for correlation API
---
## [1.2.0] - 2026-12-29
### Added
- Airspy SDR support
- GPS coordinate persistence
- SoapySDR device detection improvements
### Fixed
- RTL-SDR and HackRF detection via SoapySDR
---
## [1.1.0] - 2026-12-18
### Added
- Satellite tracking with TLE data
- Full-screen dashboard for aircraft radar
- Full-screen dashboard for satellite tracking
---
## [1.0.0] - 2026-12-15
### Initial Release
- Pager decoding (POCSAG/FLEX)
- 433MHz sensor decoding
- ADS-B aircraft tracking
- WiFi reconnaissance
- Bluetooth scanning
- Multi-SDR support (RTL-SDR, LimeSDR, HackRF)
+60 -3
View File
@@ -3,22 +3,71 @@
FROM python:3.11-slim
LABEL maintainer="INTERCEPT Project"
LABEL description="Signal Intelligence Platform for SDR monitoring"
# Set working directory
WORKDIR /app
# Install system dependencies for RTL-SDR tools
# Install system dependencies for SDR tools
RUN apt-get update && apt-get install -y --no-install-recommends \
# RTL-SDR tools
rtl-sdr \
librtlsdr-dev \
libusb-1.0-0-dev \
# 433MHz decoder
rtl-433 \
# Pager decoder
multimon-ng \
# Audio tools for Listening Post
ffmpeg \
# WiFi tools (aircrack-ng suite)
aircrack-ng \
iw \
wireless-tools \
# Bluetooth tools
bluez \
# Cleanup
bluetooth \
# GPS support
gpsd-clients \
# Utilities
curl \
procps \
&& 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
@@ -28,13 +77,21 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create data directory for persistence
RUN mkdir -p /app/data
# Expose web interface port
EXPOSE 5050
# Environment variables with defaults
ENV INTERCEPT_HOST=0.0.0.0 \
INTERCEPT_PORT=5050 \
INTERCEPT_LOG_LEVEL=INFO
INTERCEPT_LOG_LEVEL=INFO \
PYTHONUNBUFFERED=1
# Health check using the new endpoint
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -sf http://localhost:5050/health || exit 1
# Run the application
CMD ["python", "intercept.py"]
+71 -55
View File
@@ -8,29 +8,78 @@
<p align="center">
<strong>Signal Intelligence Platform</strong><br>
A web-based front-end for signal intelligence tools.
A web-based interface for software-defined radio tools.
</p>
<p align="center">
<img src="static/images/screenshots/screenshot2.png" alt="Screenshot">
<img src="static/images/screenshots/logo-banner.png" alt="Screenshot">
</p>
---
## What is INTERCEPT?
INTERCEPT provides a unified web interface for signal intelligence tools:
## Features
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
- **433MHz Sensors** - Weather stations, TPMS, IoT via rtl_433
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map
- **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 Recon** - Monitor mode scanning via aircrack-ng
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
- **Bluetooth Scanning** - Device discovery and tracker detection
---
## Community
## Installation / Debian / Ubuntu / MacOS
```
**1. Clone and run:**
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
sudo -E venv/bin/python intercept.py
```
### Docker (Alternative)
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
docker compose up -d
```
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
### Open the Interface
After starting, open **http://localhost:5050** in your browser.
---
## Hardware Requirements
| Hardware | Purpose | Price |
|----------|---------|-------|
| **RTL-SDR** | Required for all SDR features | ~$25-35 |
| **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.
| :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.
---
## Discord Server
<p align="center">
<a href="https://discord.gg/z3g3NJMe">Join our Discord</a>
@@ -38,62 +87,23 @@ INTERCEPT provides a unified web interface for signal intelligence tools:
---
## Quick Start
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
sudo python3 intercept.py
```
Open http://localhost:5050 in your browser.
> **Note:** Requires Python 3.9+ and external tools. See [Hardware & Installation](docs/HARDWARE.md).
---
## Requirements
- **Python 3.9+**
- **SDR Hardware** - RTL-SDR (~$25), LimeSDR, or HackRF
- **External Tools** - rtl-sdr, multimon-ng, rtl_433, dump1090, aircrack-ng
Quick install (Ubuntu/Debian):
```bash
sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability aircrack-ng bluez
```
See [Hardware & Installation](docs/HARDWARE.md) for full details.
---
## Documentation
| Document | Description |
|----------|-------------|
| [Features](docs/FEATURES.md) | Complete feature list for all modules |
| [Usage Guide](docs/USAGE.md) | Detailed instructions for each mode |
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Solutions for common issues |
| [Hardware & Installation](docs/HARDWARE.md) | SDR hardware and tool installation |
---
## Development
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.
Contributions and improvements welcome.
- [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 software is for educational purposes only.**
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
- Intercepting communications without consent may be illegal
- WiFi/Bluetooth attacks require explicit permission
- You are responsible for compliance with applicable laws
---
@@ -112,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/)
+1
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
{
"version": "2026-01-11_fae1348c",
"downloaded": "2026-01-12T15:55:42.769654Z"
}
+294 -49
View File
@@ -25,49 +25,99 @@ 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
from utils.cleanup import DataStore, cleanup_manager
from utils.constants import (
MAX_AIRCRAFT_AGE_SECONDS,
MAX_WIFI_NETWORK_AGE_SECONDS,
MAX_BT_DEVICE_AGE_SECONDS,
QUEUE_MAX_SIZE,
)
# Track application start time for uptime calculation
import time as _time
_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
# ============================================
# Pager decoder
current_process = None
output_queue = queue.Queue()
output_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
process_lock = threading.Lock()
# RTL_433 sensor
sensor_process = None
sensor_queue = queue.Queue()
sensor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
sensor_lock = threading.Lock()
# WiFi
wifi_process = None
wifi_queue = queue.Queue()
wifi_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
wifi_lock = threading.Lock()
# Bluetooth
bt_process = None
bt_queue = queue.Queue()
bt_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
bt_lock = threading.Lock()
# ADS-B aircraft
adsb_process = None
adsb_queue = queue.Queue()
adsb_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
adsb_lock = threading.Lock()
# Satellite/Iridium
satellite_process = None
satellite_queue = queue.Queue()
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
# ============================================
@@ -76,23 +126,30 @@ satellite_lock = threading.Lock()
logging_enabled = False
log_file_path = 'pager_messages.log'
# WiFi state
# WiFi state - using DataStore for automatic cleanup
wifi_monitor_interface = None
wifi_networks = {} # BSSID -> network info
wifi_clients = {} # Client MAC -> client info
wifi_handshakes = [] # Captured handshakes
wifi_networks = DataStore(max_age_seconds=MAX_WIFI_NETWORK_AGE_SECONDS, name='wifi_networks')
wifi_clients = DataStore(max_age_seconds=MAX_WIFI_NETWORK_AGE_SECONDS, name='wifi_clients')
wifi_handshakes = [] # Captured handshakes (list, not auto-cleaned)
# Bluetooth state
# Bluetooth state - using DataStore for automatic cleanup
bt_interface = None
bt_devices = {} # MAC -> device info
bt_beacons = {} # MAC -> beacon info (AirTags, Tiles, iBeacons)
bt_services = {} # MAC -> list of services
bt_devices = DataStore(max_age_seconds=MAX_BT_DEVICE_AGE_SECONDS, name='bt_devices')
bt_beacons = DataStore(max_age_seconds=MAX_BT_DEVICE_AGE_SECONDS, name='bt_beacons')
bt_services = {} # MAC -> list of services (not auto-cleaned, user-requested)
# Aircraft (ADS-B) state
adsb_aircraft = {} # ICAO hex -> aircraft info
# Aircraft (ADS-B) state - using DataStore for automatic cleanup
adsb_aircraft = DataStore(max_age_seconds=MAX_AIRCRAFT_AGE_SECONDS, name='adsb_aircraft')
# Satellite state
satellite_passes = [] # Predicted satellite passes
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
# Register data stores with cleanup manager
cleanup_manager.register(wifi_networks)
cleanup_manager.register(wifi_clients)
cleanup_manager.register(bt_devices)
cleanup_manager.register(bt_beacons)
cleanup_manager.register(adsb_aircraft)
# ============================================
@@ -107,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')
@@ -122,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."""
@@ -130,15 +301,16 @@ def get_dependencies() -> Response:
# Determine OS for install instructions
system = platform.system().lower()
if system == 'darwin':
install_method = 'brew'
pkg_manager = 'brew'
elif system == 'linux':
install_method = 'apt'
pkg_manager = 'apt'
else:
install_method = 'manual'
pkg_manager = 'manual'
return jsonify({
'status': 'success',
'os': system,
'install_method': install_method,
'pkg_manager': pkg_manager,
'modes': results
})
@@ -159,14 +331,14 @@ def export_aircraft() -> Response:
for icao, ac in adsb_aircraft.items():
writer.writerow([
icao,
ac.get('callsign', ''),
ac.get('altitude', ''),
ac.get('speed', ''),
ac.get('heading', ''),
ac.get('lat', ''),
ac.get('lon', ''),
ac.get('squawk', ''),
ac.get('lastSeen', '')
ac.get('callsign', '') if isinstance(ac, dict) else '',
ac.get('altitude', '') if isinstance(ac, dict) else '',
ac.get('speed', '') if isinstance(ac, dict) else '',
ac.get('heading', '') if isinstance(ac, dict) else '',
ac.get('lat', '') if isinstance(ac, dict) else '',
ac.get('lon', '') if isinstance(ac, dict) else '',
ac.get('squawk', '') if isinstance(ac, dict) else '',
ac.get('lastSeen', '') if isinstance(ac, dict) else ''
])
response = Response(output.getvalue(), mimetype='text/csv')
@@ -175,7 +347,7 @@ def export_aircraft() -> Response:
else:
return jsonify({
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
'aircraft': list(adsb_aircraft.values())
'aircraft': adsb_aircraft.values()
})
@@ -195,11 +367,11 @@ def export_wifi() -> Response:
for bssid, net in wifi_networks.items():
writer.writerow([
bssid,
net.get('ssid', ''),
net.get('channel', ''),
net.get('signal', ''),
net.get('encryption', ''),
net.get('clients', 0)
net.get('ssid', '') if isinstance(net, dict) else '',
net.get('channel', '') if isinstance(net, dict) else '',
net.get('signal', '') if isinstance(net, dict) else '',
net.get('encryption', '') if isinstance(net, dict) else '',
net.get('clients', 0) if isinstance(net, dict) else 0
])
response = Response(output.getvalue(), mimetype='text/csv')
@@ -208,8 +380,8 @@ def export_wifi() -> Response:
else:
return jsonify({
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
'networks': list(wifi_networks.values()),
'clients': list(wifi_clients.values())
'networks': wifi_networks.values(),
'clients': wifi_clients.values()
})
@@ -229,11 +401,11 @@ def export_bluetooth() -> Response:
for mac, dev in bt_devices.items():
writer.writerow([
mac,
dev.get('name', ''),
dev.get('rssi', ''),
dev.get('type', ''),
dev.get('manufacturer', ''),
dev.get('lastSeen', '')
dev.get('name', '') if isinstance(dev, dict) else '',
dev.get('rssi', '') if isinstance(dev, dict) else '',
dev.get('type', '') if isinstance(dev, dict) else '',
dev.get('manufacturer', '') if isinstance(dev, dict) else '',
dev.get('lastSeen', '') if isinstance(dev, dict) else ''
])
response = Response(output.getvalue(), mimetype='text/csv')
@@ -242,15 +414,42 @@ def export_bluetooth() -> Response:
else:
return jsonify({
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
'devices': list(bt_devices.values()),
'beacons': list(bt_beacons.values())
'devices': bt_devices.values(),
'beacons': bt_beacons.values()
})
@app.route('/health')
def health_check() -> Response:
"""Health check endpoint for monitoring."""
import time
return jsonify({
'status': 'healthy',
'version': VERSION,
'uptime_seconds': round(time.time() - _app_start_time, 2),
'processes': {
'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),
},
'data': {
'aircraft_count': len(adsb_aircraft),
'wifi_networks_count': len(wifi_networks),
'wifi_clients_count': len(wifi_clients),
'bt_devices_count': len(bt_devices),
}
})
@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
@@ -259,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:
@@ -284,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})
@@ -336,17 +544,54 @@ 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()
# Initialize database for settings storage
from utils.database import init_db
init_db()
# Start automatic cleanup of stale data entries
cleanup_manager.start()
# Register blueprints
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")
+45 -1
View File
@@ -7,7 +7,51 @@ import os
import sys
# Application version
VERSION = "1.1.0"
VERSION = "2.9.5"
# Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [
{
"version": "2.9.5",
"date": "January 2026",
"highlights": [
"Enhanced TSCM with MAC-randomization resistant detection",
"Clickable score cards and device detail expansion",
"RF scanning improvements with status feedback",
"Root privilege check and warning display",
]
},
{
"version": "2.9.0",
"date": "January 2026",
"highlights": [
"New dropdown navigation menus for cleaner UI",
"TSCM baseline recording now captures device data",
"Device identity engine integration for threat detection",
"Welcome screen with mode selection",
]
},
{
"version": "2.8.0",
"date": "December 2025",
"highlights": [
"Added TSCM counter-surveillance mode",
"WiFi/Bluetooth device correlation engine",
"Tracker detection (AirTag, Tile, SmartTag)",
"Risk scoring and threat classification",
]
},
{
"version": "2.7.0",
"date": "November 2025",
"highlights": [
"Multi-SDR hardware support via SoapySDR",
"LimeSDR, HackRF, Airspy, SDRplay support",
"Improved aircraft database with photo lookup",
"GPS auto-detection and integration",
]
},
]
def _get_env(key: str, default: str) -> str:
+436
View File
@@ -0,0 +1,436 @@
"""
TSCM (Technical Surveillance Countermeasures) Frequency Database
Known surveillance device frequencies, sweep presets, and threat signatures
for counter-surveillance operations.
"""
from __future__ import annotations
# =============================================================================
# Known Surveillance Frequencies (MHz)
# =============================================================================
SURVEILLANCE_FREQUENCIES = {
'wireless_mics': [
{'start': 49.0, 'end': 50.0, 'name': '49 MHz Wireless Mics', 'risk': 'medium'},
{'start': 72.0, 'end': 76.0, 'name': 'VHF Low Band Mics', 'risk': 'medium'},
{'start': 170.0, 'end': 216.0, 'name': 'VHF High Band Wireless', 'risk': 'medium'},
{'start': 470.0, 'end': 698.0, 'name': 'UHF TV Band Wireless', 'risk': 'medium'},
{'start': 902.0, 'end': 928.0, 'name': '900 MHz ISM Wireless', 'risk': 'high'},
{'start': 1880.0, 'end': 1920.0, 'name': 'DECT Wireless', 'risk': 'high'},
],
'wireless_cameras': [
{'start': 900.0, 'end': 930.0, 'name': '900 MHz Video TX', 'risk': 'high'},
{'start': 1200.0, 'end': 1300.0, 'name': '1.2 GHz Video', 'risk': 'high'},
{'start': 2400.0, 'end': 2483.5, 'name': '2.4 GHz WiFi Cameras', 'risk': 'high'},
{'start': 5150.0, 'end': 5850.0, 'name': '5.8 GHz Video', 'risk': 'high'},
],
'gps_trackers': [
{'start': 824.0, 'end': 849.0, 'name': 'Cellular 850 Uplink', 'risk': 'high'},
{'start': 869.0, 'end': 894.0, 'name': 'Cellular 850 Downlink', 'risk': 'high'},
{'start': 1710.0, 'end': 1755.0, 'name': 'AWS Uplink', 'risk': 'high'},
{'start': 1850.0, 'end': 1910.0, 'name': 'PCS Uplink', 'risk': 'high'},
{'start': 1930.0, 'end': 1990.0, 'name': 'PCS Downlink', 'risk': 'high'},
],
'body_worn': [
{'start': 49.0, 'end': 50.0, 'name': '49 MHz Body Wires', 'risk': 'critical'},
{'start': 72.0, 'end': 76.0, 'name': 'VHF Low Band Wires', 'risk': 'critical'},
{'start': 150.0, 'end': 174.0, 'name': 'VHF High Band', 'risk': 'critical'},
{'start': 380.0, 'end': 400.0, 'name': 'TETRA Band', 'risk': 'high'},
{'start': 406.0, 'end': 420.0, 'name': 'Federal/Government', 'risk': 'critical'},
{'start': 450.0, 'end': 470.0, 'name': 'UHF Business Band', 'risk': 'high'},
],
'common_bugs': [
{'start': 88.0, 'end': 108.0, 'name': 'FM Broadcast Band Bugs', 'risk': 'low'},
{'start': 140.0, 'end': 150.0, 'name': 'Low VHF Bugs', 'risk': 'high'},
{'start': 418.0, 'end': 419.0, 'name': '418 MHz ISM', 'risk': 'medium'},
{'start': 433.0, 'end': 434.8, 'name': '433 MHz ISM Band', 'risk': 'medium'},
{'start': 868.0, 'end': 870.0, 'name': '868 MHz ISM (Europe)', 'risk': 'medium'},
{'start': 315.0, 'end': 316.0, 'name': '315 MHz ISM (US)', 'risk': 'medium'},
],
'ism_bands': [
{'start': 26.96, 'end': 27.41, 'name': 'CB Radio / ISM 27 MHz', 'risk': 'low'},
{'start': 40.66, 'end': 40.70, 'name': 'ISM 40 MHz', 'risk': 'low'},
{'start': 315.0, 'end': 316.0, 'name': 'ISM 315 MHz (US)', 'risk': 'medium'},
{'start': 433.05, 'end': 434.79, 'name': 'ISM 433 MHz (EU)', 'risk': 'medium'},
{'start': 868.0, 'end': 868.6, 'name': 'ISM 868 MHz (EU)', 'risk': 'medium'},
{'start': 902.0, 'end': 928.0, 'name': 'ISM 915 MHz (US)', 'risk': 'medium'},
{'start': 2400.0, 'end': 2483.5, 'name': 'ISM 2.4 GHz', 'risk': 'medium'},
],
}
# =============================================================================
# Sweep Presets
# =============================================================================
SWEEP_PRESETS = {
'quick': {
'name': 'Quick Scan',
'description': 'Fast 2-minute check of most common bug frequencies',
'duration_seconds': 120,
'ranges': [
{'start': 88.0, 'end': 108.0, 'step': 0.1, 'name': 'FM Band'},
{'start': 433.0, 'end': 435.0, 'step': 0.025, 'name': '433 MHz ISM'},
{'start': 868.0, 'end': 870.0, 'step': 0.025, 'name': '868 MHz ISM'},
],
'wifi': True,
'bluetooth': True,
'rf': True,
},
'standard': {
'name': 'Standard Sweep',
'description': 'Comprehensive 5-minute sweep of common surveillance bands',
'duration_seconds': 300,
'ranges': [
{'start': 25.0, 'end': 50.0, 'step': 0.1, 'name': 'HF/Low VHF'},
{'start': 88.0, 'end': 108.0, 'step': 0.1, 'name': 'FM Band'},
{'start': 140.0, 'end': 175.0, 'step': 0.025, 'name': 'VHF'},
{'start': 380.0, 'end': 450.0, 'step': 0.025, 'name': 'UHF Low'},
{'start': 868.0, 'end': 930.0, 'step': 0.05, 'name': 'ISM 868/915'},
],
'wifi': True,
'bluetooth': True,
'rf': True,
},
'full': {
'name': 'Full Spectrum',
'description': 'Complete 15-minute spectrum sweep (24 MHz - 1.7 GHz)',
'duration_seconds': 900,
'ranges': [
{'start': 24.0, 'end': 1700.0, 'step': 0.1, 'name': 'Full Spectrum'},
],
'wifi': True,
'bluetooth': True,
'rf': True,
},
'wireless_cameras': {
'name': 'Wireless Cameras',
'description': 'Focus on video transmission frequencies',
'duration_seconds': 180,
'ranges': [
{'start': 900.0, 'end': 930.0, 'step': 0.1, 'name': '900 MHz Video'},
{'start': 1200.0, 'end': 1300.0, 'step': 0.5, 'name': '1.2 GHz Video'},
],
'wifi': True, # WiFi cameras
'bluetooth': False,
'rf': True,
},
'body_worn': {
'name': 'Body-Worn Devices',
'description': 'Detect body wires and covert transmitters',
'duration_seconds': 240,
'ranges': [
{'start': 49.0, 'end': 50.0, 'step': 0.01, 'name': '49 MHz'},
{'start': 72.0, 'end': 76.0, 'step': 0.01, 'name': 'VHF Low'},
{'start': 150.0, 'end': 174.0, 'step': 0.0125, 'name': 'VHF High'},
{'start': 406.0, 'end': 420.0, 'step': 0.0125, 'name': 'Federal'},
{'start': 450.0, 'end': 470.0, 'step': 0.0125, 'name': 'UHF'},
],
'wifi': False,
'bluetooth': True, # BLE bugs
'rf': True,
},
'gps_trackers': {
'name': 'GPS Trackers',
'description': 'Detect cellular-based GPS tracking devices',
'duration_seconds': 180,
'ranges': [
{'start': 824.0, 'end': 894.0, 'step': 0.1, 'name': 'Cellular 850'},
{'start': 1850.0, 'end': 1990.0, 'step': 0.1, 'name': 'PCS Band'},
],
'wifi': False,
'bluetooth': True, # BLE trackers
'rf': True,
},
'bluetooth_only': {
'name': 'Bluetooth/BLE Trackers',
'description': 'Focus on BLE tracking devices (AirTag, Tile, etc.)',
'duration_seconds': 60,
'ranges': [],
'wifi': False,
'bluetooth': True,
'rf': False,
},
'wifi_only': {
'name': 'WiFi Devices',
'description': 'Scan for hidden WiFi cameras and access points',
'duration_seconds': 60,
'ranges': [],
'wifi': True,
'bluetooth': False,
'rf': False,
},
}
# =============================================================================
# Known Tracker Signatures
# =============================================================================
BLE_TRACKER_SIGNATURES = {
'apple_airtag': {
'name': 'Apple AirTag',
'company_id': 0x004C,
'patterns': ['findmy', 'airtag'],
'risk': 'high',
'description': 'Apple Find My network tracker',
},
'tile': {
'name': 'Tile Tracker',
'company_id': 0x00ED,
'patterns': ['tile'],
'oui_prefixes': ['C4:E7', 'DC:54', 'E6:43'],
'risk': 'high',
'description': 'Tile Bluetooth tracker',
},
'samsung_smarttag': {
'name': 'Samsung SmartTag',
'company_id': 0x0075,
'patterns': ['smarttag', 'smartthings'],
'risk': 'high',
'description': 'Samsung SmartThings tracker',
},
'chipolo': {
'name': 'Chipolo',
'company_id': 0x0A09,
'patterns': ['chipolo'],
'risk': 'high',
'description': 'Chipolo Bluetooth tracker',
},
'generic_beacon': {
'name': 'Unknown BLE Beacon',
'company_id': None,
'patterns': [],
'risk': 'medium',
'description': 'Unidentified BLE beacon device',
},
}
# =============================================================================
# Threat Classification
# =============================================================================
THREAT_TYPES = {
'new_device': {
'name': 'New Device',
'description': 'Device not present in baseline',
'default_severity': 'medium',
},
'tracker': {
'name': 'Tracking Device',
'description': 'Known BLE tracker detected',
'default_severity': 'high',
},
'unknown_signal': {
'name': 'Unknown Signal',
'description': 'Unidentified RF transmission',
'default_severity': 'medium',
},
'burst_transmission': {
'name': 'Burst Transmission',
'description': 'Intermittent/store-and-forward signal detected',
'default_severity': 'high',
},
'hidden_camera': {
'name': 'Potential Hidden Camera',
'description': 'WiFi camera or video transmitter detected',
'default_severity': 'critical',
},
'gsm_bug': {
'name': 'GSM/Cellular Bug',
'description': 'Cellular transmission in non-phone device context',
'default_severity': 'critical',
},
'rogue_ap': {
'name': 'Rogue Access Point',
'description': 'Unauthorized WiFi access point',
'default_severity': 'high',
},
'anomaly': {
'name': 'Signal Anomaly',
'description': 'Unusual signal pattern or behavior',
'default_severity': 'low',
},
}
SEVERITY_LEVELS = {
'critical': {
'level': 4,
'color': '#ff0000',
'description': 'Immediate action required - active surveillance likely',
},
'high': {
'level': 3,
'color': '#ff6600',
'description': 'Strong indicator of surveillance device',
},
'medium': {
'level': 2,
'color': '#ffcc00',
'description': 'Potential threat - requires investigation',
},
'low': {
'level': 1,
'color': '#00cc00',
'description': 'Minor anomaly - low probability of threat',
},
}
# =============================================================================
# WiFi Camera Detection Patterns
# =============================================================================
WIFI_CAMERA_PATTERNS = {
'ssid_patterns': [
'cam', 'camera', 'ipcam', 'webcam', 'dvr', 'nvr',
'hikvision', 'dahua', 'reolink', 'wyze', 'ring',
'arlo', 'nest', 'blink', 'eufy', 'yi',
],
'oui_manufacturers': [
'Hikvision',
'Dahua',
'Axis Communications',
'Hanwha Techwin',
'Vivotek',
'Ubiquiti',
'Wyze Labs',
'Amazon Technologies', # Ring
'Google', # Nest
],
'mac_prefixes': {
'C0:25:E9': 'TP-Link Camera',
'A4:DA:22': 'TP-Link Camera',
'78:8C:B5': 'TP-Link Camera',
'D4:6E:0E': 'TP-Link Camera',
'2C:AA:8E': 'Wyze Camera',
'AC:CF:85': 'Hikvision',
'54:C4:15': 'Hikvision',
'C0:56:E3': 'Hikvision',
'3C:EF:8C': 'Dahua',
'A0:BD:1D': 'Dahua',
'E4:24:6C': 'Dahua',
},
}
# =============================================================================
# Utility Functions
# =============================================================================
def get_frequency_risk(frequency_mhz: float) -> tuple[str, str]:
"""
Determine the risk level for a given frequency.
Returns:
Tuple of (risk_level, category_name)
"""
for category, ranges in SURVEILLANCE_FREQUENCIES.items():
for freq_range in ranges:
if freq_range['start'] <= frequency_mhz <= freq_range['end']:
return freq_range['risk'], freq_range['name']
return 'low', 'Unknown Band'
def get_sweep_preset(preset_name: str) -> dict | None:
"""Get a sweep preset by name."""
return SWEEP_PRESETS.get(preset_name)
def get_all_sweep_presets() -> dict:
"""Get all available sweep presets."""
return {
name: {
'name': preset['name'],
'description': preset['description'],
'duration_seconds': preset['duration_seconds'],
}
for name, preset in SWEEP_PRESETS.items()
}
def is_known_tracker(device_name: str | None, manufacturer_data: bytes | None = None) -> dict | None:
"""
Check if a BLE device matches known tracker signatures.
Returns:
Tracker info dict if match found, None otherwise
"""
if device_name:
name_lower = device_name.lower()
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
for pattern in tracker_info.get('patterns', []):
if pattern in name_lower:
return tracker_info
if manufacturer_data and len(manufacturer_data) >= 2:
company_id = int.from_bytes(manufacturer_data[:2], 'little')
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
if tracker_info.get('company_id') == company_id:
return tracker_info
return None
def is_potential_camera(ssid: str | None = None, mac: str | None = None, vendor: str | None = None) -> bool:
"""Check if a WiFi device might be a hidden camera."""
if ssid:
ssid_lower = ssid.lower()
for pattern in WIFI_CAMERA_PATTERNS['ssid_patterns']:
if pattern in ssid_lower:
return True
if mac:
mac_prefix = mac[:8].upper()
if mac_prefix in WIFI_CAMERA_PATTERNS['mac_prefixes']:
return True
if vendor:
vendor_lower = vendor.lower()
for manufacturer in WIFI_CAMERA_PATTERNS['oui_manufacturers']:
if manufacturer.lower() in vendor_lower:
return True
return False
def get_threat_severity(threat_type: str, context: dict | None = None) -> str:
"""
Determine threat severity based on type and context.
Args:
threat_type: Type of threat from THREAT_TYPES
context: Optional context dict with signal_strength, etc.
Returns:
Severity level string
"""
threat_info = THREAT_TYPES.get(threat_type, {})
base_severity = threat_info.get('default_severity', 'medium')
if context:
# Upgrade severity based on signal strength (closer = more concerning)
signal = context.get('signal_strength')
if signal and signal > -50: # Very strong signal
if base_severity == 'medium':
return 'high'
elif base_severity == 'high':
return 'critical'
return base_severity
+37
View File
@@ -0,0 +1,37 @@
# INTERCEPT - Signal Intelligence Platform
# Docker Compose configuration for easy deployment
services:
intercept:
build: .
container_name: intercept
ports:
- "5050:5050"
# Privileged mode required for USB SDR device access
# Alternatively, use device mapping (see below)
privileged: true
# USB device mapping (alternative to privileged mode)
# devices:
# - /dev/bus/usb:/dev/bus/usb
volumes:
# Persist data directory
- ./data:/app/data
# Optional: mount logs directory
# - ./logs:/app/logs
environment:
- INTERCEPT_HOST=0.0.0.0
- INTERCEPT_PORT=5050
- INTERCEPT_LOG_LEVEL=INFO
# Network mode for WiFi scanning (requires host network)
# network_mode: host
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# Optional: Add volume for persistent SQLite database
# volumes:
# intercept-data:
+48 -2
View File
@@ -31,6 +31,10 @@ Complete feature list for all modules.
- **Emergency squawk highlighting** - visual alerts for 7500/7600/7700
- **Aircraft details popup** - callsign, altitude, speed, heading, squawk, ICAO
<p align="center">
<img src="/static/images/screenshots/screenshot_radar.png" alt="Screenshot">
</p>
## Satellite Tracking
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
@@ -43,6 +47,13 @@ Complete feature list for all modules.
- **Telemetry panel** - real-time azimuth, elevation, range, velocity
- **Multiple satellite tracking** simultaneously
<p align="center">
<img src="/static/images/screenshots/screenshot_sat.png" alt="Screenshot">
</p>
<p align="center">
<img src="/static/images/screenshots/screenshot_sat_2.png" alt="Screenshot">
</p>
## WiFi Reconnaissance
- **Monitor mode** management via airmon-ng
@@ -64,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
@@ -108,3 +153,4 @@ Complete feature list for all modules.
- **GPS dongle support** - USB GPS receivers for precise observer location
- **Disclaimer acceptance** on first use
- **Auto-stop** when switching between modes
+206 -75
View File
@@ -1,91 +1,75 @@
# Hardware & Installation
# Hardware & Advanced Setup
## Supported SDR Hardware
| Hardware | Frequency Range | Gain Range | TX | Price | Notes |
|----------|-----------------|------------|-----|-------|-------|
| **RTL-SDR** | 24 - 1766 MHz | 0 - 50 dB | No | ~$25 | Most common, budget-friendly |
| **LimeSDR** | 0.1 - 3800 MHz | 0 - 73 dB | Yes | ~$300 | Wide range, requires SoapySDR |
| **HackRF** | 1 - 6000 MHz | 0 - 62 dB | Yes | ~$300 | Ultra-wide range, requires SoapySDR |
| Hardware | Frequency Range | Price | Notes |
|----------|-----------------|-------|-------|
| **RTL-SDR** | 24 - 1766 MHz | ~$25-35 | Recommended for beginners |
| **LimeSDR** | 0.1 - 3800 MHz | ~$300 | Wide range, requires SoapySDR |
| **HackRF** | 1 - 6000 MHz | ~$300 | Ultra-wide range, requires SoapySDR |
INTERCEPT automatically detects connected devices and shows hardware-specific capabilities in the UI.
INTERCEPT automatically detects connected devices.
## Requirements
---
### Hardware
- **SDR Device** - RTL-SDR, LimeSDR, or HackRF
- **WiFi adapter** capable of monitor mode (for WiFi features)
- **Bluetooth adapter** (for Bluetooth features)
- **GPS dongle** (optional, for precise location)
### Software
- **Python 3.9+** required
- External tools (see installation below)
## Tool Installation
### Core SDR Tools
| Tool | macOS | Ubuntu/Debian | Purpose |
|------|-------|---------------|---------|
| rtl-sdr | `brew install librtlsdr` | `sudo apt install rtl-sdr` | RTL-SDR support |
| multimon-ng | `brew install multimon-ng` | `sudo apt install multimon-ng` | Pager decoding |
| rtl_433 | `brew install rtl_433` | `sudo apt install rtl-433` | 433MHz sensors |
| dump1090 | `brew install dump1090-mutability` | `sudo apt install dump1090-mutability` | ADS-B aircraft |
| aircrack-ng | `brew install aircrack-ng` | `sudo apt install aircrack-ng` | WiFi reconnaissance |
| bluez | Built-in (limited) | `sudo apt install bluez bluetooth` | Bluetooth scanning |
### LimeSDR / HackRF Support (Optional)
| Tool | macOS | Ubuntu/Debian | Purpose |
|------|-------|---------------|---------|
| SoapySDR | `brew install soapysdr` | `sudo apt install soapysdr-tools` | Universal SDR abstraction |
| LimeSDR | `brew install limesuite soapylms7` | `sudo apt install limesuite soapysdr-module-lms7` | LimeSDR support |
| HackRF | `brew install hackrf soapyhackrf` | `sudo apt install hackrf soapysdr-module-hackrf` | HackRF support |
| readsb | Build from source | Build from source | ADS-B with SoapySDR |
> **Note:** RTL-SDR works out of the box. LimeSDR and HackRF require SoapySDR plus the hardware-specific driver.
## Quick Install Commands
### Ubuntu/Debian
```bash
# Core tools
sudo apt update
sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability aircrack-ng bluez bluetooth
# LimeSDR (optional)
sudo apt install soapysdr-tools limesuite soapysdr-module-lms7
# HackRF (optional)
sudo apt install hackrf soapysdr-module-hackrf
```
## Quick Install
### macOS (Homebrew)
```bash
# Core tools
brew install librtlsdr multimon-ng rtl_433 dump1090-mutability aircrack-ng
# LimeSDR (optional)
```bash
# Install Homebrew if needed
/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 ffmpeg
# ADS-B aircraft tracking
brew install dump1090-mutability
# WiFi tools (optional)
brew install aircrack-ng
# LimeSDR support (optional)
brew install soapysdr limesuite soapylms7
# HackRF (optional)
# HackRF support (optional)
brew install hackrf soapyhackrf
```
### Arch Linux
```bash
# Core tools
sudo pacman -S rtl-sdr multimon-ng
yay -S rtl_433 dump1090
### Debian / Ubuntu / Raspberry Pi OS
# LimeSDR/HackRF (optional)
sudo pacman -S soapysdr limesuite hackrf
```bash
# Update package lists
sudo apt update
# Core tools (required)
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
# Alternative: dump1090-fa (FlightAware version)
# WiFi tools (optional)
sudo apt install -y aircrack-ng
# Bluetooth tools (optional)
sudo apt install -y bluez bluetooth
# LimeSDR support (optional)
sudo apt install -y soapysdr-tools limesuite soapysdr-module-lms7
# HackRF support (optional)
sudo apt install -y hackrf soapysdr-module-hackrf
```
## Linux udev Rules
---
If your SDR isn't detected, add udev rules:
## RTL-SDR Setup (Linux)
### Add udev rules
If your RTL-SDR isn't detected, create udev rules:
```bash
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
@@ -97,9 +81,9 @@ sudo udevadm control --reload-rules
sudo udevadm trigger
```
Then unplug and replug your device.
Then unplug and replug your RTL-SDR.
## Blacklist DVB-T Driver (Linux)
### Blacklist DVB-T driver
The default DVB-T driver conflicts with rtl-sdr:
@@ -108,18 +92,165 @@ echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtl.conf
sudo modprobe -r dvb_usb_rtl28xxu
```
---
## Verify Installation
Check what's installed:
### Check dependencies
```bash
python3 intercept.py --check-deps
```
Test SDR detection:
### Test SDR detection
```bash
# RTL-SDR
rtl_test
# LimeSDR/HackRF
# LimeSDR/HackRF (via SoapySDR)
SoapySDRUtil --find
```
---
## Python Environment
### Using setup.sh (Recommended)
```bash
./setup.sh
```
This automatically:
- Detects your OS
- Creates a virtual environment if needed (for PEP 668 systems)
- Installs Python dependencies
- Checks for required tools
### Manual setup
```bash
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
---
## Running INTERCEPT
After installation:
```bash
sudo -E venv/bin/python intercept.py
# Custom port
INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py
```
Open **http://localhost:5050** in your browser.
---
## Complete Tool Reference
| Tool | Package (Debian) | Package (macOS) | Required For |
|------|------------------|-----------------|--------------|
| `rtl_fm` | rtl-sdr | librtlsdr | Pager, Listening Post |
| `rtl_test` | rtl-sdr | librtlsdr | SDR detection |
| `multimon-ng` | multimon-ng | multimon-ng | Pager decoding |
| `rtl_433` | rtl-433 | rtl_433 | 433MHz sensors |
| `dump1090` | dump1090-mutability | dump1090-mutability | ADS-B tracking |
| `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) |
| `hcitool` | bluez | N/A | Bluetooth scanning |
| `bluetoothctl` | bluez | N/A | Bluetooth control |
| `hciconfig` | bluez | N/A | Bluetooth config |
### Optional tools:
| Tool | Package (Debian) | Package (macOS) | Purpose |
|------|------------------|-----------------|---------|
| `ffmpeg` | ffmpeg | ffmpeg | Alternative audio encoder |
| `SoapySDRUtil` | soapysdr-tools | soapysdr | LimeSDR/HackRF support |
| `LimeUtil` | limesuite | limesuite | LimeSDR native tools |
| `hackrf_info` | hackrf | hackrf | HackRF native tools |
### Python dependencies (requirements.txt):
| Package | Purpose |
|---------|---------|
| `flask` | Web server |
| `skyfield` | Satellite tracking |
| `bleak` | BLE scanning with manufacturer data (TSCM) |
---
## dump1090 Notes
### Package names vary by distribution:
- `dump1090-mutability` - Most common
- `dump1090-fa` - FlightAware version (recommended)
- `dump1090` - Generic
### Not in repositories (Debian Trixie)?
Install FlightAware's version:
https://flightaware.com/adsb/piaware/install
Or build from source:
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 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
+89
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+898
View File
@@ -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>
+8 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "intercept"
version = "1.0.0"
version = "2.9.5"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md"
requires-python = ">=3.9"
@@ -28,12 +28,19 @@ classifiers = [
dependencies = [
"flask>=2.0.0",
"skyfield>=1.45",
"pyserial>=3.5",
]
[project.urls]
Homepage = "https://github.com/smittix/intercept"
Repository = "https://github.com/smittix/intercept"
Issues = "https://github.com/smittix/intercept/issues"
[project.optional-dependencies]
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",
+1
View File
@@ -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
+5
View File
@@ -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
+17
View File
@@ -7,13 +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
View File
@@ -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'],
}
})
+200 -31
View File
@@ -22,6 +22,20 @@ from utils.validation import (
)
from utils.sse import format_sse
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
ADSB_SBS_PORT,
ADSB_TERMINATE_TIMEOUT,
PROCESS_TERMINATE_TIMEOUT,
SBS_SOCKET_TIMEOUT,
SBS_RECONNECT_DELAY,
SOCKET_BUFFER_SIZE,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
SOCKET_CONNECT_TIMEOUT,
ADSB_UPDATE_INTERVAL,
DUMP1090_START_WAIT,
)
from utils import aircraft_db
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
@@ -30,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 = [
@@ -63,22 +85,22 @@ def find_dump1090():
def check_dump1090_service():
"""Check if dump1090 SBS port (30003) is available."""
"""Check if dump1090 SBS port is available."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2)
result = sock.connect_ex(('localhost', 30003))
sock.settimeout(SOCKET_CONNECT_TIMEOUT)
result = sock.connect_ex(('localhost', ADSB_SBS_PORT))
sock.close()
if result == 0:
return 'localhost:30003'
except Exception:
return f'localhost:{ADSB_SBS_PORT}'
except OSError:
pass
return None
def parse_sbs_stream(service_addr):
"""Parse SBS format data from dump1090 port 30003."""
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time
"""Parse SBS format data from dump1090 SBS port."""
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received
host, port = service_addr.split(':')
port = int(port)
@@ -90,7 +112,7 @@ def parse_sbs_stream(service_addr):
while adsb_using_service:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
sock.settimeout(SBS_SOCKET_TIMEOUT)
sock.connect((host, port))
adsb_connected = True
logger.info("Connected to SBS stream")
@@ -98,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(4096).decode('utf-8', errors='ignore')
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:
@@ -112,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]
@@ -121,7 +154,19 @@ def parse_sbs_stream(service_addr):
if not icao:
continue
aircraft = app_module.adsb_aircraft.get(icao, {'icao': icao})
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()
@@ -141,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]))
@@ -152,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]:
@@ -168,13 +218,13 @@ def parse_sbs_stream(service_addr):
if parts[17]:
aircraft['squawk'] = parts[17]
app_module.adsb_aircraft[icao] = aircraft
app_module.adsb_aircraft.set(icao, aircraft)
pending_updates.add(icao)
adsb_messages_received += 1
adsb_last_message_time = time.time()
now = time.time()
if now - last_update >= 1.0:
if now - last_update >= ADSB_UPDATE_INTERVAL:
for update_icao in pending_updates:
if update_icao in app_module.adsb_aircraft:
app_module.adsb_queue.put({
@@ -189,10 +239,10 @@ def parse_sbs_stream(service_addr):
sock.close()
adsb_connected = False
except Exception as e:
except OSError as e:
adsb_connected = False
logger.warning(f"SBS connection error: {e}, reconnecting...")
time.sleep(2)
time.sleep(SBS_RECONNECT_DELAY)
adsb_connected = False
logger.info("SBS stream parser stopped")
@@ -200,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
})
@@ -291,9 +368,12 @@ def start_adsb():
if app_module.adsb_process:
try:
app_module.adsb_process.terminate()
app_module.adsb_process.wait(timeout=2)
except Exception:
pass
app_module.adsb_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
except (subprocess.TimeoutExpired, OSError):
try:
app_module.adsb_process.kill()
except OSError:
pass
app_module.adsb_process = None
# Create device object and build command via abstraction layer
@@ -301,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
@@ -314,16 +396,32 @@ def start_adsb():
app_module.adsb_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
stderr=subprocess.PIPE
)
time.sleep(3)
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=('localhost:30003',), daemon=True)
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
thread.start()
return jsonify({'status': 'started', 'message': 'ADS-B tracking started'})
@@ -340,13 +438,14 @@ def stop_adsb():
if app_module.adsb_process:
app_module.adsb_process.terminate()
try:
app_module.adsb_process.wait(timeout=5)
app_module.adsb_process.wait(timeout=ADSB_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired:
app_module.adsb_process.kill()
app_module.adsb_process = None
adsb_using_service = False
app_module.adsb_aircraft = {}
app_module.adsb_aircraft.clear()
_looked_up_icaos.clear()
return jsonify({'status': 'stopped'})
@@ -355,16 +454,15 @@ def stream_adsb():
"""SSE stream for ADS-B aircraft."""
def generate():
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
msg = app_module.adsb_queue.get(timeout=1)
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
@@ -378,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
View File
@@ -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)
+256
View File
@@ -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")
+98 -13
View File
@@ -21,8 +21,20 @@ 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 (
BT_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
SUBPROCESS_TIMEOUT_SHORT,
SERVICE_ENUM_TIMEOUT,
PROCESS_START_WAIT,
BT_RESET_DELAY,
BT_ADAPTER_DOWN_WAIT,
PROCESS_TERMINATE_TIMEOUT,
)
bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
@@ -32,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:
@@ -113,7 +159,7 @@ def detect_bt_interfaces():
if platform.system() == 'Linux':
try:
result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=5)
result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
for block in blocks:
if block.strip():
@@ -127,8 +173,12 @@ def detect_bt_interfaces():
'type': 'hci',
'status': 'up' if is_up else 'down'
})
except Exception:
pass
except FileNotFoundError:
logger.debug("hciconfig not found")
except subprocess.TimeoutExpired:
logger.warning("hciconfig timed out")
except subprocess.SubprocessError as e:
logger.warning(f"Error running hciconfig: {e}")
elif platform.system() == 'Darwin':
interfaces.append({
@@ -203,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()
}
@@ -289,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 = {}
@@ -373,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:
+119
View File
@@ -0,0 +1,119 @@
"""Device correlation routes."""
from __future__ import annotations
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.correlation import get_correlations
from utils.logging import get_logger
logger = get_logger('intercept.correlation')
correlation_bp = Blueprint('correlation', __name__, url_prefix='/correlation')
@correlation_bp.route('', methods=['GET'])
def get_device_correlations() -> Response:
"""
Get device correlations between WiFi and Bluetooth.
Query params:
min_confidence: Minimum confidence threshold (default 0.5)
include_historical: Include database correlations (default true)
"""
min_confidence = request.args.get('min_confidence', 0.5, type=float)
include_historical = request.args.get('include_historical', 'true').lower() == 'true'
try:
# Get current device data
wifi_devices = dict(app_module.wifi_networks)
wifi_devices.update(dict(app_module.wifi_clients))
bt_devices = dict(app_module.bt_devices)
# Calculate correlations
correlations = get_correlations(
wifi_devices=wifi_devices,
bt_devices=bt_devices,
min_confidence=min_confidence,
include_historical=include_historical
)
return jsonify({
'status': 'success',
'correlations': correlations,
'wifi_count': len(wifi_devices),
'bt_count': len(bt_devices)
})
except Exception as e:
logger.error(f"Error calculating correlations: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@correlation_bp.route('/analyze', methods=['POST'])
def analyze_correlation() -> Response:
"""
Analyze specific device pair for correlation.
Request body:
wifi_mac: WiFi device MAC address
bt_mac: Bluetooth device MAC address
"""
data = request.json or {}
wifi_mac = data.get('wifi_mac')
bt_mac = data.get('bt_mac')
if not wifi_mac or not bt_mac:
return jsonify({
'status': 'error',
'message': 'wifi_mac and bt_mac are required'
}), 400
try:
# Get device data
wifi_device = app_module.wifi_networks.get(wifi_mac)
if not wifi_device:
wifi_device = app_module.wifi_clients.get(wifi_mac)
bt_device = app_module.bt_devices.get(bt_mac)
if not wifi_device:
return jsonify({
'status': 'error',
'message': f'WiFi device {wifi_mac} not found'
}), 404
if not bt_device:
return jsonify({
'status': 'error',
'message': f'Bluetooth device {bt_mac} not found'
}), 404
# Calculate correlation for this specific pair
correlations = get_correlations(
wifi_devices={wifi_mac: wifi_device},
bt_devices={bt_mac: bt_device},
min_confidence=0.0, # Show even low confidence for analysis
include_historical=True
)
if correlations:
return jsonify({
'status': 'success',
'correlation': correlations[0]
})
else:
return jsonify({
'status': 'success',
'correlation': None,
'message': 'No correlation detected between these devices'
})
except Exception as e:
logger.error(f"Error analyzing correlation: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
+47 -77
View File
@@ -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,10 +11,8 @@ 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,
@@ -42,65 +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('/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
Called on page load to seamlessly enable GPS if gpsd is running.
Returns current status if already connected.
"""
import socket
# Check if already running
reader = get_gps_reader()
if reader and reader.is_running:
position = reader.position
return jsonify({
'status': 'error',
'message': 'GPS reader already running'
}), 409
'status': 'connected',
'source': 'gpsd',
'has_fix': position is not None,
'position': position.to_dict() if position else None
})
data = request.json or {}
device_path = data.get('device')
baudrate = data.get('baudrate', 9600)
# Try to connect to gpsd on localhost:2947
host = 'localhost'
port = 2947
if not device_path:
# First check if gpsd is reachable
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(1.0)
sock.connect((host, port))
sock.close()
except Exception:
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
'status': 'unavailable',
'message': 'gpsd not running'
})
# Clear the queue
while not _gps_queue.empty():
@@ -109,32 +83,26 @@ def start_gps_reader():
except queue.Empty:
break
# Start the GPS reader
success = start_gps(device_path, baudrate)
# Start the gpsd client
success = start_gpsd(host, port, callback=_position_callback)
if success:
# Register callback for SSE streaming
reader = get_gps_reader()
if reader:
reader.add_callback(_position_callback)
return jsonify({
'status': 'started',
'device': device_path,
'baudrate': baudrate
'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 start GPS reader: {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)
@@ -146,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:
@@ -155,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
@@ -184,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({
@@ -195,20 +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
return jsonify({
'running': reader.is_running,
'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,
+895
View File
@@ -0,0 +1,895 @@
"""Listening Post routes for radio monitoring and frequency scanning."""
from __future__ import annotations
import json
import os
import queue
import select
import signal
import shutil
import subprocess
import threading
import time
from datetime import datetime
from typing import Generator, Optional, List, Dict
from flask import Blueprint, jsonify, request, Response
from utils.logging import get_logger
from utils.sse import format_sse
from utils.constants import (
SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
PROCESS_TERMINATE_TIMEOUT,
)
from utils.sdr import SDRFactory, SDRType
logger = get_logger('intercept.listening_post')
listening_post_bp = Blueprint('listening_post', __name__, url_prefix='/listening')
# ============================================
# GLOBAL STATE
# ============================================
# Audio demodulation state
audio_process = None
audio_rtl_process = None
audio_lock = threading.Lock()
audio_running = False
audio_frequency = 0.0
audio_modulation = 'fm'
# Scanner state
scanner_thread: Optional[threading.Thread] = None
scanner_running = False
scanner_lock = threading.Lock()
scanner_paused = False
scanner_current_freq = 0.0
scanner_config = {
'start_freq': 88.0,
'end_freq': 108.0,
'step': 0.1,
'modulation': 'wfm',
'squelch': 20,
'dwell_time': 10.0, # Seconds to stay on active frequency
'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
activity_log: List[Dict] = []
activity_log_lock = threading.Lock()
MAX_LOG_ENTRIES = 500
# SSE queue for scanner events
scanner_queue: queue.Queue = queue.Queue(maxsize=100)
# ============================================
# HELPER FUNCTIONS
# ============================================
def find_rtl_fm() -> str | None:
"""Find rtl_fm binary."""
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 add_activity_log(event_type: str, frequency: float, details: str = ''):
"""Add entry to activity log."""
with activity_log_lock:
entry = {
'timestamp': datetime.utcnow().isoformat() + 'Z',
'type': event_type,
'frequency': frequency,
'details': details,
}
activity_log.insert(0, entry)
# Trim log
while len(activity_log) > MAX_LOG_ENTRIES:
activity_log.pop()
# Also push to SSE queue
try:
scanner_queue.put_nowait({
'type': 'log',
'entry': entry
})
except queue.Full:
pass
# ============================================
# SCANNER IMPLEMENTATION
# ============================================
def scanner_loop():
"""Main scanner loop - scans frequencies looking for signals."""
global scanner_running, scanner_paused, scanner_current_freq, scanner_skip_signal
global audio_process, audio_rtl_process, audio_running, audio_frequency
logger.info("Scanner thread started")
add_activity_log('scanner_start', scanner_config['start_freq'],
f"Scanning {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz")
rtl_fm_path = find_rtl_fm()
if not rtl_fm_path:
logger.error("rtl_fm not found")
add_activity_log('error', 0, 'rtl_fm not found')
scanner_running = False
return
current_freq = scanner_config['start_freq']
last_signal_time = 0
signal_detected = False
try:
while scanner_running:
# Check if paused
if scanner_paused:
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
try:
scanner_queue.put_nowait({
'type': 'freq_change',
'frequency': current_freq,
'scanning': not signal_detected
})
except queue.Full:
pass
# Start rtl_fm at this frequency
freq_hz = int(current_freq * 1e6)
# Sample rates
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
# Don't use squelch in rtl_fm - we want to analyze raw audio
rtl_cmd = [
rtl_fm_path,
'-M', mod,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-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
rtl_proc = subprocess.Popen(
rtl_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL
)
# Read audio data for analysis
audio_data = b''
# Read audio samples for a short period
sample_duration = 0.25 # 250ms - balance between speed and detection
bytes_needed = int(resample_rate * 2 * sample_duration) # 16-bit mono
while len(audio_data) < bytes_needed and scanner_running:
chunk = rtl_proc.stdout.read(4096)
if not chunk:
break
audio_data += chunk
# Clean up rtl_fm
rtl_proc.terminate()
try:
rtl_proc.wait(timeout=1)
except subprocess.TimeoutExpired:
rtl_proc.kill()
# Analyze audio level
audio_detected = False
rms = 0
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
# 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 500-10000 based on squelch
threshold = 500 + (squelch * 95)
else:
# AM/NFM: threshold 300-6500 based on squelch
threshold = 300 + (squelch * 62)
audio_detected = rms > threshold
# Send level info to clients
try:
scanner_queue.put_nowait({
'type': 'scan_update',
'frequency': current_freq,
'level': int(rms),
'threshold': int(threshold) if 'threshold' in dir() else 0,
'detected': audio_detected
})
except queue.Full:
pass
if audio_detected and scanner_running:
if not signal_detected:
# New signal found!
signal_detected = True
last_signal_time = time.time()
add_activity_log('signal_found', current_freq,
f'Signal detected on {current_freq:.3f} MHz ({mod.upper()})')
logger.info(f"Signal found at {current_freq} MHz")
# Start audio streaming for user
_start_audio_stream(current_freq, mod)
try:
scanner_queue.put_nowait({
'type': 'signal_found',
'frequency': current_freq,
'modulation': mod,
'audio_streaming': True
})
except queue.Full:
pass
# Check for skip signal
if scanner_skip_signal:
scanner_skip_signal = False
signal_detected = False
_stop_audio_stream()
try:
scanner_queue.put_nowait({
'type': 'signal_skipped',
'frequency': current_freq
})
except queue.Full:
pass
# Move to next frequency (step is in kHz, convert to MHz)
current_freq += step_mhz
if current_freq > scanner_config['end_freq']:
current_freq = scanner_config['start_freq']
continue
# Stay on this frequency (dwell) but check periodically
dwell_start = time.time()
while (time.time() - dwell_start) < scanner_config['dwell_time'] and scanner_running:
if scanner_skip_signal:
break
time.sleep(0.2)
last_signal_time = time.time()
else:
# No signal at this frequency
if signal_detected:
# Signal lost
duration = time.time() - last_signal_time + scanner_config['dwell_time']
add_activity_log('signal_lost', current_freq,
f'Signal lost after {duration:.1f}s')
signal_detected = False
# Stop audio
_stop_audio_stream()
try:
scanner_queue.put_nowait({
'type': 'signal_lost',
'frequency': current_freq
})
except queue.Full:
pass
# Move to next frequency (step is in kHz, convert to MHz)
current_freq += step_mhz
if current_freq > scanner_config['end_freq']:
current_freq = scanner_config['start_freq']
add_activity_log('scan_cycle', current_freq, 'Scan cycle complete')
time.sleep(scanner_config['scan_delay'])
except Exception as e:
logger.error(f"Scanner error at {current_freq} MHz: {e}")
time.sleep(0.5)
except Exception as e:
logger.error(f"Scanner loop error: {e}")
finally:
scanner_running = False
_stop_audio_stream()
add_activity_log('scanner_stop', scanner_current_freq, 'Scanner stopped')
logger.info("Scanner thread stopped")
def _start_audio_stream(frequency: float, modulation: str):
"""Start audio streaming at given frequency."""
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation
with audio_lock:
# Stop any existing stream
_stop_audio_stream_internal()
ffmpeg_path = find_ffmpeg()
if not ffmpeg_path:
logger.error("ffmpeg not found")
return
# 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
elif modulation in ['usb', 'lsb']:
sample_rate = 12000
resample_rate = 12000
else:
sample_rate = 24000
resample_rate = 24000
# 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',
'pipe:1'
]
try:
# Use shell pipe for reliable streaming (Python subprocess piping can be unreliable)
shell_cmd = f"{' '.join(sdr_cmd)} 2>/dev/null | {' '.join(encoder_cmd)}"
logger.info(f"Starting audio pipeline: {shell_cmd}")
audio_rtl_process = None # Not used in shell mode
audio_process = subprocess.Popen(
shell_cmd,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
start_new_session=True # Create new process group for clean shutdown
)
# 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}")
def _stop_audio_stream():
"""Stop audio streaming."""
with audio_lock:
_stop_audio_stream_internal()
def _stop_audio_stream_internal():
"""Internal stop (must hold lock)."""
global audio_process, audio_rtl_process, audio_running, audio_frequency
# 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
# ============================================
@listening_post_bp.route('/tools')
def check_tools() -> Response:
"""Check for required tools."""
rtl_fm = find_rtl_fm()
rx_fm = find_rx_fm()
ffmpeg = find_ffmpeg()
# 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,
'available': (rtl_fm is not None or rx_fm is not None) and ffmpeg is not None,
'supported_sdr_types': supported_sdr_types
})
@listening_post_bp.route('/scanner/start', methods=['POST'])
def start_scanner() -> Response:
"""Start the frequency scanner."""
global scanner_thread, scanner_running, scanner_config
with scanner_lock:
if scanner_running:
return jsonify({
'status': 'error',
'message': 'Scanner already running'
}), 409
data = request.json or {}
# Update scanner config
try:
scanner_config['start_freq'] = float(data.get('start_freq', 88.0))
scanner_config['end_freq'] = float(data.get('end_freq', 108.0))
scanner_config['step'] = float(data.get('step', 0.1))
scanner_config['modulation'] = str(data.get('modulation', 'wfm')).lower()
scanner_config['squelch'] = int(data.get('squelch', 20))
scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0))
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',
'message': f'Invalid parameter: {e}'
}), 400
# Validate
if scanner_config['start_freq'] >= scanner_config['end_freq']:
return jsonify({
'status': 'error',
'message': 'start_freq must be less than end_freq'
}), 400
# 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
scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
scanner_thread.start()
return jsonify({
'status': 'started',
'config': scanner_config
})
@listening_post_bp.route('/scanner/stop', methods=['POST'])
def stop_scanner() -> Response:
"""Stop the frequency scanner."""
global scanner_running
scanner_running = False
_stop_audio_stream()
return jsonify({'status': 'stopped'})
@listening_post_bp.route('/scanner/pause', methods=['POST'])
def pause_scanner() -> Response:
"""Pause/resume the scanner."""
global scanner_paused
scanner_paused = not scanner_paused
if scanner_paused:
add_activity_log('scanner_pause', scanner_current_freq, 'Scanner paused')
else:
add_activity_log('scanner_resume', scanner_current_freq, 'Scanner resumed')
return jsonify({
'status': 'paused' if scanner_paused else 'resumed',
'paused': scanner_paused
})
# Flag to trigger skip from API
scanner_skip_signal = False
@listening_post_bp.route('/scanner/skip', methods=['POST'])
def skip_signal() -> Response:
"""Skip current signal and continue scanning."""
global scanner_skip_signal
if not scanner_running:
return jsonify({
'status': 'error',
'message': 'Scanner not running'
}), 400
scanner_skip_signal = True
add_activity_log('signal_skip', scanner_current_freq, f'Skipped signal at {scanner_current_freq:.3f} MHz')
return jsonify({
'status': 'skipped',
'frequency': scanner_current_freq
})
@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."""
return jsonify({
'running': scanner_running,
'paused': scanner_paused,
'current_freq': scanner_current_freq,
'config': scanner_config,
'audio_streaming': audio_running,
'audio_frequency': audio_frequency
})
@listening_post_bp.route('/scanner/stream')
def stream_scanner_events() -> Response:
"""SSE stream for scanner events."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
while True:
try:
msg = scanner_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
@listening_post_bp.route('/scanner/log')
def get_activity_log() -> Response:
"""Get activity log."""
limit = request.args.get('limit', 100, type=int)
with activity_log_lock:
return jsonify({
'log': activity_log[:limit],
'total': len(activity_log)
})
@listening_post_bp.route('/scanner/log/clear', methods=['POST'])
def clear_activity_log() -> Response:
"""Clear activity log."""
with activity_log_lock:
activity_log.clear()
return jsonify({'status': 'cleared'})
@listening_post_bp.route('/presets')
def get_presets() -> Response:
"""Get scanner presets."""
presets = [
{'name': 'FM Broadcast', 'start': 88.0, 'end': 108.0, 'step': 0.2, 'mod': 'wfm'},
{'name': 'Air Band', 'start': 118.0, 'end': 137.0, 'step': 0.025, 'mod': 'am'},
{'name': 'Marine VHF', 'start': 156.0, 'end': 163.0, 'step': 0.025, 'mod': 'fm'},
{'name': 'Amateur 2m', 'start': 144.0, 'end': 148.0, 'step': 0.0125, 'mod': 'fm'},
{'name': 'Amateur 70cm', 'start': 430.0, 'end': 440.0, 'step': 0.025, 'mod': 'fm'},
{'name': 'PMR446', 'start': 446.0, 'end': 446.2, 'step': 0.0125, 'mod': 'fm'},
{'name': 'FRS/GMRS', 'start': 462.5, 'end': 467.7, 'step': 0.025, 'mod': 'fm'},
{'name': 'Weather Radio', 'start': 162.4, 'end': 162.55, 'step': 0.025, 'mod': 'fm'},
]
return jsonify({'presets': presets})
# ============================================
# MANUAL AUDIO ENDPOINTS (for direct listening)
# ============================================
@listening_post_bp.route('/audio/start', methods=['POST'])
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
time.sleep(0.5)
data = request.json or {}
try:
frequency = float(data.get('frequency', 0))
modulation = str(data.get('modulation', 'wfm')).lower()
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',
'message': f'Invalid parameter: {e}'
}), 400
if frequency <= 0:
return jsonify({
'status': 'error',
'message': 'frequency is required'
}), 400
valid_mods = ['fm', 'wfm', 'am', 'usb', 'lsb']
if modulation not in valid_mods:
return jsonify({
'status': 'error',
'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:
return jsonify({
'status': 'started',
'frequency': frequency,
'modulation': modulation
})
else:
return jsonify({
'status': 'error',
'message': 'Failed to start audio. Check SDR device.'
}), 500
@listening_post_bp.route('/audio/stop', methods=['POST'])
def stop_audio() -> Response:
"""Stop audio."""
_stop_audio_stream()
return jsonify({'status': 'stopped'})
@listening_post_bp.route('/audio/status')
def audio_status() -> Response:
"""Get audio status."""
return jsonify({
'running': audio_running,
'frequency': audio_frequency,
'modulation': audio_modulation
})
@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 Response(b'', mimetype='audio/mpeg', status=204)
def generate():
try:
while audio_running and audio_process and audio_process.poll() is None:
# 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(),
mimetype='audio/mpeg',
headers={
'Content-Type': 'audio/mpeg',
'Cache-Control': 'no-cache, no-store',
'X-Accel-Buffering': 'no',
'Transfer-Encoding': 'chunked',
}
)
+8 -2
View File
@@ -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
View File
@@ -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
+166
View File
@@ -0,0 +1,166 @@
"""Settings management routes."""
from __future__ import annotations
from flask import Blueprint, jsonify, request, Response
from utils.database import (
get_setting,
set_setting,
delete_setting,
get_all_settings,
get_correlations,
)
from utils.logging import get_logger
logger = get_logger('intercept.settings')
settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
@settings_bp.route('', methods=['GET'])
def get_settings() -> Response:
"""Get all settings."""
try:
settings = get_all_settings()
return jsonify({
'status': 'success',
'settings': settings
})
except Exception as e:
logger.error(f"Error getting settings: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('', methods=['POST'])
def save_settings() -> Response:
"""Save one or more settings."""
data = request.json or {}
if not data:
return jsonify({
'status': 'error',
'message': 'No settings provided'
}), 400
try:
saved = []
for key, value in data.items():
# Validate key (alphanumeric, underscores, dots, hyphens)
if not key or not all(c.isalnum() or c in '_.-' for c in key):
continue
set_setting(key, value)
saved.append(key)
return jsonify({
'status': 'success',
'saved': saved
})
except Exception as e:
logger.error(f"Error saving settings: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('/<key>', methods=['GET'])
def get_single_setting(key: str) -> Response:
"""Get a single setting by key."""
try:
value = get_setting(key)
if value is None:
return jsonify({
'status': 'not_found',
'key': key
}), 404
return jsonify({
'status': 'success',
'key': key,
'value': value
})
except Exception as e:
logger.error(f"Error getting setting {key}: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('/<key>', methods=['PUT'])
def update_single_setting(key: str) -> Response:
"""Update a single setting."""
data = request.json or {}
value = data.get('value')
if value is None and 'value' not in data:
return jsonify({
'status': 'error',
'message': 'Value is required'
}), 400
try:
set_setting(key, value)
return jsonify({
'status': 'success',
'key': key,
'value': value
})
except Exception as e:
logger.error(f"Error updating setting {key}: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('/<key>', methods=['DELETE'])
def delete_single_setting(key: str) -> Response:
"""Delete a setting."""
try:
deleted = delete_setting(key)
if deleted:
return jsonify({
'status': 'success',
'key': key,
'deleted': True
})
else:
return jsonify({
'status': 'not_found',
'key': key
}), 404
except Exception as e:
logger.error(f"Error deleting setting {key}: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
# =============================================================================
# Device Correlation Endpoints
# =============================================================================
@settings_bp.route('/correlations', methods=['GET'])
def get_device_correlations() -> Response:
"""Get device correlations between WiFi and Bluetooth."""
min_confidence = request.args.get('min_confidence', 0.5, type=float)
try:
correlations = get_correlations(min_confidence)
return jsonify({
'status': 'success',
'correlations': correlations
})
except Exception as e:
logger.error(f"Error getting correlations: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
+2276
View File
File diff suppressed because it is too large Load Diff
+353 -41
View File
@@ -16,12 +16,32 @@ 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 (
WIFI_TERMINATE_TIMEOUT,
PMKID_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
WIFI_CSV_PARSE_INTERVAL,
WIFI_CSV_TIMEOUT_WARNING,
SUBPROCESS_TIMEOUT_SHORT,
SUBPROCESS_TIMEOUT_MEDIUM,
SUBPROCESS_TIMEOUT_LONG,
DEAUTH_TIMEOUT,
MIN_DEAUTH_COUNT,
MAX_DEAUTH_COUNT,
DEFAULT_DEAUTH_COUNT,
PROCESS_START_WAIT,
MONITOR_MODE_DELAY,
WIFI_CAPTURE_PATH_PREFIX,
HANDSHAKE_CAPTURE_PATH_PREFIX,
PMKID_CAPTURE_PATH_PREFIX,
)
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
@@ -37,7 +57,7 @@ def detect_wifi_interfaces():
if platform.system() == 'Darwin': # macOS
try:
result = subprocess.run(['networksetup', '-listallhardwareports'],
capture_output=True, text=True, timeout=5)
capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
lines = result.stdout.split('\n')
for i, line in enumerate(lines):
if 'Wi-Fi' in line or 'AirPort' in line:
@@ -51,12 +71,16 @@ def detect_wifi_interfaces():
'status': 'up'
})
break
except Exception as e:
except FileNotFoundError:
logger.debug("networksetup not found")
except subprocess.TimeoutExpired:
logger.warning("networksetup timed out")
except subprocess.SubprocessError as e:
logger.error(f"Error detecting macOS interfaces: {e}")
try:
result = subprocess.run(['system_profiler', 'SPUSBDataType'],
capture_output=True, text=True, timeout=10)
capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_MEDIUM)
if 'Wireless' in result.stdout or 'WLAN' in result.stdout or '802.11' in result.stdout:
interfaces.append({
'name': 'USB WiFi Adapter',
@@ -64,12 +88,16 @@ def detect_wifi_interfaces():
'monitor_capable': True,
'status': 'detected'
})
except Exception:
pass
except FileNotFoundError:
logger.debug("system_profiler not found")
except subprocess.TimeoutExpired:
logger.debug("system_profiler timed out")
except subprocess.SubprocessError as e:
logger.debug(f"Error running system_profiler: {e}")
else: # Linux
try:
result = subprocess.run(['iw', 'dev'], capture_output=True, text=True, timeout=5)
result = subprocess.run(['iw', 'dev'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
current_iface = None
for line in result.stdout.split('\n'):
line = line.strip()
@@ -77,33 +105,144 @@ 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
try:
result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=5)
result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
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'
})
except Exception:
pass
except Exception as e:
'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:
logger.debug(f"Error running iwconfig: {e}")
except subprocess.TimeoutExpired:
logger.warning("iw command timed out")
except subprocess.SubprocessError as e:
logger.error(f"Error detecting Linux interfaces: {e}")
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 = {}
@@ -220,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
@@ -270,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'):
@@ -312,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
@@ -372,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:
@@ -396,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'})
@@ -423,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 = {}
@@ -447,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,
@@ -479,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
@@ -521,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'})
@@ -546,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,
@@ -575,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'})
@@ -592,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,
@@ -631,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:
@@ -661,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'})
@@ -752,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."""
+571 -239
View File
@@ -1,18 +1,60 @@
#!/bin/bash
#
# INTERCEPT Setup Script
# Installs Python dependencies and checks for external tools
#
#!/usr/bin/env bash
# INTERCEPT Setup Script (best-effort installs, hard-fail verification)
set -e
# ---- Force bash even if launched with sh ----
if [ -z "${BASH_VERSION:-}" ]; then
echo "[x] This script must be run with bash (not sh)."
echo " Run: bash $0"
exec bash "$0" "$@"
fi
# Colors for output
set -Eeuo pipefail
# Ensure admin paths are searchable (many tools live here)
export PATH="/usr/local/sbin:/usr/sbin:/sbin:/opt/homebrew/sbin:/opt/homebrew/bin:$PATH"
# ----------------------------
# Pretty output
# ----------------------------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
NC='\033[0m'
info() { echo -e "${BLUE}[*]${NC} $*"; }
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
fail() { echo -e "${RED}[x]${NC} $*"; }
# ----------------------------
# Progress tracking
# ----------------------------
CURRENT_STEP=0
TOTAL_STEPS=0
progress() {
local msg="$1"
((CURRENT_STEP++)) || true
local pct=$((CURRENT_STEP * 100 / TOTAL_STEPS))
local filled=$((pct / 5))
local empty=$((20 - filled))
local bar=$(printf '█%.0s' $(seq 1 $filled 2>/dev/null) || true)
bar+=$(printf '░%.0s' $(seq 1 $empty 2>/dev/null) || true)
echo -e "${BLUE}[${CURRENT_STEP}/${TOTAL_STEPS}]${NC} ${bar} ${pct}% - ${msg}"
}
on_error() {
local line="$1"
local cmd="${2:-unknown}"
fail "Setup failed at line ${line}: ${cmd}"
exit 1
}
trap 'on_error $LINENO "$BASH_COMMAND"' ERR
# ----------------------------
# Banner
# ----------------------------
echo -e "${BLUE}"
echo " ___ _ _ _____ _____ ____ ____ _____ ____ _____ "
echo " |_ _| \\ | |_ _| ____| _ \\ / ___| ____| _ \\_ _|"
@@ -20,268 +62,558 @@ echo " | || \\| | | | | _| | |_) | | | _| | |_) || | "
echo " | || |\\ | | | | |___| _ <| |___| |___| __/ | | "
echo " |___|_| \\_| |_| |_____|_| \\_\\\\____|_____|_| |_| "
echo -e "${NC}"
echo "Signal Intelligence Platform - Setup Script"
echo "INTERCEPT - Setup Script"
echo "============================================"
echo ""
echo
# ----------------------------
# Helpers
# ----------------------------
cmd_exists() {
local c="$1"
command -v "$c" >/dev/null 2>&1 && return 0
[[ -x "/usr/sbin/$c" || -x "/sbin/$c" || -x "/usr/local/sbin/$c" || -x "/opt/homebrew/sbin/$c" ]] && return 0
return 1
}
have_any() {
local c
for c in "$@"; do
cmd_exists "$c" && return 0
done
return 1
}
need_sudo() {
if [[ "$(id -u)" -eq 0 ]]; then
SUDO=""
ok "Running as root"
else
if cmd_exists sudo; then
SUDO="sudo"
else
fail "sudo is not installed and you're not root."
echo "Either run as root or install sudo first."
exit 1
fi
fi
}
# Detect OS
detect_os() {
if [[ "$OSTYPE" == "darwin"* ]]; then
OS="macos"
PKG_MANAGER="brew"
elif [[ -f /etc/debian_version ]]; then
OS="debian"
PKG_MANAGER="apt"
elif [[ -f /etc/redhat-release ]]; then
OS="redhat"
PKG_MANAGER="dnf"
elif [[ -f /etc/arch-release ]]; then
OS="arch"
PKG_MANAGER="pacman"
else
OS="unknown"
PKG_MANAGER="unknown"
fi
echo -e "${BLUE}Detected OS:${NC} $OS (package manager: $PKG_MANAGER)"
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
OS="macos"
elif [[ -f /etc/debian_version ]]; then
OS="debian"
else
OS="unknown"
fi
info "Detected OS: ${OS}"
[[ "$OS" != "unknown" ]] || { fail "Unsupported OS (macOS + Debian/Ubuntu only)."; exit 1; }
}
# Check if a command exists
check_cmd() {
command -v "$1" &> /dev/null
# ----------------------------
# Required tool checks (with alternates)
# ----------------------------
missing_required=()
check_required() {
local label="$1"; shift
local desc="$1"; shift
if have_any "$@"; then
ok "${label} - ${desc}"
else
warn "${label} - ${desc} (missing, required)"
missing_required+=("$label")
fi
}
# Install Python dependencies
install_python_deps() {
echo ""
echo -e "${BLUE}[1/3] Installing Python dependencies...${NC}"
if ! check_cmd python3; then
echo -e "${RED}Error: Python 3 is not installed${NC}"
echo "Please install Python 3.9 or later"
exit 1
fi
# Check Python version (need 3.9+)
PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
PYTHON_MAJOR=$(python3 -c 'import sys; print(sys.version_info.major)')
PYTHON_MINOR=$(python3 -c 'import sys; print(sys.version_info.minor)')
echo "Python version: $PYTHON_VERSION"
if [ "$PYTHON_MAJOR" -lt 3 ] || ([ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 9 ]); then
echo -e "${RED}Error: Python 3.9 or later is required${NC}"
echo "You have Python $PYTHON_VERSION"
echo ""
echo "Please upgrade Python:"
echo " Ubuntu/Debian: sudo apt install python3.11"
echo " macOS: brew install python@3.11"
exit 1
fi
# Check if we're in a virtual environment
if [ -n "$VIRTUAL_ENV" ]; then
echo "Using virtual environment: $VIRTUAL_ENV"
pip install -r requirements.txt
elif [ -d "venv" ]; then
echo "Found existing venv, activating..."
source venv/bin/activate
pip install -r requirements.txt
else
# Try direct pip install first, fall back to venv if it fails (PEP 668)
echo "Attempting to install dependencies..."
if python3 -m pip install -r requirements.txt 2>/dev/null; then
echo -e "${GREEN}Python dependencies installed successfully${NC}"
return
fi
# If pip install failed (likely PEP 668), create a virtual environment
echo ""
echo -e "${YELLOW}System Python is externally managed (PEP 668).${NC}"
echo "Creating virtual environment..."
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
echo ""
echo -e "${YELLOW}NOTE: A virtual environment was created.${NC}"
echo "You must activate it before running INTERCEPT:"
echo " source venv/bin/activate"
echo " sudo venv/bin/python intercept.py"
fi
echo -e "${GREEN}Python dependencies installed successfully${NC}"
}
# Check external tools
check_tools() {
echo ""
echo -e "${BLUE}[2/3] Checking external tools...${NC}"
echo ""
info "Checking required tools..."
missing_required=()
MISSING_TOOLS=()
echo
info "Core SDR:"
check_required "rtl_fm" "RTL-SDR FM demodulator" rtl_fm
check_required "rtl_test" "RTL-SDR device detection" rtl_test
check_required "multimon-ng" "Pager decoder" multimon-ng
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
check_required "dump1090" "ADS-B decoder" dump1090
check_required "acarsdec" "ACARS decoder" acarsdec
# Core SDR tools
echo "Core SDR Tools:"
check_tool "rtl_fm" "RTL-SDR FM demodulator"
check_tool "rtl_test" "RTL-SDR device detection"
check_tool "multimon-ng" "Pager decoder"
check_tool "rtl_433" "433MHz sensor decoder"
check_tool "dump1090" "ADS-B decoder"
echo
info "GPS:"
check_required "gpsd" "GPS daemon" gpsd
echo ""
echo "Additional SDR Hardware (optional):"
check_tool "SoapySDRUtil" "SoapySDR (for LimeSDR/HackRF)"
check_tool "LimeUtil" "LimeSDR tools"
check_tool "hackrf_info" "HackRF tools"
echo
info "Audio:"
check_required "ffmpeg" "Audio encoder/decoder" ffmpeg
echo ""
echo "WiFi Tools:"
check_tool "airmon-ng" "WiFi monitor mode"
check_tool "airodump-ng" "WiFi scanner"
echo
info "WiFi:"
check_required "airmon-ng" "Monitor mode helper" airmon-ng
check_required "airodump-ng" "WiFi scanner" airodump-ng
check_required "aireplay-ng" "Injection/deauth" aireplay-ng
check_required "hcxdumptool" "PMKID capture" hcxdumptool
check_required "hcxpcapngtool" "PMKID/pcapng conversion" hcxpcapngtool
echo ""
echo "Bluetooth Tools:"
check_tool "bluetoothctl" "Bluetooth controller"
check_tool "hcitool" "Bluetooth HCI tool"
echo
info "Bluetooth:"
check_required "bluetoothctl" "Bluetooth controller CLI" bluetoothctl
check_required "hcitool" "Bluetooth scan utility" hcitool
check_required "hciconfig" "Bluetooth adapter config" hciconfig
if [ ${#MISSING_TOOLS[@]} -gt 0 ]; then
echo ""
echo -e "${YELLOW}Some tools are missing. See installation instructions below.${NC}"
fi
echo
info "SoapySDR:"
check_required "SoapySDRUtil" "SoapySDR CLI utility" SoapySDRUtil
echo
}
check_tool() {
local cmd=$1
local desc=$2
if check_cmd "$cmd"; then
echo -e " ${GREEN}${NC} $cmd - $desc"
# ----------------------------
# Python venv + deps
# ----------------------------
check_python_version() {
if ! cmd_exists python3; then
fail "python3 not found."
[[ "$OS" == "macos" ]] && echo "Install with: brew install python"
[[ "$OS" == "debian" ]] && echo "Install with: sudo apt-get install python3"
exit 1
fi
local ver
ver="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
info "Python version: ${ver}"
python3 - <<'PY'
import sys
raise SystemExit(0 if sys.version_info >= (3,9) else 1)
PY
ok "Python version OK (>= 3.9)"
}
install_python_deps() {
progress "Setting up Python environment"
check_python_version
if [[ ! -f requirements.txt ]]; then
warn "requirements.txt not found; skipping Python dependency install."
return 0
fi
# On Debian/Ubuntu, try apt packages first as they're more reliable
if [[ "$OS" == "debian" ]]; then
info "Installing Python packages via apt (more reliable on Debian/Ubuntu)..."
$SUDO apt-get install -y python3-flask python3-requests python3-serial >/dev/null 2>&1 || true
# skyfield may not be available in all distros, try apt first then pip
if ! $SUDO apt-get install -y python3-skyfield >/dev/null 2>&1; then
warn "python3-skyfield not in apt, will try pip later"
fi
ok "Installed available Python packages via apt"
fi
if [[ ! -d venv ]]; then
python3 -m venv --system-site-packages venv
ok "Created venv/ (with system site-packages)"
else
ok "Using existing venv/"
fi
# shellcheck disable=SC1091
source venv/bin/activate
python -m pip install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
ok "Upgraded pip tooling"
progress "Installing Python dependencies"
# Try pip install, but don't fail if apt packages already satisfied deps
if ! python -m pip install -r requirements.txt 2>/dev/null; then
warn "Some pip packages failed - checking if apt packages cover them..."
# Verify critical packages are available
python -c "import flask; import requests" 2>/dev/null || {
fail "Critical Python packages (flask, requests) not installed"
echo "Try: sudo apt install python3-flask python3-requests"
exit 1
}
ok "Core Python dependencies available"
else
ok "Python dependencies installed"
fi
echo
}
# ----------------------------
# macOS install (Homebrew)
# ----------------------------
ensure_brew() {
cmd_exists brew && return 0
warn "Homebrew not found. Installing Homebrew..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
if [[ -x /opt/homebrew/bin/brew ]]; then
eval "$(/opt/homebrew/bin/brew shellenv)"
elif [[ -x /usr/local/bin/brew ]]; then
eval "$(/usr/local/bin/brew shellenv)"
fi
cmd_exists brew || { fail "Homebrew install failed. Install manually then re-run."; exit 1; }
}
brew_install() {
local pkg="$1"
if brew list --formula "$pkg" >/dev/null 2>&1; then
ok "brew: ${pkg} already installed"
return 0
fi
info "brew: installing ${pkg}..."
if brew install "$pkg" 2>&1; then
ok "brew: installed ${pkg}"
return 0
else
return 1
fi
}
install_multimon_ng_from_source_macos() {
info "multimon-ng not available via Homebrew. Building from source..."
# Ensure build dependencies are installed
brew_install cmake
brew_install libsndfile
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning multimon-ng..."
git clone --depth 1 https://github.com/EliasOewornal/multimon-ng.git "$tmp_dir/multimon-ng" >/dev/null 2>&1 \
|| { fail "Failed to clone multimon-ng"; exit 1; }
cd "$tmp_dir/multimon-ng"
info "Compiling multimon-ng..."
mkdir -p build && cd build
cmake .. >/dev/null 2>&1 || { fail "cmake failed for multimon-ng"; exit 1; }
make >/dev/null 2>&1 || { fail "make failed for multimon-ng"; exit 1; }
# Install to /usr/local/bin (no sudo needed on Homebrew systems typically)
if [[ -w /usr/local/bin ]]; then
install -m 0755 multimon-ng /usr/local/bin/multimon-ng
else
echo -e " ${RED}${NC} $cmd - $desc ${YELLOW}(not found)${NC}"
MISSING_TOOLS+=("$cmd")
sudo install -m 0755 multimon-ng /usr/local/bin/multimon-ng
fi
ok "multimon-ng installed successfully from source"
)
}
# Show installation instructions
show_install_instructions() {
echo ""
echo -e "${BLUE}[3/3] Installation instructions for missing tools${NC}"
echo ""
install_macos_packages() {
TOTAL_STEPS=13
CURRENT_STEP=0
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
echo -e "${GREEN}All tools are installed!${NC}"
return
progress "Checking Homebrew"
ensure_brew
progress "Installing RTL-SDR libraries"
brew_install librtlsdr
progress "Installing multimon-ng"
# multimon-ng is not in Homebrew core, so build from source
if ! cmd_exists multimon-ng; then
install_multimon_ng_from_source_macos
else
ok "multimon-ng already installed"
fi
progress "Installing ffmpeg"
brew_install ffmpeg
progress "Installing rtl_433"
brew_install rtl_433
progress "Installing dump1090"
(brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew"
progress "Installing acarsdec"
(brew_install acarsdec) || warn "acarsdec not available via Homebrew"
progress "Installing aircrack-ng"
brew_install aircrack-ng
progress "Installing hcxtools"
brew_install hcxtools
progress "Installing SoapySDR"
brew_install soapysdr
progress "Installing gpsd"
brew_install gpsd
warn "macOS note: hcitool/hciconfig are Linux (BlueZ) utilities and often unavailable on macOS."
info "TSCM BLE scanning uses bleak library (installed via pip) for manufacturer data detection."
echo
}
# ----------------------------
# Debian/Ubuntu install (APT)
# ----------------------------
apt_install() {
local pkgs="$*"
local output
local ret=0
output=$($SUDO apt-get install -y --no-install-recommends "$@" 2>&1) || ret=$?
if [[ $ret -ne 0 ]]; then
fail "Failed to install: $pkgs"
echo "$output" | tail -10
fail "Try running: sudo apt-get update && sudo apt-get install -y $pkgs"
return 1
fi
}
apt_try_install_any() {
local p
for p in "$@"; do
if $SUDO apt-get install -y --no-install-recommends "$p" >/dev/null 2>&1; then
ok "apt: installed ${p}"
return 0
fi
done
return 1
}
install_dump1090_from_source_debian() {
info "dump1090 not available via APT. Building from source (required)..."
apt_install build-essential git pkg-config \
librtlsdr-dev libusb-1.0-0-dev \
libncurses-dev tcl-dev python3-dev
# Run in subshell to isolate EXIT trap
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning FlightAware dump1090..."
git clone --depth 1 https://github.com/flightaware/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|| { fail "Failed to clone FlightAware dump1090"; exit 1; }
cd "$tmp_dir/dump1090"
info "Compiling FlightAware dump1090..."
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
ok "dump1090 installed successfully (FlightAware)."
exit 0
fi
echo "Run the following commands to install missing tools:"
echo ""
warn "FlightAware build failed. Falling back to antirez/dump1090..."
rm -rf "$tmp_dir/dump1090"
git clone --depth 1 https://github.com/antirez/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|| { fail "Failed to clone antirez dump1090"; exit 1; }
cd "$tmp_dir/dump1090"
info "Compiling antirez dump1090..."
make >/dev/null 2>&1 || { fail "Failed to build dump1090 from source (required)."; exit 1; }
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
ok "dump1090 installed successfully (antirez)."
)
}
install_acarsdec_from_source_debian() {
info "acarsdec not available via APT. Building from source..."
apt_install build-essential git cmake \
librtlsdr-dev libusb-1.0-0-dev libsndfile1-dev
# Run in subshell to isolate EXIT trap
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning acarsdec..."
git clone --depth 1 https://github.com/TLeconte/acarsdec.git "$tmp_dir/acarsdec" >/dev/null 2>&1 \
|| { warn "Failed to clone acarsdec"; exit 1; }
cd "$tmp_dir/acarsdec"
mkdir -p build && cd build
info "Compiling acarsdec..."
if cmake .. -Drtl=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then
$SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec
ok "acarsdec installed successfully."
else
warn "Failed to build acarsdec from source. ACARS decoding will not be available."
fi
)
}
setup_udev_rules_debian() {
[[ -d /etc/udev/rules.d ]] || { warn "udev not found; skipping RTL-SDR udev rules."; return 0; }
local rules_file="/etc/udev/rules.d/20-rtlsdr.rules"
[[ -f "$rules_file" ]] && { ok "RTL-SDR udev rules already present: $rules_file"; return 0; }
info "Installing RTL-SDR udev rules..."
$SUDO tee "$rules_file" >/dev/null <<'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 || true
$SUDO udevadm trigger || true
ok "udev rules installed. Unplug/replug your RTL-SDR if connected."
echo
}
blacklist_kernel_drivers_debian() {
local blacklist_file="/etc/modprobe.d/blacklist-rtlsdr.conf"
if [[ -f "$blacklist_file" ]]; then
ok "RTL-SDR kernel driver blacklist already present"
return 0
fi
info "Blacklisting conflicting DVB kernel drivers..."
$SUDO tee "$blacklist_file" >/dev/null <<'EOF'
# Blacklist DVB-T drivers to allow rtl-sdr to access RTL2832U devices
blacklist dvb_usb_rtl28xxu
blacklist rtl2832
blacklist rtl2830
blacklist r820t
EOF
# Unload modules if currently loaded
for mod in dvb_usb_rtl28xxu rtl2832 rtl2830 r820t; do
if lsmod | grep -q "^$mod"; then
$SUDO modprobe -r "$mod" 2>/dev/null || true
fi
done
ok "Kernel drivers blacklisted. Unplug/replug your RTL-SDR if connected."
echo
}
install_debian_packages() {
need_sudo
# Suppress needrestart prompts (Ubuntu Server 22.04+)
export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a
TOTAL_STEPS=17
CURRENT_STEP=0
progress "Updating APT package lists"
$SUDO apt-get update -y >/dev/null
progress "Installing RTL-SDR"
apt_install rtl-sdr
progress "Installing multimon-ng"
apt_install multimon-ng
progress "Installing ffmpeg"
apt_install ffmpeg
progress "Installing rtl_433"
apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available"
progress "Installing aircrack-ng"
apt_install aircrack-ng || true
progress "Installing hcxdumptool"
apt_install hcxdumptool || true
progress "Installing hcxtools"
apt_install hcxtools || true
progress "Installing Bluetooth tools"
apt_install bluez bluetooth || true
progress "Installing SoapySDR"
# Exclude xtrx-dkms - its kernel module fails to build on newer kernels (6.14+)
# and causes apt to hang. Most users don't have XTRX hardware anyway.
apt_install soapysdr-tools xtrx-dkms- || true
progress "Installing gpsd"
apt_install gpsd gpsd-clients || true
progress "Installing Python packages"
apt_install python3-venv python3-pip || true
# Install Python packages via apt (more reliable than pip on modern Debian/Ubuntu)
$SUDO apt-get install -y python3-flask python3-requests python3-serial >/dev/null 2>&1 || true
$SUDO apt-get install -y python3-skyfield >/dev/null 2>&1 || true
# bleak for BLE scanning with manufacturer data (TSCM mode)
$SUDO apt-get install -y python3-bleak >/dev/null 2>&1 || true
progress "Installing dump1090"
if ! cmd_exists dump1090 && ! cmd_exists dump1090-mutability; then
#export DEBIAN_FRONTEND=noninteractive
apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
fi
if ! cmd_exists dump1090; then
if cmd_exists dump1090-mutability; then
$SUDO ln -s $(which dump1090-mutability) /usr/local/sbin/dump1090
fi
fi
cmd_exists dump1090 || install_dump1090_from_source_debian
progress "Installing acarsdec"
if ! cmd_exists acarsdec; then
apt_install acarsdec || true
fi
cmd_exists acarsdec || install_acarsdec_from_source_debian
progress "Configuring udev rules"
setup_udev_rules_debian
progress "Blacklisting conflicting kernel drivers"
blacklist_kernel_drivers_debian
}
# ----------------------------
# Final summary / hard fail
# ----------------------------
final_summary_and_hard_fail() {
check_tools
echo "============================================"
echo
echo "To start INTERCEPT:"
echo " sudo -E venv/bin/python intercept.py"
echo
echo "Then open http://localhost:5050 in your browser"
echo
echo "============================================"
if [[ "${#missing_required[@]}" -eq 0 ]]; then
ok "All REQUIRED tools are installed."
else
fail "Missing REQUIRED tools:"
for t in "${missing_required[@]}"; do echo " - $t"; done
echo
if [[ "$OS" == "macos" ]]; then
echo -e "${YELLOW}macOS (Homebrew):${NC}"
echo ""
# Check if Homebrew is installed
if ! check_cmd brew; then
echo "First, install Homebrew:"
echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
echo ""
fi
echo "# Core SDR tools"
echo "brew install librtlsdr multimon-ng rtl_433 dump1090-mutability"
echo ""
echo "# LimeSDR support (optional)"
echo "brew install soapysdr limesuite soapylms7"
echo ""
echo "# HackRF support (optional)"
echo "brew install hackrf soapyhackrf"
echo ""
echo "# WiFi tools"
echo "brew install aircrack-ng"
elif [[ "$OS" == "debian" ]]; then
echo -e "${YELLOW}Ubuntu/Debian:${NC}"
echo ""
echo "# Core SDR tools"
echo "sudo apt update"
echo "sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability"
echo ""
echo "# LimeSDR support (optional)"
echo "sudo apt install soapysdr-tools limesuite soapysdr-module-lms7"
echo ""
echo "# HackRF support (optional)"
echo "sudo apt install hackrf soapysdr-module-hackrf"
echo ""
echo "# WiFi tools"
echo "sudo apt install aircrack-ng"
echo ""
echo "# Bluetooth tools"
echo "sudo apt install bluez bluetooth"
elif [[ "$OS" == "arch" ]]; then
echo -e "${YELLOW}Arch Linux:${NC}"
echo ""
echo "# Core SDR tools"
echo "sudo pacman -S rtl-sdr multimon-ng"
echo "yay -S rtl_433 dump1090"
echo ""
echo "# LimeSDR/HackRF support (optional)"
echo "sudo pacman -S soapysdr limesuite hackrf"
elif [[ "$OS" == "redhat" ]]; then
echo -e "${YELLOW}Fedora/RHEL:${NC}"
echo ""
echo "# Core SDR tools"
echo "sudo dnf install rtl-sdr"
echo "# multimon-ng, rtl_433, dump1090 may need to be built from source"
warn "macOS note: bluetoothctl/hcitool/hciconfig are Linux (BlueZ) tools and unavailable on macOS."
warn "Bluetooth functionality will be limited. Other features should work."
else
echo "Please install the following tools manually:"
for tool in "${MISSING_TOOLS[@]}"; do
echo " - $tool"
done
fail "Exiting because required tools are missing."
exit 1
fi
fi
}
# RTL-SDR udev rules (Linux only)
setup_udev_rules() {
if [[ "$OS" != "macos" ]] && [[ "$OS" != "unknown" ]]; then
echo ""
echo -e "${BLUE}RTL-SDR udev rules (Linux only):${NC}"
echo ""
echo "If your RTL-SDR is not detected, you may need to add udev rules:"
echo ""
echo "sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF"
echo 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"'
echo 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"'
echo "EOF'"
echo ""
echo "sudo udevadm control --reload-rules"
echo "sudo udevadm trigger"
echo ""
echo "Then unplug and replug your RTL-SDR device."
fi
}
# Main
# ----------------------------
# MAIN
# ----------------------------
main() {
detect_os
install_python_deps
check_tools
show_install_instructions
setup_udev_rules
detect_os
echo ""
echo "============================================"
echo -e "${GREEN}Setup complete!${NC}"
echo ""
echo "To start INTERCEPT:"
if [ -d "venv" ]; then
echo " source venv/bin/activate"
echo " sudo venv/bin/python intercept.py"
else
echo " sudo python3 intercept.py"
fi
echo ""
echo "Then open http://localhost:5050 in your browser"
echo ""
if [[ "$OS" == "macos" ]]; then
install_macos_packages
else
install_debian_packages
fi
install_python_deps
final_summary_and_hard_fail
}
main "$@"
+442 -90
View File
@@ -5,24 +5,27 @@
}
:root {
--bg-dark: #0a0a0f;
--bg-panel: #0d1117;
--bg-card: #161b22;
--border-glow: #00ff88;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--accent-green: #00ff88;
--accent-cyan: #00d4ff;
--accent-orange: #ff9500;
--accent-red: #ff4444;
--accent-yellow: #ffcc00;
--grid-line: rgba(0, 255, 136, 0.1);
--radar-cyan: #00ffff;
--radar-bg: #1a1a2e;
--bg-dark: #0a0c10;
--bg-panel: #0f1218;
--bg-card: #151a23;
--border-color: #1f2937;
--border-glow: #4a9eff;
--text-primary: #e8eaed;
--text-secondary: #9ca3af;
--text-dim: #4b5563;
--accent-green: #22c55e;
--accent-cyan: #4a9eff;
--accent-orange: #f59e0b;
--accent-red: #ef4444;
--accent-yellow: #eab308;
--accent-amber: #d4a853;
--grid-line: rgba(74, 158, 255, 0.08);
--radar-cyan: #4a9eff;
--radar-bg: #0f1218;
}
body {
font-family: 'Rajdhani', sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
@@ -44,18 +47,18 @@ body {
z-index: 0;
}
/* Scan line effect */
/* Scan line effect - subtle */
.scanline {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, transparent, var(--accent-green), transparent);
animation: scan 4s linear infinite;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
animation: scan 6s linear infinite;
pointer-events: none;
z-index: 1000;
opacity: 0.5;
opacity: 0.3;
}
@keyframes scan {
@@ -73,20 +76,20 @@ body {
position: relative;
z-index: 10;
padding: 12px 20px;
background: linear-gradient(180deg, rgba(0, 255, 136, 0.1) 0%, transparent 100%);
border-bottom: 1px solid rgba(0, 255, 136, 0.3);
background: var(--bg-panel);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-family: 'Orbitron', monospace;
font-size: 24px;
font-weight: 900;
letter-spacing: 4px;
color: var(--accent-green);
text-shadow: 0 0 20px var(--accent-green), 0 0 40px var(--accent-green);
font-family: 'Inter', sans-serif;
font-size: 20px;
font-weight: 700;
letter-spacing: 3px;
color: var(--text-primary);
text-transform: uppercase;
}
.logo span {
@@ -115,8 +118,8 @@ body {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 10px var(--accent-green);
background: var(--accent-cyan);
box-shadow: 0 0 10px var(--accent-cyan);
animation: pulse 2s ease-in-out infinite;
}
@@ -144,8 +147,8 @@ body {
}
.stat-badge {
background: rgba(0, 255, 136, 0.1);
border: 1px solid rgba(0, 255, 136, 0.3);
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
padding: 4px 10px;
font-family: 'JetBrains Mono', monospace;
@@ -153,7 +156,7 @@ body {
}
.stat-badge .value {
color: var(--accent-green);
color: var(--accent-cyan);
font-weight: 600;
}
@@ -165,15 +168,15 @@ body {
.datetime {
font-family: 'Orbitron', monospace;
font-size: 12px;
color: var(--accent-green);
color: var(--accent-cyan);
}
.back-link {
color: var(--accent-green);
color: var(--accent-cyan);
text-decoration: none;
font-size: 11px;
padding: 4px 10px;
border: 1px solid var(--accent-green);
border: 1px solid var(--accent-cyan);
border-radius: 4px;
}
@@ -182,17 +185,148 @@ 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);
border: 1px solid rgba(0, 255, 136, 0.2);
border: 1px solid rgba(74, 158, 255, 0.2);
overflow: hidden;
position: relative;
}
@@ -204,19 +338,19 @@ body {
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-green), transparent);
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
}
.panel-header {
padding: 10px 15px;
background: rgba(0, 255, 136, 0.05);
border-bottom: 1px solid rgba(0, 255, 136, 0.1);
background: rgba(74, 158, 255, 0.05);
border-bottom: 1px solid rgba(74, 158, 255, 0.1);
font-family: 'Orbitron', monospace;
font-size: 11px;
font-weight: 500;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--accent-green);
color: var(--accent-cyan);
display: flex;
justify-content: space-between;
align-items: center;
@@ -225,8 +359,14 @@ body {
.panel-indicator {
width: 6px;
height: 6px;
background: var(--accent-green);
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;
}
@@ -256,7 +396,7 @@ body {
/* Main display container (map + radar scope) */
.main-display {
grid-column: 1;
grid-column: 2;
grid-row: 1;
position: relative;
}
@@ -296,11 +436,11 @@ body {
/* Right sidebar */
.sidebar {
grid-column: 2;
grid-column: 3;
grid-row: 1;
display: flex;
flex-direction: column;
border-left: 1px solid rgba(0, 255, 136, 0.2);
border-left: 1px solid rgba(74, 158, 255, 0.2);
overflow: hidden;
}
@@ -310,13 +450,13 @@ body {
padding: 10px;
gap: 8px;
background: var(--bg-panel);
border-bottom: 1px solid rgba(0, 255, 136, 0.2);
border-bottom: 1px solid rgba(74, 158, 255, 0.2);
}
.view-btn {
flex: 1;
padding: 10px;
border: 1px solid rgba(0, 255, 136, 0.3);
border: 1px solid rgba(74, 158, 255, 0.3);
background: transparent;
color: var(--text-secondary);
font-family: 'Orbitron', monospace;
@@ -330,20 +470,20 @@ body {
}
.view-btn:hover {
border-color: var(--accent-green);
color: var(--accent-green);
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.view-btn.active {
background: var(--accent-green);
border-color: var(--accent-green);
background: var(--accent-cyan);
border-color: var(--accent-cyan);
color: var(--bg-dark);
}
/* Selected aircraft panel */
.selected-aircraft {
flex-shrink: 0;
max-height: 280px;
max-height: 480px;
overflow-y: auto;
}
@@ -351,12 +491,24 @@ 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;
font-weight: 700;
color: var(--accent-green);
text-shadow: 0 0 15px var(--accent-green);
color: var(--accent-cyan);
text-shadow: 0 0 15px var(--accent-cyan);
text-align: center;
margin-bottom: 12px;
}
@@ -371,7 +523,7 @@ body {
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
padding: 8px;
border-left: 2px solid var(--accent-green);
border-left: 2px solid var(--accent-cyan);
}
.telemetry-label {
@@ -403,8 +555,9 @@ body {
}
.aircraft-item {
position: relative;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(0, 255, 136, 0.15);
border: 1px solid rgba(74, 158, 255, 0.15);
border-radius: 4px;
padding: 8px 10px;
margin-bottom: 6px;
@@ -413,14 +566,14 @@ body {
}
.aircraft-item:hover {
border-color: var(--accent-green);
background: rgba(0, 255, 136, 0.05);
border-color: var(--accent-cyan);
background: rgba(74, 158, 255, 0.05);
}
.aircraft-item.selected {
border-color: var(--accent-green);
box-shadow: 0 0 15px rgba(0, 255, 136, 0.2);
background: rgba(0, 255, 136, 0.1);
border-color: var(--accent-cyan);
box-shadow: 0 0 15px rgba(74, 158, 255, 0.2);
background: rgba(74, 158, 255, 0.1);
}
.aircraft-header {
@@ -434,14 +587,14 @@ body {
font-family: 'Orbitron', monospace;
font-size: 12px;
font-weight: 600;
color: var(--accent-green);
color: var(--accent-cyan);
}
.aircraft-icao {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--text-secondary);
background: rgba(0, 255, 136, 0.1);
background: rgba(74, 158, 255, 0.1);
padding: 2px 5px;
border-radius: 3px;
}
@@ -475,10 +628,28 @@ body {
grid-row: 2;
display: flex;
align-items: center;
gap: 20px;
padding: 10px 20px;
flex-wrap: nowrap;
gap: 8px;
padding: 8px 15px;
background: var(--bg-panel);
border-top: 1px solid rgba(0, 255, 136, 0.3);
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 {
@@ -497,15 +668,15 @@ body {
}
.control-group input[type="checkbox"] {
accent-color: var(--accent-green);
accent-color: var(--accent-cyan);
}
.control-group select {
padding: 6px 10px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(0, 255, 136, 0.3);
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-green);
color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
@@ -514,9 +685,9 @@ body {
width: 80px;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(0, 255, 136, 0.3);
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-green);
color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
@@ -531,9 +702,9 @@ body {
/* Start/stop button */
.start-btn {
padding: 8px 20px;
border: 1px solid var(--accent-green);
background: rgba(0, 255, 136, 0.1);
color: var(--accent-green);
border: none;
background: var(--accent-green);
color: #fff;
font-family: 'Orbitron', monospace;
font-size: 11px;
font-weight: 600;
@@ -546,28 +717,27 @@ body {
}
.start-btn:hover {
background: var(--accent-green);
color: var(--bg-dark);
box-shadow: 0 0 20px rgba(0, 255, 136, 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 */
.gps-btn {
padding: 6px 10px;
background: rgba(0, 255, 136, 0.2);
border: 1px solid rgba(0, 255, 136, 0.3);
background: rgba(74, 158, 255, 0.2);
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-green);
color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
cursor: pointer;
@@ -578,10 +748,15 @@ body {
background: var(--bg-dark) !important;
}
.leaflet-tile-pane,
.leaflet-container .leaflet-tile-pane {
filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
}
.leaflet-control-zoom a {
background: var(--bg-panel) !important;
color: var(--accent-green) !important;
border-color: rgba(0, 255, 136, 0.3) !important;
color: var(--accent-cyan) !important;
border-color: var(--border-color) !important;
}
.leaflet-control-attribution {
@@ -600,7 +775,7 @@ body {
}
::-webkit-scrollbar-thumb {
background: var(--accent-green);
background: var(--accent-cyan);
border-radius: 3px;
}
@@ -617,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;
@@ -628,11 +815,15 @@ body {
min-height: 400px;
}
.acars-sidebar {
display: none;
}
.sidebar {
grid-column: 1;
grid-row: 2;
border-left: none;
border-top: 1px solid rgba(0, 255, 136, 0.2);
border-top: 1px solid rgba(74, 158, 255, 0.2);
max-height: 300px;
}
@@ -640,4 +831,165 @@ body {
grid-row: 3;
flex-wrap: wrap;
}
}
}
/* Airband Audio Controls */
.airband-divider {
width: 1px;
height: 20px;
background: var(--accent-cyan);
opacity: 0.4;
margin: 0 5px;
flex-shrink: 0;
}
.airband-controls {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
}
.airband-btn {
padding: 6px 12px;
background: var(--accent-green);
border: none;
color: #fff;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
display: flex;
align-items: center;
gap: 5px;
transition: all 0.2s;
flex-shrink: 0;
white-space: nowrap;
}
.airband-btn:hover {
background: #1db954;
box-shadow: 0 0 10px rgba(34, 197, 94, 0.3);
}
.airband-btn.active {
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 {
opacity: 0.5;
cursor: not-allowed;
}
.airband-icon {
font-size: 10px;
}
.airband-status {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
padding: 0 8px;
}
#airbandSquelch {
accent-color: var(--accent-cyan);
}
/* Airband Audio Visualizer */
.airband-visualizer {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
border-left: 1px solid var(--border-color);
margin-left: 5px;
}
.airband-visualizer .signal-meter {
width: 80px;
}
.airband-visualizer .meter-bar {
height: 10px;
background: linear-gradient(90deg,
var(--accent-green) 0%,
var(--accent-green) 60%,
var(--accent-orange) 60%,
var(--accent-orange) 80%,
var(--accent-red) 80%,
var(--accent-red) 100%
);
border-radius: 3px;
position: relative;
overflow: hidden;
opacity: 0.3;
}
.airband-visualizer .meter-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(90deg,
var(--accent-green) 0%,
var(--accent-green) 60%,
var(--accent-orange) 60%,
var(--accent-orange) 80%,
var(--accent-red) 80%,
var(--accent-red) 100%
);
border-radius: 3px;
width: 0%;
transition: width 0.05s ease-out;
}
.airband-visualizer .meter-peak {
position: absolute;
top: 0;
height: 100%;
width: 2px;
background: #fff;
opacity: 0.8;
transition: left 0.05s ease-out;
left: 0%;
}
#airbandSpectrumCanvas {
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); }
}
+2376 -359
View File
File diff suppressed because it is too large Load Diff
+68 -27
View File
@@ -5,22 +5,25 @@
}
:root {
--bg-dark: #0a0a0f;
--bg-panel: #0d1117;
--bg-card: #161b22;
--border-glow: #00d4ff;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--accent-cyan: #00d4ff;
--accent-green: #00ff88;
--accent-orange: #ff9500;
--accent-red: #ff4444;
--bg-dark: #0a0c10;
--bg-panel: #0f1218;
--bg-card: #151a23;
--border-color: #1f2937;
--border-glow: #4a9eff;
--text-primary: #e8eaed;
--text-secondary: #9ca3af;
--text-dim: #4b5563;
--accent-cyan: #4a9eff;
--accent-green: #22c55e;
--accent-orange: #f59e0b;
--accent-red: #ef4444;
--accent-purple: #a855f7;
--grid-line: rgba(0, 212, 255, 0.1);
--accent-amber: #d4a853;
--grid-line: rgba(74, 158, 255, 0.08);
}
body {
font-family: 'Rajdhani', sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
@@ -82,28 +85,28 @@ body {
position: relative;
z-index: 10;
padding: 12px 20px;
background: linear-gradient(180deg, rgba(0, 212, 255, 0.1) 0%, transparent 100%);
border-bottom: 1px solid rgba(0, 212, 255, 0.3);
background: var(--bg-panel);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-family: 'Orbitron', monospace;
font-size: 24px;
font-weight: 900;
letter-spacing: 4px;
color: var(--accent-cyan);
text-shadow: 0 0 20px var(--accent-cyan), 0 0 40px var(--accent-cyan);
font-family: 'Inter', sans-serif;
font-size: 20px;
font-weight: 700;
letter-spacing: 3px;
color: var(--text-primary);
text-transform: uppercase;
}
.logo span {
color: var(--text-secondary);
font-weight: 400;
font-size: 14px;
font-size: 12px;
margin-left: 15px;
letter-spacing: 2px;
letter-spacing: 1px;
}
/* Stats badges in header */
@@ -113,7 +116,7 @@ body {
}
.stat-badge {
background: rgba(0, 212, 255, 0.1);
background: var(--bg-card);
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 4px;
padding: 4px 10px;
@@ -586,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 */
@@ -600,10 +604,15 @@ body {
background: var(--bg-dark) !important;
}
.leaflet-tile-pane,
.leaflet-container .leaflet-tile-pane {
filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
}
.leaflet-control-zoom a {
background: var(--bg-panel) !important;
color: var(--accent-cyan) !important;
border-color: rgba(0, 212, 255, 0.3) !important;
border-color: var(--border-color) !important;
}
.leaflet-control-attribution {
@@ -684,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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+51
View File
@@ -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

+27
View File
@@ -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

+226
View File
@@ -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 };
}
+547
View File
@@ -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);
+281
View File
@@ -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 });
+273
View File
@@ -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, '&amp;');
s = s.replace(/'/g, '&#39;');
s = s.replace(/"/g, '&quot;');
s = s.replace(/</g, '&lt;');
s = s.replace(/>/g, '&gt;');
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
File diff suppressed because it is too large Load Diff
+5307 -1213
View File
File diff suppressed because it is too large Load Diff
+34 -8
View File
@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SATELLITE COMMAND // INTERCEPT</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Rajdhani:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<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>
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
@@ -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);
@@ -247,8 +273,8 @@
worldCopyJump: true
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '©OpenStreetMap, ©CartoDB'
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(groundMap);
}
@@ -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);
+129
View File
@@ -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
+340
View File
@@ -0,0 +1,340 @@
"""Tests for device correlation engine."""
import pytest
from datetime import datetime, timedelta
from unittest.mock import patch, MagicMock
class TestDeviceCorrelator:
"""Tests for DeviceCorrelator class."""
def test_correlate_same_oui(self):
"""Test correlation detects same OUI."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator(time_window_seconds=60)
wifi_devices = {
'AA:BB:CC:11:22:33': {
'first_seen': datetime.now(),
'last_seen': datetime.now(),
'essid': 'TestNetwork',
'power': -65
}
}
bt_devices = {
'AA:BB:CC:44:55:66': {
'first_seen': datetime.now(),
'last_seen': datetime.now(),
'name': 'TestPhone',
'rssi': -60
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
assert len(correlations) >= 1
assert correlations[0]['wifi_mac'] == 'AA:BB:CC:11:22:33'
assert correlations[0]['bt_mac'] == 'AA:BB:CC:44:55:66'
assert correlations[0]['confidence'] > 0
def test_correlate_timing(self):
"""Test correlation considers timing."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator(time_window_seconds=30)
now = datetime.now()
# Devices appearing at the same time
wifi_devices = {
'11:22:33:44:55:66': {
'first_seen': now,
'last_seen': now,
'essid': 'Network1'
}
}
bt_devices = {
'77:88:99:AA:BB:CC': {
'first_seen': now,
'last_seen': now,
'name': 'Device1'
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
# Should have some confidence from timing correlation
if correlations:
assert correlations[0]['confidence'] > 0
def test_correlate_no_overlap(self):
"""Test no correlation when devices don't overlap."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator(
time_window_seconds=30,
min_confidence=0.6
)
now = datetime.now()
old = now - timedelta(hours=1)
wifi_devices = {
'11:22:33:44:55:66': {
'first_seen': old,
'last_seen': old,
'essid': 'OldNetwork'
}
}
bt_devices = {
'77:88:99:AA:BB:CC': {
'first_seen': now,
'last_seen': now,
'name': 'NewDevice'
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
# With high min_confidence and no OUI match, should be empty
assert len(correlations) == 0
def test_correlate_manufacturer_match(self):
"""Test correlation boosts confidence for same manufacturer."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator(time_window_seconds=60)
now = datetime.now()
wifi_devices = {
'11:22:33:44:55:66': {
'first_seen': now,
'last_seen': now,
'manufacturer': 'Apple',
'essid': 'Network'
}
}
bt_devices = {
'77:88:99:AA:BB:CC': {
'first_seen': now,
'last_seen': now,
'manufacturer': 'Apple',
'name': 'iPhone'
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
# Should have correlation with bonus for manufacturer match
assert len(correlations) >= 1
def test_correlate_empty_inputs(self):
"""Test correlation handles empty inputs."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator()
# Empty WiFi
assert correlator.correlate({}, {'AA:BB:CC:DD:EE:FF': {}}) == []
# Empty Bluetooth
assert correlator.correlate({'AA:BB:CC:DD:EE:FF': {}}, {}) == []
# Both empty
assert correlator.correlate({}, {}) == []
def test_correlate_sorting(self):
"""Test correlations are sorted by confidence."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator(
time_window_seconds=60,
min_confidence=0.0
)
now = datetime.now()
wifi_devices = {
'AA:BB:CC:11:11:11': {
'first_seen': now,
'last_seen': now,
'manufacturer': 'Apple'
},
'11:22:33:44:55:66': {
'first_seen': now,
'last_seen': now
}
}
bt_devices = {
'AA:BB:CC:22:22:22': {
'first_seen': now,
'last_seen': now,
'manufacturer': 'Apple'
},
'77:88:99:AA:BB:CC': {
'first_seen': now,
'last_seen': now
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
if len(correlations) >= 2:
# Should be sorted by confidence (highest first)
assert correlations[0]['confidence'] >= correlations[1]['confidence']
class TestGetCorrelations:
"""Tests for get_correlations function."""
@patch('utils.correlation.correlator')
@patch('utils.correlation.db_get_correlations')
def test_get_correlations_live(self, mock_db, mock_correlator):
"""Test get_correlations with live data."""
from utils.correlation import get_correlations
mock_correlator.correlate.return_value = [
{
'wifi_mac': 'AA:AA:AA:AA:AA:AA',
'bt_mac': 'BB:BB:BB:BB:BB:BB',
'confidence': 0.8
}
]
mock_db.return_value = []
wifi = {'AA:AA:AA:AA:AA:AA': {}}
bt = {'BB:BB:BB:BB:BB:BB': {}}
results = get_correlations(
wifi_devices=wifi,
bt_devices=bt,
include_historical=False
)
assert len(results) == 1
mock_correlator.correlate.assert_called_once()
@patch('utils.correlation.correlator')
@patch('utils.correlation.db_get_correlations')
def test_get_correlations_historical(self, mock_db, mock_correlator):
"""Test get_correlations includes historical data."""
from utils.correlation import get_correlations
mock_correlator.correlate.return_value = []
mock_db.return_value = [
{
'wifi_mac': 'CC:CC:CC:CC:CC:CC',
'bt_mac': 'DD:DD:DD:DD:DD:DD',
'confidence': 0.7,
'first_seen': '2024-01-01',
'last_seen': '2024-01-02'
}
]
results = get_correlations(
wifi_devices={},
bt_devices={},
include_historical=True
)
assert len(results) == 1
assert results[0]['wifi_mac'] == 'CC:CC:CC:CC:CC:CC'
@patch('utils.correlation.correlator')
@patch('utils.correlation.db_get_correlations')
def test_get_correlations_deduplication(self, mock_db, mock_correlator):
"""Test get_correlations deduplicates live and historical."""
from utils.correlation import get_correlations
# Same correlation from both sources
mock_correlator.correlate.return_value = [
{
'wifi_mac': 'AA:AA:AA:AA:AA:AA',
'bt_mac': 'BB:BB:BB:BB:BB:BB',
'confidence': 0.8
}
]
mock_db.return_value = [
{
'wifi_mac': 'AA:AA:AA:AA:AA:AA',
'bt_mac': 'BB:BB:BB:BB:BB:BB',
'confidence': 0.7,
'first_seen': '2024-01-01',
'last_seen': '2024-01-02'
}
]
wifi = {'AA:AA:AA:AA:AA:AA': {}}
bt = {'BB:BB:BB:BB:BB:BB': {}}
results = get_correlations(
wifi_devices=wifi,
bt_devices=bt,
include_historical=True
)
# Should deduplicate - only one entry for the same device pair
matching = [r for r in results
if r['wifi_mac'] == 'AA:AA:AA:AA:AA:AA']
assert len(matching) == 1
class TestCorrelationReason:
"""Tests for correlation reason generation."""
def test_reason_same_oui(self):
"""Test reason includes OUI match."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator()
now = datetime.now()
wifi_devices = {
'AA:BB:CC:11:22:33': {
'first_seen': now,
'last_seen': now
}
}
bt_devices = {
'AA:BB:CC:44:55:66': {
'first_seen': now,
'last_seen': now
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
if correlations:
assert 'OUI' in correlations[0]['reason'] or 'same' in correlations[0]['reason'].lower()
def test_reason_timing(self):
"""Test reason includes timing information."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator(time_window_seconds=60)
now = datetime.now()
wifi_devices = {
'11:22:33:44:55:66': {
'first_seen': now,
'last_seen': now
}
}
bt_devices = {
'77:88:99:AA:BB:CC': {
'first_seen': now + timedelta(seconds=5),
'last_seen': now + timedelta(seconds=5)
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
# If correlation found, should mention timing
if correlations and correlations[0]['confidence'] > 0.3:
assert 'appeared' in correlations[0]['reason'] or 'timing' in correlations[0]['reason']
+256
View File
@@ -0,0 +1,256 @@
"""Tests for database utilities."""
import os
import tempfile
import pytest
from pathlib import Path
from unittest.mock import patch
# Need to patch DB_PATH before importing database module
@pytest.fixture(autouse=True)
def temp_db():
"""Use a temporary database for each test."""
with tempfile.TemporaryDirectory() as tmpdir:
test_db_path = Path(tmpdir) / 'test_intercept.db'
test_db_dir = Path(tmpdir)
with patch('utils.database.DB_PATH', test_db_path), \
patch('utils.database.DB_DIR', test_db_dir):
# Import after patching
from utils.database import init_db, close_db
init_db()
yield test_db_path
close_db()
class TestSettingsCRUD:
"""Tests for settings CRUD operations."""
def test_set_and_get_string(self, temp_db):
"""Test setting and getting string values."""
from utils.database import set_setting, get_setting
set_setting('test_key', 'test_value')
assert get_setting('test_key') == 'test_value'
def test_set_and_get_int(self, temp_db):
"""Test setting and getting integer values."""
from utils.database import set_setting, get_setting
set_setting('int_key', 42)
result = get_setting('int_key')
assert result == 42
assert isinstance(result, int)
def test_set_and_get_float(self, temp_db):
"""Test setting and getting float values."""
from utils.database import set_setting, get_setting
set_setting('float_key', 3.14)
result = get_setting('float_key')
assert result == 3.14
assert isinstance(result, float)
def test_set_and_get_bool(self, temp_db):
"""Test setting and getting boolean values."""
from utils.database import set_setting, get_setting
set_setting('bool_true', True)
set_setting('bool_false', False)
assert get_setting('bool_true') is True
assert get_setting('bool_false') is False
def test_set_and_get_dict(self, temp_db):
"""Test setting and getting dictionary values."""
from utils.database import set_setting, get_setting
test_dict = {'name': 'test', 'value': 123, 'nested': {'a': 1}}
set_setting('dict_key', test_dict)
result = get_setting('dict_key')
assert result == test_dict
assert result['nested']['a'] == 1
def test_set_and_get_list(self, temp_db):
"""Test setting and getting list values."""
from utils.database import set_setting, get_setting
test_list = [1, 2, 3, 'four', {'five': 5}]
set_setting('list_key', test_list)
result = get_setting('list_key')
assert result == test_list
def test_get_nonexistent_key(self, temp_db):
"""Test getting a key that doesn't exist."""
from utils.database import get_setting
assert get_setting('nonexistent') is None
assert get_setting('nonexistent', 'default') == 'default'
def test_update_existing_setting(self, temp_db):
"""Test updating an existing setting."""
from utils.database import set_setting, get_setting
set_setting('update_key', 'original')
assert get_setting('update_key') == 'original'
set_setting('update_key', 'updated')
assert get_setting('update_key') == 'updated'
def test_delete_setting(self, temp_db):
"""Test deleting a setting."""
from utils.database import set_setting, get_setting, delete_setting
set_setting('delete_key', 'value')
assert get_setting('delete_key') == 'value'
result = delete_setting('delete_key')
assert result is True
assert get_setting('delete_key') is None
def test_delete_nonexistent_setting(self, temp_db):
"""Test deleting a setting that doesn't exist."""
from utils.database import delete_setting
result = delete_setting('nonexistent_key')
assert result is False
def test_get_all_settings(self, temp_db):
"""Test getting all settings."""
from utils.database import set_setting, get_all_settings
set_setting('key1', 'value1')
set_setting('key2', 42)
set_setting('key3', True)
all_settings = get_all_settings()
assert 'key1' in all_settings
assert all_settings['key1'] == 'value1'
assert all_settings['key2'] == 42
assert all_settings['key3'] is True
class TestSignalHistory:
"""Tests for signal history operations."""
def test_add_and_get_signal_reading(self, temp_db):
"""Test adding and retrieving signal readings."""
from utils.database import add_signal_reading, get_signal_history
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -65)
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -62)
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -70)
history = get_signal_history('wifi', 'AA:BB:CC:DD:EE:FF')
assert len(history) == 3
# Results should be in chronological order
assert history[0]['signal'] == -65
assert history[1]['signal'] == -62
assert history[2]['signal'] == -70
def test_signal_history_with_metadata(self, temp_db):
"""Test signal readings with metadata."""
from utils.database import add_signal_reading, get_signal_history
metadata = {'channel': 6, 'ssid': 'TestNetwork'}
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -65, metadata)
history = get_signal_history('wifi', 'AA:BB:CC:DD:EE:FF')
assert len(history) == 1
assert history[0]['metadata'] == metadata
def test_signal_history_limit(self, temp_db):
"""Test signal history respects limit parameter."""
from utils.database import add_signal_reading, get_signal_history
for i in range(10):
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -60 - i)
history = get_signal_history('wifi', 'AA:BB:CC:DD:EE:FF', limit=5)
assert len(history) == 5
def test_signal_history_different_devices(self, temp_db):
"""Test signal history isolates different devices."""
from utils.database import add_signal_reading, get_signal_history
add_signal_reading('wifi', 'AA:AA:AA:AA:AA:AA', -65)
add_signal_reading('wifi', 'BB:BB:BB:BB:BB:BB', -70)
history_a = get_signal_history('wifi', 'AA:AA:AA:AA:AA:AA')
history_b = get_signal_history('wifi', 'BB:BB:BB:BB:BB:BB')
assert len(history_a) == 1
assert len(history_b) == 1
assert history_a[0]['signal'] == -65
assert history_b[0]['signal'] == -70
def test_cleanup_old_signal_history(self, temp_db):
"""Test cleanup of old signal history."""
from utils.database import add_signal_reading, cleanup_old_signal_history
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -65)
# Cleanup with 0 hours should remove everything
deleted = cleanup_old_signal_history(max_age_hours=0)
# Note: This may or may not delete depending on timing
assert isinstance(deleted, int)
class TestDeviceCorrelations:
"""Tests for device correlation operations."""
def test_add_and_get_correlation(self, temp_db):
"""Test adding and retrieving correlations."""
from utils.database import add_correlation, get_correlations
add_correlation(
wifi_mac='AA:AA:AA:AA:AA:AA',
bt_mac='BB:BB:BB:BB:BB:BB',
confidence=0.85,
metadata={'reason': 'timing'}
)
correlations = get_correlations(min_confidence=0.5)
assert len(correlations) >= 1
found = next(
(c for c in correlations
if c['wifi_mac'] == 'AA:AA:AA:AA:AA:AA'),
None
)
assert found is not None
assert found['bt_mac'] == 'BB:BB:BB:BB:BB:BB'
assert found['confidence'] == 0.85
def test_correlation_confidence_filter(self, temp_db):
"""Test correlation filtering by confidence."""
from utils.database import add_correlation, get_correlations
add_correlation('AA:AA:AA:AA:AA:AA', 'BB:BB:BB:BB:BB:BB', 0.9)
add_correlation('CC:CC:CC:CC:CC:CC', 'DD:DD:DD:DD:DD:DD', 0.4)
high_confidence = get_correlations(min_confidence=0.7)
all_confidence = get_correlations(min_confidence=0.3)
assert len(high_confidence) == 1
assert len(all_confidence) == 2
def test_correlation_upsert(self, temp_db):
"""Test that correlations are updated on conflict."""
from utils.database import add_correlation, get_correlations
add_correlation('AA:AA:AA:AA:AA:AA', 'BB:BB:BB:BB:BB:BB', 0.5)
add_correlation('AA:AA:AA:AA:AA:AA', 'BB:BB:BB:BB:BB:BB', 0.9)
correlations = get_correlations(min_confidence=0.0)
matching = [c for c in correlations
if c['wifi_mac'] == 'AA:AA:AA:AA:AA:AA']
assert len(matching) == 1
assert matching[0]['confidence'] == 0.9
+376
View File
@@ -0,0 +1,376 @@
"""Tests for Flask routes and API endpoints."""
import json
import pytest
from unittest.mock import patch, MagicMock
@pytest.fixture(scope='session')
def app():
"""Create application for testing."""
import app as app_module
from routes import register_blueprints
from utils.database import init_db
app_module.app.config['TESTING'] = True
# Initialize database for settings tests
init_db()
# Register blueprints only if not already registered (normally done in main())
# Check if any blueprint is already registered to avoid re-registration
if 'pager' not in app_module.app.blueprints:
register_blueprints(app_module.app)
return app_module.app
@pytest.fixture
def client(app):
"""Create test client."""
return app.test_client()
class TestHealthEndpoint:
"""Tests for health check endpoint."""
def test_health_check(self, client):
"""Test health endpoint returns expected data."""
response = client.get('/health')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'healthy'
assert 'version' in data
assert 'uptime_seconds' in data
assert 'processes' in data
assert 'data' in data
def test_health_process_status(self, client):
"""Test health endpoint reports process status."""
response = client.get('/health')
data = json.loads(response.data)
processes = data['processes']
assert 'pager' in processes
assert 'sensor' in processes
assert 'adsb' in processes
assert 'wifi' in processes
assert 'bluetooth' in processes
class TestDevicesEndpoint:
"""Tests for devices endpoint."""
def test_get_devices(self, client):
"""Test getting device list."""
response = client.get('/devices')
assert response.status_code == 200
data = json.loads(response.data)
assert isinstance(data, list)
@patch('app.SDRFactory.detect_devices')
def test_devices_returns_list(self, mock_detect, client):
"""Test devices endpoint returns list format."""
mock_device = MagicMock()
mock_device.to_dict.return_value = {
'index': 0,
'name': 'Test RTL-SDR',
'sdr_type': 'rtlsdr'
}
mock_detect.return_value = [mock_device]
response = client.get('/devices')
data = json.loads(response.data)
assert len(data) == 1
assert data[0]['name'] == 'Test RTL-SDR'
class TestDependenciesEndpoint:
"""Tests for dependencies endpoint."""
def test_get_dependencies(self, client):
"""Test getting dependency status."""
response = client.get('/dependencies')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'os' in data
assert 'pkg_manager' in data
assert 'modes' in data
class TestSettingsEndpoints:
"""Tests for settings API endpoints."""
def test_get_settings(self, client):
"""Test getting all settings."""
response = client.get('/settings')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'settings' in data
def test_save_settings(self, client):
"""Test saving settings."""
response = client.post(
'/settings',
data=json.dumps({'test_key': 'test_value'}),
content_type='application/json'
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'test_key' in data['saved']
def test_save_empty_settings(self, client):
"""Test saving empty settings returns error."""
response = client.post(
'/settings',
data=json.dumps({}),
content_type='application/json'
)
assert response.status_code == 400
def test_get_single_setting(self, client):
"""Test getting a single setting."""
# First save a setting
client.post(
'/settings',
data=json.dumps({'my_setting': 'my_value'}),
content_type='application/json'
)
# Then retrieve it
response = client.get('/settings/my_setting')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['value'] == 'my_value'
def test_get_nonexistent_setting(self, client):
"""Test getting a setting that doesn't exist."""
response = client.get('/settings/nonexistent_key_xyz')
assert response.status_code == 404
def test_update_setting(self, client):
"""Test updating a setting via PUT."""
response = client.put(
'/settings/update_test',
data=json.dumps({'value': 'updated_value'}),
content_type='application/json'
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['value'] == 'updated_value'
def test_delete_setting(self, client):
"""Test deleting a setting."""
# First create a setting
client.post(
'/settings',
data=json.dumps({'delete_me': 'value'}),
content_type='application/json'
)
# Then delete it
response = client.delete('/settings/delete_me')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['deleted'] is True
class TestCorrelationEndpoints:
"""Tests for correlation API endpoints."""
def test_get_correlations(self, client):
"""Test getting device correlations."""
response = client.get('/correlation')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'correlations' in data
assert 'wifi_count' in data
assert 'bt_count' in data
def test_correlations_with_confidence_filter(self, client):
"""Test correlation endpoint respects confidence filter."""
response = client.get('/correlation?min_confidence=0.8')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
class TestListeningPostEndpoints:
"""Tests for listening post endpoints."""
def test_tools_check(self, client):
"""Test listening post tools availability check."""
response = client.get('/listening/tools')
assert response.status_code == 200
data = json.loads(response.data)
assert 'rtl_fm' in data
assert 'available' in data
def test_scanner_status(self, client):
"""Test scanner status endpoint."""
response = client.get('/listening/scanner/status')
assert response.status_code == 200
data = json.loads(response.data)
assert 'running' in data
assert 'paused' in data
assert 'current_freq' in data
def test_presets(self, client):
"""Test scanner presets endpoint."""
response = client.get('/listening/presets')
assert response.status_code == 200
data = json.loads(response.data)
assert 'presets' in data
assert len(data['presets']) > 0
# Check preset structure
preset = data['presets'][0]
assert 'name' in preset
assert 'start' in preset
assert 'end' in preset
assert 'mod' in preset
def test_scanner_stop_when_not_running(self, client):
"""Test stopping scanner when not running."""
response = client.post('/listening/scanner/stop')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'stopped'
def test_activity_log(self, client):
"""Test getting activity log."""
response = client.get('/listening/scanner/log')
assert response.status_code == 200
data = json.loads(response.data)
assert 'log' in data
assert 'total' in data
def test_scanner_skip_when_not_running(self, client):
"""Test skip signal when scanner not running returns error."""
response = client.post('/listening/scanner/skip')
assert response.status_code == 400
data = json.loads(response.data)
assert data['status'] == 'error'
class TestAudioEndpoints:
"""Tests for audio demodulation endpoints."""
def test_audio_status(self, client):
"""Test audio status endpoint."""
response = client.get('/listening/audio/status')
assert response.status_code == 200
data = json.loads(response.data)
assert 'running' in data
assert 'frequency' in data
assert 'modulation' in data
def test_audio_stop_when_not_running(self, client):
"""Test stopping audio when not running."""
response = client.post('/listening/audio/stop')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'stopped'
def test_audio_start_missing_frequency(self, client):
"""Test starting audio without frequency returns error."""
response = client.post(
'/listening/audio/start',
data=json.dumps({}),
content_type='application/json'
)
assert response.status_code == 400
data = json.loads(response.data)
assert data['status'] == 'error'
assert 'frequency' in data['message'].lower()
def test_audio_start_invalid_modulation(self, client):
"""Test starting audio with invalid modulation returns error."""
response = client.post(
'/listening/audio/start',
data=json.dumps({
'frequency': 98.1,
'modulation': 'invalid_mode'
}),
content_type='application/json'
)
assert response.status_code == 400
data = json.loads(response.data)
assert data['status'] == 'error'
assert 'modulation' in data['message'].lower()
def test_audio_stream_when_not_running(self, client):
"""Test audio stream when not running returns error."""
response = client.get('/listening/audio/stream')
assert response.status_code == 400
data = json.loads(response.data)
assert data['status'] == 'error'
class TestExportEndpoints:
"""Tests for data export endpoints."""
def test_export_aircraft_json(self, client):
"""Test exporting aircraft data as JSON."""
response = client.get('/export/aircraft?format=json')
assert response.status_code == 200
assert response.content_type == 'application/json'
def test_export_aircraft_csv(self, client):
"""Test exporting aircraft data as CSV."""
response = client.get('/export/aircraft?format=csv')
assert response.status_code == 200
assert 'text/csv' in response.content_type
def test_export_wifi_json(self, client):
"""Test exporting WiFi data as JSON."""
response = client.get('/export/wifi?format=json')
assert response.status_code == 200
assert response.content_type == 'application/json'
def test_export_wifi_csv(self, client):
"""Test exporting WiFi data as CSV."""
response = client.get('/export/wifi?format=csv')
assert response.status_code == 200
assert 'text/csv' in response.content_type
def test_export_bluetooth_json(self, client):
"""Test exporting Bluetooth data as JSON."""
response = client.get('/export/bluetooth?format=json')
assert response.status_code == 200
assert response.content_type == 'application/json'
def test_export_bluetooth_csv(self, client):
"""Test exporting Bluetooth data as CSV."""
response = client.get('/export/bluetooth?format=csv')
assert response.status_code == 200
assert 'text/csv' in response.content_type
+83
View File
@@ -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
+120
View File
@@ -0,0 +1,120 @@
"""Comprehensive tests for validation utilities."""
import pytest
from utils.validation import (
validate_frequency,
validate_gain,
validate_device_index,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
class TestFrequencyValidation:
"""Tests for frequency validation."""
def test_valid_frequencies(self):
"""Test valid frequency values."""
assert validate_frequency('152.0') == '152.0'
assert validate_frequency(152.0) == '152.0'
assert validate_frequency('1090') == '1090'
assert validate_frequency(433.92) == '433.92'
def test_frequency_range(self):
"""Test frequency range limits."""
# RTL-SDR typical range: 24MHz - 1766MHz
assert validate_frequency('24') == '24'
assert validate_frequency('1700') == '1700'
def test_invalid_frequencies(self):
"""Test invalid frequency values."""
with pytest.raises(ValueError):
validate_frequency('')
with pytest.raises(ValueError):
validate_frequency('abc')
with pytest.raises(ValueError):
validate_frequency(-100)
with pytest.raises(ValueError):
validate_frequency(0)
class TestGainValidation:
"""Tests for gain validation."""
def test_valid_gains(self):
"""Test valid gain values."""
assert validate_gain('0') == '0'
assert validate_gain('40') == '40'
assert validate_gain(49.6) == '49.6'
assert validate_gain('auto') == 'auto'
def test_invalid_gains(self):
"""Test invalid gain values."""
with pytest.raises(ValueError):
validate_gain(-10)
with pytest.raises(ValueError):
validate_gain(100)
with pytest.raises(ValueError):
validate_gain('invalid')
class TestDeviceIndexValidation:
"""Tests for device index validation."""
def test_valid_indices(self):
"""Test valid device indices."""
assert validate_device_index('0') == '0'
assert validate_device_index(0) == '0'
assert validate_device_index('1') == '1'
assert validate_device_index(3) == '3'
def test_invalid_indices(self):
"""Test invalid device indices."""
with pytest.raises(ValueError):
validate_device_index(-1)
with pytest.raises(ValueError):
validate_device_index('abc')
with pytest.raises(ValueError):
validate_device_index(100)
class TestRtlTcpHostValidation:
"""Tests for RTL-TCP host validation."""
def test_valid_hosts(self):
"""Test valid host values."""
assert validate_rtl_tcp_host('localhost') == 'localhost'
assert validate_rtl_tcp_host('127.0.0.1') == '127.0.0.1'
assert validate_rtl_tcp_host('192.168.1.1') == '192.168.1.1'
assert validate_rtl_tcp_host('server.example.com') == 'server.example.com'
def test_invalid_hosts(self):
"""Test invalid host values."""
with pytest.raises(ValueError):
validate_rtl_tcp_host('')
with pytest.raises(ValueError):
validate_rtl_tcp_host('invalid host with spaces')
with pytest.raises(ValueError):
validate_rtl_tcp_host('host;rm -rf /')
class TestRtlTcpPortValidation:
"""Tests for RTL-TCP port validation."""
def test_valid_ports(self):
"""Test valid port values."""
assert validate_rtl_tcp_port(1234) == 1234
assert validate_rtl_tcp_port('1234') == 1234
assert validate_rtl_tcp_port(30003) == 30003
assert validate_rtl_tcp_port(65535) == 65535
def test_invalid_ports(self):
"""Test invalid port values."""
with pytest.raises(ValueError):
validate_rtl_tcp_port(0)
with pytest.raises(ValueError):
validate_rtl_tcp_port(-1)
with pytest.raises(ValueError):
validate_rtl_tcp_port(70000)
with pytest.raises(ValueError):
validate_rtl_tcp_port('abc')
+221
View File
@@ -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']
+268
View File
@@ -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)}
+17
View File
@@ -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.
+213
View File
@@ -0,0 +1,213 @@
"""
INTERCEPT - Constants and Magic Numbers
Centralized location for all hardcoded values used throughout the application.
This improves maintainability and makes the codebase self-documenting.
"""
from __future__ import annotations
# =============================================================================
# NETWORK PORTS
# =============================================================================
# ADS-B SBS data output port (dump1090 default)
ADSB_SBS_PORT = 30003
# GPS daemon port (gpsd default)
GPSD_PORT = 2947
# RTL-TCP server port (rtl_tcp default)
RTL_TCP_PORT = 1234
# =============================================================================
# PROCESS TIMEOUTS (seconds)
# =============================================================================
# General process termination timeout
PROCESS_TERMINATE_TIMEOUT = 2
# ADS-B process termination (dump1090 needs longer)
ADSB_TERMINATE_TIMEOUT = 5
# WiFi process termination (airodump-ng)
WIFI_TERMINATE_TIMEOUT = 3
# Bluetooth process termination
BT_TERMINATE_TIMEOUT = 3
# PMKID process termination
PMKID_TERMINATE_TIMEOUT = 5
# Socket connection timeout
SOCKET_CONNECT_TIMEOUT = 2
# SBS stream socket timeout
SBS_SOCKET_TIMEOUT = 5
# Subprocess command timeout (short operations)
SUBPROCESS_TIMEOUT_SHORT = 5
# Subprocess command timeout (medium operations)
SUBPROCESS_TIMEOUT_MEDIUM = 10
# Subprocess command timeout (long operations like airmon-ng)
SUBPROCESS_TIMEOUT_LONG = 15
# External HTTP request timeout (TLE fetching, etc.)
HTTP_REQUEST_TIMEOUT = 10
# Deauth command timeout
DEAUTH_TIMEOUT = 30
# Service enumeration timeout (sdptool browse)
SERVICE_ENUM_TIMEOUT = 30
# =============================================================================
# SSE (Server-Sent Events) SETTINGS
# =============================================================================
# Keepalive interval for SSE streams (seconds)
SSE_KEEPALIVE_INTERVAL = 30.0
# Queue get timeout for SSE generators (seconds)
SSE_QUEUE_TIMEOUT = 1.0
# =============================================================================
# DATA RETENTION / CLEANUP (seconds)
# =============================================================================
# Maximum age for aircraft data before cleanup
MAX_AIRCRAFT_AGE_SECONDS = 300 # 5 minutes
# Maximum age for WiFi network data before cleanup
MAX_WIFI_NETWORK_AGE_SECONDS = 600 # 10 minutes
# Maximum age for Bluetooth device data before cleanup
MAX_BT_DEVICE_AGE_SECONDS = 300 # 5 minutes
# ADS-B queue batch update interval
ADSB_UPDATE_INTERVAL = 1.0 # seconds
# =============================================================================
# QUEUE LIMITS
# =============================================================================
# Maximum queue size for all data queues
QUEUE_MAX_SIZE = 1000
# GPS queue size (smaller, more frequent updates)
GPS_QUEUE_MAX_SIZE = 100
# =============================================================================
# DATA PARSING
# =============================================================================
# WiFi CSV parse interval (seconds)
WIFI_CSV_PARSE_INTERVAL = 2.0
# Minimum time before warning about no CSV data
WIFI_CSV_TIMEOUT_WARNING = 5.0
# Socket receive buffer size
SOCKET_BUFFER_SIZE = 4096
# PTY read buffer size
PTY_BUFFER_SIZE = 1024
# =============================================================================
# EXTERNAL SERVICE LIMITS
# =============================================================================
# Maximum response size for external HTTP requests (bytes)
MAX_HTTP_RESPONSE_SIZE = 1024 * 1024 # 1 MB
# Deauth packet count limits
MIN_DEAUTH_COUNT = 1
MAX_DEAUTH_COUNT = 100
DEFAULT_DEAUTH_COUNT = 5
# =============================================================================
# VALIDATION LIMITS
# =============================================================================
# Squelch range
MIN_SQUELCH = 0
MAX_SQUELCH = 1000
# Valid GPS baudrates
VALID_GPS_BAUDRATES = [4800, 9600, 19200, 38400, 57600, 115200]
# Port range
MIN_PORT = 1
MAX_PORT = 65535
# =============================================================================
# SATELLITE TRACKING
# =============================================================================
# Default observer location (London)
DEFAULT_LATITUDE = 51.5074
DEFAULT_LONGITUDE = -0.1278
# Allowed TLE hosts for security
ALLOWED_TLE_HOSTS = [
'celestrak.org',
'celestrak.com',
'www.celestrak.org',
'www.celestrak.com'
]
# Earth radius (km) - WGS84 mean
EARTH_RADIUS_KM = 6371
# Trajectory calculation points
TRAJECTORY_POINTS = 30
GROUND_TRACK_POINTS = 60
ORBIT_TRACK_RANGE_MINUTES = 45
# =============================================================================
# SLEEP/DELAY TIMES (seconds)
# =============================================================================
# Wait after starting process before checking status
PROCESS_START_WAIT = 0.5
# Wait after dump1090 start before connecting
DUMP1090_START_WAIT = 3.0
# Delay between monitor mode operations
MONITOR_MODE_DELAY = 1.0
# Bluetooth adapter reset delays
BT_RESET_DELAY = 0.5
BT_ADAPTER_DOWN_WAIT = 1.0
# SBS reconnection delay on error
SBS_RECONNECT_DELAY = 2.0
# =============================================================================
# FILE PATHS
# =============================================================================
# Default pager log file
DEFAULT_PAGER_LOG_FILE = 'pager_messages.log'
# WiFi capture temp path prefix
WIFI_CAPTURE_PATH_PREFIX = '/tmp/intercept_wifi'
# Handshake capture path prefix
HANDSHAKE_CAPTURE_PATH_PREFIX = '/tmp/intercept_handshake_'
# PMKID capture path prefix
PMKID_CAPTURE_PATH_PREFIX = '/tmp/intercept_pmkid_'
+313
View File
@@ -0,0 +1,313 @@
"""
Device correlation engine for matching WiFi and Bluetooth devices.
Uses timing-based correlation to identify when WiFi and Bluetooth
signals likely belong to the same physical device.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
from utils.database import add_correlation, get_correlations as db_get_correlations
logger = logging.getLogger('intercept.correlation')
@dataclass
class DeviceObservation:
"""A single observation of a device."""
mac: str
first_seen: datetime
last_seen: datetime
rssi: int | None = None
name: str | None = None
manufacturer: str | None = None
class DeviceCorrelator:
"""
Correlates WiFi and Bluetooth devices based on timing patterns.
Devices are considered potentially correlated if:
1. They appear within a short time window of each other
2. They have similar signal strength patterns (optional)
3. They share the same OUI/manufacturer (bonus confidence)
"""
def __init__(
self,
time_window_seconds: int = 30,
min_confidence: float = 0.5,
rssi_threshold: int = 20
):
"""
Initialize correlator.
Args:
time_window_seconds: Max time difference for correlation (default 30s)
min_confidence: Minimum confidence score to report (default 0.5)
rssi_threshold: Max RSSI difference for signal-based correlation
"""
self.time_window = timedelta(seconds=time_window_seconds)
self.min_confidence = min_confidence
self.rssi_threshold = rssi_threshold
def correlate(
self,
wifi_devices: dict[str, dict[str, Any]],
bt_devices: dict[str, dict[str, Any]]
) -> list[dict]:
"""
Find correlations between WiFi and Bluetooth devices.
Args:
wifi_devices: Dict of WiFi devices keyed by MAC
bt_devices: Dict of Bluetooth devices keyed by MAC
Returns:
List of correlation results with confidence scores
"""
correlations = []
for wifi_mac, wifi_data in wifi_devices.items():
wifi_obs = self._to_observation(wifi_mac, wifi_data, 'wifi')
if not wifi_obs:
continue
for bt_mac, bt_data in bt_devices.items():
bt_obs = self._to_observation(bt_mac, bt_data, 'bluetooth')
if not bt_obs:
continue
confidence = self._calculate_confidence(wifi_obs, bt_obs)
if confidence >= self.min_confidence:
correlations.append({
'wifi_mac': wifi_mac,
'wifi_name': wifi_obs.name,
'bt_mac': bt_mac,
'bt_name': bt_obs.name,
'confidence': round(confidence, 2),
'reason': self._get_correlation_reason(wifi_obs, bt_obs)
})
# Persist high-confidence correlations
if confidence >= 0.7:
try:
add_correlation(
wifi_mac=wifi_mac,
bt_mac=bt_mac,
confidence=confidence,
metadata={
'wifi_name': wifi_obs.name,
'bt_name': bt_obs.name
}
)
except Exception as e:
logger.debug(f"Failed to persist correlation: {e}")
# Sort by confidence (highest first)
correlations.sort(key=lambda x: x['confidence'], reverse=True)
return correlations
def _to_observation(
self,
mac: str,
data: dict[str, Any],
device_type: str
) -> DeviceObservation | None:
"""Convert device dict to observation."""
try:
# Handle different timestamp formats
first_seen = data.get('first_seen') or data.get('firstSeen')
last_seen = data.get('last_seen') or data.get('lastSeen')
if isinstance(first_seen, str):
first_seen = datetime.fromisoformat(first_seen.replace('Z', '+00:00'))
elif isinstance(first_seen, (int, float)):
first_seen = datetime.fromtimestamp(first_seen / 1000)
else:
first_seen = datetime.now()
if isinstance(last_seen, str):
last_seen = datetime.fromisoformat(last_seen.replace('Z', '+00:00'))
elif isinstance(last_seen, (int, float)):
last_seen = datetime.fromtimestamp(last_seen / 1000)
else:
last_seen = datetime.now()
# Get RSSI (different field names)
rssi = data.get('rssi') or data.get('power') or data.get('signal')
if rssi is not None:
rssi = int(rssi)
# Get name
name = data.get('name') or data.get('essid') or data.get('ssid')
# Get manufacturer
manufacturer = data.get('manufacturer') or data.get('vendor')
return DeviceObservation(
mac=mac,
first_seen=first_seen,
last_seen=last_seen,
rssi=rssi,
name=name,
manufacturer=manufacturer
)
except Exception as e:
logger.debug(f"Failed to parse device {mac}: {e}")
return None
def _calculate_confidence(
self,
wifi: DeviceObservation,
bt: DeviceObservation
) -> float:
"""
Calculate correlation confidence score.
Score components:
- Timing overlap: 0.0-0.5 (primary factor)
- Same manufacturer: +0.2
- Similar RSSI: +0.1
- Both named: +0.1
Returns:
Confidence score 0.0-1.0
"""
confidence = 0.0
# Timing correlation (most important)
time_diff = abs((wifi.first_seen - bt.first_seen).total_seconds())
if time_diff <= self.time_window.total_seconds():
# Linear decay from 0.5 to 0.0 as time difference increases
timing_score = 0.5 * (1 - time_diff / self.time_window.total_seconds())
confidence += timing_score
else:
# Check if observation windows overlap at all
wifi_end = wifi.last_seen
bt_end = bt.last_seen
# If observation periods overlap
if wifi.first_seen <= bt_end and bt.first_seen <= wifi_end:
confidence += 0.25 # Partial credit for overlapping presence
# Manufacturer match
if wifi.manufacturer and bt.manufacturer:
wifi_mfg = wifi.manufacturer.lower()
bt_mfg = bt.manufacturer.lower()
if wifi_mfg == bt_mfg:
confidence += 0.2
elif wifi_mfg[:5] == bt_mfg[:5]: # Partial match
confidence += 0.1
# OUI match (first 3 octets of MAC)
wifi_oui = wifi.mac[:8].upper()
bt_oui = bt.mac[:8].upper()
if wifi_oui == bt_oui:
confidence += 0.15
# RSSI similarity
if wifi.rssi is not None and bt.rssi is not None:
rssi_diff = abs(wifi.rssi - bt.rssi)
if rssi_diff <= self.rssi_threshold:
rssi_score = 0.1 * (1 - rssi_diff / self.rssi_threshold)
confidence += rssi_score
# Both have names (suggests user device)
if wifi.name and bt.name:
confidence += 0.05
return min(confidence, 1.0)
def _get_correlation_reason(
self,
wifi: DeviceObservation,
bt: DeviceObservation
) -> str:
"""Generate human-readable reason for correlation."""
reasons = []
time_diff = abs((wifi.first_seen - bt.first_seen).total_seconds())
if time_diff <= self.time_window.total_seconds():
reasons.append(f"appeared within {int(time_diff)}s")
wifi_oui = wifi.mac[:8].upper()
bt_oui = bt.mac[:8].upper()
if wifi_oui == bt_oui:
reasons.append("same OUI")
if wifi.manufacturer and bt.manufacturer:
if wifi.manufacturer.lower() == bt.manufacturer.lower():
reasons.append(f"same manufacturer ({wifi.manufacturer})")
if wifi.rssi is not None and bt.rssi is not None:
rssi_diff = abs(wifi.rssi - bt.rssi)
if rssi_diff <= self.rssi_threshold:
reasons.append("similar signal strength")
return "; ".join(reasons) if reasons else "timing overlap"
# Global correlator instance
correlator = DeviceCorrelator()
def get_correlations(
wifi_devices: dict[str, dict] | None = None,
bt_devices: dict[str, dict] | None = None,
min_confidence: float = 0.5,
include_historical: bool = True
) -> list[dict]:
"""
Get device correlations.
Args:
wifi_devices: Current WiFi devices (or None to use only historical)
bt_devices: Current Bluetooth devices (or None to use only historical)
min_confidence: Minimum confidence threshold
include_historical: Include correlations from database
Returns:
List of correlations sorted by confidence
"""
results = []
# Get live correlations
if wifi_devices and bt_devices:
correlator.min_confidence = min_confidence
results.extend(correlator.correlate(wifi_devices, bt_devices))
# Get historical correlations from database
if include_historical:
try:
historical = db_get_correlations(min_confidence)
for h in historical:
# Avoid duplicates
existing = next(
(r for r in results
if r['wifi_mac'] == h['wifi_mac'] and r['bt_mac'] == h['bt_mac']),
None
)
if not existing:
results.append({
'wifi_mac': h['wifi_mac'],
'bt_mac': h['bt_mac'],
'confidence': h['confidence'],
'reason': 'historical correlation',
'first_seen': h['first_seen'],
'last_seen': h['last_seen']
})
except Exception as e:
logger.debug(f"Failed to get historical correlations: {e}")
# Sort by confidence
results.sort(key=lambda x: x['confidence'], reverse=True)
return results
+795
View File
@@ -0,0 +1,795 @@
"""
SQLite database utilities for persistent settings storage.
"""
from __future__ import annotations
import json
import logging
import sqlite3
import threading
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Any
logger = logging.getLogger('intercept.database')
# Database file location
DB_DIR = Path(__file__).parent.parent / 'instance'
DB_PATH = DB_DIR / 'intercept.db'
# Thread-local storage for connections
_local = threading.local()
def get_db_path() -> Path:
"""Get the database file path, creating directory if needed."""
DB_DIR.mkdir(parents=True, exist_ok=True)
return DB_PATH
def get_connection() -> sqlite3.Connection:
"""Get a thread-local database connection."""
if not hasattr(_local, 'connection') or _local.connection is None:
db_path = get_db_path()
_local.connection = sqlite3.connect(str(db_path), check_same_thread=False)
_local.connection.row_factory = sqlite3.Row
# Enable foreign keys
_local.connection.execute('PRAGMA foreign_keys = ON')
return _local.connection
@contextmanager
def get_db():
"""Context manager for database operations."""
conn = get_connection()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
def init_db() -> None:
"""Initialize the database schema."""
db_path = get_db_path()
logger.info(f"Initializing database at {db_path}")
with get_db() as conn:
# Settings table for key-value storage
conn.execute('''
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
value_type TEXT DEFAULT 'string',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Signal history table for graphs
conn.execute('''
CREATE TABLE IF NOT EXISTS signal_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mode TEXT NOT NULL,
device_id TEXT NOT NULL,
signal_strength REAL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
metadata TEXT
)
''')
# Create index for faster queries
conn.execute('''
CREATE INDEX IF NOT EXISTS idx_signal_history_mode_device
ON signal_history(mode, device_id, timestamp)
''')
# Device correlation table
conn.execute('''
CREATE TABLE IF NOT EXISTS device_correlations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
wifi_mac TEXT,
bt_mac TEXT,
confidence REAL,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
metadata TEXT,
UNIQUE(wifi_mac, bt_mac)
)
''')
# =====================================================================
# 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")
def close_db() -> None:
"""Close the thread-local database connection."""
if hasattr(_local, 'connection') and _local.connection is not None:
_local.connection.close()
_local.connection = None
# =============================================================================
# Settings Functions
# =============================================================================
def get_setting(key: str, default: Any = None) -> Any:
"""
Get a setting value by key.
Args:
key: Setting key
default: Default value if not found
Returns:
Setting value (auto-converted from JSON for complex types)
"""
with get_db() as conn:
cursor = conn.execute(
'SELECT value, value_type FROM settings WHERE key = ?',
(key,)
)
row = cursor.fetchone()
if row is None:
return default
value, value_type = row['value'], row['value_type']
# Convert based on type
if value_type == 'json':
try:
return json.loads(value)
except json.JSONDecodeError:
return default
elif value_type == 'int':
return int(value)
elif value_type == 'float':
return float(value)
elif value_type == 'bool':
return value.lower() in ('true', '1', 'yes')
else:
return value
def set_setting(key: str, value: Any) -> None:
"""
Set a setting value.
Args:
key: Setting key
value: Setting value (will be JSON-encoded for complex types)
"""
# Determine value type and string representation
if isinstance(value, bool):
value_type = 'bool'
str_value = 'true' if value else 'false'
elif isinstance(value, int):
value_type = 'int'
str_value = str(value)
elif isinstance(value, float):
value_type = 'float'
str_value = str(value)
elif isinstance(value, (dict, list)):
value_type = 'json'
str_value = json.dumps(value)
else:
value_type = 'string'
str_value = str(value)
with get_db() as conn:
conn.execute('''
INSERT INTO settings (key, value, value_type, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
value_type = excluded.value_type,
updated_at = CURRENT_TIMESTAMP
''', (key, str_value, value_type))
def delete_setting(key: str) -> bool:
"""
Delete a setting.
Args:
key: Setting key
Returns:
True if setting was deleted, False if not found
"""
with get_db() as conn:
cursor = conn.execute('DELETE FROM settings WHERE key = ?', (key,))
return cursor.rowcount > 0
def get_all_settings() -> dict[str, Any]:
"""Get all settings as a dictionary."""
with get_db() as conn:
cursor = conn.execute('SELECT key, value, value_type FROM settings')
settings = {}
for row in cursor:
key, value, value_type = row['key'], row['value'], row['value_type']
if value_type == 'json':
try:
settings[key] = json.loads(value)
except json.JSONDecodeError:
settings[key] = value
elif value_type == 'int':
settings[key] = int(value)
elif value_type == 'float':
settings[key] = float(value)
elif value_type == 'bool':
settings[key] = value.lower() in ('true', '1', 'yes')
else:
settings[key] = value
return settings
# =============================================================================
# Signal History Functions
# =============================================================================
def add_signal_reading(
mode: str,
device_id: str,
signal_strength: float,
metadata: dict | None = None
) -> None:
"""Add a signal strength reading."""
with get_db() as conn:
conn.execute('''
INSERT INTO signal_history (mode, device_id, signal_strength, metadata)
VALUES (?, ?, ?, ?)
''', (mode, device_id, signal_strength, json.dumps(metadata) if metadata else None))
def get_signal_history(
mode: str,
device_id: str,
limit: int = 100,
since_minutes: int = 60
) -> list[dict]:
"""
Get signal history for a device.
Args:
mode: Mode (wifi, bluetooth, adsb, etc.)
device_id: Device identifier (MAC, ICAO, etc.)
limit: Maximum number of readings
since_minutes: Only get readings from last N minutes
Returns:
List of signal readings with timestamp
"""
with get_db() as conn:
cursor = conn.execute('''
SELECT signal_strength, timestamp, metadata
FROM signal_history
WHERE mode = ? AND device_id = ?
AND timestamp > datetime('now', ?)
ORDER BY timestamp DESC
LIMIT ?
''', (mode, device_id, f'-{since_minutes} minutes', limit))
results = []
for row in cursor:
results.append({
'signal': row['signal_strength'],
'timestamp': row['timestamp'],
'metadata': json.loads(row['metadata']) if row['metadata'] else None
})
return list(reversed(results)) # Return in chronological order
def cleanup_old_signal_history(max_age_hours: int = 24) -> int:
"""
Remove old signal history entries.
Args:
max_age_hours: Maximum age in hours
Returns:
Number of deleted entries
"""
with get_db() as conn:
cursor = conn.execute('''
DELETE FROM signal_history
WHERE timestamp < datetime('now', ?)
''', (f'-{max_age_hours} hours',))
return cursor.rowcount
# =============================================================================
# Device Correlation Functions
# =============================================================================
def add_correlation(
wifi_mac: str,
bt_mac: str,
confidence: float,
metadata: dict | None = None
) -> None:
"""Add or update a device correlation."""
with get_db() as conn:
conn.execute('''
INSERT INTO device_correlations (wifi_mac, bt_mac, confidence, metadata, last_seen)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(wifi_mac, bt_mac) DO UPDATE SET
confidence = excluded.confidence,
last_seen = CURRENT_TIMESTAMP,
metadata = excluded.metadata
''', (wifi_mac, bt_mac, confidence, json.dumps(metadata) if metadata else None))
def get_correlations(min_confidence: float = 0.5) -> list[dict]:
"""Get all device correlations above minimum confidence."""
with get_db() as conn:
cursor = conn.execute('''
SELECT wifi_mac, bt_mac, confidence, first_seen, last_seen, metadata
FROM device_correlations
WHERE confidence >= ?
ORDER BY confidence DESC
''', (min_confidence,))
results = []
for row in cursor:
results.append({
'wifi_mac': row['wifi_mac'],
'bt_mac': row['bt_mac'],
'confidence': row['confidence'],
'first_seen': row['first_seen'],
'last_seen': row['last_seen'],
'metadata': json.loads(row['metadata']) if row['metadata'] else None
})
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
View File
@@ -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'
}
}
}
}
}
+165 -346
View File
@@ -1,16 +1,12 @@
"""
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
@@ -19,14 +15,6 @@ 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,258 +44,29 @@ class GPSPosition:
}
def detect_gps_devices() -> list[dict]:
class GPSDClient:
"""
Detect potential GPS serial devices.
Connects to gpsd daemon for GPS data.
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.
gpsd provides a unified interface for GPS devices and handles
device management, making it ideal when gpsd is already running.
"""
def __init__(self, device_path: str, baudrate: int = 9600):
self.device_path = device_path
self.baudrate = baudrate
DEFAULT_HOST = 'localhost'
DEFAULT_PORT = 2947
def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT):
self.host = host
self.port = port
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._socket: Optional['socket.socket'] = None
self._last_update: Optional[datetime] = None
self._error: Optional[str] = None
self._callbacks: list[Callable[[GPSPosition], None]] = []
self._device: Optional[str] = None
@property
def position(self) -> Optional[GPSPosition]:
@@ -317,7 +76,7 @@ class GPSReader:
@property
def is_running(self) -> bool:
"""Check if the reader is running."""
"""Check if the client is running."""
return self._running
@property
@@ -332,6 +91,11 @@ class GPSReader:
with self._lock:
return self._error
@property
def device_path(self) -> str:
"""Return gpsd connection info."""
return f"gpsd://{self.host}:{self.port}"
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
"""Add a callback to be called on position updates."""
self._callbacks.append(callback)
@@ -342,109 +106,162 @@ class GPSReader:
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
"""Start receiving GPS data from gpsd."""
import socket
if self._running:
return True
try:
self._serial = serial.Serial(
self.device_path,
baudrate=self.baudrate,
timeout=1.0
)
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._socket.settimeout(5.0)
self._socket.connect((self.host, self.port))
# Enable JSON watch mode
watch_cmd = '?WATCH={"enable":true,"json":true}\n'
self._socket.send(watch_cmd.encode('ascii'))
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}")
logger.info(f"Connected to gpsd at {self.host}:{self.port}")
print(f"[GPS] Connected to gpsd at {self.host}:{self.port}", flush=True)
return True
except serial.SerialException as e:
except Exception as e:
self._error = str(e)
logger.error(f"Failed to open GPS device {self.device_path}: {e}")
logger.error(f"Failed to connect to gpsd at {self.host}:{self.port}: {e}")
if self._socket:
try:
self._socket.close()
except Exception:
pass
self._socket = None
return False
def stop(self) -> None:
"""Stop reading GPS data."""
"""Stop receiving GPS data."""
self._running = False
if self._serial:
if self._socket:
try:
self._serial.close()
# Disable watch mode
self._socket.send(b'?WATCH={"enable":false}\n')
self._socket.close()
except Exception:
pass
self._serial = None
self._socket = None
if self._thread:
self._thread.join(timeout=2.0)
self._thread = None
logger.info(f"Stopped GPS reader on {self.device_path}")
logger.info(f"Disconnected from gpsd at {self.host}:{self.port}")
def _read_loop(self) -> None:
"""Background thread loop for reading GPS data."""
"""Background thread loop for reading gpsd data."""
import json
import socket
buffer = ""
sentence_count = 0
bytes_read = 0
message_count = 0
print(f"[GPS] Read loop started on {self.device_path} at {self.baudrate} baud", flush=True)
print(f"[GPS] gpsd read loop started", flush=True)
while self._running and self._serial:
while self._running and self._socket:
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')
self._socket.settimeout(1.0)
data = self._socket.recv(4096)
# Process complete lines
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if not data:
logger.warning("gpsd connection closed")
with self._lock:
self._error = "Connection closed by gpsd"
break
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)
buffer += data.decode('ascii', errors='ignore')
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)
# Process complete JSON lines
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
except serial.SerialException as e:
logger.error(f"GPS read error: {e}")
if not line:
continue
try:
msg = json.loads(line)
msg_class = msg.get('class', '')
message_count += 1
if message_count <= 5 or message_count % 20 == 0:
print(f"[GPS] gpsd msg [{message_count}]: {msg_class}", flush=True)
if msg_class == 'TPV':
self._handle_tpv(msg)
elif msg_class == 'DEVICES':
# Track connected device
devices = msg.get('devices', [])
if devices:
self._device = devices[0].get('path', 'unknown')
print(f"[GPS] gpsd device: {self._device}", flush=True)
except json.JSONDecodeError:
logger.debug(f"Invalid JSON from gpsd: {line[:50]}")
except socket.timeout:
continue
except Exception as e:
logger.error(f"gpsd read error: {e}")
with self._lock:
self._error = str(e)
break
except Exception as e:
logger.debug(f"GPS parse error: {e}")
def _handle_tpv(self, msg: dict) -> None:
"""Handle TPV (Time-Position-Velocity) message from gpsd."""
# mode: 0=unknown, 1=no fix, 2=2D fix, 3=3D fix
mode = msg.get('mode', 0)
if mode < 2:
# No fix yet
return
lat = msg.get('lat')
lon = msg.get('lon')
if lat is None or lon is None:
return
# Parse timestamp
timestamp = None
time_str = msg.get('time')
if time_str:
try:
# gpsd uses ISO format: 2024-01-01T12:00:00.000Z
timestamp = datetime.fromisoformat(time_str.replace('Z', '+00:00'))
except (ValueError, AttributeError):
pass
position = GPSPosition(
latitude=lat,
longitude=lon,
altitude=msg.get('alt'),
speed=msg.get('speed'), # m/s in gpsd
heading=msg.get('track'),
fix_quality=mode,
timestamp=timestamp,
device=self._device or f"gpsd://{self.host}:{self.port}",
)
print(f"[GPS] gpsd FIX: {lat:.6f}, {lon:.6f} (mode: {mode})", flush=True)
self._update_position(position)
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
@@ -457,57 +274,59 @@ class GPSReader:
logger.error(f"GPS callback error: {e}")
# Global GPS reader instance
_gps_reader: Optional[GPSReader] = None
# Global GPS client instance
_gps_client: Optional[GPSDClient] = None
_gps_lock = threading.Lock()
def get_gps_reader() -> Optional[GPSReader]:
"""Get the global GPS reader instance."""
def get_gps_reader() -> Optional[GPSDClient]:
"""Get the global GPS client instance."""
with _gps_lock:
return _gps_reader
return _gps_client
def start_gps(device_path: str, baudrate: int = 9600) -> bool:
def start_gpsd(host: str = 'localhost', port: int = 2947,
callback: Optional[Callable[[GPSPosition], None]] = None) -> bool:
"""
Start the global GPS reader.
Start the global GPS client connected to gpsd.
Args:
device_path: Path to the GPS serial device
baudrate: Serial baudrate (default 9600)
host: gpsd host (default localhost)
port: gpsd port (default 2947)
callback: Optional callback for position updates
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 = GPSReader(device_path, baudrate)
return _gps_reader.start()
_gps_client = GPSDClient(host, port)
# Register callback BEFORE starting to avoid race condition
if callback:
_gps_client.add_callback(callback)
return _gps_client.start()
def stop_gps() -> None:
"""Stop the global GPS reader."""
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
+206
View File
@@ -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()
+6
View File
@@ -30,6 +30,8 @@ from .detection import detect_all_devices
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,
@@ -49,6 +51,8 @@ class SDRFactory:
SDRType.RTL_SDR: RTLSDRCommandBuilder,
SDRType.LIME_SDR: LimeSDRCommandBuilder,
SDRType.HACKRF: HackRFCommandBuilder,
SDRType.AIRSPY: AirspyCommandBuilder,
SDRType.SDRPLAY: SDRPlayCommandBuilder,
}
@classmethod
@@ -214,6 +218,8 @@ __all__ = [
'RTLSDRCommandBuilder',
'LimeSDRCommandBuilder',
'HackRFCommandBuilder',
'AirspyCommandBuilder',
'SDRPlayCommandBuilder',
# Validation
'SDRValidationError',
'validate_frequency',
+165
View File
@@ -0,0 +1,165 @@
"""
Airspy command builder implementation.
Uses SoapySDR-based tools for FM demodulation and signal capture.
Airspy R2/Mini supports 24 MHz to 1.8 GHz frequency range.
Airspy HF+ supports 9 kHz - 31 MHz and 60-260 MHz.
"""
from __future__ import annotations
from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
class AirspyCommandBuilder(CommandBuilder):
"""Airspy command builder using SoapySDR tools."""
# Airspy R2/Mini capabilities (most common)
# HF+ has different range but same interface
CAPABILITIES = SDRCapabilities(
sdr_type=SDRType.AIRSPY,
freq_min_mhz=24.0, # 24 MHz (HF+ goes lower)
freq_max_mhz=1800.0, # 1.8 GHz
gain_min=0.0,
gain_max=45.0, # LNA (0-15) + Mixer (0-15) + VGA (0-15)
sample_rates=[2500000, 3000000, 6000000, 10000000],
supports_bias_t=True,
supports_ppm=False, # Airspy has TCXO, no PPM needed
tx_capable=False
)
def _build_device_string(self, device: SDRDevice) -> str:
"""Build SoapySDR device string for Airspy."""
driver = device.driver if device.driver in ('airspy', 'airspyhf') else 'airspy'
if device.serial and device.serial != 'N/A':
return f'driver={driver},serial={device.serial}'
return f'driver={driver}'
def _format_gain(self, gain: float) -> str:
"""
Format gain string for Airspy.
Airspy has three gain stages:
- LNA: 0-15 dB
- Mixer: 0-15 dB
- VGA: 0-15 dB
This distributes the requested gain across stages.
"""
if gain <= 15:
return f'LNA={int(gain)},MIX=0,VGA=0'
elif gain <= 30:
return f'LNA=15,MIX={int(gain - 15)},VGA=0'
else:
vga = min(15, int(gain - 30))
return f'LNA=15,MIX=15,VGA={vga}'
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 Airspy.
"""
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', self._format_gain(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 Airspy capabilities."""
return self.CAPABILITIES
@classmethod
def get_sdr_type(cls) -> SDRType:
"""Return SDR type."""
return SDRType.AIRSPY
+11 -3
View File
@@ -18,6 +18,8 @@ class SDRType(Enum):
RTL_SDR = "rtlsdr"
LIME_SDR = "limesdr"
HACKRF = "hackrf"
AIRSPY = "airspy"
SDRPLAY = "sdrplay"
# Future support
# USRP = "usrp"
# BLADE_RF = "bladerf"
@@ -92,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).
@@ -105,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
@@ -115,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.
@@ -123,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
@@ -135,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).
@@ -145,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
+46 -20
View File
@@ -28,11 +28,15 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
from .rtlsdr import RTLSDRCommandBuilder
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)
@@ -60,6 +64,9 @@ def _driver_to_sdr_type(driver: str) -> Optional[SDRType]:
'lime': SDRType.LIME_SDR,
'limesdr': SDRType.LIME_SDR,
'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,
@@ -140,23 +147,35 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
return devices
def detect_soapy_devices() -> list[SDRDevice]:
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.
This detects LimeSDR, HackRF, USRP, BladeRF, and other SoapySDR-compatible
devices. RTL-SDR devices may also appear here but we prefer the native
detection for those.
This detects LimeSDR, HackRF, Airspy, and other SoapySDR-compatible devices.
Args:
skip_types: Set of SDRType values to skip (e.g., if already found via native detection)
"""
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
@@ -177,7 +196,7 @@ def detect_soapy_devices() -> list[SDRDevice]:
# Start of new device block
if line.startswith('Found device'):
if current_device.get('driver'):
_add_soapy_device(devices, current_device, device_counts)
_add_soapy_device(devices, current_device, device_counts, skip_types)
current_device = {}
continue
@@ -190,7 +209,7 @@ def detect_soapy_devices() -> list[SDRDevice]:
# Don't forget the last device
if current_device.get('driver'):
_add_soapy_device(devices, current_device, device_counts)
_add_soapy_device(devices, current_device, device_counts, skip_types)
except subprocess.TimeoutExpired:
logger.warning("SoapySDRUtil timed out")
@@ -203,7 +222,8 @@ def detect_soapy_devices() -> list[SDRDevice]:
def _add_soapy_device(
devices: list[SDRDevice],
device_info: dict,
device_counts: dict[SDRType, int]
device_counts: dict[SDRType, int],
skip_types: set[SDRType]
) -> None:
"""Add a device from SoapySDR detection to the list."""
driver = device_info.get('driver', '').lower()
@@ -213,8 +233,9 @@ def _add_soapy_device(
logger.debug(f"Unknown SoapySDR driver: {driver}")
return
# Skip RTL-SDR devices from SoapySDR (we use native detection)
if sdr_type == SDRType.RTL_SDR:
# Skip device types that were already found via native detection
if sdr_type in skip_types:
logger.debug(f"Skipping {driver} from SoapySDR (already found via native detection)")
return
# Track device index per type
@@ -294,19 +315,24 @@ def detect_all_devices() -> list[SDRDevice]:
Returns a unified list of SDRDevice objects sorted by type and index.
"""
devices: list[SDRDevice] = []
skip_in_soapy: set[SDRType] = set()
# RTL-SDR via native tool (primary method)
devices.extend(detect_rtlsdr_devices())
rtlsdr_devices = detect_rtlsdr_devices()
devices.extend(rtlsdr_devices)
if rtlsdr_devices:
skip_in_soapy.add(SDRType.RTL_SDR)
# SoapySDR devices (LimeSDR, HackRF, etc.)
soapy_devices = detect_soapy_devices()
# Native HackRF detection (primary method)
hackrf_devices = detect_hackrf_devices()
devices.extend(hackrf_devices)
if hackrf_devices:
skip_in_soapy.add(SDRType.HACKRF)
# SoapySDR devices (LimeSDR, Airspy, and fallback for HackRF/RTL-SDR if native failed)
soapy_devices = detect_soapy_devices(skip_types=skip_in_soapy)
devices.extend(soapy_devices)
# Native HackRF detection (fallback if SoapySDR didn't find it)
hackrf_from_soapy = any(d.sdr_type == SDRType.HACKRF for d in soapy_devices)
if not hackrf_from_soapy:
devices.extend(detect_hackrf_devices())
# Sort by type name, then index
devices.sort(key=lambda d: (d.sdr_type.value, d.index))
+18 -3
View File
@@ -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',
+9 -3
View File
@@ -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
View File
@@ -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'
]
+143
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
"""
TSCM (Technical Surveillance Countermeasures) Utilities Package
Provides baseline recording, threat detection, correlation analysis,
BLE scanning, and MAC-randomization resistant device identity tools
for counter-surveillance operations.
"""
from __future__ import annotations
__all__ = ['detector', 'baseline', 'correlation', 'ble_scanner', 'device_identity']
+388
View File
@@ -0,0 +1,388 @@
"""
TSCM Baseline Recording and Comparison
Records environment "fingerprints" and compares current scans
against baselines to detect new or anomalous devices.
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from utils.database import (
create_tscm_baseline,
get_active_tscm_baseline,
get_tscm_baseline,
update_tscm_baseline,
)
logger = logging.getLogger('intercept.tscm.baseline')
class BaselineRecorder:
"""
Records and manages TSCM environment baselines.
"""
def __init__(self):
self.recording = False
self.current_baseline_id: int | None = None
self.wifi_networks: dict[str, dict] = {} # BSSID -> network info
self.bt_devices: dict[str, dict] = {} # MAC -> device info
self.rf_frequencies: dict[float, dict] = {} # Frequency -> signal info
def start_recording(
self,
name: str,
location: str | None = None,
description: str | None = None
) -> int:
"""
Start recording a new baseline.
Args:
name: Baseline name
location: Optional location description
description: Optional description
Returns:
Baseline ID
"""
self.recording = True
self.wifi_networks = {}
self.bt_devices = {}
self.rf_frequencies = {}
# Create baseline in database
self.current_baseline_id = create_tscm_baseline(
name=name,
location=location,
description=description
)
logger.info(f"Started baseline recording: {name} (ID: {self.current_baseline_id})")
return self.current_baseline_id
def stop_recording(self) -> dict:
"""
Stop recording and finalize baseline.
Returns:
Final baseline summary
"""
if not self.recording or not self.current_baseline_id:
return {'error': 'Not recording'}
self.recording = False
# Convert to lists for storage
wifi_list = list(self.wifi_networks.values())
bt_list = list(self.bt_devices.values())
rf_list = list(self.rf_frequencies.values())
# Update database
update_tscm_baseline(
self.current_baseline_id,
wifi_networks=wifi_list,
bt_devices=bt_list,
rf_frequencies=rf_list
)
summary = {
'baseline_id': self.current_baseline_id,
'wifi_count': len(wifi_list),
'bt_count': len(bt_list),
'rf_count': len(rf_list),
}
logger.info(
f"Baseline recording complete: {summary['wifi_count']} WiFi, "
f"{summary['bt_count']} BT, {summary['rf_count']} RF"
)
baseline_id = self.current_baseline_id
self.current_baseline_id = None
return summary
def add_wifi_device(self, device: dict) -> None:
"""Add a WiFi device to the current baseline."""
if not self.recording:
return
mac = device.get('bssid', device.get('mac', '')).upper()
if not mac:
return
# Update or add device
if mac in self.wifi_networks:
# Update with latest info
self.wifi_networks[mac].update({
'last_seen': datetime.now().isoformat(),
'power': device.get('power', self.wifi_networks[mac].get('power')),
})
else:
self.wifi_networks[mac] = {
'bssid': mac,
'essid': device.get('essid', device.get('ssid', '')),
'channel': device.get('channel'),
'power': device.get('power', device.get('signal')),
'vendor': device.get('vendor', ''),
'encryption': device.get('privacy', device.get('encryption', '')),
'first_seen': datetime.now().isoformat(),
'last_seen': datetime.now().isoformat(),
}
def add_bt_device(self, device: dict) -> None:
"""Add a Bluetooth device to the current baseline."""
if not self.recording:
return
mac = device.get('mac', device.get('address', '')).upper()
if not mac:
return
if mac in self.bt_devices:
self.bt_devices[mac].update({
'last_seen': datetime.now().isoformat(),
'rssi': device.get('rssi', self.bt_devices[mac].get('rssi')),
})
else:
self.bt_devices[mac] = {
'mac': mac,
'name': device.get('name', ''),
'rssi': device.get('rssi', device.get('signal')),
'manufacturer': device.get('manufacturer', ''),
'type': device.get('type', ''),
'first_seen': datetime.now().isoformat(),
'last_seen': datetime.now().isoformat(),
}
def add_rf_signal(self, signal: dict) -> None:
"""Add an RF signal to the current baseline."""
if not self.recording:
return
frequency = signal.get('frequency')
if not frequency:
return
# Round to 0.1 MHz for grouping
freq_key = round(frequency, 1)
if freq_key in self.rf_frequencies:
existing = self.rf_frequencies[freq_key]
existing['last_seen'] = datetime.now().isoformat()
existing['hit_count'] = existing.get('hit_count', 1) + 1
# Update max signal level
new_level = signal.get('level', signal.get('power', -100))
if new_level > existing.get('max_level', -100):
existing['max_level'] = new_level
else:
self.rf_frequencies[freq_key] = {
'frequency': freq_key,
'level': signal.get('level', signal.get('power')),
'max_level': signal.get('level', signal.get('power', -100)),
'modulation': signal.get('modulation', ''),
'first_seen': datetime.now().isoformat(),
'last_seen': datetime.now().isoformat(),
'hit_count': 1,
}
def get_recording_status(self) -> dict:
"""Get current recording status and counts."""
return {
'recording': self.recording,
'baseline_id': self.current_baseline_id,
'wifi_count': len(self.wifi_networks),
'bt_count': len(self.bt_devices),
'rf_count': len(self.rf_frequencies),
}
class BaselineComparator:
"""
Compares current scan results against a baseline.
"""
def __init__(self, baseline: dict):
"""
Initialize comparator with a baseline.
Args:
baseline: Baseline dict from database
"""
self.baseline = baseline
self.baseline_wifi = {
d.get('bssid', d.get('mac', '')).upper(): d
for d in baseline.get('wifi_networks', [])
if d.get('bssid') or d.get('mac')
}
self.baseline_bt = {
d.get('mac', d.get('address', '')).upper(): d
for d in baseline.get('bt_devices', [])
if d.get('mac') or d.get('address')
}
self.baseline_rf = {
round(d.get('frequency', 0), 1): d
for d in baseline.get('rf_frequencies', [])
if d.get('frequency')
}
def compare_wifi(self, current_devices: list[dict]) -> dict:
"""
Compare current WiFi devices against baseline.
Returns:
Dict with new, missing, and matching devices
"""
current_macs = {
d.get('bssid', d.get('mac', '')).upper(): d
for d in current_devices
if d.get('bssid') or d.get('mac')
}
new_devices = []
missing_devices = []
matching_devices = []
# Find new devices
for mac, device in current_macs.items():
if mac not in self.baseline_wifi:
new_devices.append(device)
else:
matching_devices.append(device)
# Find missing devices
for mac, device in self.baseline_wifi.items():
if mac not in current_macs:
missing_devices.append(device)
return {
'new': new_devices,
'missing': missing_devices,
'matching': matching_devices,
'new_count': len(new_devices),
'missing_count': len(missing_devices),
'matching_count': len(matching_devices),
}
def compare_bluetooth(self, current_devices: list[dict]) -> dict:
"""Compare current Bluetooth devices against baseline."""
current_macs = {
d.get('mac', d.get('address', '')).upper(): d
for d in current_devices
if d.get('mac') or d.get('address')
}
new_devices = []
missing_devices = []
matching_devices = []
for mac, device in current_macs.items():
if mac not in self.baseline_bt:
new_devices.append(device)
else:
matching_devices.append(device)
for mac, device in self.baseline_bt.items():
if mac not in current_macs:
missing_devices.append(device)
return {
'new': new_devices,
'missing': missing_devices,
'matching': matching_devices,
'new_count': len(new_devices),
'missing_count': len(missing_devices),
'matching_count': len(matching_devices),
}
def compare_rf(self, current_signals: list[dict]) -> dict:
"""Compare current RF signals against baseline."""
current_freqs = {
round(s.get('frequency', 0), 1): s
for s in current_signals
if s.get('frequency')
}
new_signals = []
missing_signals = []
matching_signals = []
for freq, signal in current_freqs.items():
if freq not in self.baseline_rf:
new_signals.append(signal)
else:
matching_signals.append(signal)
for freq, signal in self.baseline_rf.items():
if freq not in current_freqs:
missing_signals.append(signal)
return {
'new': new_signals,
'missing': missing_signals,
'matching': matching_signals,
'new_count': len(new_signals),
'missing_count': len(missing_signals),
'matching_count': len(matching_signals),
}
def compare_all(
self,
wifi_devices: list[dict] | None = None,
bt_devices: list[dict] | None = None,
rf_signals: list[dict] | None = None
) -> dict:
"""
Compare all current data against baseline.
Returns:
Dict with comparison results for each category
"""
results = {
'wifi': None,
'bluetooth': None,
'rf': None,
'total_new': 0,
'total_missing': 0,
}
if wifi_devices is not None:
results['wifi'] = self.compare_wifi(wifi_devices)
results['total_new'] += results['wifi']['new_count']
results['total_missing'] += results['wifi']['missing_count']
if bt_devices is not None:
results['bluetooth'] = self.compare_bluetooth(bt_devices)
results['total_new'] += results['bluetooth']['new_count']
results['total_missing'] += results['bluetooth']['missing_count']
if rf_signals is not None:
results['rf'] = self.compare_rf(rf_signals)
results['total_new'] += results['rf']['new_count']
results['total_missing'] += results['rf']['missing_count']
return results
def get_comparison_for_active_baseline(
wifi_devices: list[dict] | None = None,
bt_devices: list[dict] | None = None,
rf_signals: list[dict] | None = None
) -> dict | None:
"""
Convenience function to compare against the active baseline.
Returns:
Comparison results or None if no active baseline
"""
baseline = get_active_tscm_baseline()
if not baseline:
return None
comparator = BaselineComparator(baseline)
return comparator.compare_all(wifi_devices, bt_devices, rf_signals)
+476
View File
@@ -0,0 +1,476 @@
"""
BLE Scanner for TSCM
Cross-platform BLE scanning with manufacturer data detection.
Supports macOS and Linux using the bleak library with fallback to system tools.
Detects:
- Apple AirTags (company ID 0x004C)
- Tile trackers
- Samsung SmartTags
- ESP32/ESP8266 devices (Espressif, company ID 0x02E5)
- Generic BLE devices with suspicious characteristics
"""
import asyncio
import logging
import platform
import re
import subprocess
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
logger = logging.getLogger('intercept.tscm.ble')
# Manufacturer company IDs (Bluetooth SIG assigned)
COMPANY_IDS = {
0x004C: 'Apple',
0x02E5: 'Espressif',
0x0059: 'Nordic Semiconductor',
0x000D: 'Texas Instruments',
0x0075: 'Samsung',
0x00E0: 'Google',
0x0006: 'Microsoft',
0x01DA: 'Tile',
}
# Known tracker signatures
TRACKER_SIGNATURES = {
# Apple AirTag detection patterns
'airtag': {
'company_id': 0x004C,
'data_patterns': [
b'\x12\x19', # AirTag/Find My advertisement prefix
b'\x07\x19', # Offline Finding
],
'name_patterns': ['airtag', 'findmy', 'find my'],
},
# Tile tracker
'tile': {
'company_id': 0x01DA,
'name_patterns': ['tile'],
},
# Samsung SmartTag
'smarttag': {
'company_id': 0x0075,
'name_patterns': ['smarttag', 'smart tag', 'galaxy smart'],
},
# ESP32/ESP8266
'espressif': {
'company_id': 0x02E5,
'name_patterns': ['esp32', 'esp8266', 'espressif'],
},
}
@dataclass
class BLEDevice:
"""Represents a detected BLE device with full advertisement data."""
mac: str
name: Optional[str] = None
rssi: Optional[int] = None
manufacturer_id: Optional[int] = None
manufacturer_name: Optional[str] = None
manufacturer_data: bytes = field(default_factory=bytes)
service_uuids: list = field(default_factory=list)
tx_power: Optional[int] = None
is_connectable: bool = True
# Detection flags
is_airtag: bool = False
is_tile: bool = False
is_smarttag: bool = False
is_espressif: bool = False
is_tracker: bool = False
tracker_type: Optional[str] = None
first_seen: datetime = field(default_factory=datetime.now)
last_seen: datetime = field(default_factory=datetime.now)
detection_count: int = 1
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
'mac': self.mac,
'name': self.name or 'Unknown',
'rssi': self.rssi,
'manufacturer_id': self.manufacturer_id,
'manufacturer_name': self.manufacturer_name,
'service_uuids': self.service_uuids,
'tx_power': self.tx_power,
'is_connectable': self.is_connectable,
'is_airtag': self.is_airtag,
'is_tile': self.is_tile,
'is_smarttag': self.is_smarttag,
'is_espressif': self.is_espressif,
'is_tracker': self.is_tracker,
'tracker_type': self.tracker_type,
'detection_count': self.detection_count,
'type': 'ble',
}
class BLEScanner:
"""
Cross-platform BLE scanner with manufacturer data detection.
Uses bleak library for proper BLE scanning, with fallback to
system tools (hcitool/btmgmt on Linux, system_profiler on macOS).
"""
def __init__(self):
self.devices: dict[str, BLEDevice] = {}
self._bleak_available = self._check_bleak()
self._scanning = False
def _check_bleak(self) -> bool:
"""Check if bleak library is available."""
try:
import bleak
return True
except ImportError:
logger.warning("bleak library not available - using fallback scanning")
return False
async def scan_async(self, duration: int = 10) -> list[BLEDevice]:
"""
Perform async BLE scan using bleak.
Args:
duration: Scan duration in seconds
Returns:
List of detected BLE devices
"""
if not self._bleak_available:
# Use synchronous fallback
return self._scan_fallback(duration)
try:
from bleak import BleakScanner
from bleak.backends.device import BLEDevice as BleakDevice
from bleak.backends.scanner import AdvertisementData
detected = {}
def detection_callback(device: BleakDevice, adv_data: AdvertisementData):
"""Callback for each detected device."""
mac = device.address.upper()
if mac in detected:
# Update existing device
detected[mac].rssi = adv_data.rssi
detected[mac].last_seen = datetime.now()
detected[mac].detection_count += 1
else:
# Create new device entry
ble_device = BLEDevice(
mac=mac,
name=adv_data.local_name or device.name,
rssi=adv_data.rssi,
service_uuids=list(adv_data.service_uuids) if adv_data.service_uuids else [],
tx_power=adv_data.tx_power,
)
# Parse manufacturer data
if adv_data.manufacturer_data:
for company_id, data in adv_data.manufacturer_data.items():
ble_device.manufacturer_id = company_id
ble_device.manufacturer_name = COMPANY_IDS.get(company_id, f'Unknown ({hex(company_id)})')
ble_device.manufacturer_data = bytes(data)
# Check for known trackers
self._identify_tracker(ble_device, company_id, data)
# Also check name patterns
self._check_name_patterns(ble_device)
detected[mac] = ble_device
logger.info(f"Starting BLE scan with bleak (duration={duration}s)")
scanner = BleakScanner(detection_callback=detection_callback)
await scanner.start()
await asyncio.sleep(duration)
await scanner.stop()
# Update internal device list
for mac, device in detected.items():
if mac in self.devices:
self.devices[mac].rssi = device.rssi
self.devices[mac].last_seen = device.last_seen
self.devices[mac].detection_count += 1
else:
self.devices[mac] = device
logger.info(f"BLE scan complete: {len(detected)} devices found")
return list(detected.values())
except Exception as e:
logger.error(f"Bleak scan failed: {e}")
return self._scan_fallback(duration)
def scan(self, duration: int = 10) -> list[BLEDevice]:
"""
Synchronous wrapper for BLE scanning.
Args:
duration: Scan duration in seconds
Returns:
List of detected BLE devices
"""
if self._bleak_available:
try:
# Try to get existing event loop
try:
loop = asyncio.get_running_loop()
# We're in an async context, can't use run()
future = asyncio.ensure_future(self.scan_async(duration))
return asyncio.get_event_loop().run_until_complete(future)
except RuntimeError:
# No running loop, create one
return asyncio.run(self.scan_async(duration))
except Exception as e:
logger.error(f"Async scan failed: {e}")
return self._scan_fallback(duration)
else:
return self._scan_fallback(duration)
def _identify_tracker(self, device: BLEDevice, company_id: int, data: bytes):
"""Identify if device is a known tracker type."""
# Apple AirTag detection
if company_id == 0x004C: # Apple
# Check for Find My / AirTag advertisement patterns
if len(data) >= 2:
# AirTag advertisements have specific byte patterns
if data[0] == 0x12 and data[1] == 0x19:
device.is_airtag = True
device.is_tracker = True
device.tracker_type = 'AirTag'
logger.info(f"AirTag detected: {device.mac}")
elif data[0] == 0x07: # Offline Finding
device.is_airtag = True
device.is_tracker = True
device.tracker_type = 'AirTag (Offline)'
logger.info(f"AirTag (offline mode) detected: {device.mac}")
# Tile tracker
elif company_id == 0x01DA: # Tile
device.is_tile = True
device.is_tracker = True
device.tracker_type = 'Tile'
logger.info(f"Tile tracker detected: {device.mac}")
# Samsung SmartTag
elif company_id == 0x0075: # Samsung
# Check if it's specifically a SmartTag
device.is_smarttag = True
device.is_tracker = True
device.tracker_type = 'SmartTag'
logger.info(f"Samsung SmartTag detected: {device.mac}")
# Espressif (ESP32/ESP8266)
elif company_id == 0x02E5: # Espressif
device.is_espressif = True
device.tracker_type = 'ESP32/ESP8266'
logger.info(f"ESP32/ESP8266 device detected: {device.mac}")
def _check_name_patterns(self, device: BLEDevice):
"""Check device name for tracker patterns."""
if not device.name:
return
name_lower = device.name.lower()
# Check each tracker type
for tracker_type, sig in TRACKER_SIGNATURES.items():
patterns = sig.get('name_patterns', [])
for pattern in patterns:
if pattern in name_lower:
if tracker_type == 'airtag':
device.is_airtag = True
device.is_tracker = True
device.tracker_type = 'AirTag'
elif tracker_type == 'tile':
device.is_tile = True
device.is_tracker = True
device.tracker_type = 'Tile'
elif tracker_type == 'smarttag':
device.is_smarttag = True
device.is_tracker = True
device.tracker_type = 'SmartTag'
elif tracker_type == 'espressif':
device.is_espressif = True
device.tracker_type = 'ESP32/ESP8266'
logger.info(f"Tracker identified by name: {device.name} -> {tracker_type}")
return
def _scan_fallback(self, duration: int = 10) -> list[BLEDevice]:
"""
Fallback scanning using system tools when bleak is unavailable.
Works on both macOS and Linux.
"""
system = platform.system()
if system == 'Darwin':
return self._scan_macos(duration)
else:
return self._scan_linux(duration)
def _scan_macos(self, duration: int = 10) -> list[BLEDevice]:
"""Fallback BLE scanning on macOS using system_profiler."""
devices = []
try:
import json
result = subprocess.run(
['system_profiler', 'SPBluetoothDataType', '-json'],
capture_output=True, text=True, timeout=15
)
data = json.loads(result.stdout)
bt_data = data.get('SPBluetoothDataType', [{}])[0]
# Get connected/paired devices
for section in ['device_connected', 'device_title']:
section_data = bt_data.get(section, {})
if isinstance(section_data, dict):
for name, info in section_data.items():
if isinstance(info, dict):
mac = info.get('device_address', '').upper()
if mac:
device = BLEDevice(
mac=mac,
name=name,
)
# Check name patterns
self._check_name_patterns(device)
devices.append(device)
logger.info(f"macOS fallback scan found {len(devices)} devices")
except Exception as e:
logger.error(f"macOS fallback scan failed: {e}")
return devices
def _scan_linux(self, duration: int = 10) -> list[BLEDevice]:
"""Fallback BLE scanning on Linux using bluetoothctl/btmgmt."""
import shutil
devices = []
seen_macs = set()
# Method 1: Try btmgmt for BLE devices
if shutil.which('btmgmt'):
try:
logger.info("Trying btmgmt find...")
result = subprocess.run(
['btmgmt', 'find'],
capture_output=True, text=True, timeout=duration + 5
)
for line in result.stdout.split('\n'):
if 'dev_found' in line.lower() or ('type' in line.lower() and ':' in line):
mac_match = re.search(
r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:'
r'[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})',
line
)
if mac_match:
mac = mac_match.group(1).upper()
if mac not in seen_macs:
seen_macs.add(mac)
name_match = re.search(r'name\s+(.+?)(?:\s|$)', line, re.I)
name = name_match.group(1) if name_match else None
device = BLEDevice(mac=mac, name=name)
self._check_name_patterns(device)
devices.append(device)
logger.info(f"btmgmt found {len(devices)} devices")
except Exception as e:
logger.warning(f"btmgmt failed: {e}")
# Method 2: Try hcitool lescan
if not devices and shutil.which('hcitool'):
try:
logger.info("Trying hcitool lescan...")
# Start lescan in background
process = subprocess.Popen(
['hcitool', 'lescan', '--duplicates'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
import time
time.sleep(duration)
process.terminate()
stdout, _ = process.communicate(timeout=2)
for line in stdout.split('\n'):
mac_match = re.search(
r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:'
r'[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})',
line
)
if mac_match:
mac = mac_match.group(1).upper()
if mac not in seen_macs:
seen_macs.add(mac)
# Extract name (comes after MAC)
parts = line.strip().split()
name = ' '.join(parts[1:]) if len(parts) > 1 else None
device = BLEDevice(mac=mac, name=name if name != '(unknown)' else None)
self._check_name_patterns(device)
devices.append(device)
logger.info(f"hcitool lescan found {len(devices)} devices")
except Exception as e:
logger.warning(f"hcitool lescan failed: {e}")
return devices
def get_trackers(self) -> list[BLEDevice]:
"""Get all detected tracker devices."""
return [d for d in self.devices.values() if d.is_tracker]
def get_espressif_devices(self) -> list[BLEDevice]:
"""Get all detected ESP32/ESP8266 devices."""
return [d for d in self.devices.values() if d.is_espressif]
def clear(self):
"""Clear all detected devices."""
self.devices.clear()
# Singleton instance
_scanner: Optional[BLEScanner] = None
def get_ble_scanner() -> BLEScanner:
"""Get the global BLE scanner instance."""
global _scanner
if _scanner is None:
_scanner = BLEScanner()
return _scanner
def scan_ble_devices(duration: int = 10) -> list[dict]:
"""
Convenience function to scan for BLE devices.
Args:
duration: Scan duration in seconds
Returns:
List of device dictionaries
"""
scanner = get_ble_scanner()
devices = scanner.scan(duration)
return [d.to_dict() for d in devices]
+959
View File
@@ -0,0 +1,959 @@
"""
TSCM Cross-Protocol Correlation Engine
Correlates Bluetooth, Wi-Fi, and RF indicators to detect potential surveillance activity.
Implements scoring model for risk assessment and provides actionable intelligence.
DISCLAIMER: This system performs wireless and RF surveillance screening.
Findings indicate anomalies and indicators, not confirmed surveillance devices.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from typing import Optional
logger = logging.getLogger('intercept.tscm.correlation')
class RiskLevel(Enum):
"""Risk classification levels."""
INFORMATIONAL = 'informational' # Score 0-2
NEEDS_REVIEW = 'review' # Score 3-5
HIGH_INTEREST = 'high_interest' # Score 6+
class IndicatorType(Enum):
"""Types of risk indicators."""
UNKNOWN_DEVICE = 'unknown_device'
AUDIO_CAPABLE = 'audio_capable'
PERSISTENT = 'persistent'
MEETING_CORRELATED = 'meeting_correlated'
CROSS_PROTOCOL = 'cross_protocol'
HIDDEN_IDENTITY = 'hidden_identity'
ROGUE_AP = 'rogue_ap'
BURST_TRANSMISSION = 'burst_transmission'
STABLE_RSSI = 'stable_rssi'
HIGH_FREQ_ADVERTISING = 'high_freq_advertising'
MAC_ROTATION = 'mac_rotation'
NARROWBAND_SIGNAL = 'narrowband_signal'
ALWAYS_ON_CARRIER = 'always_on_carrier'
# Tracker-specific indicators
KNOWN_TRACKER = 'known_tracker'
AIRTAG_DETECTED = 'airtag_detected'
TILE_DETECTED = 'tile_detected'
SMARTTAG_DETECTED = 'smarttag_detected'
ESP32_DEVICE = 'esp32_device'
GENERIC_CHIPSET = 'generic_chipset'
# Scoring weights for each indicator
INDICATOR_SCORES = {
IndicatorType.UNKNOWN_DEVICE: 1,
IndicatorType.AUDIO_CAPABLE: 2,
IndicatorType.PERSISTENT: 2,
IndicatorType.MEETING_CORRELATED: 2,
IndicatorType.CROSS_PROTOCOL: 3,
IndicatorType.HIDDEN_IDENTITY: 2,
IndicatorType.ROGUE_AP: 3,
IndicatorType.BURST_TRANSMISSION: 2,
IndicatorType.STABLE_RSSI: 1,
IndicatorType.HIGH_FREQ_ADVERTISING: 1,
IndicatorType.MAC_ROTATION: 1,
IndicatorType.NARROWBAND_SIGNAL: 2,
IndicatorType.ALWAYS_ON_CARRIER: 2,
# Tracker scores - higher for covert tracking devices
IndicatorType.KNOWN_TRACKER: 3,
IndicatorType.AIRTAG_DETECTED: 3,
IndicatorType.TILE_DETECTED: 2,
IndicatorType.SMARTTAG_DETECTED: 2,
IndicatorType.ESP32_DEVICE: 2,
IndicatorType.GENERIC_CHIPSET: 1,
}
# Known tracker device signatures
TRACKER_SIGNATURES = {
# Apple AirTag - OUI prefixes
'airtag_oui': ['4C:E6:76', '7C:04:D0', 'DC:A4:CA', 'F0:B3:EC'],
# Tile trackers
'tile_oui': ['D0:03:DF', 'EC:2E:4E'],
# Samsung SmartTag
'smarttag_oui': ['8C:71:F8', 'CC:2D:83', 'F0:5C:D5'],
# ESP32/ESP8266 Espressif chipsets
'espressif_oui': ['24:0A:C4', '24:6F:28', '24:62:AB', '30:AE:A4',
'3C:61:05', '3C:71:BF', '40:F5:20', '48:3F:DA',
'4C:11:AE', '54:43:B2', '58:BF:25', '5C:CF:7F',
'60:01:94', '68:C6:3A', '7C:9E:BD', '84:0D:8E',
'84:CC:A8', '84:F3:EB', '8C:AA:B5', '90:38:0C',
'94:B5:55', '98:CD:AC', 'A4:7B:9D', 'A4:CF:12',
'AC:67:B2', 'B4:E6:2D', 'BC:DD:C2', 'C4:4F:33',
'C8:2B:96', 'CC:50:E3', 'D8:A0:1D', 'DC:4F:22',
'E0:98:06', 'E8:68:E7', 'EC:FA:BC', 'F4:CF:A2'],
# Generic/suspicious chipset vendors (potential covert devices)
'generic_chipset_oui': [
'00:1A:7D', # cyber-blue(HK)
'00:25:00', # Apple (but generic BLE)
],
}
@dataclass
class Indicator:
"""A single risk indicator."""
type: IndicatorType
description: str
score: int
details: dict = field(default_factory=dict)
timestamp: datetime = field(default_factory=datetime.now)
@dataclass
class DeviceProfile:
"""Complete profile for a detected device."""
# Identity
identifier: str # MAC, BSSID, or frequency
protocol: str # 'bluetooth', 'wifi', 'rf'
# Device info
name: Optional[str] = None
manufacturer: Optional[str] = None
device_type: Optional[str] = None
# Bluetooth-specific
services: list[str] = field(default_factory=list)
company_id: Optional[int] = None
advertising_interval: Optional[int] = None
# Wi-Fi-specific
ssid: Optional[str] = None
channel: Optional[int] = None
encryption: Optional[str] = None
beacon_interval: Optional[int] = None
is_hidden: bool = False
# RF-specific
frequency: Optional[float] = None
bandwidth: Optional[float] = None
modulation: Optional[str] = None
# Common measurements
rssi_samples: list[tuple[datetime, int]] = field(default_factory=list)
first_seen: Optional[datetime] = None
last_seen: Optional[datetime] = None
detection_count: int = 0
# Behavioral analysis
indicators: list[Indicator] = field(default_factory=list)
total_score: int = 0
risk_level: RiskLevel = RiskLevel.INFORMATIONAL
# Correlation
correlated_devices: list[str] = field(default_factory=list)
# Output
confidence: float = 0.0
recommended_action: str = 'monitor'
def add_rssi_sample(self, rssi: int) -> None:
"""Add an RSSI sample with timestamp."""
self.rssi_samples.append((datetime.now(), rssi))
# Keep last 100 samples
if len(self.rssi_samples) > 100:
self.rssi_samples = self.rssi_samples[-100:]
def get_rssi_stability(self) -> float:
"""Calculate RSSI stability (0-1, higher = more stable)."""
if len(self.rssi_samples) < 3:
return 0.0
values = [r for _, r in self.rssi_samples[-20:]]
if not values:
return 0.0
avg = sum(values) / len(values)
variance = sum((v - avg) ** 2 for v in values) / len(values)
# Convert variance to stability score (lower variance = higher stability)
# Variance of ~0 = 1.0, variance of 100+ = ~0
return max(0, 1 - (variance / 100))
def add_indicator(self, indicator_type: IndicatorType, description: str,
details: dict = None) -> None:
"""Add a risk indicator and update score."""
score = INDICATOR_SCORES.get(indicator_type, 1)
self.indicators.append(Indicator(
type=indicator_type,
description=description,
score=score,
details=details or {}
))
self._recalculate_score()
def _recalculate_score(self) -> None:
"""Recalculate total score and risk level."""
self.total_score = sum(i.score for i in self.indicators)
if self.total_score >= 6:
self.risk_level = RiskLevel.HIGH_INTEREST
self.recommended_action = 'investigate'
elif self.total_score >= 3:
self.risk_level = RiskLevel.NEEDS_REVIEW
self.recommended_action = 'review'
else:
self.risk_level = RiskLevel.INFORMATIONAL
self.recommended_action = 'monitor'
# Calculate confidence based on number and quality of indicators
indicator_count = len(self.indicators)
self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05))
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
'identifier': self.identifier,
'protocol': self.protocol,
'name': self.name,
'manufacturer': self.manufacturer,
'device_type': self.device_type,
'ssid': self.ssid,
'frequency': self.frequency,
'first_seen': self.first_seen.isoformat() if self.first_seen else None,
'last_seen': self.last_seen.isoformat() if self.last_seen else None,
'detection_count': self.detection_count,
'rssi_current': self.rssi_samples[-1][1] if self.rssi_samples else None,
'rssi_stability': self.get_rssi_stability(),
'indicators': [
{
'type': i.type.value,
'description': i.description,
'score': i.score,
}
for i in self.indicators
],
'total_score': self.total_score,
'risk_level': self.risk_level.value,
'confidence': round(self.confidence, 2),
'recommended_action': self.recommended_action,
'correlated_devices': self.correlated_devices,
}
# Known audio-capable BLE service UUIDs
AUDIO_SERVICE_UUIDS = [
'0000110b-0000-1000-8000-00805f9b34fb', # A2DP Sink
'0000110a-0000-1000-8000-00805f9b34fb', # A2DP Source
'0000111e-0000-1000-8000-00805f9b34fb', # Handsfree
'0000111f-0000-1000-8000-00805f9b34fb', # Handsfree Audio Gateway
'00001108-0000-1000-8000-00805f9b34fb', # Headset
'00001203-0000-1000-8000-00805f9b34fb', # Generic Audio
]
# Generic chipset vendors (often used in covert devices)
GENERIC_CHIPSET_VENDORS = [
'espressif',
'nordic',
'texas instruments',
'silicon labs',
'realtek',
'mediatek',
'qualcomm',
'broadcom',
'cypress',
'dialog',
]
# Suspicious frequency ranges for RF
SUSPICIOUS_RF_BANDS = [
{'start': 136, 'end': 174, 'name': 'VHF', 'risk': 'high'},
{'start': 400, 'end': 470, 'name': 'UHF', 'risk': 'high'},
{'start': 315, 'end': 316, 'name': '315 MHz ISM', 'risk': 'medium'},
{'start': 433, 'end': 435, 'name': '433 MHz ISM', 'risk': 'medium'},
{'start': 868, 'end': 870, 'name': '868 MHz ISM', 'risk': 'medium'},
{'start': 902, 'end': 928, 'name': '915 MHz ISM', 'risk': 'medium'},
]
class CorrelationEngine:
"""
Cross-protocol correlation engine for TSCM analysis.
Correlates Bluetooth, Wi-Fi, and RF indicators to identify
potential surveillance activity patterns.
"""
def __init__(self):
self.device_profiles: dict[str, DeviceProfile] = {}
self.meeting_windows: list[tuple[datetime, datetime]] = []
self.correlation_window = timedelta(minutes=5)
def start_meeting_window(self) -> None:
"""Mark the start of a sensitive period (meeting)."""
self.meeting_windows.append((datetime.now(), None))
logger.info("Meeting window started")
def end_meeting_window(self) -> None:
"""Mark the end of a sensitive period."""
if self.meeting_windows and self.meeting_windows[-1][1] is None:
start = self.meeting_windows[-1][0]
self.meeting_windows[-1] = (start, datetime.now())
logger.info("Meeting window ended")
def is_during_meeting(self, timestamp: datetime = None) -> bool:
"""Check if timestamp falls within a meeting window."""
ts = timestamp or datetime.now()
for start, end in self.meeting_windows:
if end is None:
if ts >= start:
return True
elif start <= ts <= end:
return True
return False
def get_or_create_profile(self, identifier: str, protocol: str) -> DeviceProfile:
"""Get existing profile or create new one."""
key = f"{protocol}:{identifier}"
if key not in self.device_profiles:
self.device_profiles[key] = DeviceProfile(
identifier=identifier,
protocol=protocol,
first_seen=datetime.now()
)
profile = self.device_profiles[key]
profile.last_seen = datetime.now()
profile.detection_count += 1
return profile
def analyze_bluetooth_device(self, device: dict) -> DeviceProfile:
"""
Analyze a Bluetooth device for suspicious indicators.
Args:
device: Dict with mac, name, rssi, services, manufacturer, etc.
Returns:
DeviceProfile with risk assessment
"""
mac = device.get('mac', device.get('address', '')).upper()
profile = self.get_or_create_profile(mac, 'bluetooth')
# Update profile data
profile.name = device.get('name') or profile.name
profile.manufacturer = device.get('manufacturer') or profile.manufacturer
profile.device_type = device.get('type') or profile.device_type
profile.services = device.get('services', []) or profile.services
profile.company_id = device.get('company_id') or profile.company_id
profile.advertising_interval = device.get('advertising_interval') or profile.advertising_interval
# Add RSSI sample
rssi = device.get('rssi', device.get('signal'))
if rssi:
try:
profile.add_rssi_sample(int(rssi))
except (ValueError, TypeError):
pass
# Clear previous indicators for fresh analysis
profile.indicators = []
# === Detection Logic ===
# 1. Unknown manufacturer or generic chipset
if not profile.manufacturer:
profile.add_indicator(
IndicatorType.UNKNOWN_DEVICE,
'Unknown manufacturer',
{'manufacturer': None}
)
elif any(v in profile.manufacturer.lower() for v in GENERIC_CHIPSET_VENDORS):
profile.add_indicator(
IndicatorType.UNKNOWN_DEVICE,
f'Generic chipset vendor: {profile.manufacturer}',
{'manufacturer': profile.manufacturer}
)
# 2. No human-readable name
if not profile.name or profile.name in ['Unknown', '', 'N/A']:
profile.add_indicator(
IndicatorType.HIDDEN_IDENTITY,
'No device name advertised',
{'name': profile.name}
)
# 3. Audio-capable services
if profile.services:
audio_services = [s for s in profile.services
if s.lower() in [u.lower() for u in AUDIO_SERVICE_UUIDS]]
if audio_services:
profile.add_indicator(
IndicatorType.AUDIO_CAPABLE,
'Audio-capable BLE services detected',
{'services': audio_services}
)
# Check name for audio keywords
if profile.name:
audio_keywords = ['headphone', 'headset', 'earphone', 'speaker',
'mic', 'audio', 'airpod', 'buds', 'jabra', 'bose']
if any(k in profile.name.lower() for k in audio_keywords):
profile.add_indicator(
IndicatorType.AUDIO_CAPABLE,
f'Audio device name: {profile.name}',
{'name': profile.name}
)
# 4. High-frequency advertising (< 100ms interval is suspicious)
if profile.advertising_interval and profile.advertising_interval < 100:
profile.add_indicator(
IndicatorType.HIGH_FREQ_ADVERTISING,
f'High advertising frequency: {profile.advertising_interval}ms',
{'interval': profile.advertising_interval}
)
# 5. Persistent presence
if profile.detection_count >= 3:
profile.add_indicator(
IndicatorType.PERSISTENT,
f'Persistent device ({profile.detection_count} detections)',
{'count': profile.detection_count}
)
# 6. Stable RSSI (suggests fixed placement)
rssi_stability = profile.get_rssi_stability()
if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5:
profile.add_indicator(
IndicatorType.STABLE_RSSI,
f'Stable signal strength (stability: {rssi_stability:.0%})',
{'stability': rssi_stability}
)
# 7. Meeting correlation
if self.is_during_meeting():
profile.add_indicator(
IndicatorType.MEETING_CORRELATED,
'Detected during sensitive period',
{'during_meeting': True}
)
# 8. MAC rotation pattern (random MAC prefix)
if mac and mac[1] in ['2', '6', 'A', 'E', 'a', 'e']:
profile.add_indicator(
IndicatorType.MAC_ROTATION,
'Random/rotating MAC address detected',
{'mac': mac}
)
# 9. Known tracker detection (AirTag, Tile, SmartTag, ESP32)
mac_prefix = mac[:8] if len(mac) >= 8 else ''
tracker_detected = False
# Check for tracker flags from BLE scanner (manufacturer ID detection)
if device.get('is_airtag'):
profile.add_indicator(
IndicatorType.AIRTAG_DETECTED,
'Apple AirTag detected via manufacturer data',
{'mac': mac, 'tracker_type': 'AirTag'}
)
profile.device_type = device.get('tracker_type', 'AirTag')
tracker_detected = True
if device.get('is_tile'):
profile.add_indicator(
IndicatorType.TILE_DETECTED,
'Tile tracker detected via manufacturer data',
{'mac': mac, 'tracker_type': 'Tile'}
)
profile.device_type = 'Tile Tracker'
tracker_detected = True
if device.get('is_smarttag'):
profile.add_indicator(
IndicatorType.SMARTTAG_DETECTED,
'Samsung SmartTag detected via manufacturer data',
{'mac': mac, 'tracker_type': 'SmartTag'}
)
profile.device_type = 'Samsung SmartTag'
tracker_detected = True
if device.get('is_espressif'):
profile.add_indicator(
IndicatorType.ESP32_DEVICE,
'ESP32/ESP8266 detected via Espressif manufacturer ID',
{'mac': mac, 'chipset': 'Espressif'}
)
profile.manufacturer = 'Espressif'
profile.device_type = device.get('tracker_type', 'ESP32/ESP8266')
tracker_detected = True
# Check manufacturer_id directly
mfg_id = device.get('manufacturer_id')
if mfg_id:
if mfg_id == 0x004C and not device.get('is_airtag'):
# Apple device - could be AirTag
profile.manufacturer = 'Apple'
elif mfg_id == 0x02E5 and not device.get('is_espressif'):
# Espressif device
profile.add_indicator(
IndicatorType.ESP32_DEVICE,
'ESP32/ESP8266 detected via manufacturer ID',
{'mac': mac, 'manufacturer_id': mfg_id}
)
profile.manufacturer = 'Espressif'
tracker_detected = True
# Fallback: Check for Apple AirTag by OUI
if not tracker_detected and mac_prefix in TRACKER_SIGNATURES.get('airtag_oui', []):
profile.add_indicator(
IndicatorType.AIRTAG_DETECTED,
'Apple AirTag detected - potential tracking device',
{'mac': mac, 'tracker_type': 'AirTag'}
)
profile.device_type = 'AirTag'
tracker_detected = True
# Check for Tile tracker
if mac_prefix in TRACKER_SIGNATURES.get('tile_oui', []):
profile.add_indicator(
IndicatorType.TILE_DETECTED,
'Tile tracker detected',
{'mac': mac, 'tracker_type': 'Tile'}
)
profile.device_type = 'Tile Tracker'
tracker_detected = True
# Check for Samsung SmartTag
if mac_prefix in TRACKER_SIGNATURES.get('smarttag_oui', []):
profile.add_indicator(
IndicatorType.SMARTTAG_DETECTED,
'Samsung SmartTag detected',
{'mac': mac, 'tracker_type': 'SmartTag'}
)
profile.device_type = 'Samsung SmartTag'
tracker_detected = True
# Check for ESP32/ESP8266 devices
if mac_prefix in TRACKER_SIGNATURES.get('espressif_oui', []):
profile.add_indicator(
IndicatorType.ESP32_DEVICE,
'ESP32/ESP8266 device detected - programmable hardware',
{'mac': mac, 'chipset': 'Espressif'}
)
profile.manufacturer = 'Espressif'
tracker_detected = True
# Check for generic/suspicious chipsets
if mac_prefix in TRACKER_SIGNATURES.get('generic_chipset_oui', []):
profile.add_indicator(
IndicatorType.GENERIC_CHIPSET,
'Generic chipset vendor - often used in covert devices',
{'mac': mac}
)
tracker_detected = True
# If any tracker detected, add general tracker indicator
if tracker_detected:
profile.add_indicator(
IndicatorType.KNOWN_TRACKER,
'Known tracking device signature detected',
{'mac': mac}
)
# Also check name for tracker keywords
if profile.name:
name_lower = profile.name.lower()
if 'airtag' in name_lower or 'findmy' in name_lower:
profile.add_indicator(
IndicatorType.AIRTAG_DETECTED,
f'AirTag identified by name: {profile.name}',
{'name': profile.name}
)
profile.device_type = 'AirTag'
elif 'tile' in name_lower:
profile.add_indicator(
IndicatorType.TILE_DETECTED,
f'Tile tracker identified by name: {profile.name}',
{'name': profile.name}
)
profile.device_type = 'Tile Tracker'
elif 'smarttag' in name_lower:
profile.add_indicator(
IndicatorType.SMARTTAG_DETECTED,
f'SmartTag identified by name: {profile.name}',
{'name': profile.name}
)
profile.device_type = 'Samsung SmartTag'
return profile
def analyze_wifi_device(self, device: dict) -> DeviceProfile:
"""
Analyze a Wi-Fi device/AP for suspicious indicators.
Args:
device: Dict with bssid, ssid, channel, rssi, encryption, etc.
Returns:
DeviceProfile with risk assessment
"""
bssid = device.get('bssid', device.get('mac', '')).upper()
profile = self.get_or_create_profile(bssid, 'wifi')
# Update profile data
ssid = device.get('ssid', device.get('essid', ''))
profile.ssid = ssid if ssid else profile.ssid
profile.name = ssid or f'Hidden Network ({bssid[-8:]})'
profile.channel = device.get('channel') or profile.channel
profile.encryption = device.get('encryption', device.get('privacy')) or profile.encryption
profile.beacon_interval = device.get('beacon_interval') or profile.beacon_interval
profile.is_hidden = not ssid or ssid in ['', 'Hidden', '[Hidden]']
# Extract manufacturer from OUI
if bssid and len(bssid) >= 8:
profile.manufacturer = device.get('vendor') or profile.manufacturer
# Add RSSI sample
rssi = device.get('rssi', device.get('power', device.get('signal')))
if rssi:
try:
profile.add_rssi_sample(int(rssi))
except (ValueError, TypeError):
pass
# Clear previous indicators
profile.indicators = []
# === Detection Logic ===
# 1. Hidden or unnamed SSID
if profile.is_hidden:
profile.add_indicator(
IndicatorType.HIDDEN_IDENTITY,
'Hidden or empty SSID',
{'ssid': ssid}
)
# 2. BSSID not in authorized list (would need baseline)
# For now, mark as unknown if no manufacturer
if not profile.manufacturer:
profile.add_indicator(
IndicatorType.UNKNOWN_DEVICE,
'Unknown AP manufacturer',
{'bssid': bssid}
)
# 3. Consumer device OUI in restricted environment
consumer_ouis = ['tp-link', 'netgear', 'd-link', 'linksys', 'asus']
if profile.manufacturer and any(c in profile.manufacturer.lower() for c in consumer_ouis):
profile.add_indicator(
IndicatorType.ROGUE_AP,
f'Consumer-grade AP detected: {profile.manufacturer}',
{'manufacturer': profile.manufacturer}
)
# 4. Camera device patterns
camera_keywords = ['cam', 'camera', 'ipcam', 'dvr', 'nvr', 'wyze',
'ring', 'arlo', 'nest', 'blink', 'eufy', 'yi']
if ssid and any(k in ssid.lower() for k in camera_keywords):
profile.add_indicator(
IndicatorType.AUDIO_CAPABLE, # Cameras often have mics
f'Potential camera device: {ssid}',
{'ssid': ssid}
)
# 5. Persistent presence
if profile.detection_count >= 3:
profile.add_indicator(
IndicatorType.PERSISTENT,
f'Persistent AP ({profile.detection_count} detections)',
{'count': profile.detection_count}
)
# 6. Stable RSSI (fixed placement)
rssi_stability = profile.get_rssi_stability()
if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5:
profile.add_indicator(
IndicatorType.STABLE_RSSI,
f'Stable signal (stability: {rssi_stability:.0%})',
{'stability': rssi_stability}
)
# 7. Meeting correlation
if self.is_during_meeting():
profile.add_indicator(
IndicatorType.MEETING_CORRELATED,
'Detected during sensitive period',
{'during_meeting': True}
)
# 8. Strong hidden AP (very suspicious)
if profile.is_hidden and profile.rssi_samples:
latest_rssi = profile.rssi_samples[-1][1]
if latest_rssi > -50:
profile.add_indicator(
IndicatorType.ROGUE_AP,
f'Strong hidden AP (RSSI: {latest_rssi} dBm)',
{'rssi': latest_rssi}
)
return profile
def analyze_rf_signal(self, signal: dict) -> DeviceProfile:
"""
Analyze an RF signal for suspicious indicators.
Args:
signal: Dict with frequency, power, bandwidth, modulation, etc.
Returns:
DeviceProfile with risk assessment
"""
frequency = signal.get('frequency', 0)
freq_key = f"{frequency:.3f}"
profile = self.get_or_create_profile(freq_key, 'rf')
# Update profile data
profile.frequency = frequency
profile.name = f'{frequency:.3f} MHz'
profile.bandwidth = signal.get('bandwidth') or profile.bandwidth
profile.modulation = signal.get('modulation') or profile.modulation
# Add power sample
power = signal.get('power', signal.get('level'))
if power:
try:
profile.add_rssi_sample(int(float(power)))
except (ValueError, TypeError):
pass
# Clear previous indicators
profile.indicators = []
# === Detection Logic ===
# 1. Determine frequency band risk
band_info = None
for band in SUSPICIOUS_RF_BANDS:
if band['start'] <= frequency <= band['end']:
band_info = band
break
if band_info:
if band_info['risk'] == 'high':
profile.add_indicator(
IndicatorType.NARROWBAND_SIGNAL,
f"Signal in high-risk band: {band_info['name']}",
{'band': band_info['name'], 'frequency': frequency}
)
else:
profile.add_indicator(
IndicatorType.UNKNOWN_DEVICE,
f"Signal in ISM band: {band_info['name']}",
{'band': band_info['name'], 'frequency': frequency}
)
# 2. Narrowband FM/AM (potential bug)
if profile.modulation and profile.modulation.lower() in ['fm', 'nfm', 'am']:
profile.add_indicator(
IndicatorType.NARROWBAND_SIGNAL,
f'Narrowband {profile.modulation.upper()} signal',
{'modulation': profile.modulation}
)
# 3. Persistent/always-on carrier
if profile.detection_count >= 2:
profile.add_indicator(
IndicatorType.ALWAYS_ON_CARRIER,
f'Persistent carrier ({profile.detection_count} detections)',
{'count': profile.detection_count}
)
# 4. Strong signal (close proximity)
if profile.rssi_samples:
latest_power = profile.rssi_samples[-1][1]
if latest_power > -40:
profile.add_indicator(
IndicatorType.STABLE_RSSI,
f'Strong signal suggesting close proximity ({latest_power} dBm)',
{'power': latest_power}
)
# 5. Meeting correlation
if self.is_during_meeting():
profile.add_indicator(
IndicatorType.MEETING_CORRELATED,
'Signal detected during sensitive period',
{'during_meeting': True}
)
return profile
def correlate_devices(self) -> list[dict]:
"""
Perform cross-protocol correlation analysis.
Identifies devices across protocols that may be related.
Returns:
List of correlation findings
"""
correlations = []
now = datetime.now()
# Get recent devices by protocol
bt_devices = [p for p in self.device_profiles.values()
if p.protocol == 'bluetooth' and
p.last_seen and (now - p.last_seen) < self.correlation_window]
wifi_devices = [p for p in self.device_profiles.values()
if p.protocol == 'wifi' and
p.last_seen and (now - p.last_seen) < self.correlation_window]
rf_signals = [p for p in self.device_profiles.values()
if p.protocol == 'rf' and
p.last_seen and (now - p.last_seen) < self.correlation_window]
# Correlation 1: BLE audio device + RF narrowband signal
audio_bt = [p for p in bt_devices
if any(i.type == IndicatorType.AUDIO_CAPABLE for i in p.indicators)]
narrowband_rf = [p for p in rf_signals
if any(i.type == IndicatorType.NARROWBAND_SIGNAL for i in p.indicators)]
for bt in audio_bt:
for rf in narrowband_rf:
correlation = {
'type': 'bt_audio_rf_narrowband',
'description': 'Audio-capable BLE device detected alongside narrowband RF signal',
'devices': [bt.identifier, rf.identifier],
'protocols': ['bluetooth', 'rf'],
'score_boost': 3,
'significance': 'high',
}
correlations.append(correlation)
# Add cross-protocol indicator to both
bt.add_indicator(
IndicatorType.CROSS_PROTOCOL,
f'Correlated with RF signal at {rf.frequency:.3f} MHz',
{'correlated_device': rf.identifier}
)
rf.add_indicator(
IndicatorType.CROSS_PROTOCOL,
f'Correlated with BLE device {bt.identifier}',
{'correlated_device': bt.identifier}
)
bt.correlated_devices.append(rf.identifier)
rf.correlated_devices.append(bt.identifier)
# Correlation 2: Rogue WiFi AP + RF burst activity
rogue_aps = [p for p in wifi_devices
if any(i.type == IndicatorType.ROGUE_AP for i in p.indicators)]
rf_bursts = [p for p in rf_signals
if any(i.type in [IndicatorType.BURST_TRANSMISSION,
IndicatorType.ALWAYS_ON_CARRIER] for i in p.indicators)]
for ap in rogue_aps:
for rf in rf_bursts:
correlation = {
'type': 'rogue_ap_rf_burst',
'description': 'Rogue AP detected alongside RF transmission',
'devices': [ap.identifier, rf.identifier],
'protocols': ['wifi', 'rf'],
'score_boost': 3,
'significance': 'high',
}
correlations.append(correlation)
ap.add_indicator(
IndicatorType.CROSS_PROTOCOL,
f'Correlated with RF at {rf.frequency:.3f} MHz',
{'correlated_device': rf.identifier}
)
rf.add_indicator(
IndicatorType.CROSS_PROTOCOL,
f'Correlated with AP {ap.ssid or ap.identifier}',
{'correlated_device': ap.identifier}
)
# Correlation 3: Same vendor BLE + WiFi
for bt in bt_devices:
if bt.manufacturer:
for wifi in wifi_devices:
if wifi.manufacturer and bt.manufacturer.lower() in wifi.manufacturer.lower():
correlation = {
'type': 'same_vendor_bt_wifi',
'description': f'Same vendor ({bt.manufacturer}) on BLE and WiFi',
'devices': [bt.identifier, wifi.identifier],
'protocols': ['bluetooth', 'wifi'],
'score_boost': 2,
'significance': 'medium',
}
correlations.append(correlation)
return correlations
def get_high_interest_devices(self) -> list[DeviceProfile]:
"""Get all devices classified as high interest."""
return [p for p in self.device_profiles.values()
if p.risk_level == RiskLevel.HIGH_INTEREST]
def get_all_findings(self) -> dict:
"""
Get comprehensive findings report.
Returns:
Dict with all device profiles, correlations, and summary
"""
correlations = self.correlate_devices()
devices_by_risk = {
'high_interest': [],
'needs_review': [],
'informational': [],
}
for profile in self.device_profiles.values():
devices_by_risk[profile.risk_level.value].append(profile.to_dict())
return {
'timestamp': datetime.now().isoformat(),
'summary': {
'total_devices': len(self.device_profiles),
'high_interest': len(devices_by_risk['high_interest']),
'needs_review': len(devices_by_risk['needs_review']),
'informational': len(devices_by_risk['informational']),
'correlations_found': len(correlations),
},
'devices': devices_by_risk,
'correlations': correlations,
'disclaimer': (
"This system performs wireless and RF surveillance screening. "
"Findings indicate anomalies and indicators, not confirmed surveillance devices."
),
}
def clear_old_profiles(self, max_age_hours: int = 24) -> int:
"""Remove profiles older than specified age."""
cutoff = datetime.now() - timedelta(hours=max_age_hours)
old_keys = [
k for k, v in self.device_profiles.items()
if v.last_seen and v.last_seen < cutoff
]
for key in old_keys:
del self.device_profiles[key]
return len(old_keys)
# Global correlation engine instance
_correlation_engine: CorrelationEngine | None = None
def get_correlation_engine() -> CorrelationEngine:
"""Get or create the global correlation engine."""
global _correlation_engine
if _correlation_engine is None:
_correlation_engine = CorrelationEngine()
return _correlation_engine
def reset_correlation_engine() -> None:
"""Reset the global correlation engine."""
global _correlation_engine
_correlation_engine = CorrelationEngine()
+564
View File
@@ -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
+61
View File
@@ -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