v2.26.0: fix SSE fanout crash and branded logo FOUC

- Fix SSE fanout thread AttributeError when source queue is None during
  interpreter shutdown by snapshotting to local variable with null guard
- Fix branded "i" logo rendering oversized on first page load (FOUC) by
  adding inline width/height to SVG elements across 10 templates
- Bump version to 2.26.0 in config.py, pyproject.toml, and CHANGELOG.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-03-13 11:51:27 +00:00
parent 00362bcd57
commit e00fbfddc1
183 changed files with 2006 additions and 4243 deletions
+51 -55
View File
@@ -8,25 +8,18 @@ Provides unified WiFi scanning with dual-mode architecture:
Also includes channel analysis, hidden SSID correlation, and network aggregation.
"""
from .models import (
WiFiObservation,
WiFiAccessPoint,
WiFiClient,
WiFiProbeRequest,
WiFiScanResult,
WiFiScanStatus,
WiFiCapabilities,
ChannelStats,
ChannelRecommendation,
from .channel_analyzer import (
ChannelAnalyzer,
analyze_channels,
)
from .scanner import (
UnifiedWiFiScanner,
get_wifi_scanner,
reset_wifi_scanner,
)
from .constants import (
AUTH_EAP,
# Auth
AUTH_OPEN,
AUTH_OWE,
AUTH_PSK,
AUTH_SAE,
AUTH_UNKNOWN,
# Bands
BAND_2_4_GHZ,
BAND_5_GHZ,
@@ -36,64 +29,67 @@ from .constants import (
CHANNELS_2_4_GHZ,
CHANNELS_5_GHZ,
CHANNELS_6_GHZ,
NON_OVERLAPPING_2_4_GHZ,
NON_OVERLAPPING_5_GHZ,
# Security
SECURITY_OPEN,
SECURITY_WEP,
SECURITY_WPA,
SECURITY_WPA2,
SECURITY_WPA3,
SECURITY_WPA_WPA2,
SECURITY_WPA2_WPA3,
SECURITY_ENTERPRISE,
SECURITY_UNKNOWN,
# Cipher
CIPHER_NONE,
CIPHER_WEP,
CIPHER_TKIP,
CIPHER_CCMP,
CIPHER_GCMP,
# Cipher
CIPHER_NONE,
CIPHER_TKIP,
CIPHER_UNKNOWN,
# Auth
AUTH_OPEN,
AUTH_PSK,
AUTH_SAE,
AUTH_EAP,
AUTH_OWE,
AUTH_UNKNOWN,
# Signal bands
SIGNAL_STRONG,
SIGNAL_MEDIUM,
SIGNAL_WEAK,
SIGNAL_VERY_WEAK,
SIGNAL_UNKNOWN,
CIPHER_WEP,
NON_OVERLAPPING_2_4_GHZ,
NON_OVERLAPPING_5_GHZ,
PROXIMITY_FAR,
# Proximity bands (consistent with Bluetooth)
PROXIMITY_IMMEDIATE,
PROXIMITY_NEAR,
PROXIMITY_FAR,
PROXIMITY_UNKNOWN,
SCAN_MODE_DEEP,
# Scan modes
SCAN_MODE_QUICK,
SCAN_MODE_DEEP,
SECURITY_ENTERPRISE,
# Security
SECURITY_OPEN,
SECURITY_UNKNOWN,
SECURITY_WEP,
SECURITY_WPA,
SECURITY_WPA2,
SECURITY_WPA2_WPA3,
SECURITY_WPA3,
SECURITY_WPA_WPA2,
SIGNAL_MEDIUM,
# Signal bands
SIGNAL_STRONG,
SIGNAL_UNKNOWN,
SIGNAL_VERY_WEAK,
SIGNAL_WEAK,
# Helper functions
get_band_from_channel,
get_band_from_frequency,
get_channel_from_frequency,
get_signal_band,
get_proximity_band,
get_signal_band,
get_vendor_from_mac,
)
from .channel_analyzer import (
ChannelAnalyzer,
analyze_channels,
)
from .hidden_ssid import (
HiddenSSIDCorrelator,
get_hidden_correlator,
)
from .models import (
ChannelRecommendation,
ChannelStats,
WiFiAccessPoint,
WiFiCapabilities,
WiFiClient,
WiFiObservation,
WiFiProbeRequest,
WiFiScanResult,
WiFiScanStatus,
)
from .scanner import (
UnifiedWiFiScanner,
get_wifi_scanner,
reset_wifi_scanner,
)
__all__ = [
# Main scanner
+6 -12
View File
@@ -11,26 +11,20 @@ Analyzes channel congestion based on:
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from dataclasses import dataclass
from .constants import (
BAND_2_4_GHZ,
BAND_5_GHZ,
BAND_6_GHZ,
CHANNELS_2_4_GHZ,
CHANNELS_5_GHZ,
CHANNELS_6_GHZ,
NON_OVERLAPPING_2_4_GHZ,
NON_OVERLAPPING_5_GHZ,
CHANNEL_FREQUENCIES,
CHANNEL_RSSI_INTERFERENCE_FACTOR,
CHANNEL_WEIGHT_AP_COUNT,
CHANNEL_WEIGHT_CLIENT_COUNT,
CHANNEL_RSSI_INTERFERENCE_FACTOR,
NON_OVERLAPPING_2_4_GHZ,
NON_OVERLAPPING_5_GHZ,
get_band_from_channel,
)
from .models import WiFiAccessPoint, ChannelStats, ChannelRecommendation
from .models import ChannelRecommendation, ChannelStats, WiFiAccessPoint
logger = logging.getLogger(__name__)
@@ -255,7 +249,7 @@ class ChannelAnalyzer:
if ap_count == 0:
reason = "No APs detected - clear channel"
elif ap_count == 1:
reason = f"1 AP on channel"
reason = "1 AP on channel"
else:
reason = f"{ap_count} APs on channel"
+17 -19
View File
@@ -7,18 +7,18 @@ frames, detecting potential deauth flood attacks.
from __future__ import annotations
import contextlib
import logging
import threading
import time
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime
from typing import Callable, Optional, Any
from typing import Any, Callable
from utils.constants import (
DEAUTH_DETECTION_WINDOW,
DEAUTH_ALERT_THRESHOLD,
DEAUTH_CRITICAL_THRESHOLD,
DEAUTH_DETECTION_WINDOW,
DEAUTH_SNIFF_TIMEOUT,
)
@@ -63,7 +63,7 @@ class DeauthPacketInfo:
dst_mac: str
bssid: str
reason_code: int
signal_dbm: Optional[int] = None
signal_dbm: int | None = None
@dataclass
@@ -106,20 +106,20 @@ class DeauthAlert:
# Attacker info
attacker_mac: str
attacker_vendor: Optional[str]
attacker_signal_dbm: Optional[int]
attacker_vendor: str | None
attacker_signal_dbm: int | None
is_spoofed_ap: bool
# Target info
target_mac: str
target_vendor: Optional[str]
target_vendor: str | None
target_type: str # 'client', 'broadcast', 'ap'
target_known_from_scan: bool
# Access point info
ap_bssid: str
ap_essid: Optional[str]
ap_channel: Optional[int]
ap_essid: str | None
ap_channel: int | None
# Attack info
frame_type: str
@@ -184,8 +184,8 @@ class DeauthDetector:
self,
interface: str,
event_callback: Callable[[dict], None],
get_networks: Optional[Callable[[], dict[str, Any]]] = None,
get_clients: Optional[Callable[[], dict[str, Any]]] = None,
get_networks: Callable[[], dict[str, Any]] | None = None,
get_clients: Callable[[], dict[str, Any]] | None = None,
):
"""
Initialize the deauth detector.
@@ -202,7 +202,7 @@ class DeauthDetector:
self.get_clients = get_clients
self._stop_event = threading.Event()
self._thread: Optional[threading.Thread] = None
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
# Track deauth packets by (src, dst, bssid) tuple
@@ -215,7 +215,7 @@ class DeauthDetector:
# Stats
self._packets_captured = 0
self._alerts_generated = 0
self._started_at: Optional[float] = None
self._started_at: float | None = None
@property
def is_running(self) -> bool:
@@ -296,7 +296,7 @@ class DeauthDetector:
def _sniff_loop(self):
"""Main sniffing loop using scapy."""
try:
from scapy.all import sniff, Dot11, Dot11Deauth, Dot11Disas
from scapy.all import Dot11, Dot11Deauth, Dot11Disas, sniff
except ImportError:
logger.error("scapy not installed. Install with: pip install scapy")
self.event_callback({
@@ -388,10 +388,8 @@ class DeauthDetector:
# Extract signal strength from RadioTap if available
signal_dbm = None
if pkt.haslayer(RadioTap):
try:
with contextlib.suppress(AttributeError):
signal_dbm = pkt[RadioTap].dBm_AntSignal
except AttributeError:
pass
# Create packet info
pkt_info = DeauthPacketInfo(
@@ -579,7 +577,7 @@ class DeauthDetector:
try:
networks = self.get_networks()
return {bssid.upper() for bssid in networks.keys()}
return {bssid.upper() for bssid in networks}
except Exception:
return set()
@@ -587,7 +585,7 @@ class DeauthDetector:
"""Check if source MAC matches a known AP (spoofing indicator)."""
return src_mac.upper() in self._get_known_aps()
def _get_vendor(self, mac: str) -> Optional[str]:
def _get_vendor(self, mac: str) -> str | None:
"""Get vendor from MAC OUI."""
try:
from data.oui import get_manufacturer
+9 -9
View File
@@ -17,9 +17,9 @@ from __future__ import annotations
import logging
import threading
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Callable, Optional
from typing import Callable
from .constants import (
HIDDEN_CORRELATION_WINDOW_SECONDS,
@@ -29,7 +29,7 @@ from .constants import (
logger = logging.getLogger(__name__)
# Global correlator instance
_correlator_instance: Optional['HiddenSSIDCorrelator'] = None
_correlator_instance: HiddenSSIDCorrelator | None = None
_correlator_lock = threading.Lock()
@@ -92,9 +92,9 @@ class HiddenSSIDCorrelator:
self._revealed: dict[str, CorrelationResult] = {} # BSSID -> result
# Callbacks
self._on_ssid_revealed: Optional[Callable[[CorrelationResult], None]] = None
self._on_ssid_revealed: Callable[[CorrelationResult], None] | None = None
def record_probe(self, client_mac: str, probed_ssid: str, timestamp: Optional[datetime] = None):
def record_probe(self, client_mac: str, probed_ssid: str, timestamp: datetime | None = None):
"""
Record a probe request.
@@ -122,7 +122,7 @@ class HiddenSSIDCorrelator:
# Check for correlations with known hidden APs
self._check_correlations()
def record_association(self, client_mac: str, bssid: str, timestamp: Optional[datetime] = None):
def record_association(self, client_mac: str, bssid: str, timestamp: datetime | None = None):
"""
Record a client association with an AP.
@@ -151,7 +151,7 @@ class HiddenSSIDCorrelator:
# Check for correlations
self._check_correlations()
def record_hidden_ap(self, bssid: str, timestamp: Optional[datetime] = None):
def record_hidden_ap(self, bssid: str, timestamp: datetime | None = None):
"""
Record a hidden access point (empty SSID).
@@ -171,7 +171,7 @@ class HiddenSSIDCorrelator:
# Check for correlations
self._check_correlations()
def get_revealed_ssid(self, bssid: str) -> Optional[str]:
def get_revealed_ssid(self, bssid: str) -> str | None:
"""
Get the revealed SSID for a hidden AP, if known.
@@ -185,7 +185,7 @@ class HiddenSSIDCorrelator:
result = self._revealed.get(bssid.upper())
return result.revealed_ssid if result else None
def get_correlation(self, bssid: str) -> Optional[CorrelationResult]:
def get_correlation(self, bssid: str) -> CorrelationResult | None:
"""
Get the full correlation result for a hidden AP.
+51 -54
View File
@@ -6,20 +6,17 @@ from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from .constants import (
BAND_UNKNOWN,
SECURITY_UNKNOWN,
CIPHER_UNKNOWN,
AUTH_UNKNOWN,
WIDTH_UNKNOWN,
SIGNAL_UNKNOWN,
BAND_UNKNOWN,
CIPHER_UNKNOWN,
PROXIMITY_UNKNOWN,
SCAN_MODE_QUICK,
SECURITY_UNKNOWN,
SIGNAL_UNKNOWN,
WIDTH_UNKNOWN,
get_band_from_channel,
get_signal_band,
get_proximity_band,
get_vendor_from_mac,
)
@@ -30,10 +27,10 @@ class WiFiObservation:
timestamp: datetime
bssid: str
essid: Optional[str] = None
channel: Optional[int] = None
frequency_mhz: Optional[int] = None
rssi: Optional[int] = None
essid: str | None = None
channel: int | None = None
frequency_mhz: int | None = None
rssi: int | None = None
# Security
security: str = SECURITY_UNKNOWN
@@ -58,7 +55,7 @@ class WiFiObservation:
return BAND_UNKNOWN
@property
def vendor(self) -> Optional[str]:
def vendor(self) -> str | None:
"""Get vendor name from BSSID."""
return get_vendor_from_mac(self.bssid)
@@ -89,29 +86,29 @@ class WiFiAccessPoint:
# Identity
bssid: str
essid: Optional[str] = None
essid: str | None = None
is_hidden: bool = False
revealed_essid: Optional[str] = None # Revealed through correlation
revealed_essid: str | None = None # Revealed through correlation
# Radio info
channel: Optional[int] = None
frequency_mhz: Optional[int] = None
channel: int | None = None
frequency_mhz: int | None = None
band: str = BAND_UNKNOWN
width: str = WIDTH_UNKNOWN
# Signal aggregation
rssi_samples: list[tuple[datetime, int]] = field(default_factory=list)
rssi_current: Optional[int] = None
rssi_median: Optional[float] = None
rssi_min: Optional[int] = None
rssi_max: Optional[int] = None
rssi_variance: Optional[float] = None
rssi_ema: Optional[float] = None
rssi_current: int | None = None
rssi_median: float | None = None
rssi_min: int | None = None
rssi_max: int | None = None
rssi_variance: float | None = None
rssi_ema: float | None = None
# Proximity/signal bands
signal_band: str = SIGNAL_UNKNOWN
proximity_band: str = PROXIMITY_UNKNOWN
estimated_distance_m: Optional[float] = None
estimated_distance_m: float | None = None
distance_confidence: float = 0.0
# Security
@@ -131,7 +128,7 @@ class WiFiAccessPoint:
client_count: int = 0
# Metadata
vendor: Optional[str] = None
vendor: str | None = None
# Heuristic flags
heuristic_flags: list[str] = field(default_factory=list)
@@ -141,7 +138,7 @@ class WiFiAccessPoint:
# Baseline tracking
in_baseline: bool = False
baseline_id: Optional[int] = None
baseline_id: int | None = None
@property
def display_name(self) -> str:
@@ -281,23 +278,23 @@ class WiFiClient:
# Identity
mac: str
vendor: Optional[str] = None
vendor: str | None = None
# Signal
rssi_samples: list[tuple[datetime, int]] = field(default_factory=list)
rssi_current: Optional[int] = None
rssi_median: Optional[float] = None
rssi_min: Optional[int] = None
rssi_max: Optional[int] = None
rssi_ema: Optional[float] = None
rssi_current: int | None = None
rssi_median: float | None = None
rssi_min: int | None = None
rssi_max: int | None = None
rssi_ema: float | None = None
# Proximity
signal_band: str = SIGNAL_UNKNOWN
proximity_band: str = PROXIMITY_UNKNOWN
estimated_distance_m: Optional[float] = None
estimated_distance_m: float | None = None
# Association
associated_bssid: Optional[str] = None
associated_bssid: str | None = None
is_associated: bool = False
# Probes
@@ -380,8 +377,8 @@ class WiFiProbeRequest:
timestamp: datetime
client_mac: str
probed_ssid: str
rssi: Optional[int] = None
client_vendor: Optional[str] = None
rssi: int | None = None
client_vendor: str | None = None
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
@@ -400,22 +397,22 @@ class ChannelStats:
channel: int
band: str = BAND_UNKNOWN
frequency_mhz: Optional[int] = None
frequency_mhz: int | None = None
# Counts
ap_count: int = 0
client_count: int = 0
# Signal stats
rssi_avg: Optional[float] = None
rssi_min: Optional[int] = None
rssi_max: Optional[int] = None
rssi_avg: float | None = None
rssi_min: int | None = None
rssi_max: int | None = None
# Utilization score (0.0-1.0, lower is better)
utilization_score: float = 0.0
# Recommendation rank (1 = best)
recommendation_rank: Optional[int] = None
recommendation_rank: int | None = None
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
@@ -442,7 +439,7 @@ class ChannelRecommendation:
score: float # Lower is better
reason: str
is_dfs: bool = False
recommendation_rank: Optional[int] = None
recommendation_rank: int | None = None
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
@@ -471,14 +468,14 @@ class WiFiScanResult:
# Scan metadata
scan_mode: str = SCAN_MODE_QUICK
interface: Optional[str] = None
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
duration_seconds: Optional[float] = None
interface: str | None = None
started_at: datetime | None = None
completed_at: datetime | None = None
duration_seconds: float | None = None
# Status
is_complete: bool = False
error: Optional[str] = None
error: str | None = None
warnings: list[str] = field(default_factory=list)
@property
@@ -545,14 +542,14 @@ class WiFiScanStatus:
is_scanning: bool = False
scan_mode: str = SCAN_MODE_QUICK
interface: Optional[str] = None
started_at: Optional[datetime] = None
interface: str | None = None
started_at: datetime | None = None
networks_found: int = 0
clients_found: int = 0
error: Optional[str] = None
error: str | None = None
@property
def elapsed_seconds(self) -> Optional[float]:
def elapsed_seconds(self) -> float | None:
"""Seconds since scan started."""
if self.started_at:
return (datetime.now() - self.started_at).total_seconds()
@@ -582,20 +579,20 @@ class WiFiCapabilities:
# Interfaces
interfaces: list[dict] = field(default_factory=list)
default_interface: Optional[str] = None
default_interface: str | None = None
# Quick scan tools
has_nmcli: bool = False
has_iw: bool = False
has_iwlist: bool = False
has_airport: bool = False
preferred_quick_tool: Optional[str] = None
preferred_quick_tool: str | None = None
# Deep scan tools
has_airmon_ng: bool = False
has_airodump_ng: bool = False
has_monitor_capable_interface: bool = False
monitor_interface: Optional[str] = None
monitor_interface: str | None = None
# Issues
issues: list[str] = field(default_factory=list)
+2 -2
View File
@@ -4,11 +4,11 @@ WiFi scan output parsers.
Each parser converts tool-specific output into WiFiObservation objects.
"""
from .airodump import parse_airodump_csv
from .airport import parse_airport_scan
from .nmcli import parse_nmcli_scan
from .iw import parse_iw_scan
from .iwlist import parse_iwlist_scan
from .airodump import parse_airodump_csv
from .nmcli import parse_nmcli_scan
__all__ = [
'parse_airport_scan',
+14 -15
View File
@@ -22,29 +22,28 @@ import io
import logging
import re
from datetime import datetime
from typing import Optional
from ..models import WiFiObservation
from ..constants import (
AUTH_EAP,
AUTH_OPEN,
AUTH_OWE,
AUTH_PSK,
AUTH_SAE,
AUTH_UNKNOWN,
CHANNEL_FREQUENCIES,
CIPHER_CCMP,
CIPHER_TKIP,
CIPHER_UNKNOWN,
CIPHER_WEP,
SECURITY_OPEN,
SECURITY_UNKNOWN,
SECURITY_WEP,
SECURITY_WPA,
SECURITY_WPA2,
SECURITY_WPA3,
SECURITY_WPA_WPA2,
SECURITY_UNKNOWN,
CIPHER_CCMP,
CIPHER_TKIP,
CIPHER_WEP,
CIPHER_UNKNOWN,
AUTH_PSK,
AUTH_SAE,
AUTH_EAP,
AUTH_OWE,
AUTH_OPEN,
AUTH_UNKNOWN,
CHANNEL_FREQUENCIES,
)
from ..models import WiFiObservation
logger = logging.getLogger(__name__)
@@ -63,7 +62,7 @@ def parse_airodump_csv(filepath: str) -> tuple[list[WiFiObservation], list[dict]
clients = []
try:
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
with open(filepath, encoding='utf-8', errors='replace') as f:
content = f.read()
# airodump-ng separates sections with blank lines
+15 -19
View File
@@ -12,33 +12,31 @@ from __future__ import annotations
import logging
import re
from datetime import datetime
from typing import Optional
from ..models import WiFiObservation
from ..constants import (
AUTH_EAP,
AUTH_OPEN,
AUTH_PSK,
AUTH_SAE,
AUTH_UNKNOWN,
CHANNEL_FREQUENCIES,
CIPHER_CCMP,
CIPHER_NONE,
CIPHER_TKIP,
CIPHER_UNKNOWN,
CIPHER_WEP,
SECURITY_OPEN,
SECURITY_UNKNOWN,
SECURITY_WEP,
SECURITY_WPA,
SECURITY_WPA2,
SECURITY_WPA2_WPA3,
SECURITY_WPA3,
SECURITY_WPA_WPA2,
SECURITY_WPA2_WPA3,
SECURITY_UNKNOWN,
CIPHER_CCMP,
CIPHER_TKIP,
CIPHER_WEP,
CIPHER_NONE,
CIPHER_UNKNOWN,
AUTH_PSK,
AUTH_SAE,
AUTH_EAP,
AUTH_OPEN,
AUTH_UNKNOWN,
WIDTH_20_MHZ,
WIDTH_40_MHZ,
get_band_from_channel,
CHANNEL_FREQUENCIES,
)
from ..models import WiFiObservation
logger = logging.getLogger(__name__)
@@ -68,7 +66,7 @@ def parse_airport_scan(output: str) -> list[WiFiObservation]:
return observations
def _parse_airport_line(line: str) -> Optional[WiFiObservation]:
def _parse_airport_line(line: str) -> WiFiObservation | None:
"""Parse a single line of airport output."""
# airport output is space-aligned, need careful parsing
# Format: SSID (variable width) BSSID RSSI CHANNEL HT CC SECURITY
@@ -95,10 +93,8 @@ def _parse_airport_line(line: str) -> Optional[WiFiObservation]:
ssid = line[:bssid_pos].strip()
# Handle hidden network indicator
is_hidden = False
if ssid == '--' or not ssid:
ssid = None
is_hidden = True
# Parse remainder after BSSID
remainder = line[bssid_match.end():].strip()
+13 -16
View File
@@ -24,35 +24,32 @@ from __future__ import annotations
import logging
import re
from datetime import datetime
from typing import Optional
from ..models import WiFiObservation
from ..constants import (
AUTH_EAP,
AUTH_OPEN,
AUTH_OWE,
AUTH_PSK,
AUTH_SAE,
AUTH_UNKNOWN,
CIPHER_CCMP,
CIPHER_GCMP,
CIPHER_TKIP,
CIPHER_UNKNOWN,
CIPHER_WEP,
SECURITY_OPEN,
SECURITY_WEP,
SECURITY_WPA,
SECURITY_WPA2,
SECURITY_WPA3,
SECURITY_WPA_WPA2,
SECURITY_WPA2_WPA3,
SECURITY_UNKNOWN,
CIPHER_CCMP,
CIPHER_TKIP,
CIPHER_GCMP,
CIPHER_WEP,
CIPHER_UNKNOWN,
AUTH_PSK,
AUTH_SAE,
AUTH_EAP,
AUTH_OWE,
AUTH_OPEN,
AUTH_UNKNOWN,
WIDTH_20_MHZ,
WIDTH_40_MHZ,
WIDTH_80_MHZ,
WIDTH_160_MHZ,
get_channel_from_frequency,
)
from ..models import WiFiObservation
logger = logging.getLogger(__name__)
@@ -90,7 +87,7 @@ def parse_iw_scan(output: str) -> list[WiFiObservation]:
return observations
def _parse_iw_block(lines: list[str]) -> Optional[WiFiObservation]:
def _parse_iw_block(lines: list[str]) -> WiFiObservation | None:
"""Parse a single BSS block from iw output."""
try:
# First line: BSS 00:11:22:33:44:55(on wlan0) -- associated
+11 -13
View File
@@ -25,27 +25,25 @@ from __future__ import annotations
import logging
import re
from datetime import datetime
from typing import Optional
from ..models import WiFiObservation
from ..constants import (
AUTH_EAP,
AUTH_OPEN,
AUTH_PSK,
AUTH_UNKNOWN,
CHANNEL_FREQUENCIES,
CIPHER_CCMP,
CIPHER_TKIP,
CIPHER_UNKNOWN,
CIPHER_WEP,
SECURITY_OPEN,
SECURITY_WEP,
SECURITY_WPA,
SECURITY_WPA2,
SECURITY_WPA_WPA2,
SECURITY_UNKNOWN,
CIPHER_CCMP,
CIPHER_TKIP,
CIPHER_WEP,
CIPHER_UNKNOWN,
AUTH_PSK,
AUTH_EAP,
AUTH_OPEN,
AUTH_UNKNOWN,
get_channel_from_frequency,
CHANNEL_FREQUENCIES,
)
from ..models import WiFiObservation
logger = logging.getLogger(__name__)
@@ -83,7 +81,7 @@ def parse_iwlist_scan(output: str) -> list[WiFiObservation]:
return observations
def _parse_iwlist_block(lines: list[str]) -> Optional[WiFiObservation]:
def _parse_iwlist_block(lines: list[str]) -> WiFiObservation | None:
"""Parse a single Cell block from iwlist output."""
try:
# Extract BSSID from first line
+17 -19
View File
@@ -11,30 +11,28 @@ from __future__ import annotations
import logging
import re
from datetime import datetime
from typing import Optional
from ..models import WiFiObservation
from ..constants import (
SECURITY_OPEN,
SECURITY_WEP,
SECURITY_WPA,
SECURITY_WPA2,
SECURITY_WPA3,
SECURITY_WPA_WPA2,
SECURITY_WPA2_WPA3,
SECURITY_ENTERPRISE,
SECURITY_UNKNOWN,
AUTH_EAP,
AUTH_OPEN,
AUTH_PSK,
AUTH_SAE,
AUTH_UNKNOWN,
CIPHER_CCMP,
CIPHER_TKIP,
CIPHER_UNKNOWN,
AUTH_PSK,
AUTH_SAE,
AUTH_EAP,
AUTH_OPEN,
AUTH_UNKNOWN,
SECURITY_ENTERPRISE,
SECURITY_OPEN,
SECURITY_UNKNOWN,
SECURITY_WEP,
SECURITY_WPA,
SECURITY_WPA2,
SECURITY_WPA2_WPA3,
SECURITY_WPA3,
SECURITY_WPA_WPA2,
get_channel_from_frequency,
get_band_from_frequency,
)
from ..models import WiFiObservation
logger = logging.getLogger(__name__)
@@ -62,7 +60,7 @@ def parse_nmcli_scan(output: str) -> list[WiFiObservation]:
return observations
def _parse_nmcli_line(line: str) -> Optional[WiFiObservation]:
def _parse_nmcli_line(line: str) -> WiFiObservation | None:
"""Parse a single line of nmcli terse output."""
try:
# nmcli terse format uses : as delimiter but escapes colons in values with \:
@@ -188,7 +186,7 @@ def _parse_nmcli_security(security_str: str) -> tuple[str, str, str]:
cipher = CIPHER_UNKNOWN
if security in (SECURITY_WPA2, SECURITY_WPA3, SECURITY_WPA2_WPA3, SECURITY_ENTERPRISE):
cipher = CIPHER_CCMP
elif security == SECURITY_WPA or security == SECURITY_WPA_WPA2:
elif security in (SECURITY_WPA, SECURITY_WPA_WPA2):
cipher = CIPHER_TKIP # Often TKIP for mixed mode
# Determine auth
+44 -53
View File
@@ -17,44 +17,43 @@ import shutil
import subprocess
import threading
import time
from collections.abc import Generator
from datetime import datetime
from pathlib import Path
from typing import Callable, Generator, Optional, TYPE_CHECKING
from typing import TYPE_CHECKING, Callable
if TYPE_CHECKING:
from .deauth_detector import DeauthDetector
import contextlib
from .constants import (
DEFAULT_QUICK_SCAN_TIMEOUT,
SCAN_MODE_QUICK,
SCAN_MODE_DEEP,
QUICK_SCAN_TOOLS_LINUX,
QUICK_SCAN_TOOLS_DARWIN,
TOOL_TIMEOUT_QUICK,
TOOL_TIMEOUT_DETECT,
NETWORK_STALE_TIMEOUT,
MAX_RSSI_SAMPLES,
SCAN_MODE_DEEP,
SCAN_MODE_QUICK,
TOOL_TIMEOUT_DETECT,
WIFI_EMA_ALPHA,
get_signal_band,
get_proximity_band,
get_signal_band,
get_vendor_from_mac,
)
from .models import (
ChannelRecommendation,
ChannelStats,
WiFiAccessPoint,
WiFiCapabilities,
WiFiClient,
WiFiObservation,
WiFiProbeRequest,
WiFiScanResult,
WiFiScanStatus,
WiFiCapabilities,
WiFiObservation,
ChannelStats,
ChannelRecommendation,
)
logger = logging.getLogger(__name__)
# Global scanner instance
_scanner_instance: Optional['UnifiedWiFiScanner'] = None
_scanner_instance: UnifiedWiFiScanner | None = None
_scanner_lock = threading.Lock()
@@ -66,7 +65,7 @@ class UnifiedWiFiScanner:
Deep Scan: Continuous monitoring with airodump-ng
"""
def __init__(self, interface: Optional[str] = None):
def __init__(self, interface: str | None = None):
"""
Initialize WiFi scanner.
@@ -78,7 +77,7 @@ class UnifiedWiFiScanner:
# State
self._status = WiFiScanStatus()
self._capabilities: Optional[WiFiCapabilities] = None
self._capabilities: WiFiCapabilities | None = None
# Discovered entities
self._access_points: dict[str, WiFiAccessPoint] = {} # bssid -> AP
@@ -86,24 +85,24 @@ class UnifiedWiFiScanner:
self._probe_requests: list[WiFiProbeRequest] = []
# Deep scan process
self._deep_scan_process: Optional[subprocess.Popen] = None
self._deep_scan_thread: Optional[threading.Thread] = None
self._deep_scan_process: subprocess.Popen | None = None
self._deep_scan_thread: threading.Thread | None = None
self._deep_scan_stop_event = threading.Event()
# Deauth detector
self._deauth_detector: Optional['DeauthDetector'] = None
self._deauth_detector: DeauthDetector | None = None
# Event queue for SSE streaming
self._event_queue: queue.Queue = queue.Queue(maxsize=1000)
# Callbacks
self._on_network_updated: Optional[Callable[[WiFiAccessPoint], None]] = None
self._on_client_updated: Optional[Callable[[WiFiClient], None]] = None
self._on_probe_request: Optional[Callable[[WiFiProbeRequest], None]] = None
self._on_network_updated: Callable[[WiFiAccessPoint], None] | None = None
self._on_client_updated: Callable[[WiFiClient], None] | None = None
self._on_probe_request: Callable[[WiFiProbeRequest], None] | None = None
# Baseline tracking
self._baseline_networks: set[str] = set() # BSSIDs in baseline
self._baseline_set_at: Optional[datetime] = None
self._baseline_set_at: datetime | None = None
# =========================================================================
# Properties
@@ -374,7 +373,7 @@ class UnifiedWiFiScanner:
def quick_scan(
self,
interface: Optional[str] = None,
interface: str | None = None,
timeout: float = DEFAULT_QUICK_SCAN_TIMEOUT,
) -> WiFiScanResult:
"""
@@ -664,10 +663,10 @@ class UnifiedWiFiScanner:
def start_deep_scan(
self,
interface: Optional[str] = None,
interface: str | None = None,
band: str = 'all',
channel: Optional[int] = None,
channels: Optional[list[int]] = None,
channel: int | None = None,
channels: list[int] | None = None,
) -> bool:
"""
Start continuous deep scan with airodump-ng.
@@ -733,8 +732,8 @@ class UnifiedWiFiScanner:
Returns:
True if scan was stopped.
"""
cleanup_process: Optional[subprocess.Popen] = None
cleanup_thread: Optional[threading.Thread] = None
cleanup_process: subprocess.Popen | None = None
cleanup_thread: threading.Thread | None = None
cleanup_detector = None
with self._lock:
@@ -760,8 +759,8 @@ class UnifiedWiFiScanner:
cleanup_start = time.perf_counter()
def _finalize_stop(
process: Optional[subprocess.Popen],
scan_thread: Optional[threading.Thread],
process: subprocess.Popen | None,
scan_thread: threading.Thread | None,
detector,
) -> None:
if detector:
@@ -777,10 +776,8 @@ class UnifiedWiFiScanner:
process.terminate()
process.wait(timeout=1.5)
except Exception:
try:
with contextlib.suppress(Exception):
process.kill()
except Exception:
pass
if scan_thread and scan_thread.is_alive():
scan_thread.join(timeout=1.5)
@@ -801,14 +798,14 @@ class UnifiedWiFiScanner:
self,
interface: str,
band: str,
channel: Optional[int],
channels: Optional[list[int]],
channel: int | None,
channels: list[int] | None,
):
"""Background thread for running airodump-ng."""
from .parsers.airodump import parse_airodump_csv
import tempfile
from .parsers.airodump import parse_airodump_csv
# Create temp directory for output files
with tempfile.TemporaryDirectory(prefix='wifi_scan_') as tmpdir:
output_prefix = os.path.join(tmpdir, 'scan')
@@ -829,7 +826,7 @@ class UnifiedWiFiScanner:
logger.info(f"Starting airodump-ng: {' '.join(cmd)}")
process: Optional[subprocess.Popen] = None
process: subprocess.Popen | None = None
try:
process = subprocess.Popen(
cmd,
@@ -848,10 +845,8 @@ class UnifiedWiFiScanner:
process.terminate()
process.wait(timeout=1.0)
except Exception:
try:
with contextlib.suppress(Exception):
process.kill()
except Exception:
pass
return
csv_file = f"{output_prefix}-01.csv"
@@ -1129,8 +1124,6 @@ class UnifiedWiFiScanner:
def _calculate_channel_stats(self) -> list[ChannelStats]:
"""Calculate statistics for each channel."""
from .constants import (
CHANNELS_2_4_GHZ,
CHANNELS_5_GHZ,
CHANNEL_FREQUENCIES,
get_band_from_channel,
)
@@ -1175,10 +1168,10 @@ class UnifiedWiFiScanner:
def _generate_recommendations(self, stats: list[ChannelStats]) -> list[ChannelRecommendation]:
"""Generate channel recommendations."""
from .constants import (
NON_OVERLAPPING_2_4_GHZ,
NON_OVERLAPPING_5_GHZ,
BAND_2_4_GHZ,
BAND_5_GHZ,
NON_OVERLAPPING_2_4_GHZ,
NON_OVERLAPPING_5_GHZ,
)
recommendations = []
@@ -1273,12 +1266,12 @@ class UnifiedWiFiScanner:
# Data Access
# =========================================================================
def get_network(self, bssid: str) -> Optional[WiFiAccessPoint]:
def get_network(self, bssid: str) -> WiFiAccessPoint | None:
"""Get a specific network by BSSID."""
with self._lock:
return self._access_points.get(bssid.upper())
def get_client(self, mac: str) -> Optional[WiFiClient]:
def get_client(self, mac: str) -> WiFiClient | None:
"""Get a specific client by MAC."""
with self._lock:
return self._clients.get(mac.upper())
@@ -1336,10 +1329,8 @@ class UnifiedWiFiScanner:
alert_id = event.get('id', str(time.time()))
app_module.deauth_alerts[alert_id] = event
if hasattr(app_module, 'deauth_detector_queue'):
try:
with contextlib.suppress(queue.Full):
app_module.deauth_detector_queue.put_nowait(event)
except queue.Full:
pass
except Exception as e:
logger.debug(f"Error storing deauth alert: {e}")
@@ -1389,7 +1380,7 @@ class UnifiedWiFiScanner:
self._deauth_detector = None
@property
def deauth_detector(self) -> Optional['DeauthDetector']:
def deauth_detector(self) -> DeauthDetector | None:
"""Get the deauth detector instance."""
return self._deauth_detector
@@ -1416,7 +1407,7 @@ class UnifiedWiFiScanner:
# Module-level functions
# =============================================================================
def get_wifi_scanner(interface: Optional[str] = None) -> UnifiedWiFiScanner:
def get_wifi_scanner(interface: str | None = None) -> UnifiedWiFiScanner:
"""
Get or create the global WiFi scanner instance.