mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
controller: selectable hotspot protocol (WPA2/WPA3) with device capability probing
Probe the selected WiFi device with iw to determine which hotspot protocols it can host: WPA2 requires AP mode; WPA3 requires SAE in AP mode (SAE_OFFLOAD_AP, or a mac80211 driver with the CMAC cipher for software SAE). The Raspberry Pi's brcmfmac supports neither, so Pis correctly offer WPA2 only (verified against real Pi 4/Pi 5 iw output). The protocol is saved as hotspot.protocol in controller.yaml. enable_hotspot() now always sets the Hotspot connection's key-mgmt explicitly (sae + required PMF for WPA3) so a reused profile cannot keep a stale protocol, and reverts to WPA2 if the driver rejects SAE. New GET /api/hotspot/protocols endpoint; protocol dropdowns added to both the ControllerPage React UI and the fallback controller UI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2355095391
commit
8687816b84
10 changed files with 545 additions and 16 deletions
|
|
@ -9,6 +9,7 @@ from controller.api.schemas import (
|
|||
BluetoothStatusResponse,
|
||||
HotspotActionResponse,
|
||||
HotspotDevicesResponse,
|
||||
HotspotProtocolsResponse,
|
||||
HotspotSettingsRequest,
|
||||
HotspotSettingsResponse,
|
||||
HotspotStatusResponse,
|
||||
|
|
@ -33,8 +34,10 @@ from controller.lib.admin import (
|
|||
enable_hotspot,
|
||||
enable_throttle,
|
||||
get_bluetooth_status_dict,
|
||||
get_device_hotspot_protocols,
|
||||
get_hotspot_device,
|
||||
get_hotspot_password,
|
||||
get_hotspot_protocol,
|
||||
get_hotspot_ssid,
|
||||
get_hotspot_status_dict,
|
||||
get_throttle_status_dict,
|
||||
|
|
@ -70,6 +73,13 @@ async def hotspot_devices() -> HotspotDevicesResponse:
|
|||
return HotspotDevicesResponse(devices=get_wifi_devices())
|
||||
|
||||
|
||||
@router.get("/api/hotspot/protocols", response_model=HotspotProtocolsResponse)
|
||||
async def hotspot_protocols(device: str = None) -> HotspotProtocolsResponse:
|
||||
"""List the hotspot protocols a WiFi device can host; defaults to the configured device."""
|
||||
device = device or get_hotspot_device()
|
||||
return HotspotProtocolsResponse(device=device, protocols=get_device_hotspot_protocols(device))
|
||||
|
||||
|
||||
@router.get("/api/hotspot/settings", response_model=HotspotSettingsResponse)
|
||||
async def hotspot_settings() -> HotspotSettingsResponse:
|
||||
"""Get WiFi hotspot settings."""
|
||||
|
|
@ -77,6 +87,7 @@ async def hotspot_settings() -> HotspotSettingsResponse:
|
|||
device=get_hotspot_device(),
|
||||
ssid=get_hotspot_ssid(),
|
||||
password=get_hotspot_password(),
|
||||
protocol=get_hotspot_protocol(),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -86,10 +97,12 @@ async def hotspot_settings_update(request: HotspotSettingsRequest) -> HotspotSet
|
|||
# 500 matches the other subsystem endpoints (hotspot/bluetooth/throttle) in Docker mode.
|
||||
if is_docker_mode():
|
||||
raise HTTPException(status_code=500, detail="Not available in Docker mode")
|
||||
result = update_hotspot_settings(device=request.device, ssid=request.ssid, password=request.password)
|
||||
result = update_hotspot_settings(device=request.device, ssid=request.ssid, password=request.password,
|
||||
protocol=request.protocol)
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
|
||||
return HotspotSettingsResponse(device=result["device"], ssid=result["ssid"], password=result["password"])
|
||||
return HotspotSettingsResponse(device=result["device"], ssid=result["ssid"], password=result["password"],
|
||||
protocol=result["protocol"])
|
||||
|
||||
|
||||
@router.post("/api/hotspot/enable", response_model=HotspotActionResponse)
|
||||
|
|
|
|||
|
|
@ -164,12 +164,20 @@ class HotspotDevicesResponse(BaseModel):
|
|||
devices: list[str] = Field(description="Available WiFi device names")
|
||||
|
||||
|
||||
class HotspotProtocolsResponse(BaseModel):
|
||||
"""Response model for /api/hotspot/protocols endpoint."""
|
||||
|
||||
device: str = Field(description="WiFi device name that was probed")
|
||||
protocols: list[str] = Field(description="Hotspot protocols the device can host (wpa2, wpa3)")
|
||||
|
||||
|
||||
class HotspotSettingsRequest(BaseModel):
|
||||
"""Request model for updating hotspot settings; only provided values are changed."""
|
||||
|
||||
device: Optional[str] = Field(default=None, description="WiFi device name")
|
||||
ssid: Optional[str] = Field(default=None, description="Hotspot SSID")
|
||||
password: Optional[str] = Field(default=None, description="Hotspot password (8 to 63 characters)")
|
||||
protocol: Optional[str] = Field(default=None, description="Hotspot protocol (wpa2 or wpa3)")
|
||||
|
||||
|
||||
class HotspotSettingsResponse(BaseModel):
|
||||
|
|
@ -178,6 +186,7 @@ class HotspotSettingsResponse(BaseModel):
|
|||
device: str = Field(description="WiFi device name")
|
||||
ssid: str = Field(description="Hotspot SSID")
|
||||
password: str = Field(description="Hotspot password")
|
||||
protocol: str = Field(description="Hotspot protocol (wpa2 or wpa3)")
|
||||
|
||||
|
||||
class HotspotActionResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ DEFAULT_CONFIG = {
|
|||
"device": "wlan0",
|
||||
"ssid": "WROLPi",
|
||||
"password": "wrolpi hotspot",
|
||||
"protocol": "wpa2", # wpa2 (wpa-psk) or wpa3 (sae); see get_device_hotspot_protocols
|
||||
},
|
||||
|
||||
"throttle": {
|
||||
|
|
|
|||
|
|
@ -58,7 +58,95 @@ def get_hotspot_password() -> str:
|
|||
return get_config_value('hotspot.password', 'wrolpi hotspot')
|
||||
|
||||
|
||||
def update_hotspot_settings(device: str = None, ssid: str = None, password: str = None) -> dict:
|
||||
# WPA2 uses nmcli's default `wpa-psk` key management; WPA3 uses `sae`.
|
||||
HOTSPOT_PROTOCOLS = ('wpa2', 'wpa3')
|
||||
|
||||
|
||||
def get_hotspot_protocol() -> str:
|
||||
return get_config_value('hotspot.protocol', 'wpa2')
|
||||
|
||||
|
||||
def get_device_hotspot_protocols(device: str) -> list[str]:
|
||||
"""
|
||||
Probe a WiFi device with `iw` and report which hotspot protocols it can host.
|
||||
|
||||
WPA2 only requires AP mode. WPA3 requires SAE in AP mode, which is only possible
|
||||
when the firmware offloads SAE for APs (SAE_OFFLOAD_AP), or the driver is
|
||||
mac80211-based (advertises AP/VLAN) with the CMAC cipher so wpa_supplicant can run
|
||||
SAE in software. Notably the Raspberry Pi's brcmfmac driver supports neither.
|
||||
"""
|
||||
if is_docker_mode():
|
||||
return []
|
||||
|
||||
try:
|
||||
# Absolute path like rfkill; `iw` is in /usr/sbin which is not on every PATH.
|
||||
result = subprocess.run(
|
||||
["/usr/sbin/iw", "dev", device, "info"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
wiphy = None
|
||||
for line in result.stdout.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("wiphy "):
|
||||
wiphy = stripped.split()[1]
|
||||
break
|
||||
if wiphy is None:
|
||||
return []
|
||||
|
||||
result = subprocess.run(
|
||||
["/usr/sbin/iw", "phy", f"phy{wiphy}", "info"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||
# Cannot probe without `iw`; assume the WPA2 hotspot nmcli has always created.
|
||||
return ['wpa2']
|
||||
|
||||
modes, ciphers, ext_features = set(), set(), set()
|
||||
section = None
|
||||
for line in result.stdout.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('Supported interface modes:'):
|
||||
section = 'modes'
|
||||
continue
|
||||
elif stripped.startswith('Supported Ciphers:'):
|
||||
section = 'ciphers'
|
||||
continue
|
||||
elif stripped.startswith(('Supported extended features:', 'Extended features:')):
|
||||
# Real Pi hardware prints "Supported extended features:"; older iw
|
||||
# releases print "Extended features:".
|
||||
section = 'ext'
|
||||
continue
|
||||
if not stripped.startswith('*'):
|
||||
section = None
|
||||
continue
|
||||
value = stripped.lstrip('* ').strip()
|
||||
if section == 'modes':
|
||||
modes.add(value)
|
||||
elif section == 'ciphers':
|
||||
ciphers.add(value.split()[0])
|
||||
elif section == 'ext' and '[' in stripped and ']' in stripped:
|
||||
ext_features.add(stripped.split('[', 1)[1].split(']', 1)[0].strip())
|
||||
|
||||
protocols = []
|
||||
if 'AP' in modes:
|
||||
protocols.append('wpa2')
|
||||
sae_offload_ap = 'SAE_OFFLOAD_AP' in ext_features
|
||||
software_sae = 'AP/VLAN' in modes and any('CMAC' in cipher for cipher in ciphers)
|
||||
if sae_offload_ap or software_sae:
|
||||
protocols.append('wpa3')
|
||||
return protocols
|
||||
|
||||
|
||||
def update_hotspot_settings(device: str = None, ssid: str = None, password: str = None,
|
||||
protocol: str = None) -> dict:
|
||||
"""
|
||||
Update hotspot settings in controller.yaml. Only provided values are changed.
|
||||
|
||||
|
|
@ -75,6 +163,8 @@ def update_hotspot_settings(device: str = None, ssid: str = None, password: str
|
|||
# nmcli requires a WPA password of 8 to 63 characters.
|
||||
if password is not None and not 8 <= len(password) <= 63:
|
||||
return {"success": False, "error": "Hotspot password must be 8 to 63 characters"}
|
||||
if protocol is not None and protocol not in HOTSPOT_PROTOCOLS:
|
||||
return {"success": False, "error": f"Hotspot protocol must be one of: {', '.join(HOTSPOT_PROTOCOLS)}"}
|
||||
|
||||
changed = False
|
||||
if device:
|
||||
|
|
@ -86,6 +176,9 @@ def update_hotspot_settings(device: str = None, ssid: str = None, password: str
|
|||
if password:
|
||||
config_lib.update_config('hotspot.password', password)
|
||||
changed = True
|
||||
if protocol:
|
||||
config_lib.update_config('hotspot.protocol', protocol)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
try:
|
||||
|
|
@ -93,12 +186,14 @@ def update_hotspot_settings(device: str = None, ssid: str = None, password: str
|
|||
except RuntimeError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
logger.info("Hotspot settings updated (device=%s, ssid=%s)", get_hotspot_device(), get_hotspot_ssid())
|
||||
logger.info("Hotspot settings updated (device=%s, ssid=%s, protocol=%s)",
|
||||
get_hotspot_device(), get_hotspot_ssid(), get_hotspot_protocol())
|
||||
return {
|
||||
"success": True,
|
||||
"device": get_hotspot_device(),
|
||||
"ssid": get_hotspot_ssid(),
|
||||
"password": get_hotspot_password(),
|
||||
"protocol": get_hotspot_protocol(),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -299,6 +394,7 @@ def enable_hotspot() -> dict:
|
|||
device = get_hotspot_device()
|
||||
ssid = get_hotspot_ssid()
|
||||
password = get_hotspot_password()
|
||||
protocol = get_hotspot_protocol()
|
||||
|
||||
try:
|
||||
# First ensure radio is on
|
||||
|
|
@ -349,13 +445,55 @@ def enable_hotspot() -> dict:
|
|||
timeout=30,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info("Hotspot enabled successfully on %s", device)
|
||||
return {"success": True, "ssid": ssid, "device": device}
|
||||
else:
|
||||
if result.returncode != 0:
|
||||
logger.warning("Failed to enable hotspot: %s", result.stderr.strip())
|
||||
return {"success": False, "error": result.stderr.strip() or "Unknown error"}
|
||||
|
||||
# Explicitly set the key management so a reused "Hotspot" profile does not
|
||||
# keep a previously configured protocol. WPA3 (SAE) requires PMF.
|
||||
key_mgmt = "sae" if protocol == "wpa3" else "wpa-psk"
|
||||
pmf = "3" if protocol == "wpa3" else "0" # 3=required, 0=default
|
||||
modify = subprocess.run(
|
||||
["nmcli", "connection", "modify", "Hotspot",
|
||||
"802-11-wireless-security.key-mgmt", key_mgmt,
|
||||
"802-11-wireless-security.pmf", pmf],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
up = None
|
||||
if modify.returncode == 0:
|
||||
# Re-activate so the profile change takes effect.
|
||||
up = subprocess.run(
|
||||
["nmcli", "connection", "up", "Hotspot"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if modify.returncode != 0 or up.returncode != 0:
|
||||
error = (modify.stderr if modify.returncode != 0 else up.stderr).strip()
|
||||
if protocol == "wpa3":
|
||||
# The driver likely rejected SAE; revert so the hotspot still works.
|
||||
logger.warning("Failed to enable WPA3 hotspot, reverting to WPA2: %s", error)
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "modify", "Hotspot",
|
||||
"802-11-wireless-security.key-mgmt", "wpa-psk",
|
||||
"802-11-wireless-security.pmf", "0"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "up", "Hotspot"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
return {"success": False,
|
||||
"error": f"WPA3 is not supported by {device}; hotspot reverted to WPA2: {error}"}
|
||||
# WPA2 is what nmcli created the hotspot with anyway; not fatal.
|
||||
logger.warning("Failed to explicitly set WPA2 on hotspot: %s", error)
|
||||
|
||||
logger.info("Hotspot enabled successfully on %s (%s)", device, protocol)
|
||||
return {"success": True, "ssid": ssid, "device": device}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Timeout enabling hotspot on %s", device)
|
||||
return {"success": False, "error": "Timeout enabling hotspot"}
|
||||
|
|
|
|||
|
|
@ -985,7 +985,12 @@
|
|||
<input type="text" id="hotspot-password">
|
||||
</label>
|
||||
<label>Device
|
||||
<select id="hotspot-device"
|
||||
<select id="hotspot-device" onchange="loadHotspotProtocols()"
|
||||
style="width: 100%; padding: 0.5rem; margin-bottom: 1rem; background: #1a1a2e; border: 1px solid #333; border-radius: 4px; color: #fff; font-size: 1rem;">
|
||||
</select>
|
||||
</label>
|
||||
<label>Protocol
|
||||
<select id="hotspot-protocol"
|
||||
style="width: 100%; padding: 0.5rem; margin-bottom: 1rem; background: #1a1a2e; border: 1px solid #333; border-radius: 4px; color: #fff; font-size: 1rem;">
|
||||
</select>
|
||||
</label>
|
||||
|
|
@ -1442,11 +1447,38 @@
|
|||
option.selected = name === settings.device;
|
||||
select.appendChild(option);
|
||||
}
|
||||
await loadHotspotProtocols(settings.protocol);
|
||||
} catch (e) {
|
||||
console.error('Failed to load hotspot settings', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Human labels for the hotspot protocols reported by the Controller.
|
||||
const hotspotProtocolLabels = {wpa2: 'WPA2 (most compatible)', wpa3: 'WPA3'};
|
||||
|
||||
async function loadHotspotProtocols(savedProtocol) {
|
||||
// The supported protocols depend on the selected device's hardware.
|
||||
const select = document.getElementById('hotspot-protocol');
|
||||
const device = document.getElementById('hotspot-device').value;
|
||||
if (!select || !device) return;
|
||||
savedProtocol = savedProtocol || select.value;
|
||||
try {
|
||||
const response = await apiCall(`api/hotspot/protocols?device=${encodeURIComponent(device)}`);
|
||||
select.innerHTML = '';
|
||||
// Keep the saved protocol selectable even if the device does not report it.
|
||||
const protocols = [...new Set([...(response.protocols || []), savedProtocol])].filter(Boolean);
|
||||
for (const protocol of protocols) {
|
||||
const option = document.createElement('option');
|
||||
option.value = protocol;
|
||||
option.textContent = hotspotProtocolLabels[protocol] || protocol;
|
||||
option.selected = protocol === savedProtocol;
|
||||
select.appendChild(option);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load hotspot protocols', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveHotspotSettings() {
|
||||
const btn = event.target;
|
||||
btn.disabled = true;
|
||||
|
|
@ -1455,6 +1487,7 @@
|
|||
ssid: document.getElementById('hotspot-ssid').value,
|
||||
password: document.getElementById('hotspot-password').value,
|
||||
device: document.getElementById('hotspot-device').value,
|
||||
protocol: document.getElementById('hotspot-protocol').value || null,
|
||||
});
|
||||
await updateHotspotButton();
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -21,8 +21,10 @@ from controller.lib.admin import (
|
|||
get_hotspot_status_dict,
|
||||
get_throttle_status,
|
||||
get_timezone_status_dict,
|
||||
get_device_hotspot_protocols,
|
||||
get_hotspot_device,
|
||||
get_hotspot_password,
|
||||
get_hotspot_protocol,
|
||||
get_hotspot_ssid,
|
||||
get_wifi_devices,
|
||||
migrate_hotspot_settings_from_wrolpi_config,
|
||||
|
|
@ -387,6 +389,26 @@ class TestUpdateHotspotSettings:
|
|||
assert result["ssid"] == 'WROLPi'
|
||||
assert result["password"] == 'wrolpi hotspot'
|
||||
|
||||
def test_updates_protocol(self, mock_config_path, reset_runtime_config):
|
||||
"""The protocol is saved to controller.yaml and returned."""
|
||||
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
||||
result = update_hotspot_settings(protocol='wpa3')
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["protocol"] == 'wpa3'
|
||||
assert get_hotspot_protocol() == 'wpa3'
|
||||
saved = yaml.safe_load(mock_config_path.read_text())
|
||||
assert saved['hotspot']['protocol'] == 'wpa3'
|
||||
|
||||
def test_rejects_invalid_protocol(self, mock_config_path, reset_runtime_config):
|
||||
"""Only wpa2 and wpa3 are valid protocols."""
|
||||
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
||||
result = update_hotspot_settings(protocol='wep')
|
||||
|
||||
assert result["success"] is False
|
||||
assert "protocol" in result["error"].lower()
|
||||
assert get_hotspot_protocol() == 'wpa2'
|
||||
|
||||
def test_rejects_short_password(self, mock_config_path, reset_runtime_config):
|
||||
"""nmcli requires a WPA password of 8 to 63 characters."""
|
||||
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
||||
|
|
@ -426,6 +448,134 @@ class TestUpdateHotspotSettings:
|
|||
assert "not mounted" in result["error"]
|
||||
|
||||
|
||||
# `iw dev wlan0 info` output; only the "wiphy" line matters.
|
||||
IW_DEV_INFO = """Interface wlan0
|
||||
\tifindex 3
|
||||
\twdev 0x1
|
||||
\taddr d8:3a:dd:11:22:33
|
||||
\ttype managed
|
||||
\twiphy 0
|
||||
\ttxpower 31.00 dBm
|
||||
"""
|
||||
|
||||
# Raspberry Pi brcmfmac (verified on real Pi 4/Pi 5 hardware): AP mode, but no
|
||||
# AP/VLAN (fullmac driver) and no AP-mode SAE, so WPA3 hotspot is not possible.
|
||||
# "SAE with AUTHENTICATE command" is station-mode WPA3 (joining, not hosting).
|
||||
IW_PHY_BRCMFMAC = """Wiphy phy0
|
||||
\twiphy index: 0
|
||||
\tmax # scan SSIDs: 10
|
||||
\tSupported Ciphers:
|
||||
\t\t* WEP40 (00-0f-ac:1)
|
||||
\t\t* WEP104 (00-0f-ac:5)
|
||||
\t\t* TKIP (00-0f-ac:2)
|
||||
\t\t* CCMP-128 (00-0f-ac:4)
|
||||
\t\t* CMAC (00-0f-ac:6)
|
||||
\tAvailable Antennas: TX 0 RX 0
|
||||
\tSupported interface modes:
|
||||
\t\t * IBSS
|
||||
\t\t * managed
|
||||
\t\t * AP
|
||||
\t\t * P2P-client
|
||||
\t\t * P2P-GO
|
||||
\t\t * P2P-device
|
||||
\tDevice supports SAE with AUTHENTICATE command
|
||||
\tSupported extended features:
|
||||
\t\t* [ CQM_RSSI_LIST ]: multiple CQM_RSSI_THOLD records
|
||||
\t\t* [ DFS_OFFLOAD ]: DFS offload
|
||||
"""
|
||||
|
||||
# mac80211 driver (Intel/Atheros style): AP/VLAN plus the CMAC cipher means
|
||||
# wpa_supplicant can run SAE in software for the AP.
|
||||
IW_PHY_MAC80211 = """Wiphy phy0
|
||||
\tmax # scan SSIDs: 4
|
||||
\tSupported Ciphers:
|
||||
\t\t* WEP40 (00-0f-ac:1)
|
||||
\t\t* TKIP (00-0f-ac:2)
|
||||
\t\t* CCMP-128 (00-0f-ac:4)
|
||||
\t\t* CMAC (00-0f-ac:6)
|
||||
\tSupported interface modes:
|
||||
\t\t * IBSS
|
||||
\t\t * managed
|
||||
\t\t * AP
|
||||
\t\t * AP/VLAN
|
||||
\t\t * monitor
|
||||
\t\t * mesh point
|
||||
\tSupported extended features:
|
||||
\t\t* [ RRM ]: RRM
|
||||
\t\t* [ FILS_STA ]: FILS shared key authentication support
|
||||
"""
|
||||
|
||||
# Fullmac driver with AP-mode SAE offload advertised by the firmware.
|
||||
# Older iw releases print "Extended features:" instead of "Supported extended features:".
|
||||
IW_PHY_SAE_OFFLOAD_AP = """Wiphy phy0
|
||||
\tSupported Ciphers:
|
||||
\t\t* CCMP-128 (00-0f-ac:4)
|
||||
\tSupported interface modes:
|
||||
\t\t * managed
|
||||
\t\t * AP
|
||||
\tExtended features:
|
||||
\t\t* [ SAE_OFFLOAD ]: SAE offload support
|
||||
\t\t* [ SAE_OFFLOAD_AP ]: SAE offload support (AP mode)
|
||||
"""
|
||||
|
||||
# Station-only hardware which cannot host a hotspot at all.
|
||||
IW_PHY_NO_AP = """Wiphy phy0
|
||||
\tSupported Ciphers:
|
||||
\t\t* CCMP-128 (00-0f-ac:4)
|
||||
\tSupported interface modes:
|
||||
\t\t * managed
|
||||
\t\t * monitor
|
||||
"""
|
||||
|
||||
|
||||
class TestGetDeviceHotspotProtocols:
|
||||
"""Tests for get_device_hotspot_protocols; WPA3 requires SAE support in AP mode."""
|
||||
|
||||
@staticmethod
|
||||
def _protocols(phy_info: str) -> list:
|
||||
dev_info = mock.Mock(returncode=0, stdout=IW_DEV_INFO, stderr="")
|
||||
phy = mock.Mock(returncode=0, stdout=phy_info, stderr="")
|
||||
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
||||
with mock.patch("subprocess.run", side_effect=[dev_info, phy]) as mock_run:
|
||||
result = get_device_hotspot_protocols("wlan0")
|
||||
# The wiphy index from `iw dev` is used to query the phy.
|
||||
assert mock_run.call_args_list[1][0][0] == ["/usr/sbin/iw", "phy", "phy0", "info"]
|
||||
return result
|
||||
|
||||
def test_rpi_brcmfmac_is_wpa2_only(self):
|
||||
"""The Raspberry Pi's fullmac driver cannot host a WPA3 (SAE) AP."""
|
||||
assert self._protocols(IW_PHY_BRCMFMAC) == ["wpa2"]
|
||||
|
||||
def test_mac80211_driver_supports_wpa3(self):
|
||||
"""mac80211 drivers (AP/VLAN listed) with the CMAC cipher can run software SAE."""
|
||||
assert self._protocols(IW_PHY_MAC80211) == ["wpa2", "wpa3"]
|
||||
|
||||
def test_sae_offload_ap_supports_wpa3(self):
|
||||
"""Firmware advertising SAE_OFFLOAD_AP can host a WPA3 AP."""
|
||||
assert self._protocols(IW_PHY_SAE_OFFLOAD_AP) == ["wpa2", "wpa3"]
|
||||
|
||||
def test_station_only_hardware_has_no_protocols(self):
|
||||
"""A device without AP mode cannot host a hotspot at all."""
|
||||
assert self._protocols(IW_PHY_NO_AP) == []
|
||||
|
||||
def test_returns_empty_in_docker_mode(self):
|
||||
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
|
||||
assert get_device_hotspot_protocols("wlan0") == []
|
||||
|
||||
def test_unknown_device_has_no_protocols(self):
|
||||
"""`iw dev` fails for a device which does not exist."""
|
||||
dev_info = mock.Mock(returncode=237, stdout="", stderr="command failed: No such device")
|
||||
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
||||
with mock.patch("subprocess.run", side_effect=[dev_info]):
|
||||
assert get_device_hotspot_protocols("wlan9") == []
|
||||
|
||||
def test_assumes_wpa2_when_iw_missing(self):
|
||||
"""Without `iw` we cannot probe; assume the WPA2 hotspot nmcli has always created."""
|
||||
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
||||
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
|
||||
assert get_device_hotspot_protocols("wlan0") == ["wpa2"]
|
||||
|
||||
|
||||
class TestGetWifiDevices:
|
||||
"""Tests for get_wifi_devices function."""
|
||||
|
||||
|
|
@ -514,9 +664,10 @@ class TestEnableHotspot:
|
|||
with mock.patch("controller.lib.admin.get_config_value", side_effect=mock_get_config):
|
||||
radio_on = mock.Mock(returncode=0, stdout="", stderr="")
|
||||
device_ready = mock.Mock(returncode=0, stdout="wlan1:disconnected\n", stderr="")
|
||||
hotspot_ok = mock.Mock(returncode=0, stdout="", stderr="")
|
||||
ok = mock.Mock(returncode=0, stdout="", stderr="")
|
||||
|
||||
with mock.patch("subprocess.run", side_effect=[radio_on, device_ready, hotspot_ok]) as mock_subprocess:
|
||||
with mock.patch("subprocess.run",
|
||||
side_effect=[radio_on, device_ready, ok, ok, ok]) as mock_subprocess:
|
||||
with mock.patch("time.sleep"):
|
||||
enable_hotspot()
|
||||
|
||||
|
|
@ -529,6 +680,64 @@ class TestEnableHotspot:
|
|||
assert "TestSSID" in cmd_args
|
||||
assert "testpassword123" in cmd_args
|
||||
|
||||
@staticmethod
|
||||
def _run_enable(protocol: str, results: list) -> tuple:
|
||||
"""Run enable_hotspot() with the given hotspot.protocol and subprocess results."""
|
||||
def mock_get_config(key, default=None):
|
||||
return {'hotspot.protocol': protocol}.get(key, default)
|
||||
|
||||
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
|
||||
with mock.patch("controller.lib.admin.get_config_value", side_effect=mock_get_config):
|
||||
with mock.patch("subprocess.run", side_effect=results) as mock_subprocess:
|
||||
with mock.patch("time.sleep"):
|
||||
result = enable_hotspot()
|
||||
return result, mock_subprocess.call_args_list
|
||||
|
||||
def test_wpa2_sets_psk_key_mgmt(self):
|
||||
"""The Hotspot connection is explicitly set to WPA2 so a reused profile
|
||||
does not keep a previously configured protocol."""
|
||||
ok = mock.Mock(returncode=0, stdout="", stderr="")
|
||||
device_ready = mock.Mock(returncode=0, stdout="wlan0:disconnected\n", stderr="")
|
||||
result, calls = self._run_enable('wpa2', [ok, device_ready, ok, ok, ok])
|
||||
|
||||
assert result["success"] is True
|
||||
modify_call = [c[0][0] for c in calls if 'modify' in c[0][0]]
|
||||
assert modify_call and "wpa-psk" in modify_call[0]
|
||||
|
||||
def test_wpa3_sets_sae_key_mgmt(self):
|
||||
"""WPA3 modifies the Hotspot connection to SAE with required PMF, then re-activates."""
|
||||
ok = mock.Mock(returncode=0, stdout="", stderr="")
|
||||
device_ready = mock.Mock(returncode=0, stdout="wlan0:disconnected\n", stderr="")
|
||||
result, calls = self._run_enable('wpa3', [ok, device_ready, ok, ok, ok])
|
||||
|
||||
assert result["success"] is True
|
||||
modify_call = [c[0][0] for c in calls if 'modify' in c[0][0]]
|
||||
assert modify_call and "sae" in modify_call[0]
|
||||
assert "802-11-wireless-security.pmf" in modify_call[0]
|
||||
up_call = [c[0][0] for c in calls if 'up' in c[0][0]]
|
||||
assert up_call, "Hotspot connection was not re-activated"
|
||||
|
||||
def test_wpa3_failure_reverts_to_wpa2(self):
|
||||
"""If the driver rejects WPA3, the hotspot is reverted to WPA2 and an error returned."""
|
||||
ok = mock.Mock(returncode=0, stdout="", stderr="")
|
||||
device_ready = mock.Mock(returncode=0, stdout="wlan0:disconnected\n", stderr="")
|
||||
sae_failed = mock.Mock(returncode=1, stdout="", stderr="Error: SAE not supported")
|
||||
# radio, poll, hotspot, modify(sae) fails, revert modify, revert up.
|
||||
result, calls = self._run_enable('wpa3', [ok, device_ready, ok, sae_failed, ok, ok])
|
||||
|
||||
assert result["success"] is False
|
||||
assert "wpa3" in result["error"].lower()
|
||||
modify_calls = [c[0][0] for c in calls if 'modify' in c[0][0]]
|
||||
assert len(modify_calls) == 2 and "wpa-psk" in modify_calls[1]
|
||||
|
||||
def test_wpa2_modify_failure_still_succeeds(self):
|
||||
"""A failure setting WPA2 explicitly is not fatal; nmcli already created a WPA2 hotspot."""
|
||||
ok = mock.Mock(returncode=0, stdout="", stderr="")
|
||||
device_ready = mock.Mock(returncode=0, stdout="wlan0:disconnected\n", stderr="")
|
||||
failed = mock.Mock(returncode=1, stdout="", stderr="Error: unknown property")
|
||||
result, _ = self._run_enable('wpa2', [ok, device_ready, ok, failed])
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
def test_waits_for_device_ready_after_radio_on(self):
|
||||
"""Should poll device status after turning radio on, waiting for device
|
||||
|
|
@ -546,6 +755,8 @@ class TestEnableHotspot:
|
|||
device_unavailable, # nmcli -t device status (not ready)
|
||||
device_ready, # nmcli -t device status (ready)
|
||||
hotspot_result, # nmcli device wifi hotspot
|
||||
hotspot_result, # nmcli connection modify Hotspot
|
||||
hotspot_result, # nmcli connection up Hotspot
|
||||
]):
|
||||
with mock.patch("time.sleep"): # Don't actually sleep in tests
|
||||
result = enable_hotspot()
|
||||
|
|
|
|||
|
|
@ -52,6 +52,34 @@ class TestHotspotDevicesEndpoint:
|
|||
assert response.json() == {"devices": []}
|
||||
|
||||
|
||||
class TestHotspotProtocolsEndpoint:
|
||||
"""GET /api/hotspot/protocols lists the protocols a device can host."""
|
||||
|
||||
def test_protocols_endpoint_shape(self, test_client):
|
||||
"""Should probe the requested device."""
|
||||
from unittest import mock
|
||||
with mock.patch("controller.api.admin.get_device_hotspot_protocols",
|
||||
return_value=["wpa2", "wpa3"]) as mock_protocols:
|
||||
response = test_client.get("/api/hotspot/protocols?device=wlp2s0")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"device": "wlp2s0", "protocols": ["wpa2", "wpa3"]}
|
||||
mock_protocols.assert_called_once_with("wlp2s0")
|
||||
|
||||
def test_protocols_defaults_to_configured_device(self, test_client):
|
||||
"""Should probe the configured hotspot device when none is given."""
|
||||
from unittest import mock
|
||||
with mock.patch("controller.api.admin.get_device_hotspot_protocols", return_value=["wpa2"]):
|
||||
response = test_client.get("/api/hotspot/protocols")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"device": "wlan0", "protocols": ["wpa2"]}
|
||||
|
||||
def test_protocols_empty_in_docker(self, test_client_docker_mode):
|
||||
"""Docker mode cannot host a hotspot."""
|
||||
response = test_client_docker_mode.get("/api/hotspot/protocols?device=wlan0")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"device": "wlan0", "protocols": []}
|
||||
|
||||
|
||||
class TestHotspotSettingsEndpoint:
|
||||
"""GET/POST /api/hotspot/settings manage hotspot settings in controller.yaml."""
|
||||
|
||||
|
|
@ -59,23 +87,31 @@ class TestHotspotSettingsEndpoint:
|
|||
"""Should return the current hotspot settings."""
|
||||
response = test_client.get("/api/hotspot/settings")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"device": "wlan0", "ssid": "WROLPi", "password": "wrolpi hotspot"}
|
||||
assert response.json() == {"device": "wlan0", "ssid": "WROLPi", "password": "wrolpi hotspot",
|
||||
"protocol": "wpa2"}
|
||||
|
||||
def test_post_settings(self, test_client, mock_config_path):
|
||||
"""Should update settings and persist them to controller.yaml."""
|
||||
response = test_client.post(
|
||||
"/api/hotspot/settings",
|
||||
json={"device": "wlp2s0", "ssid": "RafaelPi", "password": "password123"},
|
||||
json={"device": "wlp2s0", "ssid": "RafaelPi", "password": "password123", "protocol": "wpa3"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"device": "wlp2s0", "ssid": "RafaelPi", "password": "password123"}
|
||||
assert response.json() == {"device": "wlp2s0", "ssid": "RafaelPi", "password": "password123",
|
||||
"protocol": "wpa3"}
|
||||
assert "wlp2s0" in mock_config_path.read_text()
|
||||
assert "wpa3" in mock_config_path.read_text()
|
||||
|
||||
def test_post_rejects_short_password(self, test_client, mock_config_path):
|
||||
"""Should reject a password shorter than 8 characters."""
|
||||
response = test_client.post("/api/hotspot/settings", json={"password": "short"})
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_post_rejects_invalid_protocol(self, test_client, mock_config_path):
|
||||
"""Should reject protocols other than wpa2/wpa3."""
|
||||
response = test_client.post("/api/hotspot/settings", json={"protocol": "wep"})
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_post_rejected_in_docker(self, test_client_docker_mode):
|
||||
"""Should reject settings changes in Docker mode."""
|
||||
response = test_client_docker_mode.post("/api/hotspot/settings", json={"device": "wlp2s0"})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue