mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Add alerts/recording, WiFi/TSCM updates, optimize waterfall
This commit is contained in:
@@ -209,6 +209,11 @@ GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
|||||||
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
||||||
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
|
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
|
||||||
|
|
||||||
|
# Alerting
|
||||||
|
ALERT_WEBHOOK_URL = _get_env('ALERT_WEBHOOK_URL', '')
|
||||||
|
ALERT_WEBHOOK_SECRET = _get_env('ALERT_WEBHOOK_SECRET', '')
|
||||||
|
ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
|
||||||
|
|
||||||
# Admin credentials
|
# Admin credentials
|
||||||
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
||||||
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
||||||
|
|||||||
@@ -1543,6 +1543,7 @@ class ModeManager:
|
|||||||
"""Start WiFi scanning using Intercept's UnifiedWiFiScanner."""
|
"""Start WiFi scanning using Intercept's UnifiedWiFiScanner."""
|
||||||
interface = params.get('interface')
|
interface = params.get('interface')
|
||||||
channel = params.get('channel')
|
channel = params.get('channel')
|
||||||
|
channels = params.get('channels')
|
||||||
band = params.get('band', 'abg')
|
band = params.get('band', 'abg')
|
||||||
scan_type = params.get('scan_type', 'deep')
|
scan_type = params.get('scan_type', 'deep')
|
||||||
|
|
||||||
@@ -1573,8 +1574,21 @@ class ModeManager:
|
|||||||
else:
|
else:
|
||||||
scan_band = 'all'
|
scan_band = 'all'
|
||||||
|
|
||||||
|
channel_list = None
|
||||||
|
if channels:
|
||||||
|
if isinstance(channels, str):
|
||||||
|
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||||
|
elif isinstance(channels, (list, tuple, set)):
|
||||||
|
channel_list = list(channels)
|
||||||
|
else:
|
||||||
|
channel_list = [channels]
|
||||||
|
try:
|
||||||
|
channel_list = [int(c) for c in channel_list]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {'status': 'error', 'message': 'Invalid channels'}
|
||||||
|
|
||||||
# Start deep scan
|
# Start deep scan
|
||||||
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel):
|
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list):
|
||||||
# Start thread to sync data to agent's dictionaries
|
# Start thread to sync data to agent's dictionaries
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=self._wifi_data_sync,
|
target=self._wifi_data_sync,
|
||||||
@@ -1595,7 +1609,7 @@ class ModeManager:
|
|||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Fallback to direct airodump-ng
|
# Fallback to direct airodump-ng
|
||||||
return self._start_wifi_fallback(interface, channel, band)
|
return self._start_wifi_fallback(interface, channel, band, channels)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"WiFi scanner error: {e}")
|
logger.error(f"WiFi scanner error: {e}")
|
||||||
return {'status': 'error', 'message': str(e)}
|
return {'status': 'error', 'message': str(e)}
|
||||||
@@ -1632,7 +1646,13 @@ class ModeManager:
|
|||||||
if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance:
|
if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance:
|
||||||
self._wifi_scanner_instance.stop_deep_scan()
|
self._wifi_scanner_instance.stop_deep_scan()
|
||||||
|
|
||||||
def _start_wifi_fallback(self, interface: str | None, channel: int | None, band: str) -> dict:
|
def _start_wifi_fallback(
|
||||||
|
self,
|
||||||
|
interface: str | None,
|
||||||
|
channel: int | None,
|
||||||
|
band: str,
|
||||||
|
channels: list[int] | str | None = None,
|
||||||
|
) -> dict:
|
||||||
"""Fallback WiFi deep scan using airodump-ng directly."""
|
"""Fallback WiFi deep scan using airodump-ng directly."""
|
||||||
if not interface:
|
if not interface:
|
||||||
return {'status': 'error', 'message': 'WiFi interface required'}
|
return {'status': 'error', 'message': 'WiFi interface required'}
|
||||||
@@ -1660,7 +1680,22 @@ class ModeManager:
|
|||||||
cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band]
|
cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band]
|
||||||
if gps_manager.is_running:
|
if gps_manager.is_running:
|
||||||
cmd.append('--gpsd')
|
cmd.append('--gpsd')
|
||||||
if channel:
|
channel_list = None
|
||||||
|
if channels:
|
||||||
|
if isinstance(channels, str):
|
||||||
|
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||||
|
elif isinstance(channels, (list, tuple, set)):
|
||||||
|
channel_list = list(channels)
|
||||||
|
else:
|
||||||
|
channel_list = [channels]
|
||||||
|
try:
|
||||||
|
channel_list = [int(c) for c in channel_list]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {'status': 'error', 'message': 'Invalid channels'}
|
||||||
|
|
||||||
|
if channel_list:
|
||||||
|
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
||||||
|
elif channel:
|
||||||
cmd.extend(['-c', str(channel)])
|
cmd.extend(['-c', str(channel)])
|
||||||
cmd.append(interface)
|
cmd.append(interface)
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ def register_blueprints(app):
|
|||||||
from .sstv_general import sstv_general_bp
|
from .sstv_general import sstv_general_bp
|
||||||
from .dmr import dmr_bp
|
from .dmr import dmr_bp
|
||||||
from .websdr import websdr_bp
|
from .websdr import websdr_bp
|
||||||
|
from .alerts import alerts_bp
|
||||||
|
from .recordings import recordings_bp
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
@@ -57,6 +59,8 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
|
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
|
||||||
app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice
|
app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice
|
||||||
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
||||||
|
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
||||||
|
app.register_blueprint(recordings_bp) # Session recordings
|
||||||
|
|
||||||
# Initialize TSCM state with queue and lock from app
|
# Initialize TSCM state with queue and lock from app
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import app as app_module
|
|||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
@@ -393,6 +394,10 @@ def stream_acars() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('acars', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from utils.validation import (
|
|||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
ADSB_SBS_PORT,
|
ADSB_SBS_PORT,
|
||||||
@@ -843,6 +844,10 @@ def stream_adsb():
|
|||||||
try:
|
try:
|
||||||
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('adsb', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from config import SHARED_OBSERVER_LOCATION_ENABLED
|
|||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.validation import validate_device_index, validate_gain
|
from utils.validation import validate_device_index, validate_gain
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
AIS_TCP_PORT,
|
AIS_TCP_PORT,
|
||||||
@@ -484,6 +485,10 @@ def stream_ais():
|
|||||||
try:
|
try:
|
||||||
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('ais', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
76
routes/alerts.py
Normal file
76
routes/alerts.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Alerting API endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import queue
|
||||||
|
import time
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.alerts import get_alert_manager
|
||||||
|
from utils.sse import format_sse
|
||||||
|
|
||||||
|
alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
|
||||||
|
|
||||||
|
|
||||||
|
@alerts_bp.route('/rules', methods=['GET'])
|
||||||
|
def list_rules():
|
||||||
|
manager = get_alert_manager()
|
||||||
|
include_disabled = request.args.get('all') in ('1', 'true', 'yes')
|
||||||
|
return jsonify({'status': 'success', 'rules': manager.list_rules(include_disabled=include_disabled)})
|
||||||
|
|
||||||
|
|
||||||
|
@alerts_bp.route('/rules', methods=['POST'])
|
||||||
|
def create_rule():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
if not isinstance(data.get('match', {}), dict):
|
||||||
|
return jsonify({'status': 'error', 'message': 'match must be a JSON object'}), 400
|
||||||
|
|
||||||
|
manager = get_alert_manager()
|
||||||
|
rule_id = manager.add_rule(data)
|
||||||
|
return jsonify({'status': 'success', 'rule_id': rule_id})
|
||||||
|
|
||||||
|
|
||||||
|
@alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH'])
|
||||||
|
def update_rule(rule_id: int):
|
||||||
|
data = request.get_json() or {}
|
||||||
|
manager = get_alert_manager()
|
||||||
|
ok = manager.update_rule(rule_id, data)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Rule not found or no changes'}), 404
|
||||||
|
return jsonify({'status': 'success'})
|
||||||
|
|
||||||
|
|
||||||
|
@alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE'])
|
||||||
|
def delete_rule(rule_id: int):
|
||||||
|
manager = get_alert_manager()
|
||||||
|
ok = manager.delete_rule(rule_id)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Rule not found'}), 404
|
||||||
|
return jsonify({'status': 'success'})
|
||||||
|
|
||||||
|
|
||||||
|
@alerts_bp.route('/events', methods=['GET'])
|
||||||
|
def list_events():
|
||||||
|
manager = get_alert_manager()
|
||||||
|
limit = request.args.get('limit', default=100, type=int)
|
||||||
|
mode = request.args.get('mode')
|
||||||
|
severity = request.args.get('severity')
|
||||||
|
events = manager.list_events(limit=limit, mode=mode, severity=severity)
|
||||||
|
return jsonify({'status': 'success', 'events': events})
|
||||||
|
|
||||||
|
|
||||||
|
@alerts_bp.route('/stream', methods=['GET'])
|
||||||
|
def stream_alerts() -> Response:
|
||||||
|
manager = get_alert_manager()
|
||||||
|
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
for event in manager.stream_events(timeout=1.0):
|
||||||
|
yield format_sse(event)
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
@@ -22,6 +22,7 @@ import app as app_module
|
|||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
@@ -1727,6 +1728,10 @@ def stream_aprs() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('aprs', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import app as app_module
|
|||||||
from utils.dependencies import check_tool
|
from utils.dependencies import check_tool
|
||||||
from utils.logging import bluetooth_logger as logger
|
from utils.logging import bluetooth_logger as logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.validation import validate_bluetooth_interface
|
from utils.validation import validate_bluetooth_interface
|
||||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||||
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
||||||
@@ -563,6 +564,10 @@ def stream_bt():
|
|||||||
try:
|
try:
|
||||||
msg = app_module.bt_queue.get(timeout=1)
|
msg = app_module.bt_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('bluetooth', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import csv
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
@@ -28,12 +30,18 @@ from utils.bluetooth import (
|
|||||||
)
|
)
|
||||||
from utils.database import get_db
|
from utils.database import get_db
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.bluetooth_v2')
|
logger = logging.getLogger('intercept.bluetooth_v2')
|
||||||
|
|
||||||
# Blueprint
|
# Blueprint
|
||||||
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
|
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
|
||||||
|
|
||||||
|
# Seen-before tracking
|
||||||
|
_bt_seen_cache: set[str] = set()
|
||||||
|
_bt_session_seen: set[str] = set()
|
||||||
|
_bt_seen_lock = threading.Lock()
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# DATABASE FUNCTIONS
|
# DATABASE FUNCTIONS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -173,6 +181,13 @@ def save_observation_history(device: BTDeviceAggregate) -> None:
|
|||||||
''', (device.device_id, device.rssi_current, device.seen_count))
|
''', (device.device_id, device.rssi_current, device.seen_count))
|
||||||
|
|
||||||
|
|
||||||
|
def load_seen_device_ids() -> set[str]:
|
||||||
|
"""Load distinct device IDs from history for seen-before tracking."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history')
|
||||||
|
return {row['device_id'] for row in cursor}
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# API ENDPOINTS
|
# API ENDPOINTS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -221,6 +236,28 @@ def start_scan():
|
|||||||
# Get scanner instance
|
# Get scanner instance
|
||||||
scanner = get_bluetooth_scanner(adapter_id)
|
scanner = get_bluetooth_scanner(adapter_id)
|
||||||
|
|
||||||
|
# Initialize database tables if needed
|
||||||
|
init_bt_tables()
|
||||||
|
|
||||||
|
def _handle_seen_before(device: BTDeviceAggregate) -> None:
|
||||||
|
try:
|
||||||
|
with _bt_seen_lock:
|
||||||
|
device.seen_before = device.device_id in _bt_seen_cache
|
||||||
|
if device.device_id not in _bt_session_seen:
|
||||||
|
save_observation_history(device)
|
||||||
|
_bt_session_seen.add(device.device_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"BT seen-before update failed: {e}")
|
||||||
|
|
||||||
|
# Setup seen-before callback
|
||||||
|
if scanner._on_device_updated is None:
|
||||||
|
scanner._on_device_updated = _handle_seen_before
|
||||||
|
|
||||||
|
# Ensure cache is initialized
|
||||||
|
with _bt_seen_lock:
|
||||||
|
if not _bt_seen_cache:
|
||||||
|
_bt_seen_cache.update(load_seen_device_ids())
|
||||||
|
|
||||||
# Check if already scanning
|
# Check if already scanning
|
||||||
if scanner.is_scanning:
|
if scanner.is_scanning:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -228,8 +265,11 @@ def start_scan():
|
|||||||
'scan_status': scanner.get_status().to_dict()
|
'scan_status': scanner.get_status().to_dict()
|
||||||
})
|
})
|
||||||
|
|
||||||
# Initialize database tables if needed
|
# Refresh seen-before cache and reset session set for a new scan
|
||||||
init_bt_tables()
|
with _bt_seen_lock:
|
||||||
|
_bt_seen_cache.clear()
|
||||||
|
_bt_seen_cache.update(load_seen_device_ids())
|
||||||
|
_bt_session_seen.clear()
|
||||||
|
|
||||||
# Load active baseline if exists
|
# Load active baseline if exists
|
||||||
baseline_id = get_active_baseline_id()
|
baseline_id = get_active_baseline_id()
|
||||||
@@ -860,6 +900,10 @@ def stream_events():
|
|||||||
"""Generate SSE events from scanner."""
|
"""Generate SSE events from scanner."""
|
||||||
for event in scanner.stream_events(timeout=1.0):
|
for event in scanner.stream_events(timeout=1.0):
|
||||||
event_name, event_data = map_event_type(event)
|
event_name, event_data = map_event_type(event)
|
||||||
|
try:
|
||||||
|
process_event('bluetooth', event_data, event_name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(event_data, event=event_name)
|
yield format_sse(event_data, event=event_name)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import register_process, unregister_process
|
from utils.process import register_process, unregister_process
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
@@ -495,6 +496,10 @@ def stream_dmr() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('dmr', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from utils.database import (
|
|||||||
)
|
)
|
||||||
from utils.dsc.parser import parse_dsc_message
|
from utils.dsc.parser import parse_dsc_message
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.validation import validate_device_index, validate_gain
|
from utils.validation import validate_device_index, validate_gain
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.dependencies import get_tool_path
|
from utils.dependencies import get_tool_path
|
||||||
@@ -525,6 +526,10 @@ def stream() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.dsc_queue.get(timeout=1)
|
msg = app_module.dsc_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('dsc', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
@@ -1182,6 +1183,10 @@ def stream_scanner_events() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('listening_scanner', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -1521,6 +1526,7 @@ waterfall_config = {
|
|||||||
'bin_size': 10000,
|
'bin_size': 10000,
|
||||||
'gain': 40,
|
'gain': 40,
|
||||||
'device': 0,
|
'device': 0,
|
||||||
|
'max_bins': 1024,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1607,6 +1613,9 @@ def _waterfall_loop():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if all_bins:
|
if all_bins:
|
||||||
|
max_bins = int(waterfall_config.get('max_bins') or 0)
|
||||||
|
if max_bins > 0 and len(all_bins) > max_bins:
|
||||||
|
all_bins = _downsample_bins(all_bins, max_bins)
|
||||||
msg = {
|
msg = {
|
||||||
'type': 'waterfall_sweep',
|
'type': 'waterfall_sweep',
|
||||||
'start_freq': sweep_start_hz / 1e6,
|
'start_freq': sweep_start_hz / 1e6,
|
||||||
@@ -1655,6 +1664,11 @@ def start_waterfall() -> Response:
|
|||||||
waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
|
waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
|
||||||
waterfall_config['gain'] = int(data.get('gain', 40))
|
waterfall_config['gain'] = int(data.get('gain', 40))
|
||||||
waterfall_config['device'] = int(data.get('device', 0))
|
waterfall_config['device'] = int(data.get('device', 0))
|
||||||
|
if data.get('max_bins') is not None:
|
||||||
|
max_bins = int(data.get('max_bins', waterfall_config['max_bins']))
|
||||||
|
if max_bins < 64 or max_bins > 4096:
|
||||||
|
return jsonify({'status': 'error', 'message': 'max_bins must be between 64 and 4096'}), 400
|
||||||
|
waterfall_config['max_bins'] = max_bins
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
||||||
|
|
||||||
@@ -1714,6 +1728,10 @@ def stream_waterfall() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('waterfall', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -1725,3 +1743,20 @@ def stream_waterfall() -> Response:
|
|||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
return response
|
return response
|
||||||
|
def _downsample_bins(values: list[float], target: int) -> list[float]:
|
||||||
|
"""Downsample bins to a target length using simple averaging."""
|
||||||
|
if target <= 0 or len(values) <= target:
|
||||||
|
return values
|
||||||
|
|
||||||
|
out: list[float] = []
|
||||||
|
step = len(values) / target
|
||||||
|
for i in range(target):
|
||||||
|
start = int(i * step)
|
||||||
|
end = int((i + 1) * step)
|
||||||
|
if end <= start:
|
||||||
|
end = min(start + 1, len(values))
|
||||||
|
chunk = values[start:end]
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
out.append(sum(chunk) / len(chunk))
|
||||||
|
return out
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from utils.validation import (
|
|||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||||
from utils.dependencies import get_tool_path
|
from utils.dependencies import get_tool_path
|
||||||
@@ -471,6 +472,10 @@ def stream() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.output_queue.get(timeout=1)
|
msg = app_module.output_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('pager', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
109
routes/recordings.py
Normal file
109
routes/recordings.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""Session recording API endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, send_file
|
||||||
|
|
||||||
|
from utils.recording import get_recording_manager, RECORDING_ROOT
|
||||||
|
|
||||||
|
recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings')
|
||||||
|
|
||||||
|
|
||||||
|
@recordings_bp.route('/start', methods=['POST'])
|
||||||
|
def start_recording():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
mode = (data.get('mode') or '').strip()
|
||||||
|
if not mode:
|
||||||
|
return jsonify({'status': 'error', 'message': 'mode is required'}), 400
|
||||||
|
|
||||||
|
label = data.get('label')
|
||||||
|
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
|
||||||
|
|
||||||
|
manager = get_recording_manager()
|
||||||
|
session = manager.start_recording(mode=mode, label=label, metadata=metadata)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'session': {
|
||||||
|
'id': session.id,
|
||||||
|
'mode': session.mode,
|
||||||
|
'label': session.label,
|
||||||
|
'started_at': session.started_at.isoformat(),
|
||||||
|
'file_path': str(session.file_path),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@recordings_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_recording():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
mode = data.get('mode')
|
||||||
|
session_id = data.get('id')
|
||||||
|
|
||||||
|
manager = get_recording_manager()
|
||||||
|
session = manager.stop_recording(mode=mode, session_id=session_id)
|
||||||
|
if not session:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No active recording found'}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'session': {
|
||||||
|
'id': session.id,
|
||||||
|
'mode': session.mode,
|
||||||
|
'label': session.label,
|
||||||
|
'started_at': session.started_at.isoformat(),
|
||||||
|
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
|
||||||
|
'event_count': session.event_count,
|
||||||
|
'size_bytes': session.size_bytes,
|
||||||
|
'file_path': str(session.file_path),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@recordings_bp.route('', methods=['GET'])
|
||||||
|
def list_recordings():
|
||||||
|
manager = get_recording_manager()
|
||||||
|
limit = request.args.get('limit', default=50, type=int)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'recordings': manager.list_recordings(limit=limit),
|
||||||
|
'active': manager.get_active(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@recordings_bp.route('/<session_id>', methods=['GET'])
|
||||||
|
def get_recording(session_id: str):
|
||||||
|
manager = get_recording_manager()
|
||||||
|
rec = manager.get_recording(session_id)
|
||||||
|
if not rec:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
||||||
|
return jsonify({'status': 'success', 'recording': rec})
|
||||||
|
|
||||||
|
|
||||||
|
@recordings_bp.route('/<session_id>/download', methods=['GET'])
|
||||||
|
def download_recording(session_id: str):
|
||||||
|
manager = get_recording_manager()
|
||||||
|
rec = manager.get_recording(session_id)
|
||||||
|
if not rec:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
||||||
|
|
||||||
|
file_path = Path(rec['file_path'])
|
||||||
|
try:
|
||||||
|
resolved_root = RECORDING_ROOT.resolve()
|
||||||
|
resolved_file = file_path.resolve()
|
||||||
|
if resolved_root not in resolved_file.parents:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||||
|
except Exception:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
file_path,
|
||||||
|
mimetype='application/x-ndjson',
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=file_path.name,
|
||||||
|
)
|
||||||
@@ -18,6 +18,7 @@ from utils.validation import (
|
|||||||
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
|
|
||||||
rtlamr_bp = Blueprint('rtlamr', __name__)
|
rtlamr_bp = Blueprint('rtlamr', __name__)
|
||||||
@@ -295,6 +296,10 @@ def stream_rtlamr() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.rtlamr_queue.get(timeout=1)
|
msg = app_module.rtlamr_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('rtlamr', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from utils.validation import (
|
|||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
|
||||||
@@ -233,6 +234,10 @@ def stream_sensor() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.sensor_queue.get(timeout=1)
|
msg = app_module.sensor_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('sensor', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from flask import Blueprint, jsonify, request, Response, send_file
|
|||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.sstv import (
|
from utils.sstv import (
|
||||||
get_sstv_decoder,
|
get_sstv_decoder,
|
||||||
is_sstv_available,
|
is_sstv_available,
|
||||||
@@ -401,6 +402,10 @@ def stream_progress():
|
|||||||
try:
|
try:
|
||||||
progress = _sstv_queue.get(timeout=1)
|
progress = _sstv_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('sstv', progress, progress.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(progress)
|
yield format_sse(progress)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from flask import Blueprint, Response, jsonify, request, send_file
|
|||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.sstv import (
|
from utils.sstv import (
|
||||||
DecodeProgress,
|
DecodeProgress,
|
||||||
get_general_sstv_decoder,
|
get_general_sstv_decoder,
|
||||||
@@ -274,6 +275,10 @@ def stream_progress():
|
|||||||
try:
|
try:
|
||||||
progress = _sstv_general_queue.get(timeout=1)
|
progress = _sstv_general_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('sstv_general', progress, progress.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(progress)
|
yield format_sse(progress)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ from utils.tscm.device_identity import (
|
|||||||
ingest_ble_dict,
|
ingest_ble_dict,
|
||||||
ingest_wifi_dict,
|
ingest_wifi_dict,
|
||||||
)
|
)
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
|
||||||
# Import unified Bluetooth scanner helper for TSCM integration
|
# Import unified Bluetooth scanner helper for TSCM integration
|
||||||
try:
|
try:
|
||||||
@@ -627,6 +628,10 @@ def sweep_stream():
|
|||||||
try:
|
try:
|
||||||
if tscm_queue:
|
if tscm_queue:
|
||||||
msg = tscm_queue.get(timeout=1)
|
msg = tscm_queue.get(timeout=1)
|
||||||
|
try:
|
||||||
|
process_event('tscm', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield f"data: {json.dumps(msg)}\n\n"
|
yield f"data: {json.dumps(msg)}\n\n"
|
||||||
else:
|
else:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
@@ -2023,6 +2028,7 @@ def _run_sweep(
|
|||||||
comparator = BaselineComparator(baseline)
|
comparator = BaselineComparator(baseline)
|
||||||
baseline_comparison = comparator.compare_all(
|
baseline_comparison = comparator.compare_all(
|
||||||
wifi_devices=list(all_wifi.values()),
|
wifi_devices=list(all_wifi.values()),
|
||||||
|
wifi_clients=list(all_wifi_clients.values()),
|
||||||
bt_devices=list(all_bt.values()),
|
bt_devices=list(all_bt.values()),
|
||||||
rf_signals=all_rf
|
rf_signals=all_rf
|
||||||
)
|
)
|
||||||
@@ -2132,6 +2138,7 @@ def _run_sweep(
|
|||||||
'total_new': baseline_comparison['total_new'],
|
'total_new': baseline_comparison['total_new'],
|
||||||
'total_missing': baseline_comparison['total_missing'],
|
'total_missing': baseline_comparison['total_missing'],
|
||||||
'wifi': baseline_comparison.get('wifi'),
|
'wifi': baseline_comparison.get('wifi'),
|
||||||
|
'wifi_clients': baseline_comparison.get('wifi_clients'),
|
||||||
'bluetooth': baseline_comparison.get('bluetooth'),
|
'bluetooth': baseline_comparison.get('bluetooth'),
|
||||||
'rf': baseline_comparison.get('rf'),
|
'rf': baseline_comparison.get('rf'),
|
||||||
})
|
})
|
||||||
@@ -2297,6 +2304,7 @@ def compare_against_baseline():
|
|||||||
|
|
||||||
Expects JSON body with:
|
Expects JSON body with:
|
||||||
- wifi_devices: list of WiFi devices (optional)
|
- wifi_devices: list of WiFi devices (optional)
|
||||||
|
- wifi_clients: list of WiFi clients (optional)
|
||||||
- bt_devices: list of Bluetooth devices (optional)
|
- bt_devices: list of Bluetooth devices (optional)
|
||||||
- rf_signals: list of RF signals (optional)
|
- rf_signals: list of RF signals (optional)
|
||||||
|
|
||||||
@@ -2305,12 +2313,14 @@ def compare_against_baseline():
|
|||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
|
||||||
wifi_devices = data.get('wifi_devices')
|
wifi_devices = data.get('wifi_devices')
|
||||||
|
wifi_clients = data.get('wifi_clients')
|
||||||
bt_devices = data.get('bt_devices')
|
bt_devices = data.get('bt_devices')
|
||||||
rf_signals = data.get('rf_signals')
|
rf_signals = data.get('rf_signals')
|
||||||
|
|
||||||
# Use the convenience function that gets active baseline
|
# Use the convenience function that gets active baseline
|
||||||
comparison = get_comparison_for_active_baseline(
|
comparison = get_comparison_for_active_baseline(
|
||||||
wifi_devices=wifi_devices,
|
wifi_devices=wifi_devices,
|
||||||
|
wifi_clients=wifi_clients,
|
||||||
bt_devices=bt_devices,
|
bt_devices=bt_devices,
|
||||||
rf_signals=rf_signals
|
rf_signals=rf_signals
|
||||||
)
|
)
|
||||||
@@ -2404,7 +2414,10 @@ def feed_wifi():
|
|||||||
"""Feed WiFi device data for baseline recording."""
|
"""Feed WiFi device data for baseline recording."""
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if data:
|
if data:
|
||||||
_baseline_recorder.add_wifi_device(data)
|
if data.get('is_client'):
|
||||||
|
_baseline_recorder.add_wifi_client(data)
|
||||||
|
else:
|
||||||
|
_baseline_recorder.add_wifi_device(data)
|
||||||
return jsonify({'status': 'success'})
|
return jsonify({'status': 'success'})
|
||||||
|
|
||||||
|
|
||||||
@@ -3056,12 +3069,14 @@ def get_baseline_diff(baseline_id: int, sweep_id: int):
|
|||||||
results = json.loads(results)
|
results = json.loads(results)
|
||||||
|
|
||||||
current_wifi = results.get('wifi_devices', [])
|
current_wifi = results.get('wifi_devices', [])
|
||||||
|
current_wifi_clients = results.get('wifi_clients', [])
|
||||||
current_bt = results.get('bt_devices', [])
|
current_bt = results.get('bt_devices', [])
|
||||||
current_rf = results.get('rf_signals', [])
|
current_rf = results.get('rf_signals', [])
|
||||||
|
|
||||||
diff = calculate_baseline_diff(
|
diff = calculate_baseline_diff(
|
||||||
baseline=baseline,
|
baseline=baseline,
|
||||||
current_wifi=current_wifi,
|
current_wifi=current_wifi,
|
||||||
|
current_wifi_clients=current_wifi_clients,
|
||||||
current_bt=current_bt,
|
current_bt=current_bt,
|
||||||
current_rf=current_rf,
|
current_rf=current_rf,
|
||||||
sweep_id=sweep_id
|
sweep_id=sweep_id
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from utils.logging import wifi_logger as logger
|
|||||||
from utils.process import is_valid_mac, is_valid_channel
|
from utils.process import is_valid_mac, is_valid_channel
|
||||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from data.oui import get_manufacturer
|
from data.oui import get_manufacturer
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
WIFI_TERMINATE_TIMEOUT,
|
WIFI_TERMINATE_TIMEOUT,
|
||||||
@@ -50,6 +51,31 @@ pmkid_process = None
|
|||||||
pmkid_lock = threading.Lock()
|
pmkid_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_channel_list(raw_channels: Any) -> list[int] | None:
|
||||||
|
"""Parse a channel list from string/list input."""
|
||||||
|
if raw_channels in (None, '', []):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(raw_channels, str):
|
||||||
|
parts = [p.strip() for p in re.split(r'[\s,]+', raw_channels) if p.strip()]
|
||||||
|
elif isinstance(raw_channels, (list, tuple, set)):
|
||||||
|
parts = list(raw_channels)
|
||||||
|
else:
|
||||||
|
parts = [raw_channels]
|
||||||
|
|
||||||
|
channels: list[int] = []
|
||||||
|
seen = set()
|
||||||
|
for part in parts:
|
||||||
|
if part in (None, ''):
|
||||||
|
continue
|
||||||
|
ch = validate_wifi_channel(part)
|
||||||
|
if ch not in seen:
|
||||||
|
channels.append(ch)
|
||||||
|
seen.add(ch)
|
||||||
|
|
||||||
|
return channels or None
|
||||||
|
|
||||||
|
|
||||||
def detect_wifi_interfaces():
|
def detect_wifi_interfaces():
|
||||||
"""Detect available WiFi interfaces."""
|
"""Detect available WiFi interfaces."""
|
||||||
interfaces = []
|
interfaces = []
|
||||||
@@ -608,6 +634,7 @@ def start_wifi_scan():
|
|||||||
|
|
||||||
data = request.json
|
data = request.json
|
||||||
channel = data.get('channel')
|
channel = data.get('channel')
|
||||||
|
channels = data.get('channels')
|
||||||
band = data.get('band', 'abg')
|
band = data.get('band', 'abg')
|
||||||
|
|
||||||
# Use provided interface or fall back to stored monitor interface
|
# Use provided interface or fall back to stored monitor interface
|
||||||
@@ -658,7 +685,16 @@ def start_wifi_scan():
|
|||||||
interface
|
interface
|
||||||
]
|
]
|
||||||
|
|
||||||
if channel:
|
channel_list = None
|
||||||
|
if channels:
|
||||||
|
try:
|
||||||
|
channel_list = _parse_channel_list(channels)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
if channel_list:
|
||||||
|
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
||||||
|
elif channel:
|
||||||
cmd.extend(['-c', str(channel)])
|
cmd.extend(['-c', str(channel)])
|
||||||
|
|
||||||
logger.info(f"Running: {' '.join(cmd)}")
|
logger.info(f"Running: {' '.join(cmd)}")
|
||||||
@@ -852,6 +888,9 @@ def check_handshake_status():
|
|||||||
|
|
||||||
file_size = os.path.getsize(capture_file)
|
file_size = os.path.getsize(capture_file)
|
||||||
handshake_found = False
|
handshake_found = False
|
||||||
|
handshake_valid: bool | None = None
|
||||||
|
handshake_checked = False
|
||||||
|
handshake_reason: str | None = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if target_bssid and is_valid_mac(target_bssid):
|
if target_bssid and is_valid_mac(target_bssid):
|
||||||
@@ -862,20 +901,38 @@ def check_handshake_status():
|
|||||||
capture_output=True, text=True, timeout=10
|
capture_output=True, text=True, timeout=10
|
||||||
)
|
)
|
||||||
output = result.stdout + result.stderr
|
output = result.stdout + result.stderr
|
||||||
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
|
output_lower = output.lower()
|
||||||
if '0 handshake' not in output:
|
handshake_checked = True
|
||||||
handshake_found = True
|
|
||||||
|
if 'no valid wpa handshakes found' in output_lower:
|
||||||
|
handshake_valid = False
|
||||||
|
handshake_reason = 'No valid WPA handshake found'
|
||||||
|
elif '0 handshake' in output_lower:
|
||||||
|
handshake_valid = False
|
||||||
|
elif '1 handshake' in output_lower or ('handshake' in output_lower and 'wpa' in output_lower):
|
||||||
|
handshake_valid = True
|
||||||
|
else:
|
||||||
|
handshake_valid = False
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking handshake: {e}")
|
logger.error(f"Error checking handshake: {e}")
|
||||||
|
|
||||||
|
if handshake_valid:
|
||||||
|
handshake_found = True
|
||||||
|
normalized_bssid = target_bssid.upper() if target_bssid else None
|
||||||
|
if normalized_bssid and normalized_bssid not in app_module.wifi_handshakes:
|
||||||
|
app_module.wifi_handshakes.append(normalized_bssid)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
||||||
'file_exists': True,
|
'file_exists': True,
|
||||||
'file_size': file_size,
|
'file_size': file_size,
|
||||||
'file': capture_file,
|
'file': capture_file,
|
||||||
'handshake_found': handshake_found
|
'handshake_found': handshake_found,
|
||||||
|
'handshake_valid': handshake_valid,
|
||||||
|
'handshake_checked': handshake_checked,
|
||||||
|
'handshake_reason': handshake_reason
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -1086,6 +1143,10 @@ def stream_wifi():
|
|||||||
try:
|
try:
|
||||||
msg = app_module.wifi_queue.get(timeout=1)
|
msg = app_module.wifi_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('wifi', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ from utils.wifi import (
|
|||||||
SCAN_MODE_DEEP,
|
SCAN_MODE_DEEP,
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
|
from utils.validation import validate_wifi_channel
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -89,15 +91,30 @@ def start_deep_scan():
|
|||||||
interface: Monitor mode interface (e.g., 'wlan0mon')
|
interface: Monitor mode interface (e.g., 'wlan0mon')
|
||||||
band: Band to scan ('2.4', '5', 'all')
|
band: Band to scan ('2.4', '5', 'all')
|
||||||
channel: Optional specific channel to monitor
|
channel: Optional specific channel to monitor
|
||||||
|
channels: Optional list or comma-separated channels to monitor
|
||||||
"""
|
"""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
interface = data.get('interface')
|
interface = data.get('interface')
|
||||||
band = data.get('band', 'all')
|
band = data.get('band', 'all')
|
||||||
channel = data.get('channel')
|
channel = data.get('channel')
|
||||||
|
channels = data.get('channels')
|
||||||
|
|
||||||
|
channel_list = None
|
||||||
|
if channels:
|
||||||
|
if isinstance(channels, str):
|
||||||
|
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||||
|
elif isinstance(channels, (list, tuple, set)):
|
||||||
|
channel_list = list(channels)
|
||||||
|
else:
|
||||||
|
channel_list = [channels]
|
||||||
|
try:
|
||||||
|
channel_list = [validate_wifi_channel(c) for c in channel_list]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'error': 'Invalid channels'}), 400
|
||||||
|
|
||||||
if channel:
|
if channel:
|
||||||
try:
|
try:
|
||||||
channel = int(channel)
|
channel = validate_wifi_channel(channel)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({'error': 'Invalid channel'}), 400
|
return jsonify({'error': 'Invalid channel'}), 400
|
||||||
|
|
||||||
@@ -106,6 +123,7 @@ def start_deep_scan():
|
|||||||
interface=interface,
|
interface=interface,
|
||||||
band=band,
|
band=band,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
channels=channel_list,
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
@@ -391,6 +409,10 @@ def event_stream():
|
|||||||
scanner = get_wifi_scanner()
|
scanner = get_wifi_scanner()
|
||||||
|
|
||||||
for event in scanner.get_event_stream():
|
for event in scanner.get_event_stream():
|
||||||
|
try:
|
||||||
|
process_event('wifi', event, event.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield format_sse(event)
|
yield format_sse(event)
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
|||||||
@@ -4201,6 +4201,12 @@ header h1 .tagline {
|
|||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bt-detail-btn.active {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
border-color: rgba(34, 197, 94, 0.6);
|
||||||
|
color: #9fffd1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Selected device highlight */
|
/* Selected device highlight */
|
||||||
.bt-device-row.selected {
|
.bt-device-row.selected {
|
||||||
background: rgba(0, 212, 255, 0.1);
|
background: rgba(0, 212, 255, 0.1);
|
||||||
@@ -4392,6 +4398,17 @@ header h1 .tagline {
|
|||||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bt-history-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
.bt-device-name {
|
.bt-device-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -163,6 +163,47 @@
|
|||||||
color: var(--text-muted, #666);
|
color: var(--text-muted, #666);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Settings Feed Lists */
|
||||||
|
.settings-feed {
|
||||||
|
background: var(--bg-tertiary, #12121f);
|
||||||
|
border: 1px solid var(--border-color, #1a1a2e);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-feed-item {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-feed-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-feed-title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-feed-meta {
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-feed-empty {
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Toggle Switch */
|
/* Toggle Switch */
|
||||||
.toggle-switch {
|
.toggle-switch {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
194
static/js/core/alerts.js
Normal file
194
static/js/core/alerts.js
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
const AlertCenter = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let alerts = [];
|
||||||
|
let rules = [];
|
||||||
|
let eventSource = null;
|
||||||
|
|
||||||
|
const TRACKER_RULE_NAME = 'Tracker Detected';
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
loadRules();
|
||||||
|
loadFeed();
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
eventSource = new EventSource('/alerts/stream');
|
||||||
|
eventSource.onmessage = function(e) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.type === 'keepalive') return;
|
||||||
|
handleAlert(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Alerts] SSE parse error', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
eventSource.onerror = function() {
|
||||||
|
console.warn('[Alerts] SSE connection error');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAlert(alert) {
|
||||||
|
alerts.unshift(alert);
|
||||||
|
alerts = alerts.slice(0, 50);
|
||||||
|
updateFeedUI();
|
||||||
|
|
||||||
|
if (typeof showNotification === 'function') {
|
||||||
|
const severity = (alert.severity || '').toLowerCase();
|
||||||
|
if (['high', 'critical'].includes(severity)) {
|
||||||
|
showNotification(alert.title || 'Alert', alert.message || 'Alert triggered');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFeedUI() {
|
||||||
|
const list = document.getElementById('alertsFeedList');
|
||||||
|
const countEl = document.getElementById('alertsFeedCount');
|
||||||
|
if (countEl) countEl.textContent = `(${alerts.length})`;
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
if (alerts.length === 0) {
|
||||||
|
list.innerHTML = '<div class="settings-feed-empty">No alerts yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = alerts.map(alert => {
|
||||||
|
const title = escapeHtml(alert.title || 'Alert');
|
||||||
|
const message = escapeHtml(alert.message || '');
|
||||||
|
const severity = escapeHtml(alert.severity || 'medium');
|
||||||
|
const createdAt = alert.created_at ? new Date(alert.created_at).toLocaleString() : '';
|
||||||
|
return `
|
||||||
|
<div class="settings-feed-item">
|
||||||
|
<div class="settings-feed-title">
|
||||||
|
<span>${title}</span>
|
||||||
|
<span style="color: var(--text-dim);">${severity.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-feed-meta">${message}</div>
|
||||||
|
<div class="settings-feed-meta" style="margin-top: 4px;">${createdAt}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFeed() {
|
||||||
|
fetch('/alerts/events?limit=20')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
alerts = data.events || [];
|
||||||
|
updateFeedUI();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[Alerts] Load feed failed', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRules() {
|
||||||
|
fetch('/alerts/rules?all=1')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
rules = data.rules || [];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[Alerts] Load rules failed', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableTrackerAlerts() {
|
||||||
|
ensureTrackerRule(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableTrackerAlerts() {
|
||||||
|
ensureTrackerRule(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTrackerRule(enabled) {
|
||||||
|
loadRules();
|
||||||
|
setTimeout(() => {
|
||||||
|
const existing = rules.find(r => r.name === TRACKER_RULE_NAME);
|
||||||
|
if (existing) {
|
||||||
|
fetch(`/alerts/rules/${existing.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled })
|
||||||
|
}).then(() => loadRules());
|
||||||
|
} else if (enabled) {
|
||||||
|
fetch('/alerts/rules', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: TRACKER_RULE_NAME,
|
||||||
|
mode: 'bluetooth',
|
||||||
|
event_type: 'device_update',
|
||||||
|
match: { is_tracker: true },
|
||||||
|
severity: 'high',
|
||||||
|
enabled: true,
|
||||||
|
notify: { webhook: true }
|
||||||
|
})
|
||||||
|
}).then(() => loadRules());
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBluetoothWatchlist(address, name) {
|
||||||
|
if (!address) return;
|
||||||
|
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
|
||||||
|
if (existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch('/alerts/rules', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name ? `Watchlist ${name}` : `Watchlist ${address}`,
|
||||||
|
mode: 'bluetooth',
|
||||||
|
event_type: 'device_update',
|
||||||
|
match: { address: address },
|
||||||
|
severity: 'medium',
|
||||||
|
enabled: true,
|
||||||
|
notify: { webhook: true }
|
||||||
|
})
|
||||||
|
}).then(() => loadRules());
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBluetoothWatchlist(address) {
|
||||||
|
if (!address) return;
|
||||||
|
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
|
||||||
|
if (!existing) return;
|
||||||
|
fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
|
||||||
|
.then(() => loadRules());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWatchlisted(address) {
|
||||||
|
return rules.some(r => r.mode === 'bluetooth' && r.match && r.match.address === address && r.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
loadFeed,
|
||||||
|
enableTrackerAlerts,
|
||||||
|
disableTrackerAlerts,
|
||||||
|
addBluetoothWatchlist,
|
||||||
|
removeBluetoothWatchlist,
|
||||||
|
isWatchlisted,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (typeof AlertCenter !== 'undefined') {
|
||||||
|
AlertCenter.init();
|
||||||
|
}
|
||||||
|
});
|
||||||
136
static/js/core/recordings.js
Normal file
136
static/js/core/recordings.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
const RecordingUI = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let recordings = [];
|
||||||
|
let active = [];
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
fetch('/recordings')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status !== 'success') return;
|
||||||
|
recordings = data.recordings || [];
|
||||||
|
active = data.active || [];
|
||||||
|
renderActive();
|
||||||
|
renderRecordings();
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[Recording] Load failed', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
const modeSelect = document.getElementById('recordingModeSelect');
|
||||||
|
const labelInput = document.getElementById('recordingLabelInput');
|
||||||
|
const mode = modeSelect ? modeSelect.value : '';
|
||||||
|
const label = labelInput ? labelInput.value : '';
|
||||||
|
if (!mode) return;
|
||||||
|
|
||||||
|
fetch('/recordings/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mode, label })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => {
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[Recording] Start failed', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
const modeSelect = document.getElementById('recordingModeSelect');
|
||||||
|
const mode = modeSelect ? modeSelect.value : '';
|
||||||
|
if (!mode) return;
|
||||||
|
|
||||||
|
fetch('/recordings/stop', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mode })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => refresh())
|
||||||
|
.catch(err => console.error('[Recording] Stop failed', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopById(sessionId) {
|
||||||
|
fetch('/recordings/stop', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: sessionId })
|
||||||
|
}).then(() => refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActive() {
|
||||||
|
const container = document.getElementById('recordingActiveList');
|
||||||
|
if (!container) return;
|
||||||
|
if (!active.length) {
|
||||||
|
container.innerHTML = '<div class="settings-feed-empty">No active recordings</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = active.map(session => {
|
||||||
|
return `
|
||||||
|
<div class="settings-feed-item">
|
||||||
|
<div class="settings-feed-title">
|
||||||
|
<span>${escapeHtml(session.mode)}</span>
|
||||||
|
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.stopById('${session.id}')">Stop</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-feed-meta">Started: ${new Date(session.started_at).toLocaleString()}</div>
|
||||||
|
<div class="settings-feed-meta">Events: ${session.event_count || 0}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecordings() {
|
||||||
|
const container = document.getElementById('recordingList');
|
||||||
|
if (!container) return;
|
||||||
|
if (!recordings.length) {
|
||||||
|
container.innerHTML = '<div class="settings-feed-empty">No recordings yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = recordings.map(rec => {
|
||||||
|
return `
|
||||||
|
<div class="settings-feed-item">
|
||||||
|
<div class="settings-feed-title">
|
||||||
|
<span>${escapeHtml(rec.mode)}${rec.label ? ` • ${escapeHtml(rec.label)}` : ''}</span>
|
||||||
|
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-feed-meta">${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? ` → ${new Date(rec.stopped_at).toLocaleString()}` : ''}</div>
|
||||||
|
<div class="settings-feed-meta">Events: ${rec.event_count || 0} • ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(sessionId) {
|
||||||
|
window.open(`/recordings/${sessionId}/download`, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
refresh,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
stopById,
|
||||||
|
download,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (typeof RecordingUI !== 'undefined') {
|
||||||
|
RecordingUI.init();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -922,5 +922,13 @@ function switchSettingsTab(tabName) {
|
|||||||
loadUpdateStatus();
|
loadUpdateStatus();
|
||||||
} else if (tabName === 'location') {
|
} else if (tabName === 'location') {
|
||||||
loadObserverLocation();
|
loadObserverLocation();
|
||||||
|
} else if (tabName === 'alerts') {
|
||||||
|
if (typeof AlertCenter !== 'undefined') {
|
||||||
|
AlertCenter.loadFeed();
|
||||||
|
}
|
||||||
|
} else if (tabName === 'recording') {
|
||||||
|
if (typeof RecordingUI !== 'undefined') {
|
||||||
|
RecordingUI.refresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -367,6 +367,9 @@ const BluetoothMode = (function() {
|
|||||||
const badgesEl = document.getElementById('btDetailBadges');
|
const badgesEl = document.getElementById('btDetailBadges');
|
||||||
let badgesHtml = `<span class="bt-detail-badge ${protocol}">${protocol.toUpperCase()}</span>`;
|
let badgesHtml = `<span class="bt-detail-badge ${protocol}">${protocol.toUpperCase()}</span>`;
|
||||||
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
|
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
|
||||||
|
if (device.seen_before) {
|
||||||
|
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Tracker badge
|
// Tracker badge
|
||||||
if (device.is_tracker) {
|
if (device.is_tracker) {
|
||||||
@@ -455,6 +458,8 @@ const BluetoothMode = (function() {
|
|||||||
? new Date(device.last_seen).toLocaleTimeString()
|
? new Date(device.last_seen).toLocaleTimeString()
|
||||||
: '--';
|
: '--';
|
||||||
|
|
||||||
|
updateWatchlistButton(device);
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
const servicesContainer = document.getElementById('btDetailServices');
|
const servicesContainer = document.getElementById('btDetailServices');
|
||||||
const servicesList = document.getElementById('btDetailServicesList');
|
const servicesList = document.getElementById('btDetailServicesList');
|
||||||
@@ -473,6 +478,22 @@ const BluetoothMode = (function() {
|
|||||||
highlightSelectedDevice(deviceId);
|
highlightSelectedDevice(deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update watchlist button state
|
||||||
|
*/
|
||||||
|
function updateWatchlistButton(device) {
|
||||||
|
const btn = document.getElementById('btDetailWatchBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
if (typeof AlertCenter === 'undefined') {
|
||||||
|
btn.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.style.display = '';
|
||||||
|
const watchlisted = AlertCenter.isWatchlisted(device.address);
|
||||||
|
btn.textContent = watchlisted ? 'Watching' : 'Watchlist';
|
||||||
|
btn.classList.toggle('active', watchlisted);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear device selection
|
* Clear device selection
|
||||||
*/
|
*/
|
||||||
@@ -531,7 +552,7 @@ const BluetoothMode = (function() {
|
|||||||
if (!device) return;
|
if (!device) return;
|
||||||
|
|
||||||
navigator.clipboard.writeText(device.address).then(() => {
|
navigator.clipboard.writeText(device.address).then(() => {
|
||||||
const btn = document.querySelector('.bt-detail-btn');
|
const btn = document.getElementById('btDetailCopyBtn');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
const originalText = btn.textContent;
|
const originalText = btn.textContent;
|
||||||
btn.textContent = 'Copied!';
|
btn.textContent = 'Copied!';
|
||||||
@@ -544,6 +565,25 @@ const BluetoothMode = (function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle Bluetooth watchlist for selected device
|
||||||
|
*/
|
||||||
|
function toggleWatchlist() {
|
||||||
|
if (!selectedDeviceId) return;
|
||||||
|
const device = devices.get(selectedDeviceId);
|
||||||
|
if (!device || typeof AlertCenter === 'undefined') return;
|
||||||
|
|
||||||
|
if (AlertCenter.isWatchlisted(device.address)) {
|
||||||
|
AlertCenter.removeBluetoothWatchlist(device.address);
|
||||||
|
showInfo('Removed from watchlist');
|
||||||
|
} else {
|
||||||
|
AlertCenter.addBluetoothWatchlist(device.address, device.name || device.address);
|
||||||
|
showInfo('Added to watchlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => updateWatchlistButton(device), 200);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select a device - opens modal with details
|
* Select a device - opens modal with details
|
||||||
*/
|
*/
|
||||||
@@ -1094,6 +1134,7 @@ const BluetoothMode = (function() {
|
|||||||
const trackerConfidence = device.tracker_confidence;
|
const trackerConfidence = device.tracker_confidence;
|
||||||
const riskScore = device.risk_score || 0;
|
const riskScore = device.risk_score || 0;
|
||||||
const agentName = device._agent || 'Local';
|
const agentName = device._agent || 'Local';
|
||||||
|
const seenBefore = device.seen_before === true;
|
||||||
|
|
||||||
// Calculate RSSI bar width (0-100%)
|
// Calculate RSSI bar width (0-100%)
|
||||||
// RSSI typically ranges from -100 (weak) to -30 (very strong)
|
// RSSI typically ranges from -100 (weak) to -30 (very strong)
|
||||||
@@ -1147,6 +1188,7 @@ const BluetoothMode = (function() {
|
|||||||
let secondaryParts = [addr];
|
let secondaryParts = [addr];
|
||||||
if (mfr) secondaryParts.push(mfr);
|
if (mfr) secondaryParts.push(mfr);
|
||||||
secondaryParts.push('Seen ' + seenCount + '×');
|
secondaryParts.push('Seen ' + seenCount + '×');
|
||||||
|
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
|
||||||
// Add agent name if not Local
|
// Add agent name if not Local
|
||||||
if (agentName !== 'Local') {
|
if (agentName !== 'Local') {
|
||||||
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
|
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
|
||||||
@@ -1361,6 +1403,7 @@ const BluetoothMode = (function() {
|
|||||||
selectDevice,
|
selectDevice,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
copyAddress,
|
copyAddress,
|
||||||
|
toggleWatchlist,
|
||||||
|
|
||||||
// Agent handling
|
// Agent handling
|
||||||
handleAgentChange,
|
handleAgentChange,
|
||||||
|
|||||||
@@ -3021,15 +3021,23 @@ let spectrumCanvas = null;
|
|||||||
let spectrumCtx = null;
|
let spectrumCtx = null;
|
||||||
let waterfallStartFreq = 88;
|
let waterfallStartFreq = 88;
|
||||||
let waterfallEndFreq = 108;
|
let waterfallEndFreq = 108;
|
||||||
|
let waterfallRowImage = null;
|
||||||
|
let waterfallPalette = null;
|
||||||
|
let lastWaterfallDraw = 0;
|
||||||
|
const WATERFALL_MIN_INTERVAL_MS = 80;
|
||||||
|
|
||||||
function initWaterfallCanvas() {
|
function initWaterfallCanvas() {
|
||||||
waterfallCanvas = document.getElementById('waterfallCanvas');
|
waterfallCanvas = document.getElementById('waterfallCanvas');
|
||||||
spectrumCanvas = document.getElementById('spectrumCanvas');
|
spectrumCanvas = document.getElementById('spectrumCanvas');
|
||||||
if (waterfallCanvas) waterfallCtx = waterfallCanvas.getContext('2d');
|
if (waterfallCanvas) waterfallCtx = waterfallCanvas.getContext('2d');
|
||||||
if (spectrumCanvas) spectrumCtx = spectrumCanvas.getContext('2d');
|
if (spectrumCanvas) spectrumCtx = spectrumCanvas.getContext('2d');
|
||||||
|
if (waterfallCtx && waterfallCanvas) {
|
||||||
|
waterfallRowImage = waterfallCtx.createImageData(waterfallCanvas.width, 1);
|
||||||
|
if (!waterfallPalette) waterfallPalette = buildWaterfallPalette();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function dBmToColor(normalized) {
|
function dBmToRgb(normalized) {
|
||||||
// Viridis-inspired: dark blue -> cyan -> green -> yellow
|
// Viridis-inspired: dark blue -> cyan -> green -> yellow
|
||||||
const n = Math.max(0, Math.min(1, normalized));
|
const n = Math.max(0, Math.min(1, normalized));
|
||||||
let r, g, b;
|
let r, g, b;
|
||||||
@@ -3054,7 +3062,15 @@ function dBmToColor(normalized) {
|
|||||||
g = Math.round(255 - t * 55);
|
g = Math.round(255 - t * 55);
|
||||||
b = Math.round(20 - t * 20);
|
b = Math.round(20 - t * 20);
|
||||||
}
|
}
|
||||||
return `rgb(${r},${g},${b})`;
|
return [r, g, b];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWaterfallPalette() {
|
||||||
|
const palette = new Array(256);
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
palette[i] = dBmToRgb(i / 255);
|
||||||
|
}
|
||||||
|
return palette;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawWaterfallRow(bins) {
|
function drawWaterfallRow(bins) {
|
||||||
@@ -3062,9 +3078,8 @@ function drawWaterfallRow(bins) {
|
|||||||
const w = waterfallCanvas.width;
|
const w = waterfallCanvas.width;
|
||||||
const h = waterfallCanvas.height;
|
const h = waterfallCanvas.height;
|
||||||
|
|
||||||
// Scroll existing content down by 1 pixel
|
// Scroll existing content down by 1 pixel (GPU-accelerated)
|
||||||
const imageData = waterfallCtx.getImageData(0, 0, w, h - 1);
|
waterfallCtx.drawImage(waterfallCanvas, 0, 0, w, h - 1, 0, 1, w, h - 1);
|
||||||
waterfallCtx.putImageData(imageData, 0, 1);
|
|
||||||
|
|
||||||
// Find min/max for normalization
|
// Find min/max for normalization
|
||||||
let minVal = Infinity, maxVal = -Infinity;
|
let minVal = Infinity, maxVal = -Infinity;
|
||||||
@@ -3074,13 +3089,24 @@ function drawWaterfallRow(bins) {
|
|||||||
}
|
}
|
||||||
const range = maxVal - minVal || 1;
|
const range = maxVal - minVal || 1;
|
||||||
|
|
||||||
// Draw new row at top
|
// Draw new row at top using ImageData
|
||||||
const binWidth = w / bins.length;
|
if (!waterfallRowImage || waterfallRowImage.width !== w) {
|
||||||
for (let i = 0; i < bins.length; i++) {
|
waterfallRowImage = waterfallCtx.createImageData(w, 1);
|
||||||
const normalized = (bins[i] - minVal) / range;
|
|
||||||
waterfallCtx.fillStyle = dBmToColor(normalized);
|
|
||||||
waterfallCtx.fillRect(Math.floor(i * binWidth), 0, Math.ceil(binWidth) + 1, 1);
|
|
||||||
}
|
}
|
||||||
|
const rowData = waterfallRowImage.data;
|
||||||
|
const palette = waterfallPalette || buildWaterfallPalette();
|
||||||
|
const binCount = bins.length;
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const idx = Math.min(binCount - 1, Math.floor((x / w) * binCount));
|
||||||
|
const normalized = (bins[idx] - minVal) / range;
|
||||||
|
const color = palette[Math.max(0, Math.min(255, Math.floor(normalized * 255)))] || [0, 0, 0];
|
||||||
|
const offset = x * 4;
|
||||||
|
rowData[offset] = color[0];
|
||||||
|
rowData[offset + 1] = color[1];
|
||||||
|
rowData[offset + 2] = color[2];
|
||||||
|
rowData[offset + 3] = 255;
|
||||||
|
}
|
||||||
|
waterfallCtx.putImageData(waterfallRowImage, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawSpectrumLine(bins, startFreq, endFreq) {
|
function drawSpectrumLine(bins, startFreq, endFreq) {
|
||||||
@@ -3154,6 +3180,7 @@ function startWaterfall() {
|
|||||||
const binSize = parseInt(document.getElementById('waterfallBinSize')?.value || 10000);
|
const binSize = parseInt(document.getElementById('waterfallBinSize')?.value || 10000);
|
||||||
const gain = parseInt(document.getElementById('waterfallGain')?.value || 40);
|
const gain = parseInt(document.getElementById('waterfallGain')?.value || 40);
|
||||||
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
||||||
|
const maxBins = document.getElementById('waterfallCanvas')?.width || 800;
|
||||||
|
|
||||||
if (startFreq >= endFreq) {
|
if (startFreq >= endFreq) {
|
||||||
if (typeof showNotification === 'function') showNotification('Error', 'End frequency must be greater than start');
|
if (typeof showNotification === 'function') showNotification('Error', 'End frequency must be greater than start');
|
||||||
@@ -3166,7 +3193,14 @@ function startWaterfall() {
|
|||||||
fetch('/listening/waterfall/start', {
|
fetch('/listening/waterfall/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ start_freq: startFreq, end_freq: endFreq, bin_size: binSize, gain: gain, device: device })
|
body: JSON.stringify({
|
||||||
|
start_freq: startFreq,
|
||||||
|
end_freq: endFreq,
|
||||||
|
bin_size: binSize,
|
||||||
|
gain: gain,
|
||||||
|
device: device,
|
||||||
|
max_bins: maxBins,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -3176,6 +3210,7 @@ function startWaterfall() {
|
|||||||
document.getElementById('stopWaterfallBtn').style.display = 'block';
|
document.getElementById('stopWaterfallBtn').style.display = 'block';
|
||||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||||
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
||||||
|
lastWaterfallDraw = 0;
|
||||||
initWaterfallCanvas();
|
initWaterfallCanvas();
|
||||||
connectWaterfallSSE();
|
connectWaterfallSSE();
|
||||||
} else {
|
} else {
|
||||||
@@ -3204,6 +3239,9 @@ function connectWaterfallSSE() {
|
|||||||
waterfallEventSource.onmessage = function(event) {
|
waterfallEventSource.onmessage = function(event) {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
if (msg.type === 'waterfall_sweep') {
|
if (msg.type === 'waterfall_sweep') {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastWaterfallDraw < WATERFALL_MIN_INTERVAL_MS) return;
|
||||||
|
lastWaterfallDraw = now;
|
||||||
drawWaterfallRow(msg.bins);
|
drawWaterfallRow(msg.bins);
|
||||||
drawSpectrumLine(msg.bins, msg.start_freq, msg.end_freq);
|
drawSpectrumLine(msg.bins, msg.start_freq, msg.end_freq);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,40 @@ const WiFiMode = (function() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getChannelPresetList(preset) {
|
||||||
|
switch (preset) {
|
||||||
|
case '2.4-common':
|
||||||
|
return '1,6,11';
|
||||||
|
case '2.4-all':
|
||||||
|
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
|
||||||
|
case '5-low':
|
||||||
|
return '36,40,44,48';
|
||||||
|
case '5-mid':
|
||||||
|
return '52,56,60,64';
|
||||||
|
case '5-high':
|
||||||
|
return '149,153,157,161,165';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChannelConfig() {
|
||||||
|
const preset = document.getElementById('wifiChannelPreset')?.value || '';
|
||||||
|
const listInput = document.getElementById('wifiChannelList')?.value || '';
|
||||||
|
const singleInput = document.getElementById('wifiChannel')?.value || '';
|
||||||
|
|
||||||
|
const listValue = listInput.trim();
|
||||||
|
const presetValue = getChannelPresetList(preset);
|
||||||
|
|
||||||
|
const channels = listValue || presetValue || '';
|
||||||
|
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
channels: channels || null,
|
||||||
|
channel: Number.isFinite(channel) ? channel : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// State
|
// State
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@@ -463,7 +497,7 @@ const WiFiMode = (function() {
|
|||||||
try {
|
try {
|
||||||
const iface = elements.interfaceSelect?.value || null;
|
const iface = elements.interfaceSelect?.value || null;
|
||||||
const band = document.getElementById('wifiBand')?.value || 'all';
|
const band = document.getElementById('wifiBand')?.value || 'all';
|
||||||
const channel = document.getElementById('wifiChannel')?.value || null;
|
const channelConfig = buildChannelConfig();
|
||||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
@@ -476,7 +510,8 @@ const WiFiMode = (function() {
|
|||||||
interface: iface,
|
interface: iface,
|
||||||
scan_type: 'deep',
|
scan_type: 'deep',
|
||||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||||
channel: channel ? parseInt(channel) : null,
|
channel: channelConfig.channel,
|
||||||
|
channels: channelConfig.channels,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -486,7 +521,8 @@ const WiFiMode = (function() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
interface: iface,
|
interface: iface,
|
||||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||||
channel: channel ? parseInt(channel) : null,
|
channel: channelConfig.channel,
|
||||||
|
channels: channelConfig.channels,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -806,7 +806,8 @@
|
|||||||
<div class="bt-detail-services" id="btDetailServices" style="display: none;">
|
<div class="bt-detail-services" id="btDetailServices" style="display: none;">
|
||||||
<span class="bt-detail-services-list" id="btDetailServicesList"></span>
|
<span class="bt-detail-services-list" id="btDetailServicesList"></span>
|
||||||
</div>
|
</div>
|
||||||
<button class="bt-detail-btn" onclick="BluetoothMode.copyAddress()">Copy</button>
|
<button class="bt-detail-btn" id="btDetailWatchBtn" onclick="BluetoothMode.toggleWatchlist()">Watchlist</button>
|
||||||
|
<button class="bt-detail-btn" id="btDetailCopyBtn" onclick="BluetoothMode.copyAddress()">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -6058,11 +6059,44 @@
|
|||||||
: 'Monitor mode: <span style="color: var(--accent-red);">Inactive</span>';
|
: 'Monitor mode: <span style="color: var(--accent-red);">Inactive</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getWifiChannelPresetList(preset) {
|
||||||
|
switch (preset) {
|
||||||
|
case '2.4-common':
|
||||||
|
return '1,6,11';
|
||||||
|
case '2.4-all':
|
||||||
|
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
|
||||||
|
case '5-low':
|
||||||
|
return '36,40,44,48';
|
||||||
|
case '5-mid':
|
||||||
|
return '52,56,60,64';
|
||||||
|
case '5-high':
|
||||||
|
return '149,153,157,161,165';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWifiChannelConfig() {
|
||||||
|
const preset = document.getElementById('wifiChannelPreset')?.value || '';
|
||||||
|
const listInput = document.getElementById('wifiChannelList')?.value || '';
|
||||||
|
const singleInput = document.getElementById('wifiChannel')?.value || '';
|
||||||
|
|
||||||
|
const listValue = listInput.trim();
|
||||||
|
const presetValue = getWifiChannelPresetList(preset);
|
||||||
|
const channels = listValue || presetValue || '';
|
||||||
|
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
channels: channels || null,
|
||||||
|
channel: Number.isFinite(channel) ? channel : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Start WiFi scan - auto-enables monitor mode if needed
|
// Start WiFi scan - auto-enables monitor mode if needed
|
||||||
async function startWifiScan() {
|
async function startWifiScan() {
|
||||||
console.log('startWifiScan called');
|
console.log('startWifiScan called');
|
||||||
const band = document.getElementById('wifiBand').value;
|
const band = document.getElementById('wifiBand').value;
|
||||||
const channel = document.getElementById('wifiChannel').value;
|
const channelConfig = buildWifiChannelConfig();
|
||||||
|
|
||||||
// Auto-enable monitor mode if not already enabled
|
// Auto-enable monitor mode if not already enabled
|
||||||
if (!monitorInterface) {
|
if (!monitorInterface) {
|
||||||
@@ -6124,7 +6158,8 @@
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
interface: monitorInterface,
|
interface: monitorInterface,
|
||||||
band: band,
|
band: band,
|
||||||
channel: channel || null
|
channel: channelConfig.channel,
|
||||||
|
channels: channelConfig.channels,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const scanData = await scanResp.json();
|
const scanData = await scanResp.json();
|
||||||
@@ -6821,7 +6856,7 @@
|
|||||||
|
|
||||||
if (data.handshake_found) {
|
if (data.handshake_found) {
|
||||||
// Handshake captured!
|
// Handshake captured!
|
||||||
statusSpan.textContent = '✓ HANDSHAKE CAPTURED!';
|
statusSpan.textContent = '✓ VALID HANDSHAKE CAPTURED!';
|
||||||
statusSpan.style.color = 'var(--accent-green)';
|
statusSpan.style.color = 'var(--accent-green)';
|
||||||
handshakeCount++;
|
handshakeCount++;
|
||||||
document.getElementById('handshakeCount').textContent = handshakeCount;
|
document.getElementById('handshakeCount').textContent = handshakeCount;
|
||||||
@@ -6854,7 +6889,11 @@
|
|||||||
activeCapture.capturedFile = data.file;
|
activeCapture.capturedFile = data.file;
|
||||||
} else if (data.file_exists) {
|
} else if (data.file_exists) {
|
||||||
const sizeKB = (data.file_size / 1024).toFixed(1);
|
const sizeKB = (data.file_size / 1024).toFixed(1);
|
||||||
statusSpan.textContent = 'Capturing... (' + sizeKB + ' KB, ' + elapsedStr + ')';
|
let extra = '';
|
||||||
|
if (data.handshake_checked && data.handshake_valid === false) {
|
||||||
|
extra = data.handshake_reason ? ' • ' + data.handshake_reason : ' • No valid handshake yet';
|
||||||
|
}
|
||||||
|
statusSpan.textContent = 'Capturing... (' + sizeKB + ' KB, ' + elapsedStr + ')' + extra;
|
||||||
statusSpan.style.color = 'var(--accent-orange)';
|
statusSpan.style.color = 'var(--accent-orange)';
|
||||||
} else if (data.status === 'stopped') {
|
} else if (data.status === 'stopped') {
|
||||||
statusSpan.textContent = 'Capture stopped';
|
statusSpan.textContent = 'Capture stopped';
|
||||||
@@ -10905,6 +10944,13 @@
|
|||||||
if (client.score >= 3) {
|
if (client.score >= 3) {
|
||||||
addHighInterestDevice(client, 'wifi');
|
addHighInterestDevice(client, 'wifi');
|
||||||
}
|
}
|
||||||
|
if (isRecordingBaseline) {
|
||||||
|
fetch('/tscm/feed/wifi', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(client)
|
||||||
|
}).catch(e => console.error('Baseline feed error:', e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12331,6 +12377,11 @@
|
|||||||
const id = item.bssid || item.mac || '';
|
const id = item.bssid || item.mac || '';
|
||||||
return `${escapeHtml(name)} ${id ? `<span class="device-detail-id">${escapeHtml(id)}</span>` : ''}`;
|
return `${escapeHtml(name)} ${id ? `<span class="device-detail-id">${escapeHtml(id)}</span>` : ''}`;
|
||||||
}
|
}
|
||||||
|
if (protocol === 'wifi_clients') {
|
||||||
|
const name = item.vendor || 'WiFi Client';
|
||||||
|
const id = item.mac || item.address || '';
|
||||||
|
return `${escapeHtml(name)} ${id ? `<span class="device-detail-id">${escapeHtml(id)}</span>` : ''}`;
|
||||||
|
}
|
||||||
if (protocol === 'bluetooth') {
|
if (protocol === 'bluetooth') {
|
||||||
const name = item.name || 'Unknown';
|
const name = item.name || 'Unknown';
|
||||||
const id = item.mac || item.address || '';
|
const id = item.mac || item.address || '';
|
||||||
@@ -12357,6 +12408,7 @@
|
|||||||
|
|
||||||
const sections = [
|
const sections = [
|
||||||
{ key: 'wifi', label: 'WiFi' },
|
{ key: 'wifi', label: 'WiFi' },
|
||||||
|
{ key: 'wifi_clients', label: 'WiFi Clients' },
|
||||||
{ key: 'bluetooth', label: 'Bluetooth' },
|
{ key: 'bluetooth', label: 'Bluetooth' },
|
||||||
{ key: 'rf', label: 'RF' },
|
{ key: 'rf', label: 'RF' },
|
||||||
];
|
];
|
||||||
@@ -12759,7 +12811,7 @@
|
|||||||
|
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
document.getElementById('tscmBaselineStatus').textContent =
|
document.getElementById('tscmBaselineStatus').textContent =
|
||||||
`Baseline saved: ${data.wifi_count} WiFi, ${data.bt_count} BT, ${data.rf_count} RF`;
|
`Baseline saved: ${data.wifi_count} WiFi, ${data.wifi_client_count || 0} Clients, ${data.bt_count} BT, ${data.rf_count} RF`;
|
||||||
document.getElementById('tscmBaselineStatus').style.color = '#00ff88';
|
document.getElementById('tscmBaselineStatus').style.color = '#00ff88';
|
||||||
loadTscmBaselines();
|
loadTscmBaselines();
|
||||||
} else {
|
} else {
|
||||||
@@ -14574,6 +14626,9 @@
|
|||||||
<script src="{{ url_for('static', filename='js/core/updater.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/updater.js') }}"></script>
|
||||||
<!-- Settings Manager -->
|
<!-- Settings Manager -->
|
||||||
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
|
||||||
|
<!-- Alerts + Recording -->
|
||||||
|
<script src="{{ url_for('static', filename='js/core/alerts.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/core/recordings.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -69,7 +69,22 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Channel (empty = hop)</label>
|
<label>Channel Preset</label>
|
||||||
|
<select id="wifiChannelPreset">
|
||||||
|
<option value="">Auto hop (all)</option>
|
||||||
|
<option value="2.4-common">2.4 GHz Common (1,6,11)</option>
|
||||||
|
<option value="2.4-all">2.4 GHz All (1-13)</option>
|
||||||
|
<option value="5-low">5 GHz Low (36-48)</option>
|
||||||
|
<option value="5-mid">5 GHz Mid/DFS (52-64)</option>
|
||||||
|
<option value="5-high">5 GHz High (149-165)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Channel List (overrides preset)</label>
|
||||||
|
<input type="text" id="wifiChannelList" placeholder="e.g., 1,6,11 or 36,40,44,48">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Channel (single)</label>
|
||||||
<input type="text" id="wifiChannel" placeholder="e.g., 6 or 36">
|
<input type="text" id="wifiChannel" placeholder="e.g., 6 or 36">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
<button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button>
|
<button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button>
|
||||||
<button class="settings-tab" data-tab="updates" onclick="switchSettingsTab('updates')">Updates</button>
|
<button class="settings-tab" data-tab="updates" onclick="switchSettingsTab('updates')">Updates</button>
|
||||||
<button class="settings-tab" data-tab="tools" onclick="switchSettingsTab('tools')">Tools</button>
|
<button class="settings-tab" data-tab="tools" onclick="switchSettingsTab('tools')">Tools</button>
|
||||||
|
<button class="settings-tab" data-tab="alerts" onclick="switchSettingsTab('alerts')">Alerts</button>
|
||||||
|
<button class="settings-tab" data-tab="recording" onclick="switchSettingsTab('recording')">Recording</button>
|
||||||
<button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button>
|
<button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -280,6 +282,83 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerts Section -->
|
||||||
|
<div id="settings-alerts" class="settings-section">
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Alert Feed <span id="alertsFeedCount" style="color: var(--text-dim); font-weight: 500;"></span></div>
|
||||||
|
<div id="alertsFeedList" class="settings-feed">
|
||||||
|
<div class="settings-feed-empty">No alerts yet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Quick Rules</div>
|
||||||
|
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||||
|
<button class="check-assets-btn" onclick="AlertCenter.enableTrackerAlerts()">Enable Tracker Alerts</button>
|
||||||
|
<button class="check-assets-btn" onclick="AlertCenter.disableTrackerAlerts()">Disable Tracker Alerts</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-info" style="margin-top: 10px;">
|
||||||
|
Use Bluetooth device details to add specific device watchlist alerts.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recording Section -->
|
||||||
|
<div id="settings-recording" class="settings-section">
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Start Recording</div>
|
||||||
|
<div class="settings-row" style="border-bottom: none; padding-top: 0;">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Mode</span>
|
||||||
|
<span class="settings-label-desc">Record live events for a mode</span>
|
||||||
|
</div>
|
||||||
|
<select id="recordingModeSelect" class="settings-select" style="width: 200px;">
|
||||||
|
<option value="pager">Pager</option>
|
||||||
|
<option value="sensor">433 Sensors</option>
|
||||||
|
<option value="wifi">WiFi</option>
|
||||||
|
<option value="bluetooth">Bluetooth</option>
|
||||||
|
<option value="adsb">ADS-B</option>
|
||||||
|
<option value="ais">AIS</option>
|
||||||
|
<option value="dsc">DSC</option>
|
||||||
|
<option value="acars">ACARS</option>
|
||||||
|
<option value="aprs">APRS</option>
|
||||||
|
<option value="rtlamr">RTLAMR</option>
|
||||||
|
<option value="dmr">DMR</option>
|
||||||
|
<option value="tscm">TSCM</option>
|
||||||
|
<option value="sstv">SSTV</option>
|
||||||
|
<option value="sstv_general">SSTV General</option>
|
||||||
|
<option value="listening_scanner">Listening Post</option>
|
||||||
|
<option value="waterfall">Waterfall</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" style="border-bottom: none;">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Label</span>
|
||||||
|
<span class="settings-label-desc">Optional note for the session</span>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="recordingLabelInput" class="settings-input" placeholder="Morning sweep" style="width: 200px;">
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 10px; margin-top: 10px;">
|
||||||
|
<button class="check-assets-btn" onclick="RecordingUI.start()">Start</button>
|
||||||
|
<button class="check-assets-btn" onclick="RecordingUI.stop()">Stop</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Active Sessions</div>
|
||||||
|
<div id="recordingActiveList" class="settings-feed">
|
||||||
|
<div class="settings-feed-empty">No active recordings</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Recent Recordings</div>
|
||||||
|
<div id="recordingList" class="settings-feed">
|
||||||
|
<div class="settings-feed-empty">No recordings yet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- About Section -->
|
<!-- About Section -->
|
||||||
<div id="settings-about" class="settings-section">
|
<div id="settings-about" class="settings-section">
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
|
|||||||
443
utils/alerts.py
Normal file
443
utils/alerts.py
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
"""Alerting engine for cross-mode events."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Generator
|
||||||
|
|
||||||
|
from config import ALERT_WEBHOOK_URL, ALERT_WEBHOOK_TIMEOUT, ALERT_WEBHOOK_SECRET
|
||||||
|
from utils.database import get_db
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.alerts')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AlertRule:
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
mode: str | None
|
||||||
|
event_type: str | None
|
||||||
|
match: dict
|
||||||
|
severity: str
|
||||||
|
enabled: bool
|
||||||
|
notify: dict
|
||||||
|
created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AlertManager:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._queue: queue.Queue = queue.Queue(maxsize=1000)
|
||||||
|
self._rules_cache: list[AlertRule] = []
|
||||||
|
self._rules_loaded_at = 0.0
|
||||||
|
self._cache_lock = threading.Lock()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Rule management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def invalidate_cache(self) -> None:
|
||||||
|
with self._cache_lock:
|
||||||
|
self._rules_loaded_at = 0.0
|
||||||
|
|
||||||
|
def _load_rules(self) -> None:
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
SELECT id, name, mode, event_type, match, severity, enabled, notify, created_at
|
||||||
|
FROM alert_rules
|
||||||
|
WHERE enabled = 1
|
||||||
|
ORDER BY id ASC
|
||||||
|
''')
|
||||||
|
rules: list[AlertRule] = []
|
||||||
|
for row in cursor:
|
||||||
|
match = {}
|
||||||
|
notify = {}
|
||||||
|
try:
|
||||||
|
match = json.loads(row['match']) if row['match'] else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
match = {}
|
||||||
|
try:
|
||||||
|
notify = json.loads(row['notify']) if row['notify'] else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
notify = {}
|
||||||
|
rules.append(AlertRule(
|
||||||
|
id=row['id'],
|
||||||
|
name=row['name'],
|
||||||
|
mode=row['mode'],
|
||||||
|
event_type=row['event_type'],
|
||||||
|
match=match,
|
||||||
|
severity=row['severity'] or 'medium',
|
||||||
|
enabled=bool(row['enabled']),
|
||||||
|
notify=notify,
|
||||||
|
created_at=row['created_at'],
|
||||||
|
))
|
||||||
|
with self._cache_lock:
|
||||||
|
self._rules_cache = rules
|
||||||
|
self._rules_loaded_at = time.time()
|
||||||
|
|
||||||
|
def _get_rules(self) -> list[AlertRule]:
|
||||||
|
with self._cache_lock:
|
||||||
|
stale = (time.time() - self._rules_loaded_at) > 10
|
||||||
|
if stale:
|
||||||
|
self._load_rules()
|
||||||
|
with self._cache_lock:
|
||||||
|
return list(self._rules_cache)
|
||||||
|
|
||||||
|
def list_rules(self, include_disabled: bool = False) -> list[dict]:
|
||||||
|
with get_db() as conn:
|
||||||
|
if include_disabled:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
SELECT id, name, mode, event_type, match, severity, enabled, notify, created_at
|
||||||
|
FROM alert_rules
|
||||||
|
ORDER BY id DESC
|
||||||
|
''')
|
||||||
|
else:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
SELECT id, name, mode, event_type, match, severity, enabled, notify, created_at
|
||||||
|
FROM alert_rules
|
||||||
|
WHERE enabled = 1
|
||||||
|
ORDER BY id DESC
|
||||||
|
''')
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id': row['id'],
|
||||||
|
'name': row['name'],
|
||||||
|
'mode': row['mode'],
|
||||||
|
'event_type': row['event_type'],
|
||||||
|
'match': json.loads(row['match']) if row['match'] else {},
|
||||||
|
'severity': row['severity'],
|
||||||
|
'enabled': bool(row['enabled']),
|
||||||
|
'notify': json.loads(row['notify']) if row['notify'] else {},
|
||||||
|
'created_at': row['created_at'],
|
||||||
|
}
|
||||||
|
for row in cursor
|
||||||
|
]
|
||||||
|
|
||||||
|
def add_rule(self, rule: dict) -> int:
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
INSERT INTO alert_rules (name, mode, event_type, match, severity, enabled, notify)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''', (
|
||||||
|
rule.get('name') or 'Alert Rule',
|
||||||
|
rule.get('mode'),
|
||||||
|
rule.get('event_type'),
|
||||||
|
json.dumps(rule.get('match') or {}),
|
||||||
|
rule.get('severity') or 'medium',
|
||||||
|
1 if rule.get('enabled', True) else 0,
|
||||||
|
json.dumps(rule.get('notify') or {}),
|
||||||
|
))
|
||||||
|
rule_id = cursor.lastrowid
|
||||||
|
self.invalidate_cache()
|
||||||
|
return int(rule_id)
|
||||||
|
|
||||||
|
def update_rule(self, rule_id: int, updates: dict) -> bool:
|
||||||
|
fields = []
|
||||||
|
params = []
|
||||||
|
for key in ('name', 'mode', 'event_type', 'severity'):
|
||||||
|
if key in updates:
|
||||||
|
fields.append(f"{key} = ?")
|
||||||
|
params.append(updates[key])
|
||||||
|
if 'enabled' in updates:
|
||||||
|
fields.append('enabled = ?')
|
||||||
|
params.append(1 if updates['enabled'] else 0)
|
||||||
|
if 'match' in updates:
|
||||||
|
fields.append('match = ?')
|
||||||
|
params.append(json.dumps(updates['match'] or {}))
|
||||||
|
if 'notify' in updates:
|
||||||
|
fields.append('notify = ?')
|
||||||
|
params.append(json.dumps(updates['notify'] or {}))
|
||||||
|
|
||||||
|
if not fields:
|
||||||
|
return False
|
||||||
|
|
||||||
|
params.append(rule_id)
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
f"UPDATE alert_rules SET {', '.join(fields)} WHERE id = ?",
|
||||||
|
params
|
||||||
|
)
|
||||||
|
updated = cursor.rowcount > 0
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
self.invalidate_cache()
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def delete_rule(self, rule_id: int) -> bool:
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('DELETE FROM alert_rules WHERE id = ?', (rule_id,))
|
||||||
|
deleted = cursor.rowcount > 0
|
||||||
|
if deleted:
|
||||||
|
self.invalidate_cache()
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def list_events(self, limit: int = 100, mode: str | None = None, severity: str | None = None) -> list[dict]:
|
||||||
|
query = 'SELECT id, rule_id, mode, event_type, severity, title, message, payload, created_at FROM alert_events'
|
||||||
|
clauses = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if mode:
|
||||||
|
clauses.append('mode = ?')
|
||||||
|
params.append(mode)
|
||||||
|
if severity:
|
||||||
|
clauses.append('severity = ?')
|
||||||
|
params.append(severity)
|
||||||
|
if clauses:
|
||||||
|
query += ' WHERE ' + ' AND '.join(clauses)
|
||||||
|
query += ' ORDER BY id DESC LIMIT ?'
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(query, params)
|
||||||
|
events = []
|
||||||
|
for row in cursor:
|
||||||
|
events.append({
|
||||||
|
'id': row['id'],
|
||||||
|
'rule_id': row['rule_id'],
|
||||||
|
'mode': row['mode'],
|
||||||
|
'event_type': row['event_type'],
|
||||||
|
'severity': row['severity'],
|
||||||
|
'title': row['title'],
|
||||||
|
'message': row['message'],
|
||||||
|
'payload': json.loads(row['payload']) if row['payload'] else {},
|
||||||
|
'created_at': row['created_at'],
|
||||||
|
})
|
||||||
|
return events
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Event processing
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def process_event(self, mode: str, event: dict, event_type: str | None = None) -> None:
|
||||||
|
if not isinstance(event, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type in ('keepalive', 'ping', 'status'):
|
||||||
|
return
|
||||||
|
|
||||||
|
rules = self._get_rules()
|
||||||
|
if not rules:
|
||||||
|
return
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
if rule.mode and rule.mode != mode:
|
||||||
|
continue
|
||||||
|
if rule.event_type and event_type and rule.event_type != event_type:
|
||||||
|
continue
|
||||||
|
if rule.event_type and not event_type:
|
||||||
|
continue
|
||||||
|
if not self._match_rule(rule.match, event):
|
||||||
|
continue
|
||||||
|
|
||||||
|
title = rule.name or 'Alert'
|
||||||
|
message = self._build_message(rule, event, event_type)
|
||||||
|
payload = {
|
||||||
|
'mode': mode,
|
||||||
|
'event_type': event_type,
|
||||||
|
'event': event,
|
||||||
|
'rule': {
|
||||||
|
'id': rule.id,
|
||||||
|
'name': rule.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
event_id = self._store_event(rule.id, mode, event_type, rule.severity, title, message, payload)
|
||||||
|
alert_payload = {
|
||||||
|
'id': event_id,
|
||||||
|
'rule_id': rule.id,
|
||||||
|
'mode': mode,
|
||||||
|
'event_type': event_type,
|
||||||
|
'severity': rule.severity,
|
||||||
|
'title': title,
|
||||||
|
'message': message,
|
||||||
|
'payload': payload,
|
||||||
|
'created_at': datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
self._queue_event(alert_payload)
|
||||||
|
self._maybe_send_webhook(alert_payload, rule.notify)
|
||||||
|
|
||||||
|
def _build_message(self, rule: AlertRule, event: dict, event_type: str | None) -> str:
|
||||||
|
if isinstance(rule.notify, dict) and rule.notify.get('message'):
|
||||||
|
return str(rule.notify.get('message'))
|
||||||
|
summary_bits = []
|
||||||
|
if event_type:
|
||||||
|
summary_bits.append(event_type)
|
||||||
|
if 'name' in event:
|
||||||
|
summary_bits.append(str(event.get('name')))
|
||||||
|
if 'ssid' in event:
|
||||||
|
summary_bits.append(str(event.get('ssid')))
|
||||||
|
if 'bssid' in event:
|
||||||
|
summary_bits.append(str(event.get('bssid')))
|
||||||
|
if 'address' in event:
|
||||||
|
summary_bits.append(str(event.get('address')))
|
||||||
|
if 'mac' in event:
|
||||||
|
summary_bits.append(str(event.get('mac')))
|
||||||
|
summary = ' | '.join(summary_bits) if summary_bits else 'Alert triggered'
|
||||||
|
return summary
|
||||||
|
|
||||||
|
def _store_event(
|
||||||
|
self,
|
||||||
|
rule_id: int,
|
||||||
|
mode: str,
|
||||||
|
event_type: str | None,
|
||||||
|
severity: str,
|
||||||
|
title: str,
|
||||||
|
message: str,
|
||||||
|
payload: dict,
|
||||||
|
) -> int:
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
INSERT INTO alert_events (rule_id, mode, event_type, severity, title, message, payload)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''', (
|
||||||
|
rule_id,
|
||||||
|
mode,
|
||||||
|
event_type,
|
||||||
|
severity,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
json.dumps(payload),
|
||||||
|
))
|
||||||
|
return int(cursor.lastrowid)
|
||||||
|
|
||||||
|
def _queue_event(self, alert_payload: dict) -> None:
|
||||||
|
try:
|
||||||
|
self._queue.put_nowait(alert_payload)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
self._queue.get_nowait()
|
||||||
|
self._queue.put_nowait(alert_payload)
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _maybe_send_webhook(self, payload: dict, notify: dict) -> None:
|
||||||
|
if not ALERT_WEBHOOK_URL:
|
||||||
|
return
|
||||||
|
if isinstance(notify, dict) and notify.get('webhook') is False:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
req = urllib.request.Request(
|
||||||
|
ALERT_WEBHOOK_URL,
|
||||||
|
data=json.dumps(payload).encode('utf-8'),
|
||||||
|
headers={
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'Intercept-Alert',
|
||||||
|
'X-Alert-Token': ALERT_WEBHOOK_SECRET or '',
|
||||||
|
},
|
||||||
|
method='POST'
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=ALERT_WEBHOOK_TIMEOUT) as _:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Alert webhook failed: {e}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Matching
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _match_rule(self, rule_match: dict, event: dict) -> bool:
|
||||||
|
if not rule_match:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for key, expected in rule_match.items():
|
||||||
|
actual = self._extract_value(event, key)
|
||||||
|
if not self._match_value(actual, expected):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _extract_value(self, event: dict, key: str) -> Any:
|
||||||
|
if '.' not in key:
|
||||||
|
return event.get(key)
|
||||||
|
current: Any = event
|
||||||
|
for part in key.split('.'):
|
||||||
|
if isinstance(current, dict):
|
||||||
|
current = current.get(part)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return current
|
||||||
|
|
||||||
|
def _match_value(self, actual: Any, expected: Any) -> bool:
|
||||||
|
if isinstance(expected, dict) and 'op' in expected:
|
||||||
|
op = expected.get('op')
|
||||||
|
value = expected.get('value')
|
||||||
|
return self._apply_op(op, actual, value)
|
||||||
|
|
||||||
|
if isinstance(expected, list):
|
||||||
|
return actual in expected
|
||||||
|
|
||||||
|
if isinstance(expected, str):
|
||||||
|
if actual is None:
|
||||||
|
return False
|
||||||
|
return str(actual).lower() == expected.lower()
|
||||||
|
|
||||||
|
return actual == expected
|
||||||
|
|
||||||
|
def _apply_op(self, op: str, actual: Any, value: Any) -> bool:
|
||||||
|
if op == 'exists':
|
||||||
|
return actual is not None
|
||||||
|
if op == 'eq':
|
||||||
|
return actual == value
|
||||||
|
if op == 'neq':
|
||||||
|
return actual != value
|
||||||
|
if op == 'gt':
|
||||||
|
return _safe_number(actual) is not None and _safe_number(actual) > _safe_number(value)
|
||||||
|
if op == 'gte':
|
||||||
|
return _safe_number(actual) is not None and _safe_number(actual) >= _safe_number(value)
|
||||||
|
if op == 'lt':
|
||||||
|
return _safe_number(actual) is not None and _safe_number(actual) < _safe_number(value)
|
||||||
|
if op == 'lte':
|
||||||
|
return _safe_number(actual) is not None and _safe_number(actual) <= _safe_number(value)
|
||||||
|
if op == 'in':
|
||||||
|
return actual in (value or [])
|
||||||
|
if op == 'contains':
|
||||||
|
if actual is None:
|
||||||
|
return False
|
||||||
|
if isinstance(actual, list):
|
||||||
|
return any(str(value).lower() in str(item).lower() for item in actual)
|
||||||
|
return str(value).lower() in str(actual).lower()
|
||||||
|
if op == 'regex':
|
||||||
|
if actual is None or value is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return re.search(str(value), str(actual)) is not None
|
||||||
|
except re.error:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Streaming
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def stream_events(self, timeout: float = 1.0) -> Generator[dict, None, None]:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
event = self._queue.get(timeout=timeout)
|
||||||
|
yield event
|
||||||
|
except queue.Empty:
|
||||||
|
yield {'type': 'keepalive'}
|
||||||
|
|
||||||
|
|
||||||
|
_alert_manager: AlertManager | None = None
|
||||||
|
_alert_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_alert_manager() -> AlertManager:
|
||||||
|
global _alert_manager
|
||||||
|
with _alert_lock:
|
||||||
|
if _alert_manager is None:
|
||||||
|
_alert_manager = AlertManager()
|
||||||
|
return _alert_manager
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_number(value: Any) -> float | None:
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
@@ -151,6 +151,7 @@ class BTDeviceAggregate:
|
|||||||
# Baseline tracking
|
# Baseline tracking
|
||||||
in_baseline: bool = False
|
in_baseline: bool = False
|
||||||
baseline_id: Optional[int] = None
|
baseline_id: Optional[int] = None
|
||||||
|
seen_before: bool = False
|
||||||
|
|
||||||
# Tracker detection fields
|
# Tracker detection fields
|
||||||
is_tracker: bool = False
|
is_tracker: bool = False
|
||||||
@@ -277,6 +278,7 @@ class BTDeviceAggregate:
|
|||||||
# Baseline
|
# Baseline
|
||||||
'in_baseline': self.in_baseline,
|
'in_baseline': self.in_baseline,
|
||||||
'baseline_id': self.baseline_id,
|
'baseline_id': self.baseline_id,
|
||||||
|
'seen_before': self.seen_before,
|
||||||
|
|
||||||
# Tracker detection
|
# Tracker detection
|
||||||
'tracker': {
|
'tracker': {
|
||||||
@@ -327,6 +329,7 @@ class BTDeviceAggregate:
|
|||||||
'seen_count': self.seen_count,
|
'seen_count': self.seen_count,
|
||||||
'heuristic_flags': self.heuristic_flags,
|
'heuristic_flags': self.heuristic_flags,
|
||||||
'in_baseline': self.in_baseline,
|
'in_baseline': self.in_baseline,
|
||||||
|
'seen_before': self.seen_before,
|
||||||
# Tracker info for list view
|
# Tracker info for list view
|
||||||
'is_tracker': self.is_tracker,
|
'is_tracker': self.is_tracker,
|
||||||
'tracker_type': self.tracker_type,
|
'tracker_type': self.tracker_type,
|
||||||
|
|||||||
@@ -102,6 +102,52 @@ def init_db() -> None:
|
|||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
# Alert rules
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
mode TEXT,
|
||||||
|
event_type TEXT,
|
||||||
|
match TEXT,
|
||||||
|
severity TEXT DEFAULT 'medium',
|
||||||
|
enabled BOOLEAN DEFAULT 1,
|
||||||
|
notify TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Alert events
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
rule_id INTEGER,
|
||||||
|
mode TEXT,
|
||||||
|
event_type TEXT,
|
||||||
|
severity TEXT DEFAULT 'medium',
|
||||||
|
title TEXT,
|
||||||
|
message TEXT,
|
||||||
|
payload TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (rule_id) REFERENCES alert_rules(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Session recordings
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS recording_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
mode TEXT NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
started_at TIMESTAMP NOT NULL,
|
||||||
|
stopped_at TIMESTAMP,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
event_count INTEGER DEFAULT 0,
|
||||||
|
size_bytes INTEGER DEFAULT 0,
|
||||||
|
metadata TEXT
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
# Users table for authentication
|
# Users table for authentication
|
||||||
conn.execute('''
|
conn.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
@@ -139,6 +185,7 @@ def init_db() -> None:
|
|||||||
description TEXT,
|
description TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
wifi_networks TEXT,
|
wifi_networks TEXT,
|
||||||
|
wifi_clients TEXT,
|
||||||
bt_devices TEXT,
|
bt_devices TEXT,
|
||||||
rf_frequencies TEXT,
|
rf_frequencies TEXT,
|
||||||
gps_coords TEXT,
|
gps_coords TEXT,
|
||||||
@@ -146,6 +193,14 @@ def init_db() -> None:
|
|||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
# Ensure new columns exist for older databases
|
||||||
|
try:
|
||||||
|
columns = {row['name'] for row in conn.execute("PRAGMA table_info(tscm_baselines)")}
|
||||||
|
if 'wifi_clients' not in columns:
|
||||||
|
conn.execute('ALTER TABLE tscm_baselines ADD COLUMN wifi_clients TEXT')
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Schema update skipped for tscm_baselines: {e}")
|
||||||
|
|
||||||
# TSCM Sweeps - Individual sweep sessions
|
# TSCM Sweeps - Individual sweep sessions
|
||||||
conn.execute('''
|
conn.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS tscm_sweeps (
|
CREATE TABLE IF NOT EXISTS tscm_sweeps (
|
||||||
@@ -690,6 +745,7 @@ def create_tscm_baseline(
|
|||||||
location: str | None = None,
|
location: str | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
wifi_networks: list | None = None,
|
wifi_networks: list | None = None,
|
||||||
|
wifi_clients: list | None = None,
|
||||||
bt_devices: list | None = None,
|
bt_devices: list | None = None,
|
||||||
rf_frequencies: list | None = None,
|
rf_frequencies: list | None = None,
|
||||||
gps_coords: dict | None = None
|
gps_coords: dict | None = None
|
||||||
@@ -703,13 +759,14 @@ def create_tscm_baseline(
|
|||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.execute('''
|
cursor = conn.execute('''
|
||||||
INSERT INTO tscm_baselines
|
INSERT INTO tscm_baselines
|
||||||
(name, location, description, wifi_networks, bt_devices, rf_frequencies, gps_coords)
|
(name, location, description, wifi_networks, wifi_clients, bt_devices, rf_frequencies, gps_coords)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
''', (
|
''', (
|
||||||
name,
|
name,
|
||||||
location,
|
location,
|
||||||
description,
|
description,
|
||||||
json.dumps(wifi_networks) if wifi_networks else None,
|
json.dumps(wifi_networks) if wifi_networks else None,
|
||||||
|
json.dumps(wifi_clients) if wifi_clients else None,
|
||||||
json.dumps(bt_devices) if bt_devices else None,
|
json.dumps(bt_devices) if bt_devices else None,
|
||||||
json.dumps(rf_frequencies) if rf_frequencies else None,
|
json.dumps(rf_frequencies) if rf_frequencies else None,
|
||||||
json.dumps(gps_coords) if gps_coords else None
|
json.dumps(gps_coords) if gps_coords else None
|
||||||
@@ -735,6 +792,7 @@ def get_tscm_baseline(baseline_id: int) -> dict | None:
|
|||||||
'description': row['description'],
|
'description': row['description'],
|
||||||
'created_at': row['created_at'],
|
'created_at': row['created_at'],
|
||||||
'wifi_networks': json.loads(row['wifi_networks']) if row['wifi_networks'] else [],
|
'wifi_networks': json.loads(row['wifi_networks']) if row['wifi_networks'] else [],
|
||||||
|
'wifi_clients': json.loads(row['wifi_clients']) if row['wifi_clients'] else [],
|
||||||
'bt_devices': json.loads(row['bt_devices']) if row['bt_devices'] 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 [],
|
'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,
|
'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None,
|
||||||
@@ -784,6 +842,7 @@ def set_active_tscm_baseline(baseline_id: int) -> bool:
|
|||||||
def update_tscm_baseline(
|
def update_tscm_baseline(
|
||||||
baseline_id: int,
|
baseline_id: int,
|
||||||
wifi_networks: list | None = None,
|
wifi_networks: list | None = None,
|
||||||
|
wifi_clients: list | None = None,
|
||||||
bt_devices: list | None = None,
|
bt_devices: list | None = None,
|
||||||
rf_frequencies: list | None = None
|
rf_frequencies: list | None = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -794,6 +853,9 @@ def update_tscm_baseline(
|
|||||||
if wifi_networks is not None:
|
if wifi_networks is not None:
|
||||||
updates.append('wifi_networks = ?')
|
updates.append('wifi_networks = ?')
|
||||||
params.append(json.dumps(wifi_networks))
|
params.append(json.dumps(wifi_networks))
|
||||||
|
if wifi_clients is not None:
|
||||||
|
updates.append('wifi_clients = ?')
|
||||||
|
params.append(json.dumps(wifi_clients))
|
||||||
if bt_devices is not None:
|
if bt_devices is not None:
|
||||||
updates.append('bt_devices = ?')
|
updates.append('bt_devices = ?')
|
||||||
params.append(json.dumps(bt_devices))
|
params.append(json.dumps(bt_devices))
|
||||||
|
|||||||
29
utils/event_pipeline.py
Normal file
29
utils/event_pipeline.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""Shared event pipeline for alerts and recordings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from utils.alerts import get_alert_manager
|
||||||
|
from utils.recording import get_recording_manager
|
||||||
|
|
||||||
|
IGNORE_TYPES = {'keepalive', 'ping'}
|
||||||
|
|
||||||
|
|
||||||
|
def process_event(mode: str, event: dict | Any, event_type: str | None = None) -> None:
|
||||||
|
if event_type in IGNORE_TYPES:
|
||||||
|
return
|
||||||
|
if not isinstance(event, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
get_recording_manager().record_event(mode, event, event_type)
|
||||||
|
except Exception:
|
||||||
|
# Recording failures should never break streaming
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
get_alert_manager().process_event(mode, event, event_type)
|
||||||
|
except Exception:
|
||||||
|
# Alert failures should never break streaming
|
||||||
|
pass
|
||||||
222
utils/recording.py
Normal file
222
utils/recording.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"""Session recording utilities for SSE/event streams."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from utils.database import get_db
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.recording')
|
||||||
|
|
||||||
|
RECORDING_ROOT = Path(__file__).parent.parent / 'instance' / 'recordings'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RecordingSession:
|
||||||
|
id: str
|
||||||
|
mode: str
|
||||||
|
label: str | None
|
||||||
|
file_path: Path
|
||||||
|
started_at: datetime
|
||||||
|
stopped_at: datetime | None = None
|
||||||
|
event_count: int = 0
|
||||||
|
size_bytes: int = 0
|
||||||
|
metadata: dict | None = None
|
||||||
|
|
||||||
|
_file_handle: Any | None = None
|
||||||
|
_lock: threading.Lock = threading.Lock()
|
||||||
|
|
||||||
|
def open(self) -> None:
|
||||||
|
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._file_handle = self.file_path.open('a', encoding='utf-8')
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._file_handle:
|
||||||
|
self._file_handle.flush()
|
||||||
|
self._file_handle.close()
|
||||||
|
self._file_handle = None
|
||||||
|
|
||||||
|
def write_event(self, record: dict) -> None:
|
||||||
|
if not self._file_handle:
|
||||||
|
self.open()
|
||||||
|
line = json.dumps(record, ensure_ascii=True) + '\n'
|
||||||
|
with self._lock:
|
||||||
|
self._file_handle.write(line)
|
||||||
|
self._file_handle.flush()
|
||||||
|
self.event_count += 1
|
||||||
|
self.size_bytes += len(line.encode('utf-8'))
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingManager:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._active_by_mode: dict[str, RecordingSession] = {}
|
||||||
|
self._active_by_id: dict[str, RecordingSession] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def start_recording(self, mode: str, label: str | None = None, metadata: dict | None = None) -> RecordingSession:
|
||||||
|
with self._lock:
|
||||||
|
existing = self._active_by_mode.get(mode)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
started_at = datetime.now(timezone.utc)
|
||||||
|
filename = f"{mode}_{started_at.strftime('%Y%m%d_%H%M%S')}_{session_id}.jsonl"
|
||||||
|
file_path = RECORDING_ROOT / mode / filename
|
||||||
|
|
||||||
|
session = RecordingSession(
|
||||||
|
id=session_id,
|
||||||
|
mode=mode,
|
||||||
|
label=label,
|
||||||
|
file_path=file_path,
|
||||||
|
started_at=started_at,
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
|
session.open()
|
||||||
|
|
||||||
|
self._active_by_mode[mode] = session
|
||||||
|
self._active_by_id[session_id] = session
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO recording_sessions
|
||||||
|
(id, mode, label, started_at, file_path, event_count, size_bytes, metadata)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''', (
|
||||||
|
session.id,
|
||||||
|
session.mode,
|
||||||
|
session.label,
|
||||||
|
session.started_at.isoformat(),
|
||||||
|
str(session.file_path),
|
||||||
|
session.event_count,
|
||||||
|
session.size_bytes,
|
||||||
|
json.dumps(session.metadata or {}),
|
||||||
|
))
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
def stop_recording(self, mode: str | None = None, session_id: str | None = None) -> RecordingSession | None:
|
||||||
|
with self._lock:
|
||||||
|
session = None
|
||||||
|
if session_id:
|
||||||
|
session = self._active_by_id.get(session_id)
|
||||||
|
elif mode:
|
||||||
|
session = self._active_by_mode.get(mode)
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
return None
|
||||||
|
|
||||||
|
session.stopped_at = datetime.now(timezone.utc)
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
self._active_by_mode.pop(session.mode, None)
|
||||||
|
self._active_by_id.pop(session.id, None)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute('''
|
||||||
|
UPDATE recording_sessions
|
||||||
|
SET stopped_at = ?, event_count = ?, size_bytes = ?
|
||||||
|
WHERE id = ?
|
||||||
|
''', (
|
||||||
|
session.stopped_at.isoformat(),
|
||||||
|
session.event_count,
|
||||||
|
session.size_bytes,
|
||||||
|
session.id,
|
||||||
|
))
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
def record_event(self, mode: str, event: dict, event_type: str | None = None) -> None:
|
||||||
|
if event_type in ('keepalive', 'ping'):
|
||||||
|
return
|
||||||
|
session = self._active_by_mode.get(mode)
|
||||||
|
if not session:
|
||||||
|
return
|
||||||
|
record = {
|
||||||
|
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'mode': mode,
|
||||||
|
'event_type': event_type,
|
||||||
|
'event': event,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
session.write_event(record)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Recording write failed: {e}")
|
||||||
|
|
||||||
|
def list_recordings(self, limit: int = 50) -> list[dict]:
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
SELECT id, mode, label, started_at, stopped_at, file_path, event_count, size_bytes, metadata
|
||||||
|
FROM recording_sessions
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
''', (limit,))
|
||||||
|
rows = []
|
||||||
|
for row in cursor:
|
||||||
|
rows.append({
|
||||||
|
'id': row['id'],
|
||||||
|
'mode': row['mode'],
|
||||||
|
'label': row['label'],
|
||||||
|
'started_at': row['started_at'],
|
||||||
|
'stopped_at': row['stopped_at'],
|
||||||
|
'file_path': row['file_path'],
|
||||||
|
'event_count': row['event_count'],
|
||||||
|
'size_bytes': row['size_bytes'],
|
||||||
|
'metadata': json.loads(row['metadata']) if row['metadata'] else {},
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def get_recording(self, session_id: str) -> dict | None:
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
SELECT id, mode, label, started_at, stopped_at, file_path, event_count, size_bytes, metadata
|
||||||
|
FROM recording_sessions
|
||||||
|
WHERE id = ?
|
||||||
|
''', (session_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
'id': row['id'],
|
||||||
|
'mode': row['mode'],
|
||||||
|
'label': row['label'],
|
||||||
|
'started_at': row['started_at'],
|
||||||
|
'stopped_at': row['stopped_at'],
|
||||||
|
'file_path': row['file_path'],
|
||||||
|
'event_count': row['event_count'],
|
||||||
|
'size_bytes': row['size_bytes'],
|
||||||
|
'metadata': json.loads(row['metadata']) if row['metadata'] else {},
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_active(self) -> list[dict]:
|
||||||
|
with self._lock:
|
||||||
|
sessions = []
|
||||||
|
for session in self._active_by_mode.values():
|
||||||
|
sessions.append({
|
||||||
|
'id': session.id,
|
||||||
|
'mode': session.mode,
|
||||||
|
'label': session.label,
|
||||||
|
'started_at': session.started_at.isoformat(),
|
||||||
|
'event_count': session.event_count,
|
||||||
|
'size_bytes': session.size_bytes,
|
||||||
|
})
|
||||||
|
return sessions
|
||||||
|
|
||||||
|
|
||||||
|
_recording_manager: RecordingManager | None = None
|
||||||
|
_recording_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_recording_manager() -> RecordingManager:
|
||||||
|
global _recording_manager
|
||||||
|
with _recording_lock:
|
||||||
|
if _recording_manager is None:
|
||||||
|
_recording_manager = RecordingManager()
|
||||||
|
return _recording_manager
|
||||||
@@ -526,6 +526,7 @@ class BaselineDiff:
|
|||||||
def calculate_baseline_diff(
|
def calculate_baseline_diff(
|
||||||
baseline: dict,
|
baseline: dict,
|
||||||
current_wifi: list[dict],
|
current_wifi: list[dict],
|
||||||
|
current_wifi_clients: list[dict],
|
||||||
current_bt: list[dict],
|
current_bt: list[dict],
|
||||||
current_rf: list[dict],
|
current_rf: list[dict],
|
||||||
sweep_id: int
|
sweep_id: int
|
||||||
@@ -536,6 +537,7 @@ def calculate_baseline_diff(
|
|||||||
Args:
|
Args:
|
||||||
baseline: Baseline dict from database
|
baseline: Baseline dict from database
|
||||||
current_wifi: Current WiFi devices
|
current_wifi: Current WiFi devices
|
||||||
|
current_wifi_clients: Current WiFi clients
|
||||||
current_bt: Current Bluetooth devices
|
current_bt: Current Bluetooth devices
|
||||||
current_rf: Current RF signals
|
current_rf: Current RF signals
|
||||||
sweep_id: Current sweep ID
|
sweep_id: Current sweep ID
|
||||||
@@ -569,6 +571,11 @@ def calculate_baseline_diff(
|
|||||||
for d in baseline.get('wifi_networks', [])
|
for d in baseline.get('wifi_networks', [])
|
||||||
if d.get('bssid') or d.get('mac')
|
if d.get('bssid') or d.get('mac')
|
||||||
}
|
}
|
||||||
|
baseline_wifi_clients = {
|
||||||
|
d.get('mac', d.get('address', '')).upper(): d
|
||||||
|
for d in baseline.get('wifi_clients', [])
|
||||||
|
if d.get('mac') or d.get('address')
|
||||||
|
}
|
||||||
baseline_bt = {
|
baseline_bt = {
|
||||||
d.get('mac', d.get('address', '')).upper(): d
|
d.get('mac', d.get('address', '')).upper(): d
|
||||||
for d in baseline.get('bt_devices', [])
|
for d in baseline.get('bt_devices', [])
|
||||||
@@ -583,6 +590,9 @@ def calculate_baseline_diff(
|
|||||||
# Compare WiFi
|
# Compare WiFi
|
||||||
_compare_wifi(diff, baseline_wifi, current_wifi)
|
_compare_wifi(diff, baseline_wifi, current_wifi)
|
||||||
|
|
||||||
|
# Compare WiFi clients
|
||||||
|
_compare_wifi_clients(diff, baseline_wifi_clients, current_wifi_clients)
|
||||||
|
|
||||||
# Compare Bluetooth
|
# Compare Bluetooth
|
||||||
_compare_bluetooth(diff, baseline_bt, current_bt)
|
_compare_bluetooth(diff, baseline_bt, current_bt)
|
||||||
|
|
||||||
@@ -631,6 +641,47 @@ def _compare_wifi(diff: BaselineDiff, baseline: dict, current: list[dict]) -> No
|
|||||||
'rssi': device.get('power', device.get('signal')),
|
'rssi': device.get('power', device.get('signal')),
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_wifi_clients(diff: BaselineDiff, baseline: dict, current: list[dict]) -> None:
|
||||||
|
"""Compare WiFi clients between baseline and current."""
|
||||||
|
current_macs = {
|
||||||
|
d.get('mac', d.get('address', '')).upper(): d
|
||||||
|
for d in current
|
||||||
|
if d.get('mac') or d.get('address')
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find new clients
|
||||||
|
for mac, device in current_macs.items():
|
||||||
|
if mac not in baseline:
|
||||||
|
name = device.get('vendor', 'WiFi Client')
|
||||||
|
diff.new_devices.append(DeviceChange(
|
||||||
|
identifier=mac,
|
||||||
|
protocol='wifi_client',
|
||||||
|
change_type='new',
|
||||||
|
description=f'New WiFi client: {name}',
|
||||||
|
expected=False,
|
||||||
|
details={
|
||||||
|
'vendor': name,
|
||||||
|
'rssi': device.get('rssi'),
|
||||||
|
'associated_bssid': device.get('associated_bssid'),
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
# Find missing clients
|
||||||
|
for mac, device in baseline.items():
|
||||||
|
if mac not in current_macs:
|
||||||
|
name = device.get('vendor', 'WiFi Client')
|
||||||
|
diff.missing_devices.append(DeviceChange(
|
||||||
|
identifier=mac,
|
||||||
|
protocol='wifi_client',
|
||||||
|
change_type='missing',
|
||||||
|
description=f'Missing WiFi client: {name}',
|
||||||
|
expected=True,
|
||||||
|
details={
|
||||||
|
'vendor': name,
|
||||||
|
}
|
||||||
|
))
|
||||||
else:
|
else:
|
||||||
# Check for changes
|
# Check for changes
|
||||||
baseline_dev = baseline[mac]
|
baseline_dev = baseline[mac]
|
||||||
@@ -798,6 +849,7 @@ def _calculate_baseline_health(diff: BaselineDiff, baseline: dict) -> None:
|
|||||||
# Device churn penalty
|
# Device churn penalty
|
||||||
total_baseline = (
|
total_baseline = (
|
||||||
len(baseline.get('wifi_networks', [])) +
|
len(baseline.get('wifi_networks', [])) +
|
||||||
|
len(baseline.get('wifi_clients', [])) +
|
||||||
len(baseline.get('bt_devices', [])) +
|
len(baseline.get('bt_devices', [])) +
|
||||||
len(baseline.get('rf_frequencies', []))
|
len(baseline.get('rf_frequencies', []))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class BaselineRecorder:
|
|||||||
self.recording = False
|
self.recording = False
|
||||||
self.current_baseline_id: int | None = None
|
self.current_baseline_id: int | None = None
|
||||||
self.wifi_networks: dict[str, dict] = {} # BSSID -> network info
|
self.wifi_networks: dict[str, dict] = {} # BSSID -> network info
|
||||||
|
self.wifi_clients: dict[str, dict] = {} # MAC -> client info
|
||||||
self.bt_devices: dict[str, dict] = {} # MAC -> device info
|
self.bt_devices: dict[str, dict] = {} # MAC -> device info
|
||||||
self.rf_frequencies: dict[float, dict] = {} # Frequency -> signal info
|
self.rf_frequencies: dict[float, dict] = {} # Frequency -> signal info
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ class BaselineRecorder:
|
|||||||
"""
|
"""
|
||||||
self.recording = True
|
self.recording = True
|
||||||
self.wifi_networks = {}
|
self.wifi_networks = {}
|
||||||
|
self.wifi_clients = {}
|
||||||
self.bt_devices = {}
|
self.bt_devices = {}
|
||||||
self.rf_frequencies = {}
|
self.rf_frequencies = {}
|
||||||
|
|
||||||
@@ -79,6 +81,7 @@ class BaselineRecorder:
|
|||||||
|
|
||||||
# Convert to lists for storage
|
# Convert to lists for storage
|
||||||
wifi_list = list(self.wifi_networks.values())
|
wifi_list = list(self.wifi_networks.values())
|
||||||
|
wifi_client_list = list(self.wifi_clients.values())
|
||||||
bt_list = list(self.bt_devices.values())
|
bt_list = list(self.bt_devices.values())
|
||||||
rf_list = list(self.rf_frequencies.values())
|
rf_list = list(self.rf_frequencies.values())
|
||||||
|
|
||||||
@@ -86,6 +89,7 @@ class BaselineRecorder:
|
|||||||
update_tscm_baseline(
|
update_tscm_baseline(
|
||||||
self.current_baseline_id,
|
self.current_baseline_id,
|
||||||
wifi_networks=wifi_list,
|
wifi_networks=wifi_list,
|
||||||
|
wifi_clients=wifi_client_list,
|
||||||
bt_devices=bt_list,
|
bt_devices=bt_list,
|
||||||
rf_frequencies=rf_list
|
rf_frequencies=rf_list
|
||||||
)
|
)
|
||||||
@@ -93,6 +97,7 @@ class BaselineRecorder:
|
|||||||
summary = {
|
summary = {
|
||||||
'baseline_id': self.current_baseline_id,
|
'baseline_id': self.current_baseline_id,
|
||||||
'wifi_count': len(wifi_list),
|
'wifi_count': len(wifi_list),
|
||||||
|
'wifi_client_count': len(wifi_client_list),
|
||||||
'bt_count': len(bt_list),
|
'bt_count': len(bt_list),
|
||||||
'rf_count': len(rf_list),
|
'rf_count': len(rf_list),
|
||||||
}
|
}
|
||||||
@@ -160,6 +165,33 @@ class BaselineRecorder:
|
|||||||
'last_seen': datetime.now().isoformat(),
|
'last_seen': datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def add_wifi_client(self, client: dict) -> None:
|
||||||
|
"""Add a WiFi client to the current baseline."""
|
||||||
|
if not self.recording:
|
||||||
|
return
|
||||||
|
|
||||||
|
mac = client.get('mac', client.get('address', '')).upper()
|
||||||
|
if not mac:
|
||||||
|
return
|
||||||
|
|
||||||
|
if mac in self.wifi_clients:
|
||||||
|
self.wifi_clients[mac].update({
|
||||||
|
'last_seen': datetime.now().isoformat(),
|
||||||
|
'rssi': client.get('rssi', self.wifi_clients[mac].get('rssi')),
|
||||||
|
'associated_bssid': client.get('associated_bssid', self.wifi_clients[mac].get('associated_bssid')),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
self.wifi_clients[mac] = {
|
||||||
|
'mac': mac,
|
||||||
|
'vendor': client.get('vendor', ''),
|
||||||
|
'rssi': client.get('rssi'),
|
||||||
|
'associated_bssid': client.get('associated_bssid'),
|
||||||
|
'probed_ssids': client.get('probed_ssids', []),
|
||||||
|
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
|
||||||
|
'first_seen': datetime.now().isoformat(),
|
||||||
|
'last_seen': datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
def add_rf_signal(self, signal: dict) -> None:
|
def add_rf_signal(self, signal: dict) -> None:
|
||||||
"""Add an RF signal to the current baseline."""
|
"""Add an RF signal to the current baseline."""
|
||||||
if not self.recording:
|
if not self.recording:
|
||||||
@@ -197,6 +229,7 @@ class BaselineRecorder:
|
|||||||
'recording': self.recording,
|
'recording': self.recording,
|
||||||
'baseline_id': self.current_baseline_id,
|
'baseline_id': self.current_baseline_id,
|
||||||
'wifi_count': len(self.wifi_networks),
|
'wifi_count': len(self.wifi_networks),
|
||||||
|
'wifi_client_count': len(self.wifi_clients),
|
||||||
'bt_count': len(self.bt_devices),
|
'bt_count': len(self.bt_devices),
|
||||||
'rf_count': len(self.rf_frequencies),
|
'rf_count': len(self.rf_frequencies),
|
||||||
}
|
}
|
||||||
@@ -225,6 +258,11 @@ class BaselineComparator:
|
|||||||
for d in baseline.get('bt_devices', [])
|
for d in baseline.get('bt_devices', [])
|
||||||
if d.get('mac') or d.get('address')
|
if d.get('mac') or d.get('address')
|
||||||
}
|
}
|
||||||
|
self.baseline_wifi_clients = {
|
||||||
|
d.get('mac', d.get('address', '')).upper(): d
|
||||||
|
for d in baseline.get('wifi_clients', [])
|
||||||
|
if d.get('mac') or d.get('address')
|
||||||
|
}
|
||||||
self.baseline_rf = {
|
self.baseline_rf = {
|
||||||
round(d.get('frequency', 0), 1): d
|
round(d.get('frequency', 0), 1): d
|
||||||
for d in baseline.get('rf_frequencies', [])
|
for d in baseline.get('rf_frequencies', [])
|
||||||
@@ -300,6 +338,37 @@ class BaselineComparator:
|
|||||||
'matching_count': len(matching_devices),
|
'matching_count': len(matching_devices),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def compare_wifi_clients(self, current_devices: list[dict]) -> dict:
|
||||||
|
"""Compare current WiFi clients 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_wifi_clients:
|
||||||
|
new_devices.append(device)
|
||||||
|
else:
|
||||||
|
matching_devices.append(device)
|
||||||
|
|
||||||
|
for mac, device in self.baseline_wifi_clients.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:
|
def compare_rf(self, current_signals: list[dict]) -> dict:
|
||||||
"""Compare current RF signals against baseline."""
|
"""Compare current RF signals against baseline."""
|
||||||
current_freqs = {
|
current_freqs = {
|
||||||
@@ -334,6 +403,7 @@ class BaselineComparator:
|
|||||||
def compare_all(
|
def compare_all(
|
||||||
self,
|
self,
|
||||||
wifi_devices: list[dict] | None = None,
|
wifi_devices: list[dict] | None = None,
|
||||||
|
wifi_clients: list[dict] | None = None,
|
||||||
bt_devices: list[dict] | None = None,
|
bt_devices: list[dict] | None = None,
|
||||||
rf_signals: list[dict] | None = None
|
rf_signals: list[dict] | None = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -345,6 +415,7 @@ class BaselineComparator:
|
|||||||
"""
|
"""
|
||||||
results = {
|
results = {
|
||||||
'wifi': None,
|
'wifi': None,
|
||||||
|
'wifi_clients': None,
|
||||||
'bluetooth': None,
|
'bluetooth': None,
|
||||||
'rf': None,
|
'rf': None,
|
||||||
'total_new': 0,
|
'total_new': 0,
|
||||||
@@ -356,6 +427,11 @@ class BaselineComparator:
|
|||||||
results['total_new'] += results['wifi']['new_count']
|
results['total_new'] += results['wifi']['new_count']
|
||||||
results['total_missing'] += results['wifi']['missing_count']
|
results['total_missing'] += results['wifi']['missing_count']
|
||||||
|
|
||||||
|
if wifi_clients is not None:
|
||||||
|
results['wifi_clients'] = self.compare_wifi_clients(wifi_clients)
|
||||||
|
results['total_new'] += results['wifi_clients']['new_count']
|
||||||
|
results['total_missing'] += results['wifi_clients']['missing_count']
|
||||||
|
|
||||||
if bt_devices is not None:
|
if bt_devices is not None:
|
||||||
results['bluetooth'] = self.compare_bluetooth(bt_devices)
|
results['bluetooth'] = self.compare_bluetooth(bt_devices)
|
||||||
results['total_new'] += results['bluetooth']['new_count']
|
results['total_new'] += results['bluetooth']['new_count']
|
||||||
@@ -371,6 +447,7 @@ class BaselineComparator:
|
|||||||
|
|
||||||
def get_comparison_for_active_baseline(
|
def get_comparison_for_active_baseline(
|
||||||
wifi_devices: list[dict] | None = None,
|
wifi_devices: list[dict] | None = None,
|
||||||
|
wifi_clients: list[dict] | None = None,
|
||||||
bt_devices: list[dict] | None = None,
|
bt_devices: list[dict] | None = None,
|
||||||
rf_signals: list[dict] | None = None
|
rf_signals: list[dict] | None = None
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
@@ -385,4 +462,4 @@ def get_comparison_for_active_baseline(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
comparator = BaselineComparator(baseline)
|
comparator = BaselineComparator(baseline)
|
||||||
return comparator.compare_all(wifi_devices, bt_devices, rf_signals)
|
return comparator.compare_all(wifi_devices, wifi_clients, bt_devices, rf_signals)
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ class ThreatDetector:
|
|||||||
if 'mac' in client:
|
if 'mac' in client:
|
||||||
self.baseline_wifi_macs.add(client['mac'].upper())
|
self.baseline_wifi_macs.add(client['mac'].upper())
|
||||||
|
|
||||||
|
for client in baseline.get('wifi_clients', []):
|
||||||
|
if 'mac' in client:
|
||||||
|
self.baseline_wifi_macs.add(client['mac'].upper())
|
||||||
|
|
||||||
# Bluetooth devices
|
# Bluetooth devices
|
||||||
for device in baseline.get('bt_devices', []):
|
for device in baseline.get('bt_devices', []):
|
||||||
if 'mac' in device:
|
if 'mac' in device:
|
||||||
|
|||||||
@@ -667,6 +667,7 @@ class UnifiedWiFiScanner:
|
|||||||
interface: Optional[str] = None,
|
interface: Optional[str] = None,
|
||||||
band: str = 'all',
|
band: str = 'all',
|
||||||
channel: Optional[int] = None,
|
channel: Optional[int] = None,
|
||||||
|
channels: Optional[list[int]] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Start continuous deep scan with airodump-ng.
|
Start continuous deep scan with airodump-ng.
|
||||||
@@ -702,7 +703,7 @@ class UnifiedWiFiScanner:
|
|||||||
self._deep_scan_stop_event.clear()
|
self._deep_scan_stop_event.clear()
|
||||||
self._deep_scan_thread = threading.Thread(
|
self._deep_scan_thread = threading.Thread(
|
||||||
target=self._run_deep_scan,
|
target=self._run_deep_scan,
|
||||||
args=(iface, band, channel),
|
args=(iface, band, channel, channels),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
self._deep_scan_thread.start()
|
self._deep_scan_thread.start()
|
||||||
@@ -766,7 +767,13 @@ class UnifiedWiFiScanner:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _run_deep_scan(self, interface: str, band: str, channel: Optional[int]):
|
def _run_deep_scan(
|
||||||
|
self,
|
||||||
|
interface: str,
|
||||||
|
band: str,
|
||||||
|
channel: Optional[int],
|
||||||
|
channels: Optional[list[int]],
|
||||||
|
):
|
||||||
"""Background thread for running airodump-ng."""
|
"""Background thread for running airodump-ng."""
|
||||||
from .parsers.airodump import parse_airodump_csv
|
from .parsers.airodump import parse_airodump_csv
|
||||||
|
|
||||||
@@ -779,7 +786,9 @@ class UnifiedWiFiScanner:
|
|||||||
# Build command
|
# Build command
|
||||||
cmd = ['airodump-ng', '-w', output_prefix, '--output-format', 'csv']
|
cmd = ['airodump-ng', '-w', output_prefix, '--output-format', 'csv']
|
||||||
|
|
||||||
if channel:
|
if channels:
|
||||||
|
cmd.extend(['-c', ','.join(str(c) for c in channels)])
|
||||||
|
elif channel:
|
||||||
cmd.extend(['-c', str(channel)])
|
cmd.extend(['-c', str(channel)])
|
||||||
elif band == '2.4':
|
elif band == '2.4':
|
||||||
cmd.extend(['--band', 'bg'])
|
cmd.extend(['--band', 'bg'])
|
||||||
|
|||||||
Reference in New Issue
Block a user