Security:
- Add token-based WebSocket authentication (closes critical security gap)
- In-memory token store with 24h TTL (token_store.py)
- POST /api/auth/token exchanges Basic Auth for WS token
- GET /api/auth/status public endpoint for auth check
- WebSocket validates token query param, rejects with close code 4401
- Frontend LoginPrompt modal for credential entry
- WebSocket manager handles full auth flow with auth_required state
- No-op when AUTH_ENABLED=false (preserves existing behavior)
HTTPS:
- Wire HTTPS toggle in Settings UI (POST /api/system/ssl/toggle)
- Add certificate regeneration button
- Display SSL info (expiration, SANs, SHA256 fingerprint)
Plugins:
- Wire trigger_hook("pm3_command") in PM3 service
- Wire trigger_hook("update_check") in update manager
Build/Infrastructure:
- Enable NetworkManager in pi-gen AP setup stage
- Add HF booster board detection patch for Proxmark3
- Update LED PWM control patch
- Fix BLE adapter, UPS drivers, WiFi manager improvements
- Update HTTPS support stage script
Documentation:
- Update PROJECT_STATUS.md and IMPLEMENTATION_PRIORITIES.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1013 lines
34 KiB
Python
1013 lines
34 KiB
Python
"""WiFi manager for network configuration and mode switching."""
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
from typing import List, Optional, Dict
|
|
from pathlib import Path
|
|
|
|
from .. import config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Persistent storage for WiFi mode
|
|
WIFI_MODE_FILE = Path("/opt/dangerous-pi/data/wifi_mode")
|
|
WIFI_DATA_DIR = Path("/opt/dangerous-pi/data")
|
|
|
|
|
|
class WiFiMode(str, Enum):
|
|
"""WiFi operation modes."""
|
|
AP = "ap" # Access Point only
|
|
CLIENT = "client" # Client mode only
|
|
DUAL = "dual" # AP + Client (requires USB WiFi)
|
|
AUTO = "auto" # Automatically choose best mode
|
|
OFF = "off" # WiFi disabled
|
|
|
|
|
|
@dataclass
|
|
class WiFiInterface:
|
|
"""WiFi interface information."""
|
|
name: str
|
|
mac: str
|
|
is_usb: bool
|
|
is_up: bool
|
|
connected: bool
|
|
ssid: Optional[str] = None
|
|
ip_address: Optional[str] = None
|
|
mode: str = "managed" # "AP" or "managed" (client)
|
|
|
|
|
|
@dataclass
|
|
class WiFiNetwork:
|
|
"""Available WiFi network."""
|
|
ssid: str
|
|
bssid: str
|
|
signal_strength: int
|
|
frequency: int
|
|
encrypted: bool
|
|
in_use: bool = False
|
|
|
|
|
|
@dataclass
|
|
class WiFiStatus:
|
|
"""Current WiFi status."""
|
|
mode: WiFiMode
|
|
interfaces: List[WiFiInterface]
|
|
current_ssid: Optional[str]
|
|
current_ip: Optional[str]
|
|
ap_ssid: Optional[str]
|
|
ap_ip: Optional[str]
|
|
supports_dual: bool
|
|
|
|
|
|
class WiFiManager:
|
|
"""Manages WiFi configuration and mode switching."""
|
|
|
|
def __init__(self):
|
|
"""Initialize WiFi manager."""
|
|
self._current_mode = WiFiMode.AUTO
|
|
self._interfaces: List[WiFiInterface] = []
|
|
self._supports_dual = False
|
|
self._startup_applied = False
|
|
|
|
def _save_mode(self, mode: WiFiMode) -> bool:
|
|
"""Save WiFi mode to persistent storage.
|
|
|
|
Args:
|
|
mode: WiFi mode to save
|
|
|
|
Returns:
|
|
True if saved successfully
|
|
"""
|
|
try:
|
|
# Ensure data directory exists
|
|
WIFI_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Write mode to file
|
|
WIFI_MODE_FILE.write_text(mode.value)
|
|
logger.info(f"WiFi mode saved: {mode.value}")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to save WiFi mode: {e}")
|
|
return False
|
|
|
|
def _load_mode(self) -> Optional[WiFiMode]:
|
|
"""Load WiFi mode from persistent storage.
|
|
|
|
Returns:
|
|
Saved WiFi mode or None if not found
|
|
"""
|
|
try:
|
|
if WIFI_MODE_FILE.exists():
|
|
mode_str = WIFI_MODE_FILE.read_text().strip()
|
|
mode = WiFiMode(mode_str)
|
|
logger.info(f"WiFi mode loaded: {mode.value}")
|
|
return mode
|
|
except Exception as e:
|
|
logger.warning(f"Failed to load WiFi mode: {e}")
|
|
return None
|
|
|
|
async def apply_saved_mode(self) -> bool:
|
|
"""Apply the saved WiFi mode on startup.
|
|
|
|
This should be called once during service startup to restore
|
|
the previously configured WiFi mode.
|
|
|
|
Returns:
|
|
True if mode was applied successfully
|
|
"""
|
|
if self._startup_applied:
|
|
logger.debug("Saved mode already applied, skipping")
|
|
return True
|
|
|
|
saved_mode = self._load_mode()
|
|
if saved_mode:
|
|
logger.info(f"Applying saved WiFi mode: {saved_mode.value}")
|
|
success = await self.set_mode(saved_mode, persist=False) # Don't re-save
|
|
self._startup_applied = True
|
|
return success
|
|
else:
|
|
logger.info("No saved WiFi mode found, using default (AUTO)")
|
|
self._startup_applied = True
|
|
return True
|
|
|
|
async def detect_interfaces(self) -> List[WiFiInterface]:
|
|
"""Detect available WiFi interfaces.
|
|
|
|
Returns:
|
|
List of WiFi interfaces found
|
|
"""
|
|
interfaces = []
|
|
|
|
try:
|
|
# Get all wireless interfaces using iw
|
|
result = await self._run_command("iw dev")
|
|
|
|
if not result:
|
|
return interfaces
|
|
|
|
# Parse iw dev output
|
|
# Example output:
|
|
# phy#0
|
|
# Interface wlan0
|
|
# ifindex 2
|
|
# wdev 0x1
|
|
# addr 2c:cf:67:87:db:13
|
|
# ssid Dangerous-Pi
|
|
# type AP
|
|
# channel 6 (2437 MHz), width: 20 MHz
|
|
current_interface = None
|
|
for line in result.split('\n'):
|
|
line = line.strip()
|
|
|
|
if line.startswith('Interface '):
|
|
if current_interface:
|
|
interfaces.append(current_interface)
|
|
|
|
iface_name = line.split()[1]
|
|
current_interface = {
|
|
'name': iface_name,
|
|
'mac': '',
|
|
'is_usb': self._is_usb_interface(iface_name),
|
|
'is_up': False,
|
|
'connected': False,
|
|
'ssid': None,
|
|
'ip_address': None,
|
|
'mode': 'managed' # Default to managed (client) mode
|
|
}
|
|
|
|
elif current_interface and line.startswith('addr '):
|
|
current_interface['mac'] = line.split()[1]
|
|
|
|
elif current_interface and line.startswith('ssid '):
|
|
current_interface['ssid'] = ' '.join(line.split()[1:])
|
|
|
|
elif current_interface and line.startswith('type '):
|
|
# Parse interface type: "AP" or "managed"
|
|
iface_type = line.split()[1]
|
|
current_interface['mode'] = iface_type
|
|
# Only mark as "connected" if in managed (client) mode with SSID
|
|
if iface_type == 'managed' and current_interface.get('ssid'):
|
|
current_interface['connected'] = True
|
|
|
|
if current_interface:
|
|
interfaces.append(current_interface)
|
|
|
|
# Get interface status and IP addresses
|
|
for iface_dict in interfaces:
|
|
# Check if interface is up
|
|
iface_dict['is_up'] = await self._is_interface_up(iface_dict['name'])
|
|
|
|
# Get IP address if interface is up
|
|
if iface_dict['is_up']:
|
|
iface_dict['ip_address'] = await self._get_interface_ip(iface_dict['name'])
|
|
|
|
# For AP mode, mark connected if we have IP (AP is "active")
|
|
if iface_dict['mode'] == 'AP' and iface_dict['ip_address']:
|
|
iface_dict['connected'] = True
|
|
|
|
# Convert dicts to WiFiInterface objects
|
|
self._interfaces = [WiFiInterface(**iface) for iface in interfaces]
|
|
|
|
# Check if dual mode is supported (requires at least 2 interfaces)
|
|
self._supports_dual = len(self._interfaces) >= 2
|
|
|
|
return self._interfaces
|
|
|
|
except Exception as e:
|
|
print(f"Error detecting WiFi interfaces: {e}")
|
|
return []
|
|
|
|
def _is_usb_interface(self, iface_name: str) -> bool:
|
|
"""Check if interface is USB-based.
|
|
|
|
Args:
|
|
iface_name: Interface name (e.g., wlan0)
|
|
|
|
Returns:
|
|
True if USB interface, False otherwise
|
|
"""
|
|
try:
|
|
# Check if device is USB by looking at sysfs
|
|
device_path = Path(f"/sys/class/net/{iface_name}/device")
|
|
if not device_path.exists():
|
|
return False
|
|
|
|
# Read uevent to check for USB
|
|
uevent_path = device_path / "uevent"
|
|
if uevent_path.exists():
|
|
content = uevent_path.read_text()
|
|
return "usb" in content.lower()
|
|
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
async def _is_interface_up(self, iface_name: str) -> bool:
|
|
"""Check if interface is up.
|
|
|
|
Args:
|
|
iface_name: Interface name
|
|
|
|
Returns:
|
|
True if interface is up
|
|
"""
|
|
try:
|
|
result = await self._run_command(f"ip link show {iface_name}")
|
|
return "UP" in result
|
|
except Exception:
|
|
return False
|
|
|
|
async def _get_interface_ip(self, iface_name: str) -> Optional[str]:
|
|
"""Get IP address of interface.
|
|
|
|
Args:
|
|
iface_name: Interface name
|
|
|
|
Returns:
|
|
IP address or None
|
|
"""
|
|
try:
|
|
result = await self._run_command(f"ip -4 addr show {iface_name}")
|
|
match = re.search(r'inet (\d+\.\d+\.\d+\.\d+)', result)
|
|
return match.group(1) if match else None
|
|
except Exception:
|
|
return None
|
|
|
|
async def get_status(self) -> WiFiStatus:
|
|
"""Get current WiFi status.
|
|
|
|
Returns:
|
|
WiFiStatus object with current state
|
|
"""
|
|
await self.detect_interfaces()
|
|
|
|
# Determine current mode
|
|
current_mode = await self._detect_current_mode()
|
|
|
|
# Find client and AP interfaces
|
|
client_ssid = None
|
|
client_ip = None
|
|
ap_ssid = None
|
|
ap_ip = None
|
|
|
|
for iface in self._interfaces:
|
|
# Check for AP mode interface
|
|
if iface.mode == 'AP':
|
|
ap_ip = iface.ip_address
|
|
ap_ssid = iface.ssid # SSID from iw dev output
|
|
# If no SSID from iw, try hostapd config
|
|
if not ap_ssid:
|
|
ap_ssid = await self._get_ap_ssid()
|
|
|
|
# Check for client mode interface that's connected
|
|
elif iface.mode == 'managed' and iface.connected and iface.ssid:
|
|
client_ssid = iface.ssid
|
|
client_ip = iface.ip_address
|
|
|
|
# Fallback: try to get AP SSID from hostapd if we detected AP mode
|
|
if current_mode == WiFiMode.AP and not ap_ssid:
|
|
ap_ssid = await self._get_ap_ssid()
|
|
|
|
return WiFiStatus(
|
|
mode=current_mode,
|
|
interfaces=self._interfaces,
|
|
current_ssid=client_ssid,
|
|
current_ip=client_ip,
|
|
ap_ssid=ap_ssid or "Dangerous-Pi", # Default
|
|
ap_ip=ap_ip,
|
|
supports_dual=self._supports_dual
|
|
)
|
|
|
|
async def _detect_current_mode(self) -> WiFiMode:
|
|
"""Detect current WiFi mode based on interface types.
|
|
|
|
Returns:
|
|
Current WiFi mode
|
|
"""
|
|
if not self._interfaces:
|
|
return WiFiMode.OFF
|
|
|
|
# Check interface modes from iw dev output
|
|
has_ap = any(iface.mode == 'AP' for iface in self._interfaces)
|
|
has_client = any(
|
|
iface.mode == 'managed' and iface.connected
|
|
for iface in self._interfaces
|
|
)
|
|
|
|
# Also check if hostapd is running as backup detection
|
|
if not has_ap:
|
|
has_ap = await self._is_hostapd_running()
|
|
|
|
if has_ap and has_client:
|
|
return WiFiMode.DUAL
|
|
elif has_ap:
|
|
return WiFiMode.AP
|
|
elif has_client:
|
|
return WiFiMode.CLIENT
|
|
elif any(iface.is_up for iface in self._interfaces):
|
|
return WiFiMode.AUTO # Interface up but not in AP or connected
|
|
else:
|
|
return WiFiMode.OFF
|
|
|
|
async def _is_hostapd_running(self) -> bool:
|
|
"""Check if hostapd service is running.
|
|
|
|
Returns:
|
|
True if hostapd is active
|
|
"""
|
|
try:
|
|
result = await self._run_command(
|
|
"systemctl is-active hostapd",
|
|
check=False
|
|
)
|
|
return result.strip() == "active"
|
|
except Exception:
|
|
return False
|
|
|
|
async def _get_ap_ssid(self) -> Optional[str]:
|
|
"""Get AP SSID from hostapd config.
|
|
|
|
Returns:
|
|
AP SSID or None
|
|
"""
|
|
try:
|
|
config_path = Path("/etc/hostapd/hostapd.conf")
|
|
if config_path.exists():
|
|
content = config_path.read_text()
|
|
match = re.search(r'ssid=(.+)', content)
|
|
return match.group(1) if match else None
|
|
except Exception:
|
|
return None
|
|
|
|
async def scan_networks(self, interface: Optional[str] = None) -> List[WiFiNetwork]:
|
|
"""Scan for available WiFi networks.
|
|
|
|
Args:
|
|
interface: Interface to scan with (default: first available)
|
|
|
|
Returns:
|
|
List of available networks
|
|
"""
|
|
# Ensure interfaces are detected
|
|
if not self._interfaces:
|
|
await self.detect_interfaces()
|
|
|
|
if not interface:
|
|
# Use first non-USB interface, or first available
|
|
for iface in self._interfaces:
|
|
if not iface.is_usb:
|
|
interface = iface.name
|
|
break
|
|
|
|
if not interface and self._interfaces:
|
|
interface = self._interfaces[0].name
|
|
|
|
if not interface:
|
|
return []
|
|
|
|
try:
|
|
# Request scan
|
|
# Note: Requires CAP_NET_ADMIN capability (set via systemd service)
|
|
await self._run_command(f"iw dev {interface} scan trigger", check=False)
|
|
|
|
# Wait for scan to complete
|
|
await asyncio.sleep(2)
|
|
|
|
# Get scan results
|
|
result = await self._run_command(f"iw dev {interface} scan")
|
|
|
|
networks = []
|
|
current_network = None
|
|
|
|
for line in result.split('\n'):
|
|
line = line.strip()
|
|
|
|
if line.startswith('BSS ') and line.split()[1].count(':') >= 5:
|
|
# Match BSS lines with MAC addresses (xx:xx:xx:xx:xx:xx), not "BSS Load:" etc.
|
|
if current_network:
|
|
networks.append(current_network)
|
|
|
|
# Handle format: BSS aa:bb:cc:dd:ee:ff(on wlan0)
|
|
bssid = line.split()[1].split('(')[0]
|
|
current_network = {
|
|
'ssid': '',
|
|
'bssid': bssid,
|
|
'signal_strength': 0,
|
|
'frequency': 0,
|
|
'encrypted': False,
|
|
'in_use': False
|
|
}
|
|
|
|
elif current_network:
|
|
if line.startswith('SSID: '):
|
|
current_network['ssid'] = line[6:]
|
|
elif line.startswith('freq: '):
|
|
# freq can be "2437" or "2437.0" depending on iw version
|
|
current_network['frequency'] = int(float(line[6:]))
|
|
elif line.startswith('signal: '):
|
|
# Parse signal strength (e.g., "-50.00 dBm")
|
|
signal = line[8:].split()[0]
|
|
current_network['signal_strength'] = int(float(signal))
|
|
elif 'RSN:' in line or 'WPA:' in line:
|
|
current_network['encrypted'] = True
|
|
|
|
if current_network:
|
|
networks.append(current_network)
|
|
|
|
# Convert to WiFiNetwork objects and filter out empty SSIDs
|
|
return [
|
|
WiFiNetwork(**net)
|
|
for net in networks
|
|
if net['ssid']
|
|
]
|
|
|
|
except Exception as e:
|
|
print(f"Error scanning networks: {e}")
|
|
return []
|
|
|
|
async def set_mode(self, mode: WiFiMode, persist: bool = True) -> bool:
|
|
"""Set WiFi mode.
|
|
|
|
Args:
|
|
mode: Desired WiFi mode
|
|
persist: If True, save mode to persistent storage for boot persistence
|
|
|
|
Returns:
|
|
True if mode was set successfully
|
|
"""
|
|
if mode == WiFiMode.DUAL and not self._supports_dual:
|
|
raise ValueError("Dual mode requires USB WiFi adapter")
|
|
|
|
try:
|
|
if mode == WiFiMode.AP:
|
|
await self._enable_ap_mode()
|
|
elif mode == WiFiMode.CLIENT:
|
|
await self._enable_client_mode()
|
|
elif mode == WiFiMode.DUAL:
|
|
await self._enable_dual_mode()
|
|
elif mode == WiFiMode.OFF:
|
|
await self._disable_wifi()
|
|
elif mode == WiFiMode.AUTO:
|
|
await self._enable_auto_mode()
|
|
|
|
self._current_mode = mode
|
|
|
|
# Persist mode for boot persistence
|
|
if persist:
|
|
self._save_mode(mode)
|
|
|
|
return True
|
|
except Exception as e:
|
|
print(f"Error setting WiFi mode: {e}")
|
|
return False
|
|
|
|
async def _systemctl(self, action: str, service: str):
|
|
"""Control systemd service via dbus (no sudo required with polkit rule).
|
|
|
|
Args:
|
|
action: "start", "stop", or "restart"
|
|
service: Service name (e.g., "hostapd.service")
|
|
"""
|
|
if not service.endswith(".service"):
|
|
service = f"{service}.service"
|
|
|
|
method = {
|
|
"start": "StartUnit",
|
|
"stop": "StopUnit",
|
|
"restart": "RestartUnit"
|
|
}.get(action, "StartUnit")
|
|
|
|
cmd = (
|
|
f'busctl call org.freedesktop.systemd1 '
|
|
f'/org/freedesktop/systemd1 org.freedesktop.systemd1.Manager '
|
|
f'{method} ss "{service}" "replace"'
|
|
)
|
|
await self._run_command(cmd, check=False)
|
|
|
|
async def _enable_nm_management(self):
|
|
"""Enable NetworkManager to manage wlan0 for client mode.
|
|
|
|
Uses nmcli to set wlan0 as managed (no file permissions needed).
|
|
"""
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Check current device status
|
|
status = await self._run_command("nmcli device status", check=False)
|
|
logger.warning(f"[NM Management] Current status:\n{status}")
|
|
|
|
# Use nmcli to set device as managed (works without root)
|
|
logger.warning("[NM Management] Setting wlan0 as managed...")
|
|
result = await self._run_command("nmcli device set wlan0 managed yes", check=False)
|
|
logger.warning(f"[NM Management] nmcli set managed result: '{result}'")
|
|
|
|
# Give NM time to pick up the device
|
|
logger.warning("[NM Management] Waiting 2s...")
|
|
await asyncio.sleep(2)
|
|
|
|
# Verify device is now managed
|
|
status = await self._run_command("nmcli device status", check=False)
|
|
logger.warning(f"[NM Management] Status after:\n{status}")
|
|
|
|
async def _disable_nm_management(self):
|
|
"""Disable NetworkManager management of wlan0 for AP mode.
|
|
|
|
Uses nmcli to set wlan0 as unmanaged (for hostapd).
|
|
"""
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Use nmcli to set device as unmanaged
|
|
logger.warning("[NM Management] Setting wlan0 as unmanaged...")
|
|
result = await self._run_command("nmcli device set wlan0 managed no", check=False)
|
|
logger.warning(f"[NM Management] nmcli set unmanaged result: '{result}'")
|
|
await asyncio.sleep(1)
|
|
|
|
async def _enable_ap_mode(self):
|
|
"""Enable Access Point mode."""
|
|
# Ensure NM doesn't manage wlan0
|
|
await self._disable_nm_management()
|
|
# Start hostapd and dnsmasq for AP
|
|
await self._systemctl("start", "hostapd")
|
|
await self._systemctl("start", "dnsmasq")
|
|
# Set static IP for AP
|
|
await self._run_command("ip addr add 192.168.4.1/24 dev wlan0", check=False)
|
|
print("AP mode enabled")
|
|
|
|
async def _enable_client_mode(self):
|
|
"""Enable Client mode using NetworkManager."""
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Stop AP services
|
|
logger.warning("[Client Mode] Stopping hostapd...")
|
|
await self._systemctl("stop", "hostapd")
|
|
logger.warning("[Client Mode] Stopping dnsmasq...")
|
|
await self._systemctl("stop", "dnsmasq")
|
|
|
|
# Check hostapd status
|
|
hostapd_status = await self._run_command("systemctl is-active hostapd", check=False)
|
|
logger.warning(f"[Client Mode] hostapd status after stop: {hostapd_status.strip()}")
|
|
|
|
# Enable NetworkManager to manage wlan0
|
|
logger.warning("[Client Mode] Enabling NM management...")
|
|
await self._enable_nm_management()
|
|
logger.warning("[Client Mode] Client mode enabled")
|
|
|
|
async def _enable_dual_mode(self):
|
|
"""Enable Dual mode (AP + Client)."""
|
|
# Enable both AP and client
|
|
await self._systemctl("start", "hostapd")
|
|
await self._systemctl("start", "dnsmasq")
|
|
await self._systemctl("start", "wpa_supplicant")
|
|
print("Dual mode enabled")
|
|
|
|
async def _disable_wifi(self):
|
|
"""Disable all WiFi."""
|
|
await self._systemctl("stop", "hostapd")
|
|
await self._systemctl("stop", "dnsmasq")
|
|
await self._systemctl("stop", "wpa_supplicant")
|
|
print("WiFi disabled")
|
|
|
|
async def _enable_auto_mode(self):
|
|
"""Enable Auto mode."""
|
|
# Auto-detect best mode based on saved networks and hardware
|
|
if self._supports_dual:
|
|
await self._enable_dual_mode()
|
|
else:
|
|
# Check if we have saved networks
|
|
saved_networks = await self.get_saved_networks()
|
|
if saved_networks:
|
|
await self._enable_client_mode()
|
|
else:
|
|
await self._enable_ap_mode()
|
|
print("Auto mode enabled")
|
|
|
|
async def connect_to_network(
|
|
self,
|
|
ssid: str,
|
|
password: Optional[str] = None,
|
|
interface: Optional[str] = None,
|
|
hidden: bool = False,
|
|
fallback_to_ap: bool = True
|
|
) -> bool:
|
|
"""Connect to a WiFi network using NetworkManager.
|
|
|
|
Args:
|
|
ssid: Network SSID
|
|
password: Network password (if encrypted)
|
|
interface: Interface to use (default: first available)
|
|
hidden: Whether network is hidden
|
|
fallback_to_ap: If True, revert to AP mode on connection failure
|
|
|
|
Returns:
|
|
True if connection successful
|
|
"""
|
|
# Ensure interfaces are detected before trying to connect
|
|
if not self._interfaces:
|
|
await self.detect_interfaces()
|
|
|
|
if not interface:
|
|
# Use first non-USB interface, or first available
|
|
for iface in self._interfaces:
|
|
if not iface.is_usb:
|
|
interface = iface.name
|
|
break
|
|
if not interface and self._interfaces:
|
|
interface = self._interfaces[0].name
|
|
|
|
if not interface:
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
logger.warning("[WiFi Connect] No interface found!")
|
|
return False
|
|
|
|
try:
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
|
|
logger.warning(f"[WiFi Connect] Starting connect to {ssid}")
|
|
|
|
# Switch to client mode (stops hostapd, enables NM management)
|
|
logger.warning("[WiFi Connect] Step 1: Enabling client mode...")
|
|
await self._enable_client_mode()
|
|
logger.warning("[WiFi Connect] Step 1: Client mode enabled")
|
|
|
|
# Wait for NM to be ready and detect wlan0
|
|
logger.warning("[WiFi Connect] Step 2: Waiting 2s for NM...")
|
|
await asyncio.sleep(2)
|
|
|
|
# Check device status
|
|
dev_status = await self._run_command("nmcli device status", check=False)
|
|
logger.warning(f"[WiFi Connect] Device status after wait:\n{dev_status}")
|
|
|
|
# Delete any existing saved connection for this SSID
|
|
# (handles corrupted connections with missing key-mgmt property)
|
|
ssid_escaped = ssid.replace("'", "'\\''")
|
|
logger.warning(f"[WiFi Connect] Step 3: Deleting existing connection for {ssid}...")
|
|
del_result = await self._run_command(
|
|
f"nmcli connection delete '{ssid_escaped}'",
|
|
check=False
|
|
)
|
|
logger.warning(f"[WiFi Connect] Delete result: {del_result}")
|
|
await asyncio.sleep(1)
|
|
|
|
# Build nmcli connect command
|
|
cmd = f"nmcli device wifi connect '{ssid_escaped}'"
|
|
|
|
if password:
|
|
password_escaped = password.replace("'", "'\\''")
|
|
cmd += f" password '{password_escaped}'"
|
|
|
|
if hidden:
|
|
cmd += " hidden yes"
|
|
|
|
# Attempt connection
|
|
logger.warning(f"[WiFi Connect] Step 4: Running nmcli connect...")
|
|
result = await self._run_command(cmd, check=False)
|
|
logger.warning(f"[WiFi Connect] Connect result: {result}")
|
|
|
|
# Check if connection was successful
|
|
if "successfully activated" in result.lower():
|
|
logger.warning(f"[WiFi Connect] SUCCESS - connected to {ssid}")
|
|
|
|
# Verify we have a non-AP IP address
|
|
await asyncio.sleep(2)
|
|
ip_result = await self._run_command(f"ip addr show {interface}")
|
|
logger.warning(f"[WiFi Connect] IP result: {ip_result}")
|
|
|
|
# Extract all IP addresses and check for non-AP IPs
|
|
# AP uses 192.168.4.x subnet
|
|
ip_matches = re.findall(r'inet (\d+\.\d+\.\d+\.\d+)', ip_result)
|
|
client_ips = [ip for ip in ip_matches if not ip.startswith("192.168.4.")]
|
|
|
|
if client_ips:
|
|
logger.warning(f"[WiFi Connect] Found client IP(s): {client_ips}")
|
|
# Remove the AP static IP since we're in client mode now
|
|
await self._run_command(f"ip addr del 192.168.4.1/24 dev {interface}", check=False)
|
|
# Save client mode for boot persistence
|
|
self._current_mode = WiFiMode.CLIENT
|
|
self._save_mode(WiFiMode.CLIENT)
|
|
logger.info(f"WiFi client mode saved for boot persistence")
|
|
return True
|
|
else:
|
|
logger.warning(f"[WiFi Connect] No client IP found, only AP IPs: {ip_matches}")
|
|
|
|
# Connection failed
|
|
logger.warning(f"[WiFi Connect] FAILED - {result}")
|
|
|
|
if fallback_to_ap:
|
|
print("Falling back to AP mode...")
|
|
await self._enable_ap_mode()
|
|
|
|
return False
|
|
|
|
except Exception as e:
|
|
print(f"Error connecting to network: {e}")
|
|
return False
|
|
|
|
async def _add_network_via_wpa_cli(self, ssid: str, password: Optional[str], hidden: bool):
|
|
"""Add network using wpa_cli commands."""
|
|
try:
|
|
# Add network
|
|
result = await self._run_command("sudo wpa_cli add_network")
|
|
network_id = result.strip().split('\n')[-1]
|
|
|
|
# Set SSID
|
|
await self._run_command(f'sudo wpa_cli set_network {network_id} ssid \\"{ssid}\\"')
|
|
|
|
# Set password or open network
|
|
if password:
|
|
await self._run_command(f'sudo wpa_cli set_network {network_id} psk \\"{password}\\"')
|
|
else:
|
|
await self._run_command(f'sudo wpa_cli set_network {network_id} key_mgmt NONE')
|
|
|
|
# Set scan_ssid for hidden networks
|
|
if hidden:
|
|
await self._run_command(f'sudo wpa_cli set_network {network_id} scan_ssid 1')
|
|
|
|
# Enable network
|
|
await self._run_command(f'sudo wpa_cli enable_network {network_id}')
|
|
|
|
# Save configuration
|
|
await self._run_command('sudo wpa_cli save_config')
|
|
|
|
return True
|
|
except Exception as e:
|
|
print(f"Error adding network via wpa_cli: {e}")
|
|
return False
|
|
|
|
async def disconnect_from_network(self, interface: Optional[str] = None) -> bool:
|
|
"""Disconnect from current network and return to AP mode.
|
|
|
|
Args:
|
|
interface: Interface to disconnect (default: first connected)
|
|
|
|
Returns:
|
|
True if disconnection successful
|
|
"""
|
|
if not interface:
|
|
for iface in self._interfaces:
|
|
if iface.connected:
|
|
interface = iface.name
|
|
break
|
|
|
|
if not interface:
|
|
return False
|
|
|
|
try:
|
|
# Disconnect using nmcli
|
|
await self._run_command(f"nmcli device disconnect {interface}", check=False)
|
|
# Return to AP mode
|
|
await self._enable_ap_mode()
|
|
return True
|
|
except Exception as e:
|
|
print(f"Error disconnecting: {e}")
|
|
return False
|
|
|
|
async def get_saved_networks(self) -> List[Dict[str, str]]:
|
|
"""Get list of saved WiFi networks from NetworkManager.
|
|
|
|
Returns:
|
|
List of saved networks with SSID and other info
|
|
"""
|
|
try:
|
|
result = await self._run_command(
|
|
"nmcli -t -f NAME,TYPE connection show",
|
|
check=False
|
|
)
|
|
networks = []
|
|
|
|
for line in result.split('\n'):
|
|
if line.strip():
|
|
parts = line.split(':')
|
|
if len(parts) >= 2 and parts[1] == '802-11-wireless':
|
|
networks.append({
|
|
'ssid': parts[0],
|
|
'type': 'wifi',
|
|
'enabled': True
|
|
})
|
|
|
|
return networks
|
|
except Exception as e:
|
|
print(f"Error getting saved networks: {e}")
|
|
return []
|
|
|
|
async def forget_network(self, ssid: str) -> bool:
|
|
"""Forget a saved network.
|
|
|
|
Args:
|
|
ssid: SSID of network to forget
|
|
|
|
Returns:
|
|
True if network was forgotten
|
|
"""
|
|
try:
|
|
# Delete connection using nmcli
|
|
ssid_escaped = ssid.replace("'", "'\\''")
|
|
result = await self._run_command(
|
|
f"nmcli connection delete '{ssid_escaped}'",
|
|
check=False
|
|
)
|
|
|
|
return "successfully deleted" in result.lower()
|
|
except Exception as e:
|
|
print(f"Error forgetting network: {e}")
|
|
return False
|
|
|
|
async def set_static_ip(
|
|
self,
|
|
interface: str,
|
|
ip_address: str,
|
|
netmask: str = "255.255.255.0",
|
|
gateway: Optional[str] = None,
|
|
dns: Optional[List[str]] = None
|
|
) -> bool:
|
|
"""Set static IP for interface.
|
|
|
|
Args:
|
|
interface: Interface name
|
|
ip_address: Static IP address
|
|
netmask: Network mask
|
|
gateway: Gateway IP (optional)
|
|
dns: DNS servers (optional)
|
|
|
|
Returns:
|
|
True if configuration successful
|
|
"""
|
|
try:
|
|
# Configure static IP using dhcpcd
|
|
config_path = Path("/etc/dhcpcd.conf")
|
|
|
|
# Read existing config
|
|
if config_path.exists():
|
|
content = config_path.read_text()
|
|
else:
|
|
content = ""
|
|
|
|
# Remove existing config for this interface
|
|
lines = content.split('\n')
|
|
new_lines = []
|
|
skip_interface = False
|
|
|
|
for line in lines:
|
|
if line.startswith(f'interface {interface}'):
|
|
skip_interface = True
|
|
continue
|
|
if skip_interface and (line.startswith('interface ') or line.strip() == ''):
|
|
skip_interface = False
|
|
if not skip_interface:
|
|
new_lines.append(line)
|
|
|
|
# Add new configuration
|
|
new_config = f"\ninterface {interface}\n"
|
|
new_config += f"static ip_address={ip_address}/{self._netmask_to_cidr(netmask)}\n"
|
|
|
|
if gateway:
|
|
new_config += f"static routers={gateway}\n"
|
|
|
|
if dns:
|
|
new_config += f"static domain_name_servers={' '.join(dns)}\n"
|
|
|
|
new_content = '\n'.join(new_lines) + new_config
|
|
|
|
# Write configuration (would need sudo)
|
|
# In production, this should use a proper mechanism
|
|
print(f"Would write to {config_path}:\n{new_config}")
|
|
|
|
# Restart interface
|
|
await self._run_command(f"sudo ip link set {interface} down")
|
|
await self._run_command(f"sudo ip link set {interface} up")
|
|
await self._run_command("sudo systemctl restart dhcpcd")
|
|
|
|
return True
|
|
except Exception as e:
|
|
print(f"Error setting static IP: {e}")
|
|
return False
|
|
|
|
def _netmask_to_cidr(self, netmask: str) -> int:
|
|
"""Convert netmask to CIDR notation.
|
|
|
|
Args:
|
|
netmask: Netmask (e.g., 255.255.255.0)
|
|
|
|
Returns:
|
|
CIDR prefix length (e.g., 24)
|
|
"""
|
|
return sum([bin(int(x)).count('1') for x in netmask.split('.')])
|
|
|
|
async def enable_dhcp(self, interface: str) -> bool:
|
|
"""Enable DHCP for interface.
|
|
|
|
Args:
|
|
interface: Interface name
|
|
|
|
Returns:
|
|
True if DHCP enabled
|
|
"""
|
|
try:
|
|
# Remove static IP configuration
|
|
config_path = Path("/etc/dhcpcd.conf")
|
|
|
|
if config_path.exists():
|
|
content = config_path.read_text()
|
|
lines = content.split('\n')
|
|
new_lines = []
|
|
skip_interface = False
|
|
|
|
for line in lines:
|
|
if line.startswith(f'interface {interface}'):
|
|
skip_interface = True
|
|
continue
|
|
if skip_interface and (line.startswith('interface ') or line.strip() == ''):
|
|
skip_interface = False
|
|
if not skip_interface:
|
|
new_lines.append(line)
|
|
|
|
# Write back
|
|
print(f"Would update {config_path} to enable DHCP")
|
|
|
|
# Restart interface
|
|
await self._run_command(f"sudo ip link set {interface} down")
|
|
await self._run_command(f"sudo ip link set {interface} up")
|
|
await self._run_command("sudo systemctl restart dhcpcd")
|
|
|
|
return True
|
|
except Exception as e:
|
|
print(f"Error enabling DHCP: {e}")
|
|
return False
|
|
|
|
async def _run_command(self, command: str, check: bool = True) -> str:
|
|
"""Run a shell command asynchronously.
|
|
|
|
Args:
|
|
command: Command to run
|
|
check: Raise exception on non-zero exit code
|
|
|
|
Returns:
|
|
Command output
|
|
|
|
Raises:
|
|
subprocess.CalledProcessError: If command fails and check=True
|
|
"""
|
|
loop = asyncio.get_event_loop()
|
|
|
|
def _execute():
|
|
result = subprocess.run(
|
|
command,
|
|
shell=True,
|
|
capture_output=True,
|
|
text=True,
|
|
check=check
|
|
)
|
|
return result.stdout
|
|
|
|
return await loop.run_in_executor(None, _execute)
|
|
|
|
|
|
# Global instance
|
|
wifi_manager = WiFiManager()
|