controller: address hotspot protocol review findings
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run

- Reset a saved protocol the newly probed device does not support
  instead of offering it (both UIs); a successful probe is
  authoritative, the saved value is only kept when probing fails.
- Ignore out-of-order protocol probe responses when switching devices
  quickly (both UIs).
- Verify the WPA2 rollback after a failed WPA3 activation; report that
  the hotspot may be down instead of claiming the revert succeeded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-07-28 20:59:10 -06:00
parent 8687816b84
commit 2baa9cbd2e
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
5 changed files with 67 additions and 12 deletions

View file

@ -1451,16 +1451,32 @@ function HotspotSettingsForm() {
if (!form.device) {
return;
}
// Ignore out-of-order responses when the user switches devices quickly.
let cancelled = false;
const fetchProtocols = async () => {
try {
const response = await getHotspotProtocols(form.device);
setProtocols(response.protocols || []);
if (cancelled) {
return;
}
const supported = response.protocols || [];
setProtocols(supported);
// Reset a selection the probed device does not support (e.g. WPA3 kept
// after switching to a WPA2-only adapter).
if (supported.length) {
setForm(f => supported.includes(f.protocol) ? f : {...f, protocol: supported[0]});
}
} catch (e) {
console.error('Failed to fetch hotspot protocols', e);
setProtocols([]);
if (!cancelled) {
console.error('Failed to fetch hotspot protocols', e);
setProtocols([]);
}
}
};
fetchProtocols();
return () => {
cancelled = true;
};
}, [form.device]);
const handleSave = async () => {

View file

@ -208,6 +208,19 @@ describe('ControllerPage', () => {
});
});
test('a saved protocol the device does not support is reset to a supported one', async () => {
const controllerApi = require('../../api/controller');
controllerApi.getHotspotSettings.mockResolvedValue(
{device: 'wlan0', ssid: 'WROLPi', password: 'wrolpi hotspot', protocol: 'wpa3'});
controllerApi.getHotspotProtocols.mockResolvedValue({device: 'wlan0', protocols: ['wpa2']});
renderControllerPage();
// The stale WPA3 selection is replaced by the only protocol the device supports.
await waitFor(() => {
expect(screen.getAllByText('WPA2 (most compatible)').length).toBeGreaterThan(0);
});
expect(screen.queryByText('WPA3')).not.toBeInTheDocument();
});
test('hotspot protocol dropdown only offers WPA2 on a Raspberry Pi', async () => {
const {getHotspotProtocols} = require('../../api/controller');
getHotspotProtocols.mockResolvedValue({device: 'wlan0', protocols: ['wpa2']});

View file

@ -476,18 +476,26 @@ def enable_hotspot() -> dict:
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(
revert_modify = 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,
)
revert_up = None
if revert_modify.returncode == 0:
revert_up = subprocess.run(
["nmcli", "connection", "up", "Hotspot"],
capture_output=True, text=True, timeout=30,
)
if revert_modify.returncode == 0 and revert_up.returncode == 0:
return {"success": False,
"error": f"WPA3 is not supported by {device}; hotspot reverted to WPA2: {error}"}
revert_error = (revert_modify.stderr if revert_modify.returncode != 0 else revert_up.stderr).strip()
logger.error("Reverting hotspot to WPA2 also failed: %s", revert_error)
return {"success": False,
"error": f"WPA3 is not supported by {device}; hotspot reverted to WPA2: {error}"}
"error": f"WPA3 failed on {device} and falling back to WPA2 also failed;"
f" the hotspot may be down: {error}"}
# WPA2 is what nmcli created the hotspot with anyway; not fatal.
logger.warning("Failed to explicitly set WPA2 on hotspot: %s", error)

View file

@ -1464,14 +1464,19 @@
savedProtocol = savedProtocol || select.value;
try {
const response = await apiCall(`api/hotspot/protocols?device=${encodeURIComponent(device)}`);
// Ignore out-of-order responses when the user switches devices quickly.
if (document.getElementById('hotspot-device').value !== device) return;
select.innerHTML = '';
// Keep the saved protocol selectable even if the device does not report it.
const protocols = [...new Set([...(response.protocols || []), savedProtocol])].filter(Boolean);
// Keep the saved protocol only when probing found nothing (e.g. the saved
// device is unplugged); a successful probe is authoritative.
const supported = (response.protocols || []).filter(Boolean);
const protocols = supported.length ? supported : [savedProtocol].filter(Boolean);
const selected = protocols.includes(savedProtocol) ? savedProtocol : protocols[0];
for (const protocol of protocols) {
const option = document.createElement('option');
option.value = protocol;
option.textContent = hotspotProtocolLabels[protocol] || protocol;
option.selected = protocol === savedProtocol;
option.selected = protocol === selected;
select.appendChild(option);
}
} catch (e) {

View file

@ -730,6 +730,19 @@ class TestEnableHotspot:
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_wpa3_failed_revert_is_reported(self):
"""If reverting to WPA2 also fails, the error must not claim the hotspot was reverted."""
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")
revert_failed = mock.Mock(returncode=1, stdout="", stderr="Error: connection not found")
# radio, poll, hotspot, modify(sae) fails, revert modify fails.
result, _ = self._run_enable('wpa3', [ok, device_ready, ok, sae_failed, revert_failed])
assert result["success"] is False
assert "reverted" not in result["error"].lower()
assert "wpa3" in result["error"].lower()
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="")