From 8687816b84beca4b6d9413afa6dddc87cc940006 Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 28 Jul 2026 19:52:39 -0600 Subject: [PATCH] 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 --- app/src/api/controller.js | 5 + app/src/components/admin/ControllerPage.js | 47 +++- .../components/admin/ControllerPage.test.js | 40 ++++ controller/api/admin.py | 17 +- controller/api/schemas.py | 9 + controller/defaults.py | 1 + controller/lib/admin.py | 150 +++++++++++- controller/templates/index.html | 35 ++- controller/test/test_admin.py | 215 +++++++++++++++++- controller/test/test_admin_api.py | 42 +++- 10 files changed, 545 insertions(+), 16 deletions(-) diff --git a/app/src/api/controller.js b/app/src/api/controller.js index acfe6585..ed7522dd 100644 --- a/app/src/api/controller.js +++ b/app/src/api/controller.js @@ -79,6 +79,11 @@ export async function getHotspotDevices() { return controllerFetch('/hotspot/devices'); } +export async function getHotspotProtocols(device) { + const query = device ? `?device=${encodeURIComponent(device)}` : ''; + return controllerFetch(`/hotspot/protocols${query}`); +} + export async function getHotspotSettings() { return controllerFetch('/hotspot/settings'); } diff --git a/app/src/components/admin/ControllerPage.js b/app/src/components/admin/ControllerPage.js index 17714785..f6d7d5a1 100644 --- a/app/src/components/admin/ControllerPage.js +++ b/app/src/components/admin/ControllerPage.js @@ -12,6 +12,7 @@ import { getDisks, getFstabEntries, getHotspotDevices, + getHotspotProtocols, getHotspotSettings, getSambaStatus, getServiceLogs, @@ -1410,10 +1411,17 @@ function SambaSection() { } +// Human labels for the hotspot protocols reported by the Controller. +const hotspotProtocolLabels = { + wpa2: 'WPA2 (most compatible)', + wpa3: 'WPA3', +}; + function HotspotSettingsForm() { const dockerized = useDockerized(); const [devices, setDevices] = React.useState([]); - const [form, setForm] = React.useState({device: '', ssid: '', password: ''}); + const [protocols, setProtocols] = React.useState([]); + const [form, setForm] = React.useState({device: '', ssid: '', password: '', protocol: ''}); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); const [qrOpen, setQrOpen] = React.useState(false); @@ -1422,7 +1430,12 @@ function HotspotSettingsForm() { const fetchSettings = async () => { try { const [settings, devicesResponse] = await Promise.all([getHotspotSettings(), getHotspotDevices()]); - setForm({device: settings.device, ssid: settings.ssid, password: settings.password}); + setForm({ + device: settings.device, + ssid: settings.ssid, + password: settings.password, + protocol: settings.protocol, + }); setDevices(devicesResponse.devices || []); } catch (e) { console.error('Failed to fetch hotspot settings', e); @@ -1433,6 +1446,23 @@ function HotspotSettingsForm() { fetchSettings(); }, []); + // The supported protocols depend on the selected device's hardware. + React.useEffect(() => { + if (!form.device) { + return; + } + const fetchProtocols = async () => { + try { + const response = await getHotspotProtocols(form.device); + setProtocols(response.protocols || []); + } catch (e) { + console.error('Failed to fetch hotspot protocols', e); + setProtocols([]); + } + }; + fetchProtocols(); + }, [form.device]); + const handleSave = async () => { setSaving(true); try { @@ -1461,6 +1491,10 @@ function HotspotSettingsForm() { const deviceOptions = [...new Set([...devices, form.device].filter(Boolean))] .map(device => ({key: device, value: device, text: device})); + // Keep the saved protocol selectable even if the device does not report it. + const protocolOptions = [...new Set([...protocols, form.protocol].filter(Boolean))] + .map(protocol => ({key: protocol, value: protocol, text: hotspotProtocolLabels[protocol] || protocol})); + // Special string which allows a mobile device to connect to a specific Wi-Fi. // The WPA QR format requires backslash-escaping of \ ; , " in the SSID and password. const escapeWifi = (s) => s.replace(/([\\;,"])/g, '\\$1'); @@ -1494,6 +1528,15 @@ function HotspotSettingsForm() { disabled={dockerized || saving} onChange={(e, {value}) => setForm({...form, device: value})} /> + setForm({...form, protocol: value})} + />