mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Refactor into modular structure with improvements
- Split monolithic intercept.py (15k lines) into modular structure: - routes/ - Flask blueprints for each feature - templates/ - Jinja2 HTML templates - data/ - OUI database, satellite TLEs, detection patterns - utils/ - dependencies, process management, logging - config.py - centralized configuration with env var support - Add type hints to function signatures - Replace bare except clauses with specific exceptions - Add proper logging module (replaces print statements) - Add environment variable support (INTERCEPT_* prefix) - Add test suite with pytest - Add Dockerfile for containerized deployment - Add pyproject.toml with ruff/black/mypy config - Add requirements-dev.txt for development dependencies 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
19
routes/__init__.py
Normal file
19
routes/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Routes package - registers all blueprints with the Flask app
|
||||
|
||||
def register_blueprints(app):
|
||||
"""Register all route blueprints with the Flask app."""
|
||||
from .pager import pager_bp
|
||||
from .sensor import sensor_bp
|
||||
from .wifi import wifi_bp
|
||||
from .bluetooth import bluetooth_bp
|
||||
from .adsb import adsb_bp
|
||||
from .satellite import satellite_bp
|
||||
from .iridium import iridium_bp
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
app.register_blueprint(wifi_bp)
|
||||
app.register_blueprint(bluetooth_bp)
|
||||
app.register_blueprint(adsb_bp)
|
||||
app.register_blueprint(satellite_bp)
|
||||
app.register_blueprint(iridium_bp)
|
||||
244
routes/adsb.py
Normal file
244
routes/adsb.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""ADS-B aircraft tracking routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, render_template
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import adsb_logger as logger
|
||||
|
||||
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
|
||||
|
||||
# Track if using service
|
||||
adsb_using_service = False
|
||||
|
||||
|
||||
def check_dump1090_service():
|
||||
"""Check if dump1090 SBS port (30003) is available."""
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(2)
|
||||
result = sock.connect_ex(('localhost', 30003))
|
||||
sock.close()
|
||||
if result == 0:
|
||||
return 'localhost:30003'
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def parse_sbs_stream(service_addr):
|
||||
"""Parse SBS format data from dump1090 port 30003."""
|
||||
global adsb_using_service
|
||||
|
||||
host, port = service_addr.split(':')
|
||||
port = int(port)
|
||||
|
||||
logger.info(f"SBS stream parser started, connecting to {host}:{port}")
|
||||
|
||||
while adsb_using_service:
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
sock.connect((host, port))
|
||||
logger.info("Connected to SBS stream")
|
||||
|
||||
buffer = ""
|
||||
last_update = time.time()
|
||||
pending_updates = set()
|
||||
|
||||
while adsb_using_service:
|
||||
try:
|
||||
data = sock.recv(4096).decode('utf-8', errors='ignore')
|
||||
if not data:
|
||||
break
|
||||
buffer += data
|
||||
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
parts = line.split(',')
|
||||
if len(parts) < 11 or parts[0] != 'MSG':
|
||||
continue
|
||||
|
||||
msg_type = parts[1]
|
||||
icao = parts[4].upper()
|
||||
if not icao:
|
||||
continue
|
||||
|
||||
aircraft = app_module.adsb_aircraft.get(icao, {'icao': icao})
|
||||
|
||||
if msg_type == '1' and len(parts) > 10:
|
||||
callsign = parts[10].strip()
|
||||
if callsign:
|
||||
aircraft['callsign'] = callsign
|
||||
|
||||
elif msg_type == '3' and len(parts) > 15:
|
||||
if parts[11]:
|
||||
try:
|
||||
aircraft['alt'] = int(float(parts[11]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if parts[14] and parts[15]:
|
||||
try:
|
||||
aircraft['lat'] = float(parts[14])
|
||||
aircraft['lon'] = float(parts[15])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
elif msg_type == '4' and len(parts) > 13:
|
||||
if parts[12]:
|
||||
try:
|
||||
aircraft['speed'] = int(float(parts[12]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if parts[13]:
|
||||
try:
|
||||
aircraft['heading'] = int(float(parts[13]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
elif msg_type == '5' and len(parts) > 11:
|
||||
if parts[10]:
|
||||
callsign = parts[10].strip()
|
||||
if callsign:
|
||||
aircraft['callsign'] = callsign
|
||||
if parts[11]:
|
||||
try:
|
||||
aircraft['alt'] = int(float(parts[11]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
elif msg_type == '6' and len(parts) > 17:
|
||||
if parts[17]:
|
||||
aircraft['squawk'] = parts[17]
|
||||
|
||||
app_module.adsb_aircraft[icao] = aircraft
|
||||
pending_updates.add(icao)
|
||||
|
||||
now = time.time()
|
||||
if now - last_update >= 1.0:
|
||||
for update_icao in pending_updates:
|
||||
if update_icao in app_module.adsb_aircraft:
|
||||
app_module.adsb_queue.put({
|
||||
'type': 'aircraft',
|
||||
**app_module.adsb_aircraft[update_icao]
|
||||
})
|
||||
pending_updates.clear()
|
||||
last_update = now
|
||||
|
||||
except socket.timeout:
|
||||
continue
|
||||
|
||||
sock.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"SBS connection error: {e}, reconnecting...")
|
||||
time.sleep(2)
|
||||
|
||||
logger.info("SBS stream parser stopped")
|
||||
|
||||
|
||||
@adsb_bp.route('/tools')
|
||||
def check_adsb_tools():
|
||||
"""Check for ADS-B decoding tools."""
|
||||
return jsonify({
|
||||
'dump1090': shutil.which('dump1090') is not None or shutil.which('dump1090-mutability') is not None,
|
||||
'rtl_adsb': shutil.which('rtl_adsb') is not None
|
||||
})
|
||||
|
||||
|
||||
@adsb_bp.route('/start', methods=['POST'])
|
||||
def start_adsb():
|
||||
"""Start ADS-B tracking."""
|
||||
global adsb_using_service
|
||||
|
||||
with app_module.adsb_lock:
|
||||
if app_module.adsb_process and app_module.adsb_process.poll() is None:
|
||||
return jsonify({'status': 'already_running', 'message': 'ADS-B already running'})
|
||||
if adsb_using_service:
|
||||
return jsonify({'status': 'already_running', 'message': 'ADS-B already running (using service)'})
|
||||
|
||||
data = request.json or {}
|
||||
gain = data.get('gain', '40')
|
||||
device = data.get('device', '0')
|
||||
|
||||
dump1090_path = shutil.which('dump1090') or shutil.which('dump1090-mutability')
|
||||
|
||||
if not dump1090_path:
|
||||
return jsonify({'status': 'error', 'message': 'dump1090 not found.'})
|
||||
|
||||
cmd = [dump1090_path, '--net', '--gain', gain, '--device-index', str(device), '--quiet']
|
||||
|
||||
try:
|
||||
app_module.adsb_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
if app_module.adsb_process.poll() is not None:
|
||||
return jsonify({'status': 'error', 'message': 'dump1090 failed to start.'})
|
||||
|
||||
adsb_using_service = True
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=('localhost:30003',), daemon=True)
|
||||
thread.start()
|
||||
|
||||
return jsonify({'status': 'success', 'message': 'ADS-B tracking started'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@adsb_bp.route('/stop', methods=['POST'])
|
||||
def stop_adsb():
|
||||
"""Stop ADS-B tracking."""
|
||||
global adsb_using_service
|
||||
|
||||
with app_module.adsb_lock:
|
||||
if app_module.adsb_process:
|
||||
app_module.adsb_process.terminate()
|
||||
try:
|
||||
app_module.adsb_process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.adsb_process.kill()
|
||||
app_module.adsb_process = None
|
||||
adsb_using_service = False
|
||||
|
||||
app_module.adsb_aircraft = {}
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@adsb_bp.route('/stream')
|
||||
def stream_adsb():
|
||||
"""SSE stream for ADS-B aircraft."""
|
||||
def generate():
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.adsb_queue.get(timeout=1)
|
||||
yield f"data: {json.dumps(msg)}\n\n"
|
||||
except queue.Empty:
|
||||
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@adsb_bp.route('/dashboard')
|
||||
def adsb_dashboard():
|
||||
"""Popout ADS-B dashboard."""
|
||||
return render_template('adsb_dashboard.html')
|
||||
483
routes/bluetooth.py
Normal file
483
routes/bluetooth.py
Normal file
@@ -0,0 +1,483 @@
|
||||
"""Bluetooth reconnaissance routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import pty
|
||||
import queue
|
||||
import re
|
||||
import select
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.dependencies import check_tool
|
||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
||||
|
||||
bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
|
||||
|
||||
|
||||
def classify_bt_device(name, device_class, services, manufacturer=None):
|
||||
"""Classify Bluetooth device type based on available info."""
|
||||
name_lower = (name or '').lower()
|
||||
mfr_lower = (manufacturer or '').lower()
|
||||
|
||||
audio_patterns = [
|
||||
'airpod', 'earbud', 'headphone', 'headset', 'speaker', 'audio', 'beats', 'bose',
|
||||
'jbl', 'sony wh', 'sony wf', 'sennheiser', 'jabra', 'soundcore', 'anker', 'buds',
|
||||
'earphone', 'pod', 'soundbar', 'skullcandy', 'marshall', 'b&o', 'bang', 'olufsen'
|
||||
]
|
||||
if any(x in name_lower for x in audio_patterns):
|
||||
return 'audio'
|
||||
|
||||
wearable_patterns = [
|
||||
'watch', 'band', 'fitbit', 'garmin', 'mi band', 'miband', 'amazfit',
|
||||
'galaxy watch', 'gear', 'versa', 'sense', 'charge', 'inspire'
|
||||
]
|
||||
if any(x in name_lower for x in wearable_patterns):
|
||||
return 'wearable'
|
||||
|
||||
phone_patterns = [
|
||||
'iphone', 'galaxy', 'pixel', 'phone', 'android', 'oneplus', 'huawei', 'xiaomi'
|
||||
]
|
||||
if any(x in name_lower for x in phone_patterns):
|
||||
return 'phone'
|
||||
|
||||
tracker_patterns = ['airtag', 'tile', 'smarttag', 'chipolo', 'find my']
|
||||
if any(x in name_lower for x in tracker_patterns):
|
||||
return 'tracker'
|
||||
|
||||
input_patterns = ['keyboard', 'mouse', 'controller', 'gamepad', 'remote']
|
||||
if any(x in name_lower for x in input_patterns):
|
||||
return 'input'
|
||||
|
||||
if mfr_lower in ['bose', 'jbl', 'sony', 'sennheiser', 'jabra', 'beats']:
|
||||
return 'audio'
|
||||
if mfr_lower in ['fitbit', 'garmin']:
|
||||
return 'wearable'
|
||||
if mfr_lower == 'tile':
|
||||
return 'tracker'
|
||||
|
||||
if device_class:
|
||||
major_class = (device_class >> 8) & 0x1F
|
||||
if major_class == 1:
|
||||
return 'computer'
|
||||
elif major_class == 2:
|
||||
return 'phone'
|
||||
elif major_class == 4:
|
||||
return 'audio'
|
||||
elif major_class == 5:
|
||||
return 'input'
|
||||
elif major_class == 7:
|
||||
return 'wearable'
|
||||
|
||||
return 'other'
|
||||
|
||||
|
||||
def detect_tracker(mac, name, manufacturer_data=None):
|
||||
"""Detect if device is a known tracker."""
|
||||
mac_prefix = mac[:5].upper()
|
||||
|
||||
if any(mac_prefix.startswith(p) for p in AIRTAG_PREFIXES):
|
||||
if manufacturer_data and b'\\x4c\\x00' in manufacturer_data:
|
||||
return {'type': 'airtag', 'name': 'Apple AirTag', 'risk': 'high'}
|
||||
|
||||
if any(mac_prefix.startswith(p) for p in TILE_PREFIXES):
|
||||
return {'type': 'tile', 'name': 'Tile Tracker', 'risk': 'medium'}
|
||||
|
||||
if any(mac_prefix.startswith(p) for p in SAMSUNG_TRACKER):
|
||||
return {'type': 'smarttag', 'name': 'Samsung SmartTag', 'risk': 'medium'}
|
||||
|
||||
name_lower = (name or '').lower()
|
||||
if 'airtag' in name_lower:
|
||||
return {'type': 'airtag', 'name': 'Apple AirTag', 'risk': 'high'}
|
||||
if 'tile' in name_lower:
|
||||
return {'type': 'tile', 'name': 'Tile Tracker', 'risk': 'medium'}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def detect_bt_interfaces():
|
||||
"""Detect available Bluetooth interfaces."""
|
||||
interfaces = []
|
||||
|
||||
if platform.system() == 'Linux':
|
||||
try:
|
||||
result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=5)
|
||||
blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
|
||||
for block in blocks:
|
||||
if block.strip():
|
||||
first_line = block.split('\n')[0]
|
||||
match = re.match(r'(hci\d+):', first_line)
|
||||
if match:
|
||||
iface_name = match.group(1)
|
||||
is_up = 'UP RUNNING' in block or '\tUP ' in block
|
||||
interfaces.append({
|
||||
'name': iface_name,
|
||||
'type': 'hci',
|
||||
'status': 'up' if is_up else 'down'
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elif platform.system() == 'Darwin':
|
||||
interfaces.append({
|
||||
'name': 'default',
|
||||
'type': 'macos',
|
||||
'status': 'available'
|
||||
})
|
||||
|
||||
return interfaces
|
||||
|
||||
|
||||
def stream_bt_scan(process, scan_mode):
|
||||
"""Stream Bluetooth scan output to queue."""
|
||||
try:
|
||||
app_module.bt_queue.put({'type': 'status', 'text': 'started'})
|
||||
|
||||
if scan_mode == 'hcitool':
|
||||
for line in iter(process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line or 'LE Scan' in line:
|
||||
continue
|
||||
|
||||
parts = line.split()
|
||||
if len(parts) >= 1 and ':' in parts[0]:
|
||||
mac = parts[0]
|
||||
name = ' '.join(parts[1:]) if len(parts) > 1 else ''
|
||||
|
||||
manufacturer = get_manufacturer(mac)
|
||||
device = {
|
||||
'mac': mac,
|
||||
'name': name or '[Unknown]',
|
||||
'manufacturer': manufacturer,
|
||||
'type': classify_bt_device(name, None, None, manufacturer),
|
||||
'rssi': None,
|
||||
'last_seen': time.time()
|
||||
}
|
||||
|
||||
tracker = detect_tracker(mac, name)
|
||||
if tracker:
|
||||
device['tracker'] = tracker
|
||||
|
||||
is_new = mac not in app_module.bt_devices
|
||||
app_module.bt_devices[mac] = device
|
||||
|
||||
app_module.bt_queue.put({
|
||||
**device,
|
||||
'type': 'device',
|
||||
'device_type': device.get('type', 'other'),
|
||||
'action': 'new' if is_new else 'update',
|
||||
})
|
||||
|
||||
elif scan_mode == 'bluetoothctl':
|
||||
master_fd = getattr(process, '_master_fd', None)
|
||||
if not master_fd:
|
||||
app_module.bt_queue.put({'type': 'error', 'text': 'bluetoothctl pty not available'})
|
||||
return
|
||||
|
||||
buffer = ''
|
||||
while process.poll() is None:
|
||||
readable, _, _ = select.select([master_fd], [], [], 1.0)
|
||||
if readable:
|
||||
try:
|
||||
data = os.read(master_fd, 4096)
|
||||
if not data:
|
||||
break
|
||||
buffer += data.decode('utf-8', errors='replace')
|
||||
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
line = re.sub(r'\x1b\[[0-9;]*m', '', line)
|
||||
line = re.sub(r'\r', '', line)
|
||||
|
||||
if 'Device' in line:
|
||||
match = re.search(r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s*(.*)', line)
|
||||
if match:
|
||||
mac = match.group(1).upper()
|
||||
name = match.group(2).strip()
|
||||
|
||||
manufacturer = get_manufacturer(mac)
|
||||
device = {
|
||||
'mac': mac,
|
||||
'name': name or '[Unknown]',
|
||||
'manufacturer': manufacturer,
|
||||
'type': classify_bt_device(name, None, None, manufacturer),
|
||||
'rssi': None,
|
||||
'last_seen': time.time()
|
||||
}
|
||||
|
||||
tracker = detect_tracker(mac, name)
|
||||
if tracker:
|
||||
device['tracker'] = tracker
|
||||
|
||||
is_new = mac not in app_module.bt_devices
|
||||
app_module.bt_devices[mac] = device
|
||||
|
||||
app_module.bt_queue.put({
|
||||
**device,
|
||||
'type': 'device',
|
||||
'device_type': device.get('type', 'other'),
|
||||
'action': 'new' if is_new else 'update',
|
||||
})
|
||||
except OSError:
|
||||
break
|
||||
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
app_module.bt_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
process.wait()
|
||||
app_module.bt_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.bt_lock:
|
||||
app_module.bt_process = None
|
||||
|
||||
|
||||
@bluetooth_bp.route('/reload-oui', methods=['POST'])
|
||||
def reload_oui_database_route():
|
||||
"""Reload OUI database from external file."""
|
||||
new_db = load_oui_database()
|
||||
if new_db:
|
||||
OUI_DATABASE.clear()
|
||||
OUI_DATABASE.update(new_db)
|
||||
return jsonify({'status': 'success', 'entries': len(OUI_DATABASE)})
|
||||
return jsonify({'status': 'error', 'message': 'Could not load oui_database.json'})
|
||||
|
||||
|
||||
@bluetooth_bp.route('/interfaces')
|
||||
def get_bt_interfaces():
|
||||
"""Get available Bluetooth interfaces and tools."""
|
||||
interfaces = detect_bt_interfaces()
|
||||
tools = {
|
||||
'hcitool': check_tool('hcitool'),
|
||||
'bluetoothctl': check_tool('bluetoothctl'),
|
||||
'hciconfig': check_tool('hciconfig'),
|
||||
'l2ping': check_tool('l2ping'),
|
||||
'sdptool': check_tool('sdptool')
|
||||
}
|
||||
return jsonify({
|
||||
'interfaces': interfaces,
|
||||
'tools': tools,
|
||||
'current_interface': app_module.bt_interface
|
||||
})
|
||||
|
||||
|
||||
@bluetooth_bp.route('/scan/start', methods=['POST'])
|
||||
def start_bt_scan():
|
||||
"""Start Bluetooth scanning."""
|
||||
with app_module.bt_lock:
|
||||
if app_module.bt_process:
|
||||
if app_module.bt_process.poll() is None:
|
||||
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
||||
else:
|
||||
app_module.bt_process = None
|
||||
|
||||
data = request.json
|
||||
scan_mode = data.get('mode', 'hcitool')
|
||||
interface = data.get('interface', 'hci0')
|
||||
scan_ble = data.get('scan_ble', True)
|
||||
|
||||
app_module.bt_interface = interface
|
||||
app_module.bt_devices = {}
|
||||
|
||||
while not app_module.bt_queue.empty():
|
||||
try:
|
||||
app_module.bt_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
try:
|
||||
if scan_mode == 'hcitool':
|
||||
if scan_ble:
|
||||
cmd = ['hcitool', '-i', interface, 'lescan', '--duplicates']
|
||||
else:
|
||||
cmd = ['hcitool', '-i', interface, 'scan']
|
||||
|
||||
app_module.bt_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
elif scan_mode == 'bluetoothctl':
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
app_module.bt_process = subprocess.Popen(
|
||||
['bluetoothctl'],
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
close_fds=True
|
||||
)
|
||||
os.close(slave_fd)
|
||||
app_module.bt_process._master_fd = master_fd
|
||||
|
||||
time.sleep(0.5)
|
||||
os.write(master_fd, b'power on\n')
|
||||
time.sleep(0.3)
|
||||
os.write(master_fd, b'scan on\n')
|
||||
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': f'Unknown scan mode: {scan_mode}'})
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
if app_module.bt_process.poll() is not None:
|
||||
stderr_output = app_module.bt_process.stderr.read().decode('utf-8', errors='replace').strip()
|
||||
app_module.bt_process = None
|
||||
return jsonify({'status': 'error', 'message': stderr_output or 'Process failed to start'})
|
||||
|
||||
thread = threading.Thread(target=stream_bt_scan, args=(app_module.bt_process, scan_mode))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
app_module.bt_queue.put({'type': 'info', 'text': f'Started {scan_mode} scan on {interface}'})
|
||||
return jsonify({'status': 'started', 'mode': scan_mode, 'interface': interface})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@bluetooth_bp.route('/scan/stop', methods=['POST'])
|
||||
def stop_bt_scan():
|
||||
"""Stop Bluetooth scanning."""
|
||||
with app_module.bt_lock:
|
||||
if app_module.bt_process:
|
||||
app_module.bt_process.terminate()
|
||||
try:
|
||||
app_module.bt_process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.bt_process.kill()
|
||||
app_module.bt_process = None
|
||||
return jsonify({'status': 'stopped'})
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@bluetooth_bp.route('/reset', methods=['POST'])
|
||||
def reset_bt_adapter():
|
||||
"""Reset Bluetooth adapter."""
|
||||
data = request.json
|
||||
interface = data.get('interface', 'hci0')
|
||||
|
||||
with app_module.bt_lock:
|
||||
if app_module.bt_process:
|
||||
try:
|
||||
app_module.bt_process.terminate()
|
||||
app_module.bt_process.wait(timeout=2)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
try:
|
||||
app_module.bt_process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
app_module.bt_process = None
|
||||
|
||||
try:
|
||||
subprocess.run(['pkill', '-f', 'hcitool'], capture_output=True, timeout=2)
|
||||
subprocess.run(['pkill', '-f', 'bluetoothctl'], capture_output=True, timeout=2)
|
||||
time.sleep(0.5)
|
||||
|
||||
subprocess.run(['rfkill', 'unblock', 'bluetooth'], capture_output=True, timeout=5)
|
||||
subprocess.run(['hciconfig', interface, 'down'], capture_output=True, timeout=5)
|
||||
time.sleep(1)
|
||||
subprocess.run(['hciconfig', interface, 'up'], capture_output=True, timeout=5)
|
||||
time.sleep(0.5)
|
||||
|
||||
result = subprocess.run(['hciconfig', interface], capture_output=True, text=True, timeout=5)
|
||||
is_up = 'UP RUNNING' in result.stdout
|
||||
|
||||
return jsonify({
|
||||
'status': 'success' if is_up else 'warning',
|
||||
'message': f'Adapter {interface} reset' if is_up else f'Reset attempted but adapter may still be down',
|
||||
'is_up': is_up
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@bluetooth_bp.route('/enum', methods=['POST'])
|
||||
def enum_bt_services():
|
||||
"""Enumerate services on a Bluetooth device."""
|
||||
data = request.json
|
||||
target_mac = data.get('mac')
|
||||
|
||||
if not target_mac:
|
||||
return jsonify({'status': 'error', 'message': 'Target MAC required'})
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['sdptool', 'browse', target_mac],
|
||||
capture_output=True, text=True, timeout=30
|
||||
)
|
||||
|
||||
services = []
|
||||
current_service = {}
|
||||
|
||||
for line in result.stdout.split('\n'):
|
||||
line = line.strip()
|
||||
if line.startswith('Service Name:'):
|
||||
if current_service:
|
||||
services.append(current_service)
|
||||
current_service = {'name': line.split(':', 1)[1].strip()}
|
||||
elif line.startswith('Service Description:'):
|
||||
current_service['description'] = line.split(':', 1)[1].strip()
|
||||
|
||||
if current_service:
|
||||
services.append(current_service)
|
||||
|
||||
app_module.bt_services[target_mac] = services
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'mac': target_mac,
|
||||
'services': services
|
||||
})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'status': 'error', 'message': 'Connection timed out'})
|
||||
except FileNotFoundError:
|
||||
return jsonify({'status': 'error', 'message': 'sdptool not found'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@bluetooth_bp.route('/devices')
|
||||
def get_bt_devices():
|
||||
"""Get current list of discovered Bluetooth devices."""
|
||||
return jsonify({
|
||||
'devices': list(app_module.bt_devices.values()),
|
||||
'beacons': list(app_module.bt_beacons.values()),
|
||||
'interface': app_module.bt_interface
|
||||
})
|
||||
|
||||
|
||||
@bluetooth_bp.route('/stream')
|
||||
def stream_bt():
|
||||
"""SSE stream for Bluetooth events."""
|
||||
def generate():
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.bt_queue.get(timeout=1)
|
||||
yield f"data: {json.dumps(msg)}\n\n"
|
||||
except queue.Empty:
|
||||
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
|
||||
|
||||
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
|
||||
126
routes/iridium.py
Normal file
126
routes/iridium.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Iridium monitoring routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import iridium_logger as logger
|
||||
|
||||
iridium_bp = Blueprint('iridium', __name__, url_prefix='/iridium')
|
||||
|
||||
|
||||
def monitor_iridium(process):
|
||||
"""Monitor Iridium capture and detect bursts."""
|
||||
try:
|
||||
burst_count = 0
|
||||
while process.poll() is None:
|
||||
data = process.stdout.read(1024)
|
||||
if data:
|
||||
if len(data) > 0 and burst_count < 100:
|
||||
if random.random() < 0.01:
|
||||
burst = {
|
||||
'type': 'burst',
|
||||
'time': datetime.now().strftime('%H:%M:%S.%f')[:-3],
|
||||
'frequency': f"{1616 + random.random() * 10:.3f}",
|
||||
'data': f"Frame data (simulated) - Burst #{burst_count + 1}"
|
||||
}
|
||||
app_module.satellite_queue.put(burst)
|
||||
app_module.iridium_bursts.append(burst)
|
||||
burst_count += 1
|
||||
|
||||
time.sleep(0.1)
|
||||
except Exception as e:
|
||||
logger.error(f"Monitor error: {e}")
|
||||
|
||||
|
||||
@iridium_bp.route('/tools')
|
||||
def check_iridium_tools():
|
||||
"""Check for Iridium decoding tools."""
|
||||
has_tool = shutil.which('iridium-extractor') is not None or shutil.which('iridium-parser') is not None
|
||||
return jsonify({'available': has_tool})
|
||||
|
||||
|
||||
@iridium_bp.route('/start', methods=['POST'])
|
||||
def start_iridium():
|
||||
"""Start Iridium burst capture."""
|
||||
with app_module.satellite_lock:
|
||||
if app_module.satellite_process and app_module.satellite_process.poll() is None:
|
||||
return jsonify({'status': 'error', 'message': 'Iridium capture already running'})
|
||||
|
||||
data = request.json
|
||||
freq = data.get('freq', '1626.0')
|
||||
gain = data.get('gain', '40')
|
||||
sample_rate = data.get('sampleRate', '2.048e6')
|
||||
device = data.get('device', '0')
|
||||
|
||||
if not shutil.which('iridium-extractor') and not shutil.which('rtl_fm'):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Iridium tools not found.'
|
||||
})
|
||||
|
||||
try:
|
||||
cmd = [
|
||||
'rtl_fm',
|
||||
'-f', f'{float(freq)}M',
|
||||
'-g', str(gain),
|
||||
'-s', sample_rate,
|
||||
'-d', str(device),
|
||||
'-'
|
||||
]
|
||||
|
||||
app_module.satellite_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
thread = threading.Thread(target=monitor_iridium, args=(app_module.satellite_process,), daemon=True)
|
||||
thread.start()
|
||||
|
||||
return jsonify({'status': 'started'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@iridium_bp.route('/stop', methods=['POST'])
|
||||
def stop_iridium():
|
||||
"""Stop Iridium capture."""
|
||||
with app_module.satellite_lock:
|
||||
if app_module.satellite_process:
|
||||
app_module.satellite_process.terminate()
|
||||
try:
|
||||
app_module.satellite_process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.satellite_process.kill()
|
||||
app_module.satellite_process = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@iridium_bp.route('/stream')
|
||||
def stream_iridium():
|
||||
"""SSE stream for Iridium bursts."""
|
||||
def generate():
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.satellite_queue.get(timeout=1)
|
||||
yield f"data: {json.dumps(msg)}\n\n"
|
||||
except queue.Empty:
|
||||
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
328
routes/pager.py
Normal file
328
routes/pager.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""Pager decoding routes (POCSAG/FLEX)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import pty
|
||||
import queue
|
||||
import select
|
||||
import subprocess
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import pager_logger as logger
|
||||
|
||||
pager_bp = Blueprint('pager', __name__)
|
||||
|
||||
|
||||
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||
"""Parse multimon-ng output line."""
|
||||
line = line.strip()
|
||||
|
||||
# POCSAG parsing - with message content
|
||||
pocsag_match = re.match(
|
||||
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(Alpha|Numeric):\s*(.*)',
|
||||
line
|
||||
)
|
||||
if pocsag_match:
|
||||
return {
|
||||
'protocol': pocsag_match.group(1),
|
||||
'address': pocsag_match.group(2),
|
||||
'function': pocsag_match.group(3),
|
||||
'msg_type': pocsag_match.group(4),
|
||||
'message': pocsag_match.group(5).strip() or '[No Message]'
|
||||
}
|
||||
|
||||
# POCSAG parsing - address only (no message content)
|
||||
pocsag_addr_match = re.match(
|
||||
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$',
|
||||
line
|
||||
)
|
||||
if pocsag_addr_match:
|
||||
return {
|
||||
'protocol': pocsag_addr_match.group(1),
|
||||
'address': pocsag_addr_match.group(2),
|
||||
'function': pocsag_addr_match.group(3),
|
||||
'msg_type': 'Tone',
|
||||
'message': '[Tone Only]'
|
||||
}
|
||||
|
||||
# FLEX parsing (standard format)
|
||||
flex_match = re.match(
|
||||
r'FLEX[:\|]\s*[\d\-]+[\s\|]+[\d:]+[\s\|]+([\d/A-Z]+)[\s\|]+([\d.]+)[\s\|]+\[?(\d+)\]?[\s\|]+(\w+)[\s\|]+(.*)',
|
||||
line
|
||||
)
|
||||
if flex_match:
|
||||
return {
|
||||
'protocol': 'FLEX',
|
||||
'address': flex_match.group(3),
|
||||
'function': flex_match.group(1),
|
||||
'msg_type': flex_match.group(4),
|
||||
'message': flex_match.group(5).strip() or '[No Message]'
|
||||
}
|
||||
|
||||
# Simple FLEX format
|
||||
flex_simple = re.match(r'FLEX:\s*(.+)', line)
|
||||
if flex_simple:
|
||||
return {
|
||||
'protocol': 'FLEX',
|
||||
'address': 'Unknown',
|
||||
'function': '',
|
||||
'msg_type': 'Unknown',
|
||||
'message': flex_simple.group(1).strip()
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def log_message(msg: dict[str, Any]) -> None:
|
||||
"""Log a message to file if logging is enabled."""
|
||||
if not app_module.logging_enabled:
|
||||
return
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log message: {e}")
|
||||
|
||||
|
||||
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream decoder output to queue using PTY for unbuffered output."""
|
||||
try:
|
||||
app_module.output_queue.put({'type': 'status', 'text': 'started'})
|
||||
|
||||
buffer = ""
|
||||
while True:
|
||||
try:
|
||||
ready, _, _ = select.select([master_fd], [], [], 1.0)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
if ready:
|
||||
try:
|
||||
data = os.read(master_fd, 1024)
|
||||
if not data:
|
||||
break
|
||||
buffer += data.decode('utf-8', errors='replace')
|
||||
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
parsed = parse_multimon_output(line)
|
||||
if parsed:
|
||||
parsed['timestamp'] = datetime.now().strftime('%H:%M:%S')
|
||||
app_module.output_queue.put({'type': 'message', **parsed})
|
||||
log_message(parsed)
|
||||
else:
|
||||
app_module.output_queue.put({'type': 'raw', 'text': line})
|
||||
except OSError:
|
||||
break
|
||||
|
||||
if process.poll() is not None:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
process.wait()
|
||||
app_module.output_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.process_lock:
|
||||
app_module.current_process = None
|
||||
|
||||
|
||||
@pager_bp.route('/start', methods=['POST'])
|
||||
def start_decoding() -> Response:
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
return jsonify({'status': 'error', 'message': 'Already running'})
|
||||
|
||||
data = request.json
|
||||
freq = data.get('frequency', '929.6125')
|
||||
gain = data.get('gain', '0')
|
||||
squelch = data.get('squelch', '0')
|
||||
ppm = data.get('ppm', '0')
|
||||
device = data.get('device', '0')
|
||||
protocols = data.get('protocols', ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX'])
|
||||
|
||||
# Clear queue
|
||||
while not app_module.output_queue.empty():
|
||||
try:
|
||||
app_module.output_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Build multimon-ng decoder arguments
|
||||
decoders = []
|
||||
for proto in protocols:
|
||||
if proto == 'POCSAG512':
|
||||
decoders.extend(['-a', 'POCSAG512'])
|
||||
elif proto == 'POCSAG1200':
|
||||
decoders.extend(['-a', 'POCSAG1200'])
|
||||
elif proto == 'POCSAG2400':
|
||||
decoders.extend(['-a', 'POCSAG2400'])
|
||||
elif proto == 'FLEX':
|
||||
decoders.extend(['-a', 'FLEX'])
|
||||
|
||||
# Build rtl_fm command
|
||||
rtl_cmd = [
|
||||
'rtl_fm',
|
||||
'-d', str(device),
|
||||
'-f', f'{freq}M',
|
||||
'-M', 'fm',
|
||||
'-s', '22050',
|
||||
]
|
||||
|
||||
if gain and gain != '0':
|
||||
rtl_cmd.extend(['-g', str(gain)])
|
||||
|
||||
if ppm and ppm != '0':
|
||||
rtl_cmd.extend(['-p', str(ppm)])
|
||||
|
||||
if squelch and squelch != '0':
|
||||
rtl_cmd.extend(['-l', str(squelch)])
|
||||
|
||||
rtl_cmd.append('-')
|
||||
|
||||
multimon_cmd = ['multimon-ng', '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
|
||||
|
||||
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
|
||||
try:
|
||||
# Create pipe: rtl_fm | multimon-ng
|
||||
rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
# Start a thread to monitor rtl_fm stderr for errors
|
||||
def monitor_rtl_stderr():
|
||||
for line in rtl_process.stderr:
|
||||
err_text = line.decode('utf-8', errors='replace').strip()
|
||||
if err_text:
|
||||
logger.debug(f"[RTL_FM] {err_text}")
|
||||
app_module.output_queue.put({'type': 'raw', 'text': f'[rtl_fm] {err_text}'})
|
||||
|
||||
rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr)
|
||||
rtl_stderr_thread.daemon = True
|
||||
rtl_stderr_thread.start()
|
||||
|
||||
# Create a pseudo-terminal for multimon-ng output
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
|
||||
multimon_process = subprocess.Popen(
|
||||
multimon_cmd,
|
||||
stdin=rtl_process.stdout,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
close_fds=True
|
||||
)
|
||||
|
||||
os.close(slave_fd)
|
||||
rtl_process.stdout.close()
|
||||
|
||||
app_module.current_process = multimon_process
|
||||
app_module.current_process._rtl_process = rtl_process
|
||||
app_module.current_process._master_fd = master_fd
|
||||
|
||||
# Start output thread with PTY master fd
|
||||
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
app_module.output_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
|
||||
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@pager_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoding() -> Response:
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
# Kill rtl_fm process first
|
||||
if hasattr(app_module.current_process, '_rtl_process'):
|
||||
try:
|
||||
app_module.current_process._rtl_process.terminate()
|
||||
app_module.current_process._rtl_process.wait(timeout=2)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
try:
|
||||
app_module.current_process._rtl_process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Close PTY master fd
|
||||
if hasattr(app_module.current_process, '_master_fd'):
|
||||
try:
|
||||
os.close(app_module.current_process._master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Kill multimon-ng
|
||||
app_module.current_process.terminate()
|
||||
try:
|
||||
app_module.current_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.current_process.kill()
|
||||
|
||||
app_module.current_process = None
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@pager_bp.route('/status')
|
||||
def get_status() -> Response:
|
||||
"""Check if decoder is currently running."""
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process and app_module.current_process.poll() is None:
|
||||
return jsonify({'running': True, 'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
||||
return jsonify({'running': False, 'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
||||
|
||||
|
||||
@pager_bp.route('/logging', methods=['POST'])
|
||||
def toggle_logging() -> Response:
|
||||
"""Toggle message logging."""
|
||||
data = request.json
|
||||
if 'enabled' in data:
|
||||
app_module.logging_enabled = data['enabled']
|
||||
if 'log_file' in data and data['log_file']:
|
||||
app_module.log_file_path = data['log_file']
|
||||
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
||||
|
||||
|
||||
@pager_bp.route('/stream')
|
||||
def stream() -> Response:
|
||||
import json
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.output_queue.get(timeout=1)
|
||||
yield f"data: {json.dumps(msg)}\n\n"
|
||||
except queue.Empty:
|
||||
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
|
||||
|
||||
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
|
||||
381
routes/satellite.py
Normal file
381
routes/satellite.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""Satellite tracking routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, jsonify, request, render_template, Response
|
||||
|
||||
from data.satellites import TLE_SATELLITES
|
||||
from utils.logging import satellite_logger as logger
|
||||
|
||||
satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite')
|
||||
|
||||
# Local TLE cache (can be updated via API)
|
||||
_tle_cache = dict(TLE_SATELLITES)
|
||||
|
||||
|
||||
@satellite_bp.route('/dashboard')
|
||||
def satellite_dashboard():
|
||||
"""Popout satellite tracking dashboard."""
|
||||
return render_template('satellite_dashboard.html')
|
||||
|
||||
|
||||
@satellite_bp.route('/predict', methods=['POST'])
|
||||
def predict_passes():
|
||||
"""Calculate satellite passes using skyfield."""
|
||||
try:
|
||||
from skyfield.api import load, wgs84, EarthSatellite
|
||||
from skyfield.almanac import find_discrete
|
||||
except ImportError:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'skyfield library not installed. Run: pip install skyfield'
|
||||
})
|
||||
|
||||
data = request.json
|
||||
lat = data.get('latitude', data.get('lat', 51.5074))
|
||||
lon = data.get('longitude', data.get('lon', -0.1278))
|
||||
hours = data.get('hours', 24)
|
||||
min_el = data.get('minEl', 10)
|
||||
|
||||
norad_to_name = {
|
||||
25544: 'ISS',
|
||||
25338: 'NOAA-15',
|
||||
28654: 'NOAA-18',
|
||||
33591: 'NOAA-19',
|
||||
43013: 'NOAA-20',
|
||||
40069: 'METEOR-M2',
|
||||
57166: 'METEOR-M2-3'
|
||||
}
|
||||
|
||||
sat_input = data.get('satellites', ['ISS', 'NOAA-15', 'NOAA-18', 'NOAA-19'])
|
||||
satellites = []
|
||||
for sat in sat_input:
|
||||
if isinstance(sat, int) and sat in norad_to_name:
|
||||
satellites.append(norad_to_name[sat])
|
||||
else:
|
||||
satellites.append(sat)
|
||||
|
||||
passes = []
|
||||
colors = {
|
||||
'ISS': '#00ffff',
|
||||
'NOAA-15': '#00ff00',
|
||||
'NOAA-18': '#ff6600',
|
||||
'NOAA-19': '#ff3366',
|
||||
'NOAA-20': '#00ffaa',
|
||||
'METEOR-M2': '#9370DB',
|
||||
'METEOR-M2-3': '#ff00ff'
|
||||
}
|
||||
name_to_norad = {v: k for k, v in norad_to_name.items()}
|
||||
|
||||
ts = load.timescale()
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
|
||||
t0 = ts.now()
|
||||
t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours))
|
||||
|
||||
for sat_name in satellites:
|
||||
if sat_name not in _tle_cache:
|
||||
continue
|
||||
|
||||
tle_data = _tle_cache[sat_name]
|
||||
try:
|
||||
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def above_horizon(t):
|
||||
diff = satellite - observer
|
||||
topocentric = diff.at(t)
|
||||
alt, _, _ = topocentric.altaz()
|
||||
return alt.degrees > 0
|
||||
|
||||
above_horizon.step_days = 1/720
|
||||
|
||||
try:
|
||||
times, events = find_discrete(t0, t1, above_horizon)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
i = 0
|
||||
while i < len(times):
|
||||
if i < len(events) and events[i]:
|
||||
rise_time = times[i]
|
||||
set_time = None
|
||||
for j in range(i + 1, len(times)):
|
||||
if not events[j]:
|
||||
set_time = times[j]
|
||||
i = j
|
||||
break
|
||||
|
||||
if set_time is None:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
trajectory = []
|
||||
max_elevation = 0
|
||||
num_points = 30
|
||||
|
||||
duration_seconds = (set_time.utc_datetime() - rise_time.utc_datetime()).total_seconds()
|
||||
|
||||
for k in range(num_points):
|
||||
frac = k / (num_points - 1)
|
||||
t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac))
|
||||
|
||||
diff = satellite - observer
|
||||
topocentric = diff.at(t_point)
|
||||
alt, az, _ = topocentric.altaz()
|
||||
|
||||
el = alt.degrees
|
||||
azimuth = az.degrees
|
||||
|
||||
if el > max_elevation:
|
||||
max_elevation = el
|
||||
|
||||
trajectory.append({'el': float(max(0, el)), 'az': float(azimuth)})
|
||||
|
||||
if max_elevation >= min_el:
|
||||
duration_minutes = int(duration_seconds / 60)
|
||||
|
||||
ground_track = []
|
||||
for k in range(60):
|
||||
frac = k / 59
|
||||
t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac))
|
||||
geocentric = satellite.at(t_point)
|
||||
subpoint = wgs84.subpoint(geocentric)
|
||||
ground_track.append({
|
||||
'lat': float(subpoint.latitude.degrees),
|
||||
'lon': float(subpoint.longitude.degrees)
|
||||
})
|
||||
|
||||
current_geo = satellite.at(ts.now())
|
||||
current_subpoint = wgs84.subpoint(current_geo)
|
||||
|
||||
passes.append({
|
||||
'satellite': sat_name,
|
||||
'norad': name_to_norad.get(sat_name, 0),
|
||||
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
|
||||
'startTimeISO': rise_time.utc_datetime().isoformat(),
|
||||
'maxEl': float(round(max_elevation, 1)),
|
||||
'duration': int(duration_minutes),
|
||||
'trajectory': trajectory,
|
||||
'groundTrack': ground_track,
|
||||
'currentPos': {
|
||||
'lat': float(current_subpoint.latitude.degrees),
|
||||
'lon': float(current_subpoint.longitude.degrees)
|
||||
},
|
||||
'color': colors.get(sat_name, '#00ff00')
|
||||
})
|
||||
|
||||
i += 1
|
||||
|
||||
passes.sort(key=lambda p: p['startTime'])
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'passes': passes
|
||||
})
|
||||
|
||||
|
||||
@satellite_bp.route('/position', methods=['POST'])
|
||||
def get_satellite_position():
|
||||
"""Get real-time positions of satellites."""
|
||||
try:
|
||||
from skyfield.api import load, wgs84, EarthSatellite
|
||||
except ImportError:
|
||||
return jsonify({'status': 'error', 'message': 'skyfield not installed'})
|
||||
|
||||
data = request.json
|
||||
lat = data.get('latitude', data.get('lat', 51.5074))
|
||||
lon = data.get('longitude', data.get('lon', -0.1278))
|
||||
sat_input = data.get('satellites', [])
|
||||
include_track = data.get('includeTrack', True)
|
||||
|
||||
norad_to_name = {
|
||||
25544: 'ISS',
|
||||
25338: 'NOAA-15',
|
||||
28654: 'NOAA-18',
|
||||
33591: 'NOAA-19',
|
||||
43013: 'NOAA-20',
|
||||
40069: 'METEOR-M2',
|
||||
57166: 'METEOR-M2-3'
|
||||
}
|
||||
|
||||
satellites = []
|
||||
for sat in sat_input:
|
||||
if isinstance(sat, int) and sat in norad_to_name:
|
||||
satellites.append(norad_to_name[sat])
|
||||
else:
|
||||
satellites.append(sat)
|
||||
|
||||
ts = load.timescale()
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
now = ts.now()
|
||||
now_dt = now.utc_datetime()
|
||||
|
||||
positions = []
|
||||
|
||||
for sat_name in satellites:
|
||||
if sat_name not in _tle_cache:
|
||||
continue
|
||||
|
||||
tle_data = _tle_cache[sat_name]
|
||||
try:
|
||||
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
||||
|
||||
geocentric = satellite.at(now)
|
||||
subpoint = wgs84.subpoint(geocentric)
|
||||
|
||||
diff = satellite - observer
|
||||
topocentric = diff.at(now)
|
||||
alt, az, distance = topocentric.altaz()
|
||||
|
||||
pos_data = {
|
||||
'satellite': sat_name,
|
||||
'lat': float(subpoint.latitude.degrees),
|
||||
'lon': float(subpoint.longitude.degrees),
|
||||
'altitude': float(geocentric.distance().km - 6371),
|
||||
'elevation': float(alt.degrees),
|
||||
'azimuth': float(az.degrees),
|
||||
'distance': float(distance.km),
|
||||
'visible': bool(alt.degrees > 0)
|
||||
}
|
||||
|
||||
if include_track:
|
||||
orbit_track = []
|
||||
for minutes_offset in range(-45, 46, 1):
|
||||
t_point = ts.utc(now_dt + timedelta(minutes=minutes_offset))
|
||||
try:
|
||||
geo = satellite.at(t_point)
|
||||
sp = wgs84.subpoint(geo)
|
||||
orbit_track.append({
|
||||
'lat': float(sp.latitude.degrees),
|
||||
'lon': float(sp.longitude.degrees),
|
||||
'past': minutes_offset < 0
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
pos_data['track'] = orbit_track
|
||||
|
||||
positions.append(pos_data)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'positions': positions,
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
|
||||
@satellite_bp.route('/update-tle', methods=['POST'])
|
||||
def update_tle():
|
||||
"""Update TLE data from CelesTrak."""
|
||||
global _tle_cache
|
||||
|
||||
try:
|
||||
name_mappings = {
|
||||
'ISS (ZARYA)': 'ISS',
|
||||
'NOAA 15': 'NOAA-15',
|
||||
'NOAA 18': 'NOAA-18',
|
||||
'NOAA 19': 'NOAA-19',
|
||||
'METEOR-M 2': 'METEOR-M2',
|
||||
'METEOR-M2 3': 'METEOR-M2-3'
|
||||
}
|
||||
|
||||
updated = []
|
||||
|
||||
for group in ['stations', 'weather']:
|
||||
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=tle'
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=10) as response:
|
||||
content = response.read().decode('utf-8')
|
||||
lines = content.strip().split('\n')
|
||||
|
||||
i = 0
|
||||
while i + 2 < len(lines):
|
||||
name = lines[i].strip()
|
||||
line1 = lines[i + 1].strip()
|
||||
line2 = lines[i + 2].strip()
|
||||
|
||||
if not (line1.startswith('1 ') and line2.startswith('2 ')):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
internal_name = name_mappings.get(name, name)
|
||||
|
||||
if internal_name in _tle_cache:
|
||||
_tle_cache[internal_name] = (name, line1, line2)
|
||||
updated.append(internal_name)
|
||||
|
||||
i += 3
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching {group}: {e}")
|
||||
continue
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'updated': updated
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@satellite_bp.route('/celestrak/<category>')
|
||||
def fetch_celestrak(category):
|
||||
"""Fetch TLE data from CelesTrak for a category."""
|
||||
valid_categories = [
|
||||
'stations', 'weather', 'noaa', 'goes', 'resource', 'sarsat',
|
||||
'dmc', 'tdrss', 'argos', 'planet', 'spire', 'geo', 'intelsat',
|
||||
'ses', 'iridium', 'iridium-NEXT', 'starlink', 'oneweb',
|
||||
'amateur', 'cubesat', 'visual'
|
||||
]
|
||||
|
||||
if category not in valid_categories:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid category. Valid: {valid_categories}'})
|
||||
|
||||
try:
|
||||
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={category}&FORMAT=tle'
|
||||
with urllib.request.urlopen(url, timeout=10) as response:
|
||||
content = response.read().decode('utf-8')
|
||||
|
||||
satellites = []
|
||||
lines = content.strip().split('\n')
|
||||
|
||||
i = 0
|
||||
while i + 2 < len(lines):
|
||||
name = lines[i].strip()
|
||||
line1 = lines[i + 1].strip()
|
||||
line2 = lines[i + 2].strip()
|
||||
|
||||
if not (line1.startswith('1 ') and line2.startswith('2 ')):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
norad_id = int(line1[2:7])
|
||||
satellites.append({
|
||||
'name': name,
|
||||
'norad': norad_id,
|
||||
'tle1': line1,
|
||||
'tle2': line2
|
||||
})
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
i += 3
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'category': category,
|
||||
'satellites': satellites
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
157
routes/sensor.py
Normal file
157
routes/sensor.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""RTL_433 sensor monitoring routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
|
||||
sensor_bp = Blueprint('sensor', __name__)
|
||||
|
||||
|
||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream rtl_433 JSON output to queue."""
|
||||
try:
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
|
||||
|
||||
for line in iter(process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
# rtl_433 outputs JSON objects, one per line
|
||||
data = json.loads(line)
|
||||
data['type'] = 'sensor'
|
||||
app_module.sensor_queue.put(data)
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON, send as raw
|
||||
app_module.sensor_queue.put({'type': 'raw', 'text': line})
|
||||
|
||||
except Exception as e:
|
||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
process.wait()
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.sensor_lock:
|
||||
app_module.sensor_process = None
|
||||
|
||||
|
||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||
def start_sensor() -> Response:
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
return jsonify({'status': 'error', 'message': 'Sensor already running'})
|
||||
|
||||
data = request.json
|
||||
freq = data.get('frequency', '433.92')
|
||||
gain = data.get('gain', '0')
|
||||
ppm = data.get('ppm', '0')
|
||||
device = data.get('device', '0')
|
||||
|
||||
# Clear queue
|
||||
while not app_module.sensor_queue.empty():
|
||||
try:
|
||||
app_module.sensor_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Build rtl_433 command
|
||||
cmd = [
|
||||
'rtl_433',
|
||||
'-d', str(device),
|
||||
'-f', f'{freq}M',
|
||||
'-F', 'json'
|
||||
]
|
||||
|
||||
if gain and gain != '0':
|
||||
cmd.extend(['-g', str(gain)])
|
||||
|
||||
if ppm and ppm != '0':
|
||||
cmd.extend(['-p', str(ppm)])
|
||||
|
||||
full_cmd = ' '.join(cmd)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
|
||||
try:
|
||||
app_module.sensor_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
# Start output thread
|
||||
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Monitor stderr
|
||||
def monitor_stderr():
|
||||
for line in app_module.sensor_process.stderr:
|
||||
err = line.decode('utf-8', errors='replace').strip()
|
||||
if err:
|
||||
logger.debug(f"[rtl_433] {err}")
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||
|
||||
stderr_thread = threading.Thread(target=monitor_stderr)
|
||||
stderr_thread.daemon = True
|
||||
stderr_thread.start()
|
||||
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
|
||||
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError:
|
||||
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||
def stop_sensor() -> Response:
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
app_module.sensor_process.terminate()
|
||||
try:
|
||||
app_module.sensor_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.sensor_process.kill()
|
||||
app_module.sensor_process = None
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@sensor_bp.route('/stream_sensor')
|
||||
def stream_sensor() -> Response:
|
||||
def generate() -> Generator[str, None, None]:
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.sensor_queue.get(timeout=1)
|
||||
yield f"data: {json.dumps(msg)}\n\n"
|
||||
except queue.Empty:
|
||||
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
|
||||
|
||||
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
|
||||
772
routes/wifi.py
Normal file
772
routes/wifi.py
Normal file
@@ -0,0 +1,772 @@
|
||||
"""WiFi reconnaissance routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import queue
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.dependencies import check_tool
|
||||
from utils.logging import wifi_logger as logger
|
||||
from utils.process import is_valid_mac, is_valid_channel
|
||||
from data.oui import get_manufacturer
|
||||
|
||||
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
|
||||
|
||||
# PMKID process state
|
||||
pmkid_process = None
|
||||
pmkid_lock = threading.Lock()
|
||||
|
||||
|
||||
def detect_wifi_interfaces():
|
||||
"""Detect available WiFi interfaces."""
|
||||
interfaces = []
|
||||
|
||||
if platform.system() == 'Darwin': # macOS
|
||||
try:
|
||||
result = subprocess.run(['networksetup', '-listallhardwareports'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
lines = result.stdout.split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if 'Wi-Fi' in line or 'AirPort' in line:
|
||||
for j in range(i+1, min(i+3, len(lines))):
|
||||
if 'Device:' in lines[j]:
|
||||
device = lines[j].split('Device:')[1].strip()
|
||||
interfaces.append({
|
||||
'name': device,
|
||||
'type': 'internal',
|
||||
'monitor_capable': False,
|
||||
'status': 'up'
|
||||
})
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error detecting macOS interfaces: {e}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(['system_profiler', 'SPUSBDataType'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
if 'Wireless' in result.stdout or 'WLAN' in result.stdout or '802.11' in result.stdout:
|
||||
interfaces.append({
|
||||
'name': 'USB WiFi Adapter',
|
||||
'type': 'usb',
|
||||
'monitor_capable': True,
|
||||
'status': 'detected'
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
else: # Linux
|
||||
try:
|
||||
result = subprocess.run(['iw', 'dev'], capture_output=True, text=True, timeout=5)
|
||||
current_iface = None
|
||||
for line in result.stdout.split('\n'):
|
||||
line = line.strip()
|
||||
if line.startswith('Interface'):
|
||||
current_iface = line.split()[1]
|
||||
elif current_iface and 'type' in line:
|
||||
iface_type = line.split()[-1]
|
||||
interfaces.append({
|
||||
'name': current_iface,
|
||||
'type': iface_type,
|
||||
'monitor_capable': True,
|
||||
'status': 'up'
|
||||
})
|
||||
current_iface = None
|
||||
except FileNotFoundError:
|
||||
try:
|
||||
result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=5)
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'IEEE 802.11' in line:
|
||||
iface = line.split()[0]
|
||||
interfaces.append({
|
||||
'name': iface,
|
||||
'type': 'managed',
|
||||
'monitor_capable': True,
|
||||
'status': 'up'
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error detecting Linux interfaces: {e}")
|
||||
|
||||
return interfaces
|
||||
|
||||
|
||||
def parse_airodump_csv(csv_path):
|
||||
"""Parse airodump-ng CSV output file."""
|
||||
networks = {}
|
||||
clients = {}
|
||||
|
||||
try:
|
||||
with open(csv_path, 'r', errors='replace') as f:
|
||||
content = f.read()
|
||||
|
||||
sections = content.split('\n\n')
|
||||
|
||||
for section in sections:
|
||||
lines = section.strip().split('\n')
|
||||
if not lines:
|
||||
continue
|
||||
|
||||
header = lines[0] if lines else ''
|
||||
|
||||
if 'BSSID' in header and 'ESSID' in header:
|
||||
for line in lines[1:]:
|
||||
parts = [p.strip() for p in line.split(',')]
|
||||
if len(parts) >= 14:
|
||||
bssid = parts[0]
|
||||
if bssid and ':' in bssid:
|
||||
networks[bssid] = {
|
||||
'bssid': bssid,
|
||||
'first_seen': parts[1],
|
||||
'last_seen': parts[2],
|
||||
'channel': parts[3],
|
||||
'speed': parts[4],
|
||||
'privacy': parts[5],
|
||||
'cipher': parts[6],
|
||||
'auth': parts[7],
|
||||
'power': parts[8],
|
||||
'beacons': parts[9],
|
||||
'ivs': parts[10],
|
||||
'lan_ip': parts[11],
|
||||
'essid': parts[13] or 'Hidden'
|
||||
}
|
||||
|
||||
elif 'Station MAC' in header:
|
||||
for line in lines[1:]:
|
||||
parts = [p.strip() for p in line.split(',')]
|
||||
if len(parts) >= 6:
|
||||
station = parts[0]
|
||||
if station and ':' in station:
|
||||
vendor = get_manufacturer(station)
|
||||
clients[station] = {
|
||||
'mac': station,
|
||||
'first_seen': parts[1],
|
||||
'last_seen': parts[2],
|
||||
'power': parts[3],
|
||||
'packets': parts[4],
|
||||
'bssid': parts[5],
|
||||
'probes': parts[6] if len(parts) > 6 else '',
|
||||
'vendor': vendor
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing CSV: {e}")
|
||||
|
||||
return networks, clients
|
||||
|
||||
|
||||
def stream_airodump_output(process, csv_path):
|
||||
"""Stream airodump-ng output to queue."""
|
||||
try:
|
||||
app_module.wifi_queue.put({'type': 'status', 'text': 'started'})
|
||||
last_parse = 0
|
||||
start_time = time.time()
|
||||
csv_found = False
|
||||
|
||||
while process.poll() is None:
|
||||
try:
|
||||
fd = process.stderr.fileno()
|
||||
fl = fcntl.fcntl(fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
|
||||
|
||||
stderr_data = process.stderr.read()
|
||||
if stderr_data:
|
||||
stderr_text = stderr_data.decode('utf-8', errors='replace').strip()
|
||||
if stderr_text:
|
||||
for line in stderr_text.split('\n'):
|
||||
line = line.strip()
|
||||
if line and not line.startswith('CH') and not line.startswith('Elapsed'):
|
||||
app_module.wifi_queue.put({'type': 'error', 'text': f'airodump-ng: {line}'})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
current_time = time.time()
|
||||
if current_time - last_parse >= 2:
|
||||
csv_file = csv_path + '-01.csv'
|
||||
if os.path.exists(csv_file):
|
||||
csv_found = True
|
||||
networks, clients = parse_airodump_csv(csv_file)
|
||||
|
||||
for bssid, net in networks.items():
|
||||
if bssid not in app_module.wifi_networks:
|
||||
app_module.wifi_queue.put({
|
||||
'type': 'network',
|
||||
'action': 'new',
|
||||
**net
|
||||
})
|
||||
else:
|
||||
app_module.wifi_queue.put({
|
||||
'type': 'network',
|
||||
'action': 'update',
|
||||
**net
|
||||
})
|
||||
|
||||
for mac, client in clients.items():
|
||||
if mac not in app_module.wifi_clients:
|
||||
app_module.wifi_queue.put({
|
||||
'type': 'client',
|
||||
'action': 'new',
|
||||
**client
|
||||
})
|
||||
|
||||
app_module.wifi_networks = networks
|
||||
app_module.wifi_clients = clients
|
||||
last_parse = current_time
|
||||
|
||||
if current_time - start_time > 5 and not csv_found:
|
||||
app_module.wifi_queue.put({'type': 'error', 'text': 'No scan data after 5 seconds. Check if monitor mode is properly enabled.'})
|
||||
start_time = current_time + 30
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
try:
|
||||
remaining_stderr = process.stderr.read()
|
||||
if remaining_stderr:
|
||||
stderr_text = remaining_stderr.decode('utf-8', errors='replace').strip()
|
||||
if stderr_text:
|
||||
app_module.wifi_queue.put({'type': 'error', 'text': f'airodump-ng exited: {stderr_text}'})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
exit_code = process.returncode
|
||||
if exit_code != 0 and exit_code is not None:
|
||||
app_module.wifi_queue.put({'type': 'error', 'text': f'airodump-ng exited with code {exit_code}'})
|
||||
|
||||
except Exception as e:
|
||||
app_module.wifi_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
process.wait()
|
||||
app_module.wifi_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.wifi_lock:
|
||||
app_module.wifi_process = None
|
||||
|
||||
|
||||
@wifi_bp.route('/interfaces')
|
||||
def get_wifi_interfaces():
|
||||
"""Get available WiFi interfaces."""
|
||||
interfaces = detect_wifi_interfaces()
|
||||
tools = {
|
||||
'airmon': check_tool('airmon-ng'),
|
||||
'airodump': check_tool('airodump-ng'),
|
||||
'aireplay': check_tool('aireplay-ng'),
|
||||
'iw': check_tool('iw')
|
||||
}
|
||||
return jsonify({'interfaces': interfaces, 'tools': tools, 'monitor_interface': app_module.wifi_monitor_interface})
|
||||
|
||||
|
||||
@wifi_bp.route('/monitor', methods=['POST'])
|
||||
def toggle_monitor_mode():
|
||||
"""Enable or disable monitor mode on an interface."""
|
||||
data = request.json
|
||||
interface = data.get('interface')
|
||||
action = data.get('action', 'start')
|
||||
|
||||
if not interface:
|
||||
return jsonify({'status': 'error', 'message': 'No interface specified'})
|
||||
|
||||
if action == 'start':
|
||||
if check_tool('airmon-ng'):
|
||||
try:
|
||||
def get_wireless_interfaces():
|
||||
interfaces = set()
|
||||
try:
|
||||
result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=5)
|
||||
for line in result.stdout.split('\n'):
|
||||
if line and not line.startswith(' ') and 'no wireless' not in line.lower():
|
||||
iface = line.split()[0] if line.split() else None
|
||||
if iface:
|
||||
interfaces.add(iface)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
|
||||
try:
|
||||
for iface in os.listdir('/sys/class/net'):
|
||||
if os.path.exists(f'/sys/class/net/{iface}/wireless'):
|
||||
interfaces.add(iface)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
result = subprocess.run(['ip', 'link', 'show'], capture_output=True, text=True, timeout=5)
|
||||
for match in re.finditer(r'^\d+:\s+(\S+):', result.stdout, re.MULTILINE):
|
||||
iface = match.group(1).rstrip(':')
|
||||
if iface.startswith('wl') or 'mon' in iface:
|
||||
interfaces.add(iface)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
|
||||
return interfaces
|
||||
|
||||
interfaces_before = get_wireless_interfaces()
|
||||
|
||||
kill_processes = data.get('kill_processes', False)
|
||||
if kill_processes:
|
||||
subprocess.run(['airmon-ng', 'check', 'kill'], capture_output=True, timeout=10)
|
||||
|
||||
result = subprocess.run(['airmon-ng', 'start', interface],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
time.sleep(1)
|
||||
interfaces_after = get_wireless_interfaces()
|
||||
|
||||
new_interfaces = interfaces_after - interfaces_before
|
||||
monitor_iface = None
|
||||
|
||||
if new_interfaces:
|
||||
for iface in new_interfaces:
|
||||
if 'mon' in iface:
|
||||
monitor_iface = iface
|
||||
break
|
||||
if not monitor_iface:
|
||||
monitor_iface = list(new_interfaces)[0]
|
||||
|
||||
if not monitor_iface:
|
||||
patterns = [
|
||||
r'monitor mode.*enabled.*on\s+(\S+)',
|
||||
r'\(monitor mode.*enabled.*?(\S+mon)\)',
|
||||
r'created\s+(\S+mon)',
|
||||
r'\bon\s+(\S+mon)\b',
|
||||
r'\b(\S+mon)\b.*monitor',
|
||||
r'\b(' + re.escape(interface) + r'mon)\b',
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, output, re.IGNORECASE)
|
||||
if match:
|
||||
monitor_iface = match.group(1)
|
||||
break
|
||||
|
||||
if not monitor_iface:
|
||||
try:
|
||||
result = subprocess.run(['iwconfig', interface], capture_output=True, text=True, timeout=5)
|
||||
if 'Mode:Monitor' in result.stdout:
|
||||
monitor_iface = interface
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
|
||||
if not monitor_iface:
|
||||
potential = interface + 'mon'
|
||||
if potential in interfaces_after:
|
||||
monitor_iface = potential
|
||||
|
||||
if not monitor_iface:
|
||||
monitor_iface = interface + 'mon'
|
||||
|
||||
app_module.wifi_monitor_interface = monitor_iface
|
||||
app_module.wifi_queue.put({'type': 'info', 'text': f'Monitor mode enabled on {app_module.wifi_monitor_interface}'})
|
||||
return jsonify({'status': 'success', 'monitor_interface': app_module.wifi_monitor_interface})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"Error enabling monitor mode: {e}", exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
elif check_tool('iw'):
|
||||
try:
|
||||
subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
|
||||
subprocess.run(['iw', interface, 'set', 'monitor', 'control'], capture_output=True)
|
||||
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
|
||||
app_module.wifi_monitor_interface = interface
|
||||
return jsonify({'status': 'success', 'monitor_interface': interface})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'No monitor mode tools available.'})
|
||||
|
||||
else: # stop
|
||||
if check_tool('airmon-ng'):
|
||||
try:
|
||||
subprocess.run(['airmon-ng', 'stop', app_module.wifi_monitor_interface or interface],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
app_module.wifi_monitor_interface = None
|
||||
return jsonify({'status': 'success', 'message': 'Monitor mode disabled'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
elif check_tool('iw'):
|
||||
try:
|
||||
subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
|
||||
subprocess.run(['iw', interface, 'set', 'type', 'managed'], capture_output=True)
|
||||
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
|
||||
app_module.wifi_monitor_interface = None
|
||||
return jsonify({'status': 'success', 'message': 'Monitor mode disabled'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
return jsonify({'status': 'error', 'message': 'Unknown action'})
|
||||
|
||||
|
||||
@wifi_bp.route('/scan/start', methods=['POST'])
|
||||
def start_wifi_scan():
|
||||
"""Start WiFi scanning with airodump-ng."""
|
||||
with app_module.wifi_lock:
|
||||
if app_module.wifi_process:
|
||||
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
||||
|
||||
data = request.json
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
channel = data.get('channel')
|
||||
band = data.get('band', 'abg')
|
||||
|
||||
if not interface:
|
||||
return jsonify({'status': 'error', 'message': 'No monitor interface available.'})
|
||||
|
||||
app_module.wifi_networks = {}
|
||||
app_module.wifi_clients = {}
|
||||
|
||||
while not app_module.wifi_queue.empty():
|
||||
try:
|
||||
app_module.wifi_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
csv_path = '/tmp/intercept_wifi'
|
||||
|
||||
for f in [f'/tmp/intercept_wifi-01.csv', f'/tmp/intercept_wifi-01.cap']:
|
||||
try:
|
||||
os.remove(f)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
cmd = [
|
||||
'airodump-ng',
|
||||
'-w', csv_path,
|
||||
'--output-format', 'csv,pcap',
|
||||
'--band', band,
|
||||
interface
|
||||
]
|
||||
|
||||
if channel:
|
||||
cmd.extend(['-c', str(channel)])
|
||||
|
||||
logger.info(f"Running: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
app_module.wifi_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
if app_module.wifi_process.poll() is not None:
|
||||
stderr_output = app_module.wifi_process.stderr.read().decode('utf-8', errors='replace').strip()
|
||||
stdout_output = app_module.wifi_process.stdout.read().decode('utf-8', errors='replace').strip()
|
||||
exit_code = app_module.wifi_process.returncode
|
||||
app_module.wifi_process = None
|
||||
|
||||
error_msg = stderr_output or stdout_output or f'Process exited with code {exit_code}'
|
||||
error_msg = re.sub(r'\x1b\[[0-9;]*m', '', error_msg)
|
||||
|
||||
if 'No such device' in error_msg or 'No such interface' in error_msg:
|
||||
error_msg = f'Interface "{interface}" not found.'
|
||||
elif 'Operation not permitted' in error_msg:
|
||||
error_msg = 'Permission denied. Try running with sudo.'
|
||||
|
||||
return jsonify({'status': 'error', 'message': error_msg})
|
||||
|
||||
thread = threading.Thread(target=stream_airodump_output, args=(app_module.wifi_process, csv_path))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
app_module.wifi_queue.put({'type': 'info', 'text': f'Started scanning on {interface}'})
|
||||
|
||||
return jsonify({'status': 'started', 'interface': interface})
|
||||
|
||||
except FileNotFoundError:
|
||||
return jsonify({'status': 'error', 'message': 'airodump-ng not found.'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@wifi_bp.route('/scan/stop', methods=['POST'])
|
||||
def stop_wifi_scan():
|
||||
"""Stop WiFi scanning."""
|
||||
with app_module.wifi_lock:
|
||||
if app_module.wifi_process:
|
||||
app_module.wifi_process.terminate()
|
||||
try:
|
||||
app_module.wifi_process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.wifi_process.kill()
|
||||
app_module.wifi_process = None
|
||||
return jsonify({'status': 'stopped'})
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@wifi_bp.route('/deauth', methods=['POST'])
|
||||
def send_deauth():
|
||||
"""Send deauthentication packets."""
|
||||
data = request.json
|
||||
target_bssid = data.get('bssid')
|
||||
target_client = data.get('client', 'FF:FF:FF:FF:FF:FF')
|
||||
count = data.get('count', 5)
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid:
|
||||
return jsonify({'status': 'error', 'message': 'Target BSSID required'})
|
||||
|
||||
if not is_valid_mac(target_bssid):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
|
||||
|
||||
if not is_valid_mac(target_client):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid client MAC format'})
|
||||
|
||||
try:
|
||||
count = int(count)
|
||||
if count < 1 or count > 100:
|
||||
count = 5
|
||||
except (ValueError, TypeError):
|
||||
count = 5
|
||||
|
||||
if not interface:
|
||||
return jsonify({'status': 'error', 'message': 'No monitor interface'})
|
||||
|
||||
if not check_tool('aireplay-ng'):
|
||||
return jsonify({'status': 'error', 'message': 'aireplay-ng not found'})
|
||||
|
||||
try:
|
||||
cmd = [
|
||||
'aireplay-ng',
|
||||
'--deauth', str(count),
|
||||
'-a', target_bssid,
|
||||
'-c', target_client,
|
||||
interface
|
||||
]
|
||||
|
||||
app_module.wifi_queue.put({'type': 'info', 'text': f'Sending {count} deauth packets to {target_bssid}'})
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
|
||||
if result.returncode == 0:
|
||||
return jsonify({'status': 'success', 'message': f'Sent {count} deauth packets'})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': result.stderr})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'status': 'success', 'message': 'Deauth sent (timed out)'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@wifi_bp.route('/handshake/capture', methods=['POST'])
|
||||
def capture_handshake():
|
||||
"""Start targeted handshake capture."""
|
||||
data = request.json
|
||||
target_bssid = data.get('bssid')
|
||||
channel = data.get('channel')
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid or not channel:
|
||||
return jsonify({'status': 'error', 'message': 'BSSID and channel required'})
|
||||
|
||||
if not is_valid_mac(target_bssid):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
|
||||
|
||||
if not is_valid_channel(channel):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid channel'})
|
||||
|
||||
with app_module.wifi_lock:
|
||||
if app_module.wifi_process:
|
||||
return jsonify({'status': 'error', 'message': 'Scan already running.'})
|
||||
|
||||
capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}'
|
||||
|
||||
cmd = [
|
||||
'airodump-ng',
|
||||
'-c', str(channel),
|
||||
'--bssid', target_bssid,
|
||||
'-w', capture_path,
|
||||
'--output-format', 'pcap',
|
||||
interface
|
||||
]
|
||||
|
||||
try:
|
||||
app_module.wifi_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
app_module.wifi_queue.put({'type': 'info', 'text': f'Capturing handshakes for {target_bssid}'})
|
||||
return jsonify({'status': 'started', 'capture_file': capture_path + '-01.cap'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@wifi_bp.route('/handshake/status', methods=['POST'])
|
||||
def check_handshake_status():
|
||||
"""Check if a handshake has been captured."""
|
||||
data = request.json
|
||||
capture_file = data.get('file', '')
|
||||
target_bssid = data.get('bssid', '')
|
||||
|
||||
if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture file path'})
|
||||
|
||||
if not os.path.exists(capture_file):
|
||||
with app_module.wifi_lock:
|
||||
if app_module.wifi_process and app_module.wifi_process.poll() is None:
|
||||
return jsonify({'status': 'running', 'file_exists': False, 'handshake_found': False})
|
||||
else:
|
||||
return jsonify({'status': 'stopped', 'file_exists': False, 'handshake_found': False})
|
||||
|
||||
file_size = os.path.getsize(capture_file)
|
||||
handshake_found = False
|
||||
|
||||
try:
|
||||
if target_bssid and is_valid_mac(target_bssid):
|
||||
result = subprocess.run(
|
||||
['aircrack-ng', '-a', '2', '-b', target_bssid, capture_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
|
||||
if '0 handshake' not in output:
|
||||
handshake_found = True
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking handshake: {e}")
|
||||
|
||||
return jsonify({
|
||||
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
||||
'file_exists': True,
|
||||
'file_size': file_size,
|
||||
'file': capture_file,
|
||||
'handshake_found': handshake_found
|
||||
})
|
||||
|
||||
|
||||
@wifi_bp.route('/pmkid/capture', methods=['POST'])
|
||||
def capture_pmkid():
|
||||
"""Start PMKID capture using hcxdumptool."""
|
||||
global pmkid_process
|
||||
|
||||
data = request.json
|
||||
target_bssid = data.get('bssid')
|
||||
channel = data.get('channel')
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid:
|
||||
return jsonify({'status': 'error', 'message': 'BSSID required'})
|
||||
|
||||
if not is_valid_mac(target_bssid):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
|
||||
|
||||
with pmkid_lock:
|
||||
if pmkid_process and pmkid_process.poll() is None:
|
||||
return jsonify({'status': 'error', 'message': 'PMKID capture already running'})
|
||||
|
||||
capture_path = f'/tmp/intercept_pmkid_{target_bssid.replace(":", "")}.pcapng'
|
||||
filter_file = f'/tmp/pmkid_filter_{target_bssid.replace(":", "")}'
|
||||
with open(filter_file, 'w') as f:
|
||||
f.write(target_bssid.replace(':', '').lower())
|
||||
|
||||
cmd = [
|
||||
'hcxdumptool',
|
||||
'-i', interface,
|
||||
'-o', capture_path,
|
||||
'--filterlist_ap', filter_file,
|
||||
'--filtermode', '2',
|
||||
'--enable_status', '1'
|
||||
]
|
||||
|
||||
if channel:
|
||||
cmd.extend(['-c', str(channel)])
|
||||
|
||||
try:
|
||||
pmkid_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return jsonify({'status': 'started', 'file': capture_path})
|
||||
except FileNotFoundError:
|
||||
return jsonify({'status': 'error', 'message': 'hcxdumptool not found.'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@wifi_bp.route('/pmkid/status', methods=['POST'])
|
||||
def check_pmkid_status():
|
||||
"""Check if PMKID has been captured."""
|
||||
data = request.json
|
||||
capture_file = data.get('file', '')
|
||||
|
||||
if not capture_file.startswith('/tmp/intercept_pmkid_') or '..' in capture_file:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture file path'})
|
||||
|
||||
if not os.path.exists(capture_file):
|
||||
return jsonify({'pmkid_found': False, 'file_exists': False})
|
||||
|
||||
file_size = os.path.getsize(capture_file)
|
||||
pmkid_found = False
|
||||
|
||||
try:
|
||||
hash_file = capture_file.replace('.pcapng', '.22000')
|
||||
result = subprocess.run(
|
||||
['hcxpcapngtool', '-o', hash_file, capture_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if os.path.exists(hash_file) and os.path.getsize(hash_file) > 0:
|
||||
pmkid_found = True
|
||||
except FileNotFoundError:
|
||||
pmkid_found = file_size > 1000
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({
|
||||
'pmkid_found': pmkid_found,
|
||||
'file_exists': True,
|
||||
'file_size': file_size,
|
||||
'file': capture_file
|
||||
})
|
||||
|
||||
|
||||
@wifi_bp.route('/pmkid/stop', methods=['POST'])
|
||||
def stop_pmkid():
|
||||
"""Stop PMKID capture."""
|
||||
global pmkid_process
|
||||
|
||||
with pmkid_lock:
|
||||
if pmkid_process:
|
||||
pmkid_process.terminate()
|
||||
try:
|
||||
pmkid_process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
pmkid_process.kill()
|
||||
pmkid_process = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@wifi_bp.route('/networks')
|
||||
def get_wifi_networks():
|
||||
"""Get current list of discovered networks."""
|
||||
return jsonify({
|
||||
'networks': list(app_module.wifi_networks.values()),
|
||||
'clients': list(app_module.wifi_clients.values()),
|
||||
'handshakes': app_module.wifi_handshakes,
|
||||
'monitor_interface': app_module.wifi_monitor_interface
|
||||
})
|
||||
|
||||
|
||||
@wifi_bp.route('/stream')
|
||||
def stream_wifi():
|
||||
"""SSE stream for WiFi events."""
|
||||
def generate():
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.wifi_queue.get(timeout=1)
|
||||
yield f"data: {json.dumps(msg)}\n\n"
|
||||
except queue.Empty:
|
||||
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user