From 74bc4cc9f2919f1670a4662cc42186506ee7f400 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 02:16:18 +0300 Subject: [PATCH 01/71] wifi: add direct wpa_supplicant backend --- openpilot/system/ui/lib/dhcp_client.py | 126 ++++ .../system/ui/lib/tests/test_dhcp_client.py | 179 ++++++ .../system/ui/lib/tests/test_wpa_ctrl.py | 568 ++++++++++++++++++ openpilot/system/ui/lib/udhcpc.script | 21 + openpilot/system/ui/lib/wifi_network_store.py | 568 ++++++++++++++++++ openpilot/system/ui/lib/wpa_ctrl.py | 556 +++++++++++++++++ 6 files changed, 2018 insertions(+) create mode 100644 openpilot/system/ui/lib/dhcp_client.py create mode 100644 openpilot/system/ui/lib/tests/test_dhcp_client.py create mode 100644 openpilot/system/ui/lib/tests/test_wpa_ctrl.py create mode 100755 openpilot/system/ui/lib/udhcpc.script create mode 100644 openpilot/system/ui/lib/wifi_network_store.py create mode 100644 openpilot/system/ui/lib/wpa_ctrl.py diff --git a/openpilot/system/ui/lib/dhcp_client.py b/openpilot/system/ui/lib/dhcp_client.py new file mode 100644 index 00000000000000..c98f3daf202eeb --- /dev/null +++ b/openpilot/system/ui/lib/dhcp_client.py @@ -0,0 +1,126 @@ +"""udhcpc lifecycle for a single interface.""" +import os +import re +import subprocess +import threading + +from openpilot.common.swaglog import cloudlog + +DHCP_SCRIPT = os.path.join(os.path.dirname(__file__), "udhcpc.script") +DHCP_DEFAULT_SCRIPT = "/etc/udhcpc/default.script" + + +class DhcpClient: + """Manage udhcpc for DHCP on wlan0.""" + + # Matches udhcpc's -T retry timeout below. + DISCOVER_TIMEOUT_SECONDS = 3 + DISCOVER_ATTEMPTS = 5 + + def __init__(self, iface: str = "wlan0"): + self._iface = iface + self._proc: subprocess.Popen | None = None + self._adopted = False + self._client_thread: threading.Thread | None = None + self._client_stop = threading.Event() + + def _start_client_thread(self): + self._client_stop.clear() + self._client_thread = threading.Thread(target=self._monitor_client, daemon=True) + self._client_thread.start() + + def _client_running(self) -> bool: + if self._proc is not None and self._proc.poll() is None: + return True + script = re.escape(DHCP_SCRIPT) + result = subprocess.run(["pgrep", "-f", f"^udhcpc -i {self._iface}( |$).* -s {script}( |$)"], capture_output=True, check=False) + return result.returncode == 0 + + def _flush_address(self): + subprocess.run(["sudo", "ip", "-4", "addr", "flush", "dev", self._iface], capture_output=True, check=False) + + def _flush_lease(self): + subprocess.run(["sudo", "ip", "-4", "route", "flush", "dev", self._iface], capture_output=True, check=False) + self._flush_address() + + def clear_ipv6_state(self): + delete_default_route = ["sudo", "ip", "-6", "route", "del", "default", "dev", self._iface] + for command in ( + ["sudo", "ip", "-6", "addr", "flush", "dev", self._iface, "scope", "global"], + # Router-advertised default routes require an explicit delete before the remaining routes can be flushed. + delete_default_route, + ["sudo", "ip", "-6", "route", "flush", "dev", self._iface], + ): + try: + result = subprocess.run(command, capture_output=True, check=False) + missing_default_route = ( + command == delete_default_route + and result.returncode == 2 + and b"No such process" in result.stderr + ) + if result.returncode != 0 and not missing_default_route: + cloudlog.warning(f"Failed to clear {self._iface} IPv6 state (rc={result.returncode})") + except OSError: + cloudlog.exception(f"Failed to clear {self._iface} IPv6 state") + + def _spawn(self) -> bool: + if not os.access(DHCP_DEFAULT_SCRIPT, os.X_OK): + cloudlog.error(f"udhcpc default script is not executable: {DHCP_DEFAULT_SCRIPT}") + return False + try: + self._proc = subprocess.Popen( + ["sudo", "udhcpc", "-i", self._iface, "-f", + "-t", str(self.DISCOVER_ATTEMPTS), "-T", str(self.DISCOVER_TIMEOUT_SECONDS), + "-s", DHCP_SCRIPT], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except Exception: + self._proc = None + cloudlog.exception("Failed to start udhcpc") + return False + return True + + def _monitor_client(self): + while not self._client_stop.wait(self.DISCOVER_TIMEOUT_SECONDS): + if not self._client_running(): + self._flush_lease() + self._spawn() + + def adopt(self) -> bool: + if not self._client_running(): + return False + self._adopted = True + self._start_client_thread() + return True + + def start(self): + self.stop() + # Replace any unadopted client so a fresh association gets a fresh lease. + subprocess.run(["sudo", "pkill", "-f", f"^udhcpc -i {self._iface}( |$)"], check=False) + self._flush_lease() + self._spawn() + self._start_client_thread() + + def stop(self): + self._client_stop.set() + if self._client_thread is not None: + self._client_thread.join(timeout=self.DISCOVER_TIMEOUT_SECONDS) + self._client_thread = None + had_client = self._proc is not None or self._adopted + if self._proc is not None: + try: + self._proc.terminate() + self._proc.wait(timeout=3) + except Exception: + try: + self._proc.kill() + self._proc.wait() + except Exception: + pass + self._proc = None + self._adopted = False + if had_client: + # Kill orphaned udhcpc children before flushing their lease state. + subprocess.run(["sudo", "pkill", "-f", f"^udhcpc -i {self._iface}( |$)"], check=False) + self._flush_lease() diff --git a/openpilot/system/ui/lib/tests/test_dhcp_client.py b/openpilot/system/ui/lib/tests/test_dhcp_client.py new file mode 100644 index 00000000000000..9662d97821a038 --- /dev/null +++ b/openpilot/system/ui/lib/tests/test_dhcp_client.py @@ -0,0 +1,179 @@ +import os +import subprocess +import tempfile +from pathlib import Path +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from openpilot.system.ui.lib import dhcp_client as dhcp_client_module +from openpilot.system.ui.lib.dhcp_client import DhcpClient + + +class TestDhcpClient(TestCase): + def setUp(self): + self.enterContext(patch.object(dhcp_client_module.os, "access", return_value=True)) + + def test_adopt_existing_udhcpc_without_restarting_it(self): + client = DhcpClient() + with ( + patch.object(dhcp_client_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run, + patch.object(dhcp_client_module.subprocess, "Popen") as popen, + patch.object(dhcp_client_module.threading, "Thread") as thread, + ): + assert client.adopt() + + script = dhcp_client_module.re.escape(dhcp_client_module.DHCP_SCRIPT) + run.assert_called_once_with(["pgrep", "-f", f"^udhcpc -i wlan0( |$).* -s {script}( |$)"], capture_output=True, check=False) + popen.assert_not_called() + thread.assert_called_once_with(target=client._monitor_client, daemon=True) + thread.return_value.start.assert_called_once() + + def test_start_detaches_udhcpc_from_ui_session(self): + client = DhcpClient() + with ( + patch.object(client, "stop") as stop, + patch.object(dhcp_client_module.subprocess, "run") as run, + patch.object(dhcp_client_module.subprocess, "Popen") as popen, + patch.object(dhcp_client_module.threading, "Thread") as thread, + ): + client.start() + + stop.assert_called_once() + assert [call.args[0] for call in run.call_args_list] == [ + ["sudo", "pkill", "-f", "^udhcpc -i wlan0( |$)"], + ["sudo", "ip", "-4", "route", "flush", "dev", "wlan0"], + ["sudo", "ip", "-4", "addr", "flush", "dev", "wlan0"], + ] + popen.assert_called_once_with( + ["sudo", "udhcpc", "-i", "wlan0", "-f", "-t", "5", "-T", "3", "-s", dhcp_client_module.DHCP_SCRIPT], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + thread.assert_called_once_with(target=client._monitor_client, daemon=True) + thread.return_value.start.assert_called_once() + + def test_failed_launch_starts_client_monitor(self): + client = DhcpClient() + with ( + patch.object(client, "stop"), + patch.object(dhcp_client_module.subprocess, "run"), + patch.object(dhcp_client_module.subprocess, "Popen", side_effect=OSError("exec failed")), + patch.object(dhcp_client_module.threading, "Thread") as thread, + ): + client.start() + + targets = [item.kwargs["target"] for item in thread.call_args_list] + assert client._monitor_client in targets + + def test_missing_default_script_is_reported_before_launch(self): + client = DhcpClient() + with ( + patch.object(dhcp_client_module.os, "access", return_value=False), + patch.object(dhcp_client_module.cloudlog, "error") as error, + patch.object(dhcp_client_module.subprocess, "Popen") as popen, + ): + assert not client._spawn() + + error.assert_called_once_with(f"udhcpc default script is not executable: {dhcp_client_module.DHCP_DEFAULT_SCRIPT}") + popen.assert_not_called() + + def test_start_flushes_stale_lease_before_spawning_client(self): + client = DhcpClient() + events = [] + with ( + patch.object(client, "stop"), + patch.object(dhcp_client_module.subprocess, "run"), + patch.object(client, "_flush_address", side_effect=lambda: events.append("flush")), + patch.object(client, "_spawn", side_effect=lambda: events.append("spawn") or True), + patch.object(client, "_start_client_thread"), + ): + client.start() + + assert events == ["flush", "spawn"] + + def test_exited_client_is_restarted(self): + client = DhcpClient() + client._proc = MagicMock() + client._proc.poll.return_value = 1 + + with ( + patch.object(client._client_stop, "wait", side_effect=[False, True]), + patch.object( + dhcp_client_module.subprocess, + "run", + return_value=MagicMock(returncode=1), + ), + patch.object(client, "_flush_address") as flush_address, + patch.object(client, "_spawn", return_value=True) as spawn, + ): + client._monitor_client() + + flush_address.assert_called_once() + spawn.assert_called_once() + + def test_dhcp_script_applies_metric_after_default_script(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + trace = root / "trace" + default_script = root / "default.script" + default_script.write_text('#!/bin/sh\nprintf "default %s\\n" "$1" >> "$TRACE"\n') + default_script.chmod(0o755) + ip = root / "ip" + ip.write_text('#!/bin/sh\nprintf "ip %s\\n" "$*" >> "$TRACE"\n') + ip.chmod(0o755) + env = { + **os.environ, + "PATH": f"{root}:{os.environ['PATH']}", + "TRACE": str(trace), + "UDHCPC_DEFAULT_SCRIPT": str(default_script), + "interface": "wlan0", + "router": "192.168.1.1 192.168.1.2", + } + + subprocess.run([dhcp_client_module.DHCP_SCRIPT, "renew"], env=env, check=True) + + assert trace.read_text().splitlines() == [ + "default renew", + "ip -4 route replace default via 192.168.1.1 dev wlan0 metric 600", + "ip -4 route del default via 192.168.1.1 dev wlan0 metric 0", + ] + + def test_stop_cleans_only_wlan_dhcp_routes_and_address(self): + client = DhcpClient() + client._proc = MagicMock() + with patch.object(dhcp_client_module.subprocess, "run") as run: + client.stop() + + assert client._proc is None + assert [call.args[0] for call in run.call_args_list] == [ + ["sudo", "pkill", "-f", "^udhcpc -i wlan0( |$)"], + ["sudo", "ip", "-4", "route", "flush", "dev", "wlan0"], + ["sudo", "ip", "-4", "addr", "flush", "dev", "wlan0"], + ] + + def test_clear_ipv6_state_cleans_global_addresses_and_routes(self): + client = DhcpClient() + with patch.object(dhcp_client_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run: + client.clear_ipv6_state() + + assert [item.args[0] for item in run.call_args_list] == [ + ["sudo", "ip", "-6", "addr", "flush", "dev", "wlan0", "scope", "global"], + ["sudo", "ip", "-6", "route", "del", "default", "dev", "wlan0"], + ["sudo", "ip", "-6", "route", "flush", "dev", "wlan0"], + ] + + def test_clear_ipv6_state_ignores_absent_default_route(self): + client = DhcpClient() + results = ( + MagicMock(returncode=0, stderr=b""), + MagicMock(returncode=2, stderr=b"RTNETLINK answers: No such process\n"), + MagicMock(returncode=0, stderr=b""), + ) + with ( + patch.object(dhcp_client_module.subprocess, "run", side_effect=results), + patch.object(dhcp_client_module.cloudlog, "warning") as warning, + ): + client.clear_ipv6_state() + + warning.assert_not_called() diff --git a/openpilot/system/ui/lib/tests/test_wpa_ctrl.py b/openpilot/system/ui/lib/tests/test_wpa_ctrl.py new file mode 100644 index 00000000000000..5382dcecd8faca --- /dev/null +++ b/openpilot/system/ui/lib/tests/test_wpa_ctrl.py @@ -0,0 +1,568 @@ +from pathlib import Path +import socket +import tempfile +import threading +import time +from typing import cast +from unittest import TestCase +from unittest.mock import MagicMock, call, patch + +from openpilot.system.ui.lib import wpa_ctrl as wpa_ctrl_module +from openpilot.system.ui.lib.wpa_ctrl import ( + RECV_BUF_SIZE, + SecurityType, + WpaCtrl, + decode_ssid, + normalize_ssid, + parse_event_network_id, + parse_event_ssid, + parse_scan_results, + parse_status, + flags_to_security_type, + dbm_to_percent, +) + + +class TestParseStatus(TestCase): + def test_values(self): + cases = ( + ("", {}), + ("ssid=My=Network\n", {"ssid": "My=Network"}), + ('ssid=My \\"Home\\"\n', {"ssid": 'My "Home"'}), + ( + "wpa_state=COMPLETED\nssid=MyNet\nip_address=10.0.0.5\n", + {"wpa_state": "COMPLETED", "ssid": "MyNet", "ip_address": "10.0.0.5"}, + ), + ( + "wpa_state=COMPLETED\nssid=caf\\xc3\\xa9\nip_address=10.0.0.5\n", # codespell:ignore caf + {"wpa_state": "COMPLETED", "ssid": "café", "ip_address": "10.0.0.5"}, + ), + ( + "bssid=00:11:22:33:44:55\nssid=\\x41\n", + {"bssid": "00:11:22:33:44:55", "ssid": "A"}, + ), + ) + for raw, expected in cases: + with self.subTest(raw=raw): + assert parse_status(raw) == expected + + +class TestParseEventSsid(TestCase): + def test_values(self): + cases = ( + ('id=0 ssid="MyNetwork" reason=WRONG_KEY', "MyNetwork"), + ("id=0 reason=WRONG_KEY", None), + (r'id=0 ssid="My \"Home\"" reason=WRONG_KEY', 'My "Home"'), + (r'id=0 ssid="caf\xc3\xa9" reason=WRONG_KEY', "café"), # codespell:ignore caf + ) + for event, expected in cases: + with self.subTest(event=event): + assert parse_event_ssid(event) == expected + + +class TestParseEventNetworkId(TestCase): + def test_values(self): + cases = ( + ('id=42 ssid="MyNetwork" reason=WRONG_KEY', "42"), + ('ssid="MyNetwork" reason=WRONG_KEY', None), + ) + for event, expected in cases: + with self.subTest(event=event): + assert parse_event_network_id(event) == expected + + +class TestFlagsToSecurityType(TestCase): + def test_security_types(self): + cases = ( + ("[WPA2-PSK-CCMP][ESS]", SecurityType.WPA), + ("[RSN-PSK-CCMP]", SecurityType.WPA), + ("[WPA-PSK-TKIP]", SecurityType.WPA), + ("[WPA2-PSK-CCMP][SAE]", SecurityType.WPA), + ("[RSN-PSK-CCMP][SAE-CCMP]", SecurityType.WPA), + ("[WPA2-PSK+SAE-CCMP][ESS]", SecurityType.WPA), + ("[WPA2-PSK-SHA256+SAE-CCMP][ESS]", SecurityType.UNSUPPORTED), + ("[WPA2-PSK-SHA256-CCMP][ESS]", SecurityType.UNSUPPORTED), + ("[WPA-PSK-SHA256-TKIP][ESS]", SecurityType.UNSUPPORTED), + ("[WPA2-PSK-CCMP][WPA2-PSK-SHA256-CCMP][ESS]", SecurityType.WPA), + ("[SAE]", SecurityType.UNSUPPORTED), + ("[SAE-CCMP]", SecurityType.UNSUPPORTED), + ("[OWE-CCMP][ESS]", SecurityType.UNSUPPORTED), + ("[OWE-TRANSITION][ESS]", SecurityType.UNSUPPORTED), + ("[DPP][ESS]", SecurityType.UNSUPPORTED), + ("[OSEN][ESS]", SecurityType.UNSUPPORTED), + ("[FILS-SHA256][ESS]", SecurityType.UNSUPPORTED), + ("[ESS]", SecurityType.OPEN), + ("", SecurityType.OPEN), + ("[WPA2-EAP-CCMP]", SecurityType.UNSUPPORTED), + ("[802.1X]", SecurityType.UNSUPPORTED), + ) + for flags, expected in cases: + assert flags_to_security_type(flags) == expected + + +class TestDbmToPercent(TestCase): + def test_values(self): + cases = ((-120, 0), (-100, 0), (-92, 14), (-81, 32), (-74, 44), (-70, 50), (-40, 100), (-30, 100)) + for dbm, expected in cases: + with self.subTest(dbm=dbm): + assert dbm_to_percent(dbm) == expected + + +class TestParseScanResults(TestCase): + HEADER = "bssid / frequency / signal level / flags / ssid\n" + + def test_basic(self): + raw = self.HEADER + "00:11:22:33:44:55\t2437\t-65\t[WPA2-PSK-CCMP][ESS]\tMyNetwork\n" + results = parse_scan_results(raw) + assert len(results) == 1 + r = results[0] + assert r.bssid == "00:11:22:33:44:55" + assert r.freq == 2437 + assert r.signal == -65 + assert r.ssid == "MyNetwork" + + def test_ssid_values(self): + cases = ( + ("00:11:22:33:44:55\t2437\t-65\t[ESS]\t\n", ""), + (f"00:11:22:33:44:55\t2437\t-65\t[ESS]\t{'\\x00' * 32}\n", ""), + ('00:11:22:33:44:55\t2437\t-65\t[ESS]\tcaf\\xc3\\xa9 \\"home\\"\n', 'café "home"'), + ("00:11:22:33:44:55\t2437\t-65\t[ESS]\tMyNet \n", "MyNet "), + ("00:11:22:33:44:55\t2437\t-65\t[ESS]\n", ""), + ("garbage\n00:11:22:33:44:55\t2437\t-65\t[ESS]\tGood\n", "Good"), + ) + for body, expected in cases: + with self.subTest(body=body): + results = parse_scan_results(self.HEADER + body) + assert len(results) == 1 + assert results[0].ssid == expected + + def test_large_scan_fits_in_recv_buffer(self): + lines = [self.HEADER.strip()] + for i in range(200): + bssid = f"00:11:22:33:{i // 256:02x}:{i % 256:02x}" + ssid = f"Network_{i:03d}_with_a_longer_name_padding" + lines.append(f"{bssid}\t2437\t{-30 - (i % 70)}\t[WPA2-PSK-CCMP][ESS]\t{ssid}") + raw = "\n".join(lines) + "\n" + + assert len(raw.encode()) < RECV_BUF_SIZE + + results = parse_scan_results(raw) + assert len(results) == 200 + assert results[0].ssid == "Network_000_with_a_longer_name_padding" + assert results[199].ssid == "Network_199_with_a_longer_name_padding" + +class TestDecodeSsid(TestCase): + def test_values(self): + cases = ( + ("MyNetwork", "MyNetwork"), + ("", ""), + ("\\x41\\x42", "AB"), + ("caf\\xc3\\xa9", "café"), # codespell:ignore caf + ("\\xe6\\x97\\xa5\\xe6\\x9c\\xac", "日本"), + ("\\xf0\\x9f\\x9a\\x97", "🚗"), + ("\\x1Z", "\x01Z"), + ("\\xA", "\x0a"), + ("\\xGZ", "GZ"), + ("\\101", "A"), + ("\\0X", "\x00X"), + ("\\78", "\x078"), + ("\\\\", "\\"), + ('\\"', '"'), + ("\\n", "\n"), + ("\\r", "\r"), + ("\\t", "\t"), + ("\\e", "\x1b"), + ("a\\qb", "aqb"), + ("abc\\", "abc"), + ("\\x00" * 32, ""), + ("A\\x00B", "A\x00B"), + ) + for encoded, expected in cases: + with self.subTest(encoded=encoded): + assert decode_ssid(encoded) == expected + + def test_invalid_utf8_preserves_identity(self): + for encoded, expected in (("\\xFF", b"\xff"), ("\\x80", b"\x80")): + with self.subTest(encoded=encoded): + decoded = decode_ssid(encoded) + assert decoded.encode("utf-8", errors="surrogateescape") == expected + assert normalize_ssid(decoded) == "\ufffd" + + +class TestWpaConfig(TestCase): + def setUp(self): + self.path = Path(self.enterContext(tempfile.TemporaryDirectory())) / "wpa_supplicant.conf" + + def generate(self, ssid, profile): + store = MagicMock() + store.get_profiles.return_value = [(ssid, profile)] + wpa_ctrl_module.generate_wpa_conf(store, str(self.path)) + return self.path.read_text() + + def test_emits_saved_network_priority(self): + assert " priority=42\n" in self.generate("Preferred", {"psk": "password123", "hidden": False, "priority": 42}) + + def test_grants_control_access_to_netdev_group(self): + assert "ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev\n" in self.generate("Test", {"psk": "password123"}) + + def test_emits_saved_bssid_restriction(self): + assert " bssid=00:11:22:33:44:55\n" in self.generate( + "Pinned", {"psk": "password123", "bssid": "00:11:22:33:44:55"}, + ) + + def test_encodes_control_characters_in_ssid_losslessly(self): + assert f" ssid={b'Line\nBreak\r'.hex()}\n" in self.generate("Line\nBreak\r", {"psk": "password123"}) + + +class _RacySock: + def __init__(self): + self._lock = threading.Lock() + self._last_sent: bytes = b"" + + def send(self, data: bytes): + with self._lock: + self._last_sent = data + + def recv(self, _bufsize: int) -> bytes: + time.sleep(0.005) + with self._lock: + return b"REPLY:" + self._last_sent + + +class TestWpaCtrlRequestSerialization(TestCase): + def test_request_pairs_reply_with_command_under_concurrency(self): + ctrl = WpaCtrl() + ctrl._sock = cast(socket.socket, _RacySock()) + + results: dict[str, str] = {} + errors: list[BaseException] = [] + + def worker(cmd: str): + try: + results[cmd] = ctrl.request(cmd) + except BaseException as exc: + errors.append(exc) + + threads = [threading.Thread(target=worker, args=(cmd,)) + for cmd in ("STATUS", "SCAN_RESULTS", "LIST_NETWORKS", "PING")] + for t in threads: + t.start() + for t in threads: + t.join(timeout=5) + + assert not errors, errors + for cmd in ("STATUS", "SCAN_RESULTS", "LIST_NETWORKS", "PING"): + assert results[cmd] == f"REPLY:{cmd}", \ + f"concurrent request mispaired reply for {cmd}: {results[cmd]}" + + ctrl._sock = None + + def test_interrupt_wakes_blocked_request(self): + request_started = threading.Event() + interrupted = threading.Event() + + class BlockingSock: + def send(self, _data): + request_started.set() + + def recv(self, _bufsize): + interrupted.wait(1) + raise OSError("interrupted") + + def shutdown(self, _how): + interrupted.set() + + def close(self): + pass + + ctrl = WpaCtrl() + ctrl._sock = cast(socket.socket, BlockingSock()) + errors = [] + + def run_request(): + try: + ctrl.request("STATUS") + except OSError as exc: + errors.append(str(exc)) + + request = threading.Thread(target=run_request) + request.start() + assert request_started.wait(1) + + ctrl.interrupt() + request.join(1) + + assert interrupted.is_set() + assert not request.is_alive() + assert errors == ["interrupted"] + ctrl._sock = None + + +class TestNetworkManagerCompatibility(TestCase): + def test_unmanage_skips_when_nmcli_is_absent(self): + with ( + patch.object(wpa_ctrl_module.shutil, "which", return_value=None), + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): + assert wpa_ctrl_module._unmanage_wlan0() + + run.assert_not_called() + + def test_unmanage_uses_discovered_nmcli(self): + with ( + patch.object(wpa_ctrl_module.shutil, "which", return_value="/usr/bin/nmcli"), + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): + run.return_value.returncode = 0 + + assert wpa_ctrl_module._unmanage_wlan0() + run.assert_called_once_with( + ["sudo", "/usr/bin/nmcli", "dev", "set", "wlan0", "managed", "no"], + capture_output=True, + ) + + def test_unmanage_failure_is_nonfatal(self): + with ( + patch.object(wpa_ctrl_module.shutil, "which", return_value="/usr/bin/nmcli"), + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): + run.return_value.returncode = 10 + + assert not wpa_ctrl_module._unmanage_wlan0() + + +class TestTetheringDnsmasqOwnership(TestCase): + def test_process_patterns_do_not_match_sudo_parent(self): + assert wpa_ctrl_module.TETHERING_DNSMASQ_PATTERN.startswith("^dnsmasq ") + + with patch.object(wpa_ctrl_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run: + assert wpa_ctrl_module.wpa_supplicant_running(wpa_ctrl_module.WPA_SUPPLICANT_CONF) + running_pattern = run.call_args.args[0][2] + wpa_ctrl_module.stop_wpa_supplicant(wpa_ctrl_module.WPA_SUPPLICANT_CONF) + kill_pattern = run.call_args.args[0][-1] + + assert running_pattern == kill_pattern + assert running_pattern.startswith("^wpa_supplicant ") + + def test_stop_targets_only_openpilot_tethering(self): + with patch.object(wpa_ctrl_module.subprocess, "run") as run: + wpa_ctrl_module.stop_tethering_dnsmasq() + + run.assert_called_once_with( + ["sudo", "pkill", "-f", wpa_ctrl_module.TETHERING_DNSMASQ_PATTERN], + check=False, + ) + + +class TestSupplicantBringup(TestCase): + def test_reconciles_existing_station_configuration(self): + ctrl = MagicMock() + ctrl.request.side_effect = lambda command: ( + "wpa_state=COMPLETED\nmode=station\nssid=TestNet\n" if command == "STATUS" else "OK" + ) + station_reconfigured = MagicMock() + with ( + patch.object(wpa_ctrl_module.os.path, "exists", return_value=True), + patch.object( + wpa_ctrl_module, + "wpa_supplicant_running", + side_effect=lambda conf: conf == wpa_ctrl_module.WPA_SUPPLICANT_CONF, + ), + patch.object(wpa_ctrl_module, "try_attach_ctrl", return_value=ctrl), + patch.object(wpa_ctrl_module, "_unmanage_wlan0") as unmanage, + patch.object(wpa_ctrl_module, "stop_wpa_supplicant") as kill, + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): + result = wpa_ctrl_module.ensure_wpa_supplicant(lambda: False, station_reconfigured) + + assert result is ctrl + assert call("RECONFIGURE") in ctrl.request.call_args_list + station_reconfigured.assert_called_once_with("TestNet") + unmanage.assert_not_called() + kill.assert_not_called() + run.assert_not_called() + + def test_failed_station_reconciliation_restarts_daemon(self): + stale_ctrl = MagicMock() + stale_ctrl.request.return_value = "FAIL" + fresh_ctrl = MagicMock() + station_checks = 0 + + def running(conf): + nonlocal station_checks + if conf == wpa_ctrl_module.WPA_AP_CONF: + return False + station_checks += 1 + return station_checks in (1, 3) + + with ( + patch.object( + wpa_ctrl_module.os.path, + "exists", + side_effect=lambda path: path == "/sys/class/net/wlan0", + ), + patch.object(wpa_ctrl_module, "wpa_supplicant_running", side_effect=running), + patch.object(wpa_ctrl_module, "try_attach_ctrl", side_effect=[stale_ctrl, fresh_ctrl]), + patch.object(wpa_ctrl_module, "_unmanage_wlan0", return_value=True), + patch.object(wpa_ctrl_module, "stop_wpa_supplicant") as kill, + patch.object(wpa_ctrl_module, "stop_tethering_dnsmasq"), + patch.object(wpa_ctrl_module.time, "sleep"), + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): + result = wpa_ctrl_module.ensure_wpa_supplicant(lambda: False) + + assert result is fresh_ctrl + stale_ctrl.close.assert_called_once() + kill.assert_called_once_with(wpa_ctrl_module.WPA_SUPPLICANT_CONF) + assert [ + "sudo", "wpa_supplicant", "-B", "-i", "wlan0", + "-c", wpa_ctrl_module.WPA_SUPPLICANT_CONF, "-D", "nl80211", + ] in [item.args[0] for item in run.call_args_list] + + def test_attaches_existing_hotspot_before_station_cleanup(self): + ctrl = MagicMock() + with ( + patch.object(wpa_ctrl_module.os.path, "exists", return_value=True), + patch.object( + wpa_ctrl_module, + "wpa_supplicant_running", + side_effect=lambda conf: conf == wpa_ctrl_module.WPA_AP_CONF, + ), + patch.object(wpa_ctrl_module, "try_attach_ctrl", return_value=ctrl), + patch.object(wpa_ctrl_module, "_unmanage_wlan0") as unmanage, + patch.object(wpa_ctrl_module, "stop_wpa_supplicant") as kill, + ): + result = wpa_ctrl_module.ensure_wpa_supplicant(lambda: False) + + assert result is ctrl + unmanage.assert_not_called() + kill.assert_not_called() + + def test_unreachable_hotspot_falls_back_to_station_bringup(self): + ctrl = MagicMock() + ap_running = True + station_running = False + + def running(conf): + return ap_running if conf == wpa_ctrl_module.WPA_AP_CONF else station_running + + def stop(conf): + nonlocal ap_running, station_running + if conf == wpa_ctrl_module.WPA_AP_CONF: + ap_running = False + else: + station_running = False + + def run(command, **_kwargs): + nonlocal station_running + if command[:2] == ["sudo", "wpa_supplicant"]: + station_running = True + return MagicMock(returncode=0) + + with ( + patch.object( + wpa_ctrl_module.os.path, + "exists", + side_effect=lambda path: path == "/sys/class/net/wlan0", + ), + patch.object(wpa_ctrl_module, "wpa_supplicant_running", side_effect=running), + patch.object(wpa_ctrl_module, "try_attach_ctrl", side_effect=lambda: None if ap_running else ctrl), + patch.object(wpa_ctrl_module, "_unmanage_wlan0", return_value=True), + patch.object(wpa_ctrl_module, "stop_wpa_supplicant", side_effect=stop) as kill, + patch.object(wpa_ctrl_module, "stop_tethering_dnsmasq"), + patch.object(wpa_ctrl_module.time, "sleep"), + patch.object(wpa_ctrl_module.subprocess, "run", side_effect=run), + ): + result = wpa_ctrl_module.ensure_wpa_supplicant(lambda: False) + + assert result is ctrl + assert call(wpa_ctrl_module.WPA_AP_CONF) in kill.call_args_list + assert call(wpa_ctrl_module.WPA_SUPPLICANT_CONF) in kill.call_args_list + + def test_spawns_owned_station_daemon(self): + ctrl = MagicMock() + station_checks = 0 + + def running(conf): + nonlocal station_checks + if conf == wpa_ctrl_module.WPA_AP_CONF: + return False + station_checks += 1 + return station_checks > 1 + + with ( + patch.object( + wpa_ctrl_module.os.path, + "exists", + side_effect=lambda path: path == "/sys/class/net/wlan0", + ), + patch.object(wpa_ctrl_module, "wpa_supplicant_running", side_effect=running), + patch.object(wpa_ctrl_module, "try_attach_ctrl", return_value=ctrl), + patch.object(wpa_ctrl_module, "_unmanage_wlan0", return_value=True), + patch.object(wpa_ctrl_module, "stop_wpa_supplicant") as kill, + patch.object(wpa_ctrl_module, "stop_tethering_dnsmasq"), + patch.object(wpa_ctrl_module.time, "sleep"), + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): + result = wpa_ctrl_module.ensure_wpa_supplicant(lambda: False) + + assert result is ctrl + kill.assert_called_once_with(wpa_ctrl_module.WPA_SUPPLICANT_CONF) + assert [ + "sudo", "wpa_supplicant", "-B", "-i", "wlan0", + "-c", wpa_ctrl_module.WPA_SUPPLICANT_CONF, "-D", "nl80211", + ] in [item.args[0] for item in run.call_args_list] + + def test_failed_networkmanager_handoff_does_not_mutate_interface(self): + with ( + patch.object( + wpa_ctrl_module.os.path, + "exists", + side_effect=lambda path: path == "/sys/class/net/wlan0", + ), + patch.object(wpa_ctrl_module, "wpa_supplicant_running", return_value=False), + patch.object(wpa_ctrl_module, "_unmanage_wlan0", return_value=False), + patch.object(wpa_ctrl_module, "stop_wpa_supplicant") as kill, + patch.object(wpa_ctrl_module, "stop_tethering_dnsmasq") as stop_dnsmasq, + patch.object(wpa_ctrl_module.time, "sleep"), + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): + result = wpa_ctrl_module.ensure_wpa_supplicant(lambda: False) + + assert result is None + kill.assert_not_called() + stop_dnsmasq.assert_not_called() + run.assert_not_called() + + def test_foreign_control_socket_is_not_attached_after_handoff(self): + def exists(path): + return path in ("/sys/class/net/wlan0", "/var/run/wpa_supplicant/wlan0") + + with ( + patch.object(wpa_ctrl_module.os.path, "exists", side_effect=exists), + patch.object(wpa_ctrl_module, "wpa_supplicant_running", return_value=False), + patch.object(wpa_ctrl_module, "try_attach_ctrl") as attach, + patch.object(wpa_ctrl_module, "_unmanage_wlan0", return_value=True), + patch.object(wpa_ctrl_module, "stop_wpa_supplicant"), + patch.object(wpa_ctrl_module, "stop_tethering_dnsmasq"), + patch.object(wpa_ctrl_module.time, "sleep"), + patch.object(wpa_ctrl_module.subprocess, "run"), + ): + result = wpa_ctrl_module.ensure_wpa_supplicant(lambda: False) + + assert result is None + attach.assert_not_called() + + def test_exit_before_interface_mutation(self): + with ( + patch.object(wpa_ctrl_module.os.path, "exists", return_value=True), + patch.object(wpa_ctrl_module, "_unmanage_wlan0") as unmanage, + patch.object(wpa_ctrl_module, "stop_wpa_supplicant") as kill, + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): + result = wpa_ctrl_module.ensure_wpa_supplicant(lambda: True) + + assert result is None + unmanage.assert_not_called() + kill.assert_not_called() + assert all(item.args[0][0] == "pgrep" for item in run.call_args_list) diff --git a/openpilot/system/ui/lib/udhcpc.script b/openpilot/system/ui/lib/udhcpc.script new file mode 100755 index 00000000000000..c2e5407781c0b5 --- /dev/null +++ b/openpilot/system/ui/lib/udhcpc.script @@ -0,0 +1,21 @@ +#!/bin/sh + +default_script=${UDHCPC_DEFAULT_SCRIPT:-/etc/udhcpc/default.script} +# NetworkManager's default Wi-Fi route metric is 600; AGNOS LTE uses 1000. +wifi_route_metric=600 +"$default_script" "$1" +status=$? + +if [ "$status" -ne 0 ]; then + exit "$status" +fi + +case "$1" in + bound|renew) + router=${router%% *} + if [ -n "$router" ]; then + ip -4 route replace default via "$router" dev "$interface" metric "$wifi_route_metric" && + ip -4 route del default via "$router" dev "$interface" metric 0 2>/dev/null || true + fi + ;; +esac diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py new file mode 100644 index 00000000000000..929c0b819a5091 --- /dev/null +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -0,0 +1,568 @@ +"""Persistent storage for saved WiFi networks, backed by .nmconnection files.""" + +import configparser +import os +import re +import subprocess +import tempfile +import threading +import uuid +from enum import IntEnum + +from openpilot.common.swaglog import cloudlog +from openpilot.common.utils import sudo_read + + +NM_CONNECTIONS_DIR = "/data/etc/NetworkManager/system-connections" +RUNTIME_CONNECTIONS_DIR = "/run/NetworkManager/system-connections" +NETPLAN_CONNECTIONS_DIR = "/data/etc/netplan" + +# Only key-mgmt values we can actually drive via wpa_supplicant. Anything else +# (wpa-eap, sae, ieee8021x, ...) gets skipped on load. Coercing those to +# psk="" would render as key_mgmt=NONE in wpa_supplicant.conf, silently turning +# a secure profile into an open one for the same SSID and inviting open spoofing. +_SUPPORTED_KEY_MGMT = {"wpa-psk", "none"} +_SUPPORTED_WIFI_OPTIONS = {"ssid", "mode", "hidden", "bssid"} +_SUPPORTED_SECURITY_OPTIONS = {"key-mgmt", "psk", "psk-flags", "auth-alg"} +_SUPPORTED_IPV4_METHODS = {"auto"} +_SUPPORTED_IPV6_METHODS = {"auto", "ignore"} +_SUPPORTED_IPV4_OPTIONS = {"method", "dns-priority"} +_SUPPORTED_IPV6_OPTIONS = {"method", "addr-gen-mode"} +# NetworkManager-backed openpilot profiles use this DNS priority. The direct +# stack does not consume it, but retaining it keeps the keyfile rollback-safe. +_OPENPILOT_DNS_PRIORITY = "600" +_KEYFILE_ESCAPES = { + "\\": "\\", + "n": "\n", + "r": "\r", + "s": " ", + "t": "\t", +} + + +class MeteredType(IntEnum): + UNKNOWN = 0 + YES = 1 + NO = 2 + + +def _canonical_filename(file_uuid: str, ssid: str) -> str: + """`-.nmconnection` matches netplan's runtime keyfile naming. UUID is the + stable handle; the SSID suffix is purely cosmetic, so it gets sanitized lossily.""" + ssid_safe = ssid.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace") + ssid_safe = ssid_safe.replace("/", "_").replace("\0", "_") + return f"{file_uuid}-{ssid_safe}.nmconnection" + + +def _decode_keyfile_string(value: str) -> str: + """Decode GLib keyfile string escapes.""" + decoded = [] + i = 0 + while i < len(value): + if value[i] == "\\" and i + 1 < len(value): + escaped = _KEYFILE_ESCAPES.get(value[i + 1]) + if escaped is not None: + decoded.append(escaped) + i += 2 + continue + decoded.append(value[i]) + i += 1 + return "".join(decoded) + + +def _encode_keyfile_string(value: str) -> str: + """Encode GLib keyfile string escapes, including boundary spaces.""" + leading_spaces = len(value) - len(value.lstrip(" ")) + trailing_spaces = len(value) - len(value.rstrip(" ")) + encoded = [] + for i, char in enumerate(value): + if char == "\\": + encoded.append("\\\\") + elif char == "\n": + encoded.append("\\n") + elif char == "\r": + encoded.append("\\r") + elif char == "\t": + encoded.append("\\t") + elif char == " " and (i < leading_spaces or i >= len(value) - trailing_spaces): + encoded.append("\\s") + else: + encoded.append(char) + return "".join(encoded) + + +def _encode_keyfile_ssid(ssid: str) -> str: + if not ssid: + return "" + return ";".join(str(b) for b in ssid.encode("utf-8", errors="surrogateescape")) + ";" + + +def _decode_keyfile_ssid(ssid: str) -> str: + """Decode NM keyfile byte-list SSIDs and escaped literal semicolons.""" + ssid = _decode_keyfile_string(ssid) + if r"\;" in ssid: + return ssid.replace(r"\;", ";") + if not ssid.endswith(";"): + return ssid + + try: + ssid_bytes = bytes(int(p) for p in ssid[:-1].split(";")) + except ValueError: + return ssid + + if not ssid_bytes: + return ssid + if all(b == 0 for b in ssid_bytes): + return "" + return ssid_bytes.decode("utf-8", errors="surrogateescape") + + +def _normalize_keyfile_sections(cp: configparser.ConfigParser): + for alias, canonical in ( + ("wifi", "802-11-wireless"), + ("wifi-security", "802-11-wireless-security"), + ): + if not cp.has_section(alias) and cp.has_section(canonical): + cp[alias] = dict(cp[canonical]) + + +class NetworkStore: + """Persistent storage for saved WiFi networks using .nmconnection files.""" + + def __init__(self, directory: str = NM_CONNECTIONS_DIR, runtime_directory: str | None = None, netplan_directory: str | None = None): + self._directory = directory + # Netplan exposes active keyfiles in /run without a persistent copy. Custom + # test stores remain isolated unless an explicit runtime directory is supplied. + self._runtime_directory = RUNTIME_CONNECTIONS_DIR if directory == NM_CONNECTIONS_DIR else None + if runtime_directory is not None: + self._runtime_directory = runtime_directory + self._netplan_directory = NETPLAN_CONNECTIONS_DIR if directory == NM_CONNECTIONS_DIR else None + if netplan_directory is not None: + self._netplan_directory = netplan_directory + self._lock = threading.Lock() + self._mutation_lock = threading.Lock() + self._networks: dict[str, dict] = {} + self._profiles: dict[str, list[dict]] = {} + self._load() + + def _load(self): + self._networks = {} + self._profiles = {} + sources = [(self._directory, False)] + if self._runtime_directory is not None: + sources.append((self._runtime_directory, True)) + + persistent_ssids: set[str] = set() + for directory, imported in sources: + try: + filenames = sorted(os.listdir(directory)) + except OSError: + continue + for fname in filenames: + self._load_keyfile(directory, fname, imported, persistent_ssids) + + def _find_netplan_filename(self, file_uuid: str) -> str | None: + if self._netplan_directory is None or not file_uuid: + return None + expected = f"90-NM-{file_uuid}.yaml" + if os.path.exists(os.path.join(self._netplan_directory, expected)): + return expected + try: + filenames = sorted(os.listdir(self._netplan_directory)) + except OSError: + return None + pattern = re.compile(rf"^\s*uuid\s*:\s*['\"]?{re.escape(file_uuid)}['\"]?\s*(?:#.*)?$", re.MULTILINE) + yaml_filenames = [fname for fname in filenames if fname.endswith(".yaml")] + for fname in yaml_filenames: + try: + raw = sudo_read(os.path.join(self._netplan_directory, fname)) + except OSError: + continue + if raw and pattern.search(raw): + return fname + return expected if yaml_filenames else None + + def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_ssids: set[str]): + if not fname.endswith(".nmconnection"): + return + fpath = os.path.join(directory, fname) + try: + cp = configparser.ConfigParser(interpolation=None) + raw = sudo_read(fpath) + if raw: + cp.read_string(raw) + else: + cp.read(fpath) + + _normalize_keyfile_sections(cp) + if not cp.has_section("wifi"): + return + ssid = _decode_keyfile_ssid(cp.get("wifi", "ssid", fallback="")) + mode = cp.get("wifi", "mode", fallback="infrastructure") + if not ssid or mode != "infrastructure": + return + if not imported: + persistent_ssids.add(ssid) + if set(cp.options("wifi")) - _SUPPORTED_WIFI_OPTIONS: + cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported Wi-Fi options") + return + file_uuid = cp.get("connection", "uuid", fallback="") + # Persistent /data profiles are authoritative over netplan's runtime + # copies, including unsupported or disabled persistent profiles. + if imported and ssid in persistent_ssids: + primary = self._networks.get(ssid) + if primary is not None and primary.get("_runtime_filename") is None: + primary["_runtime_filename"] = fname + primary["_netplan_filename"] = self._find_netplan_filename(file_uuid) + return + + # An open profile has no [wifi-security] section. A secure profile with a + # key-mgmt we can't reproduce (wpa-eap, sae, ...) must be skipped entirely. + psk = "" + if cp.has_section("wifi-security"): + key_mgmt = cp.get("wifi-security", "key-mgmt", fallback="").lower() + if key_mgmt not in _SUPPORTED_KEY_MGMT: + cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported key-mgmt={key_mgmt!r}") + return + # NM stores WEP as `key-mgmt=none` plus `wep-key*`/`wep-key-type`/`auth-alg=shared`. + # Loading those as open (psk="") would let generate_wpa_conf demote the secured + # SSID to key_mgmt=NONE, enabling auto-association to an open spoof of the same SSID. + wep_keys = ("wep-key0", "wep-key1", "wep-key2", "wep-key3", "wep-key-type", "auth-alg") + if key_mgmt == "none" and any(cp.has_option("wifi-security", k) for k in wep_keys): + cloudlog.warning(f"NetworkStore: skipping {ssid!r} (WEP profile, unsupported)") + return + unsupported_security_options = set(cp.options("wifi-security")) - _SUPPORTED_SECURITY_OPTIONS + auth_alg = cp.get("wifi-security", "auth-alg", fallback="").lower() + psk_flags = cp.getint("wifi-security", "psk-flags", fallback=0) + if unsupported_security_options or auth_alg not in ("", "open") or psk_flags != 0: + cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported security constraints") + return + psk = _decode_keyfile_string(cp.get("wifi-security", "psk", fallback="")) + # NM agent-managed secrets (psk-flags=1) live outside the keyfile. We can't + # drive them via wpa_supplicant, and loading with psk="" would render as + # key_mgmt=NONE, silently demoting a secure profile to open and inviting spoofs. + if key_mgmt == "wpa-psk" and not psk: + cloudlog.warning(f"NetworkStore: skipping {ssid!r} (wpa-psk with no inline secret)") + return + + # connection.autoconnect=false is user/provisioning intent. Do not load it + # only to have ENABLE_NETWORK all silently re-arm the auto-join. + if not cp.getboolean("connection", "autoconnect", fallback=True): + cloudlog.warning(f"NetworkStore: skipping {ssid!r} (connection.autoconnect=false)") + return + + ipv4 = dict(cp["ipv4"]) if cp.has_section("ipv4") else {"method": "auto"} + ipv6 = dict(cp["ipv6"]) if cp.has_section("ipv6") else {"method": "auto"} + ipv4_method = ipv4.get("method", "auto").lower() + ipv6_method = ipv6.get("method", "auto").lower() + unsupported_ipv4_options = set(ipv4) - _SUPPORTED_IPV4_OPTIONS + unsupported_ipv6_options = set(ipv6) - _SUPPORTED_IPV6_OPTIONS + ipv4_dns_priority = ipv4.get("dns-priority") + ipv6_addr_gen_mode = ipv6.get("addr-gen-mode", "default").lower() + if (ipv4_method not in _SUPPORTED_IPV4_METHODS + or ipv6_method not in _SUPPORTED_IPV6_METHODS + or unsupported_ipv4_options + or unsupported_ipv6_options + or ipv4_dns_priority not in (None, _OPENPILOT_DNS_PRIORITY) + or ipv6_addr_gen_mode != "default"): + cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported addressing configuration") + return + + # getint/getboolean can raise ValueError on malformed values; skip the bad profile. + entry = { + "psk": psk, + "metered": cp.getint("connection", "metered", fallback=0), + "priority": cp.getint("connection", "autoconnect-priority", fallback=0), + "hidden": cp.getboolean("wifi", "hidden", fallback=False), + "bssid": cp.get("wifi", "bssid", fallback=""), + "uuid": file_uuid, + "_ipv4": ipv4, + "_ipv6": ipv6, + # Remember the on-disk filename so save/remove stay consistent with noncanonical files. + "_filename": None if imported else fname, + "_runtime_filename": fname if imported else None, + "_netplan_filename": self._find_netplan_filename(file_uuid) if imported else None, + } + profiles = self._profiles.setdefault(ssid, []) + if file_uuid and any(profile.get("uuid") == file_uuid for profile in profiles): + return + profiles.append(entry) + self._networks.setdefault(ssid, entry) + except (configparser.Error, ValueError): + return + + def _render_nmconnection(self, ssid: str, entry: dict) -> tuple[str, dict]: + file_uuid = entry.get("uuid") + if not file_uuid: + try: + file_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, ssid)) + except UnicodeEncodeError: + ssid_hex = ssid.encode("utf-8", errors="surrogateescape").hex() + file_uuid = str(uuid.uuid5(uuid.NAMESPACE_OID, ssid_hex)) + entry = dict(entry) + entry["uuid"] = file_uuid + + canonical_fname = _canonical_filename(file_uuid, ssid) + canonical_path = os.path.join(self._directory, canonical_fname) + stored_fname = entry.get("_filename") + entry["_filename"] = canonical_fname + + cp = configparser.ConfigParser(interpolation=None) + connection_id = ssid.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace") + cp["connection"] = { + "id": _encode_keyfile_string(connection_id), + "uuid": file_uuid, + "type": "wifi", + "metered": str(entry.get("metered", 0)), + "autoconnect-priority": str(entry.get("priority", 0)), + } + wifi = { + "ssid": _encode_keyfile_ssid(ssid), + "mode": "infrastructure", + "hidden": str(entry.get("hidden", False)).lower(), + } + if entry.get("bssid"): + wifi["bssid"] = entry["bssid"] + cp["wifi"] = wifi + + psk = entry.get("psk", "") + if psk: + cp["wifi-security"] = { + "key-mgmt": "wpa-psk", + "psk": _encode_keyfile_string(psk), + } + + ipv4 = dict(entry.get("_ipv4", {"method": "auto"})) + ipv4["dns-priority"] = _OPENPILOT_DNS_PRIORITY + cp["ipv4"] = ipv4 + cp["ipv6"] = entry.get("_ipv6", {"method": "auto"}) + + with tempfile.NamedTemporaryFile(mode="w", dir="/tmp", delete=False) as f: + cp.write(f) + temp_path = f.name + + try: + os.chmod(temp_path, 0o600) + subprocess.run(["sudo", "install", "-d", "-m", "755", self._directory], check=True) + subprocess.run(["sudo", "install", "-o", "root", "-g", "root", "-m", "600", temp_path, canonical_path], check=True) + finally: + try: + os.unlink(temp_path) + except FileNotFoundError: + pass + + # Keep one canonical filename even when the tracked profile uses another name. + if stored_fname and stored_fname != canonical_fname: + stored_path = os.path.join(self._directory, stored_fname) + result = subprocess.run(["sudo", "rm", "-f", stored_path], check=False) + # If cleanup fails (FS read-only, etc.) both files survive. Make both files + # hold the same content so they remain one UUID-equivalent profile. Pin + # _filename to the stored name so each update retries the cleanup. + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: cleanup of noncanonical {stored_fname} failed; mirroring content to keep both files in sync") + try: + subprocess.run(["sudo", "install", "-o", "root", "-g", "root", "-m", "600", os.path.join(self._directory, canonical_fname), stored_path], check=True) + except Exception: + cloudlog.exception("NetworkStore: failed to mirror keyfile to noncanonical path") + entry["_filename"] = stored_fname + + runtime_filename = entry.get("_runtime_filename") + if self._runtime_directory is not None and runtime_filename: + runtime_path = os.path.join(self._runtime_directory, runtime_filename) + result = subprocess.run(["sudo", "rm", "-f", runtime_path], check=False) + if result.returncode != 0: + cleanup_result = subprocess.run(["sudo", "rm", "-f", canonical_path], check=False) + if cleanup_result.returncode != 0: + raise OSError(f"failed to remove {runtime_path} and roll back {canonical_path}") + raise OSError(f"failed to remove {runtime_path}") + entry["_runtime_filename"] = None + + netplan_filename = entry.get("_netplan_filename") + if self._netplan_directory is not None and netplan_filename: + netplan_path = os.path.join(self._netplan_directory, netplan_filename) + if not os.path.exists(netplan_path): + cleanup_result = subprocess.run(["sudo", "rm", "-f", canonical_path], check=False) + if cleanup_result.returncode != 0: + raise OSError(f"failed to find {netplan_path} and roll back {canonical_path}") + raise OSError(f"failed to find {netplan_path}") + result = subprocess.run(["sudo", "rm", "-f", netplan_path], check=False) + if result.returncode != 0: + cleanup_result = subprocess.run(["sudo", "rm", "-f", canonical_path], check=False) + if cleanup_result.returncode != 0: + raise OSError(f"failed to remove {netplan_path} and roll back {canonical_path}") + raise OSError(f"failed to remove {netplan_path}") + entry["_netplan_filename"] = None + + return file_uuid, entry + + def get_all(self) -> dict[str, dict]: + with self._lock: + return {k: dict(v) for k, v in self._networks.items()} + + def get_profiles(self) -> list[tuple[str, dict]]: + with self._lock: + return [ + (ssid, dict(entry)) + for ssid, profiles in self._profiles.items() + for entry in profiles + ] + + def get(self, ssid: str) -> dict | None: + with self._lock: + entry = self._networks.get(ssid) + return dict(entry) if entry else None + + def get_tethering_password(self, ssid: str) -> str | None: + directories = [self._directory] + if self._runtime_directory is not None: + directories.append(self._runtime_directory) + + for directory in directories: + try: + filenames = os.listdir(directory) + except OSError: + continue + for fname in filenames: + if not fname.endswith(".nmconnection"): + continue + try: + raw = sudo_read(os.path.join(directory, fname)) + if not raw: + continue + cp = configparser.ConfigParser(interpolation=None) + cp.read_string(raw) + _normalize_keyfile_sections(cp) + profile_ssid = _decode_keyfile_ssid(cp.get("wifi", "ssid", fallback="")) + if cp.get("wifi", "mode", fallback="infrastructure") != "ap" or profile_ssid != ssid: + continue + if cp.get("wifi-security", "key-mgmt", fallback="").lower() != "wpa-psk": + continue + password = _decode_keyfile_string(cp.get("wifi-security", "psk", fallback="")) + if password: + return password + except (configparser.Error, OSError, ValueError): + continue + return None + + def save_network(self, ssid: str, psk: str | None = None, metered: int | None = None, hidden: bool | None = None): + with self._mutation_lock: + with self._lock: + current = self._networks.get(ssid) + profiles = list(self._profiles.get(ssid, [])) + existing = dict(current or {}) + if psk is not None: + existing["psk"] = psk + elif "psk" not in existing: + existing["psk"] = "" + if metered is not None: + existing["metered"] = metered + elif "metered" not in existing: + existing["metered"] = 0 + if hidden is not None: + existing["hidden"] = hidden + elif "hidden" not in existing: + existing["hidden"] = False + + file_uuid, updated = self._render_nmconnection(ssid, existing) + updated["uuid"] = file_uuid + if current is None: + profiles.append(updated) + else: + updated_profiles = [] + replaced_primary = False + for profile in profiles: + if profile is current: + updated_profiles.append(updated) + replaced_primary = True + elif psk is not None and not profile.get("bssid"): + duplicate = dict(profile) + duplicate["psk"] = psk + duplicate_uuid, duplicate = self._render_nmconnection(ssid, duplicate) + duplicate["uuid"] = duplicate_uuid + updated_profiles.append(duplicate) + else: + updated_profiles.append(profile) + if not replaced_primary: + updated_profiles.append(updated) + profiles = updated_profiles + with self._lock: + self._profiles[ssid] = profiles + self._networks[ssid] = updated + + def remove(self, ssid: str) -> bool: + with self._mutation_lock: + with self._lock: + entry = self._networks.get(ssid) + if entry is None: + return False + profiles = list(self._profiles.get(ssid, [entry])) + + # Remove every representation so a duplicate cannot restore the network. + paths: set[str] = set() + netplan_paths: set[str] = set() + for profile in profiles: + paths.add(os.path.join(self._directory, _canonical_filename(profile.get("uuid", ""), ssid))) + tracked = profile.get("_filename") + if tracked: + paths.add(os.path.join(self._directory, tracked)) + runtime_filename = profile.get("_runtime_filename") + if self._runtime_directory is not None and runtime_filename: + paths.add(os.path.join(self._runtime_directory, runtime_filename)) + netplan_filename = profile.get("_netplan_filename") + if self._netplan_directory is not None and netplan_filename: + netplan_paths.add(os.path.join(self._netplan_directory, netplan_filename)) + for p in netplan_paths: + if not os.path.exists(p): + cloudlog.warning(f"NetworkStore: failed to find netplan source {p}") + return False + paths.update(netplan_paths) + for p in paths: + result = subprocess.run(["sudo", "rm", "-f", p], check=False) + # Keep the in-memory entry when disk removal fails. + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to remove {p} (rc={result.returncode})") + return False + with self._lock: + self._networks.pop(ssid, None) + self._profiles.pop(ssid, None) + return True + + def set_metered(self, ssid: str, metered: int): + with self._mutation_lock: + with self._lock: + profiles = list(self._profiles.get(ssid, [])) + if not profiles: + return + primary = self._networks.get(ssid) + updated_profiles = [] + updated_primary = None + for current in profiles: + updated = dict(current) + updated["metered"] = metered + file_uuid, updated = self._render_nmconnection(ssid, updated) + updated["uuid"] = file_uuid + updated_profiles.append(updated) + if current is primary: + updated_primary = updated + with self._lock: + self._profiles[ssid] = updated_profiles + self._networks[ssid] = updated_primary or updated_profiles[0] + + def get_metered(self, ssid: str) -> MeteredType: + with self._lock: + entry = self._networks.get(ssid) + if entry: + m = entry.get("metered", 0) + if m == MeteredType.YES: + return MeteredType.YES + elif m == MeteredType.NO: + return MeteredType.NO + return MeteredType.UNKNOWN + + def contains(self, ssid: str) -> bool: + with self._lock: + return ssid in self._networks + + def saved_ssids(self) -> set[str]: + with self._lock: + return set(self._networks.keys()) diff --git a/openpilot/system/ui/lib/wpa_ctrl.py b/openpilot/system/ui/lib/wpa_ctrl.py new file mode 100644 index 00000000000000..80de0661c7648b --- /dev/null +++ b/openpilot/system/ui/lib/wpa_ctrl.py @@ -0,0 +1,556 @@ +"""wpa_supplicant control socket client and parsing helpers.""" +import os +import re +import shutil +import socket +import select +import subprocess +import threading +import time +from collections.abc import Callable +from dataclasses import dataclass +from enum import IntEnum + +from openpilot.common.swaglog import cloudlog +from openpilot.common.utils import atomic_write + + +RECV_BUF_SIZE = 32768 + +WPA_SUPPLICANT_CONF = "/tmp/wpa_supplicant.conf" +WPA_AP_CONF = "/tmp/wpa_supplicant_ap.conf" +WPA_CTRL_INTERFACE = "ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev" +TETHERING_DNSMASQ_PATTERN = r"^dnsmasq .*--dhcp-range=192\.168\.43\.2" + + +class SecurityType(IntEnum): + OPEN = 0 + WPA = 1 + UNSUPPORTED = 2 + + +@dataclass(frozen=True) +class ScanResult: + bssid: str + freq: int + signal: int # dBm + flags: str + ssid: str + + +class _WpaCtrlBase: + """Shared socket lifecycle for wpa_supplicant control connections.""" + + _counter = 0 + _counter_lock = threading.Lock() + + def __init__(self, ctrl_path: str = "/var/run/wpa_supplicant/wlan0"): + self._ctrl_path = ctrl_path + self._sock: socket.socket | None = None + self._local_path: str = "" + + def _open_socket(self, prefix: str): + with _WpaCtrlBase._counter_lock: + _WpaCtrlBase._counter += 1 + idx = _WpaCtrlBase._counter + self._local_path = f"/tmp/{prefix}_{os.getpid()}_{idx}" + try: + os.unlink(self._local_path) + except OSError: + pass + sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + try: + sock.bind(self._local_path) + sock.connect(self._ctrl_path) + except Exception: + sock.close() + try: + os.unlink(self._local_path) + except OSError: + pass + self._local_path = "" + raise + self._sock = sock + + def _ensure_sock(self) -> socket.socket: + if self._sock is None: + raise RuntimeError("not opened") + return self._sock + + def close(self): + if self._sock is not None: + try: + self._sock.close() + except OSError: + pass + self._sock = None + if self._local_path: + try: + os.unlink(self._local_path) + except OSError: + pass + self._local_path = "" + + def interrupt(self): + """Wake a thread blocked on this socket without racing normal cleanup.""" + sock = self._sock + if sock is not None: + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + + def __enter__(self): + self.open() + return self + + def __exit__(self, *_): + self.close() + + def __del__(self): + self.close() + + +class WpaCtrl(_WpaCtrlBase): + """Synchronous wpa_supplicant control socket command client.""" + + def __init__(self, ctrl_path: str = "/var/run/wpa_supplicant/wlan0"): + super().__init__(ctrl_path) + self._request_lock = threading.Lock() + + def open(self): + self._open_socket("wpa_ctrl") + self._sock.settimeout(10) + + def request(self, cmd: str) -> str: + """Send command, return response string.""" + with self._request_lock: + sock = self._ensure_sock() + sock.send(cmd.encode()) + return sock.recv(RECV_BUF_SIZE).decode("utf-8", "replace") + + def close(self): + # Serialize against request() so close() waits for in-flight send/recv + # instead of ripping the fd out from under a concurrent caller. + with self._request_lock: + super().close() + + +class WpaCtrlMonitor(_WpaCtrlBase): + """Async event stream from wpa_supplicant (ATTACH/DETACH protocol).""" + + def open(self): + self._open_socket("wpa_mon") + self._sock.settimeout(10) + resp = self._raw_request("ATTACH") + if not resp.startswith("OK"): + self.close() + raise RuntimeError(f"ATTACH failed: {resp}") + + def _raw_request(self, cmd: str) -> str: + sock = self._ensure_sock() + sock.send(cmd.encode()) + return sock.recv(RECV_BUF_SIZE).decode("utf-8", "replace") + + def pending(self, timeout: float = 0) -> bool: + if self._sock is None: + return False + r, _, _ = select.select([self._sock], [], [], timeout) + return len(r) > 0 + + def recv(self, timeout: float = 1.0) -> str | None: + if self._sock is None: + return None + r, _, _ = select.select([self._sock], [], [], timeout) + if not r: + return None + data = self._sock.recv(RECV_BUF_SIZE).decode("utf-8", "replace") + # Strip priority prefix like <3> + if data.startswith("<") and ">" in data[:4]: + data = data[data.index(">") + 1:] + return data + + def close(self): + if self._sock is not None: + try: + self._raw_request("DETACH") + except (OSError, RuntimeError): + pass + super().close() + + +_HEX = "0123456789abcdefABCDEF" + + +def decode_ssid(encoded: str) -> str: + """Decode a wpa_supplicant printf_encode'd SSID (hostap common.c:526). + Escapes: \\\\, \\", \\e/n/r/t, \\xNN/\\xN, octal \\0..\\777. + Bytes are reinterpreted as UTF-8; all-null SSIDs (hidden APs) normalize to "".""" + out = bytearray() + i = 0 + n = len(encoded) + while i < n: + c = encoded[i] + if c != "\\": + out.append(ord(c) & 0xff) + i += 1 + continue + + i += 1 # consume backslash + if i >= n: + break # trailing backslash: dropped + + nxt = encoded[i] + if nxt == "\\": + out.append(ord("\\")) + i += 1 + elif nxt == '"': + out.append(ord('"')) + i += 1 + elif nxt == "n": + out.append(ord("\n")) + i += 1 + elif nxt == "r": + out.append(ord("\r")) + i += 1 + elif nxt == "t": + out.append(ord("\t")) + i += 1 + elif nxt == "e": + out.append(0x1b) + i += 1 + elif nxt == "x": + i += 1 # consume 'x' + if i + 1 < n and encoded[i] in _HEX and encoded[i + 1] in _HEX: + out.append(int(encoded[i:i + 2], 16)) + i += 2 + elif i < n and encoded[i] in _HEX: + out.append(int(encoded[i], 16)) + i += 1 + # else: malformed \x, so drop the escape and continue parsing at i + elif "0" <= nxt <= "7": + val = ord(nxt) - ord("0") + i += 1 + if i < n and "0" <= encoded[i] <= "7": + val = val * 8 + (ord(encoded[i]) - ord("0")) + i += 1 + if i < n and "0" <= encoded[i] <= "7": + val = val * 8 + (ord(encoded[i]) - ord("0")) + i += 1 + out.append(val & 0xff) + # else: unknown escape. The backslash is consumed and the char falls + # through to the next iteration and is appended as a literal. + + if not out or all(b == 0 for b in out): + return "" + return out.decode("utf-8", errors="surrogateescape") + + +def parse_scan_results(raw: str) -> list[ScanResult]: + """Parse wpa_supplicant SCAN_RESULTS output (tab-separated, first line is header).""" + results = [] + # Don't .strip() the whole payload: SSIDs may legally end with spaces and + # wpa_supplicant leaves printable spaces unescaped, so a global strip would + # clip the last line's trailing-space SSID. + lines = raw.splitlines() + if len(lines) < 2: + return results + for line in lines[1:]: + parts = line.split("\t") + if len(parts) < 4: + continue + try: + results.append(ScanResult( + bssid=parts[0], + freq=int(parts[1]), + signal=int(parts[2]), + flags=parts[3], + ssid=decode_ssid(parts[4]) if len(parts) > 4 else "", + )) + except (ValueError, IndexError): + continue + return results + + +def flags_to_security_type(flags: str) -> SecurityType: + """Convert wpa_supplicant flags string to SecurityType. + + Examples: [WPA2-PSK-CCMP][WPA-PSK-CCMP], [ESS], [WPA2-PSK-CCMP+TKIP] + """ + flags_upper = flags.upper() + flag_groups = re.findall(r"\[([^\]]+)\]", flags_upper) + + # Enterprise / 802.1X / WEP → unsupported + if "EAP" in flags_upper or "802.1X" in flags_upper: + return SecurityType.UNSUPPORTED + if "WEP" in flags_upper: + return SecurityType.UNSUPPORTED + + # WPA2/WPA3 transitional networks advertise both PSK and SAE; PSK matches first + # and connects via WPA-PSK. Pure WPA3-Personal (SAE-only) falls through below. + if any(re.search(r"(?:^|\+)(?:(?:WPA2|RSN|WPA)-)?PSK(?!-SHA256)(?:[-+]|$)", group) for group in flag_groups): + return SecurityType.WPA + # SAE-only: would need key_mgmt=SAE, which the current AGNOS kernel + wpa_supplicant + # build doesn't support. Mark unsupported so the UI doesn't prompt for a password + # only to fail the handshake. Becomes connectable on vamOS + mainline kernel. + if "SAE" in flags_upper: + return SecurityType.UNSUPPORTED + # These key-management modes are secured but do not use WPA-PSK. Treating + # them as open would configure key_mgmt=NONE and either fail or downgrade a + # transition network. + if any(mode in flags_upper for mode in ("OWE", "DPP", "OSEN", "FILS")): # codespell:ignore fils + return SecurityType.UNSUPPORTED + + # No security flags → open + if "WPA" not in flags_upper and "RSN" not in flags_upper: + return SecurityType.OPEN + + return SecurityType.UNSUPPORTED + + +def parse_status(raw: str) -> dict[str, str]: + """Parse wpa_supplicant STATUS output (key=value lines). `ssid` is decoded.""" + result = {} + for line in raw.strip().split("\n"): + if "=" in line: + key, _, value = line.partition("=") + if key == "ssid": + value = decode_ssid(value) + result[key] = value + return result + + +def dbm_to_percent(dbm: int) -> int: + """Convert dBm to percentage [0, 100], matching NetworkManager's scale.""" + v = abs(max(-100, min(-40, dbm)) + 40) + return 100 - (100 * v) // 60 + + +TEMP_DISABLED_SSID_RE = re.compile(r'\bssid="((?:\\.|[^"])*)"') +EVENT_NETWORK_ID_RE = re.compile(r"\bid=(\d+)\b") + + +def normalize_ssid(ssid: str) -> str: + display_ssid = ssid.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace") + return display_ssid.replace("\u2019", "'") # for iPhone hotspots + + +def parse_event_ssid(event: str) -> str | None: + """Extract ssid="…" from a wpa_supplicant control event (printf_encode'd), or None.""" + match = TEMP_DISABLED_SSID_RE.search(event) + if match is None: + return None + return decode_ssid(match.group(1)) + + +def parse_event_network_id(event: str) -> str | None: + """Extract a numeric network ID from a wpa_supplicant control event, or None.""" + match = EVENT_NETWORK_ID_RE.search(event) + return match.group(1) if match is not None else None + + +def wpa_supplicant_running(conf: str) -> bool: + """True if a wpa_supplicant running the given config exists. Narrow pgrep so + a system-managed daemon on another config isn't conflated with ours.""" + pattern = rf"^wpa_supplicant .* -c {re.escape(conf)}( |$)" + return subprocess.run(["pgrep", "-f", pattern], capture_output=True).returncode == 0 + + +def stop_wpa_supplicant(conf: str) -> None: + """Kill only wpa_supplicant processes running our config; a system-managed daemon survives.""" + pattern = rf"^wpa_supplicant .* -c {re.escape(conf)}( |$)" + subprocess.run(["sudo", "pkill", "-f", pattern], check=False) + + +def tethering_dnsmasq_running() -> bool: + return subprocess.run(["pgrep", "-f", TETHERING_DNSMASQ_PATTERN], capture_output=True).returncode == 0 + + +def stop_tethering_dnsmasq() -> None: + """Stop only the dnsmasq instance started for openpilot tethering.""" + subprocess.run(["sudo", "pkill", "-f", TETHERING_DNSMASQ_PATTERN], check=False) + + +def sanitize_for_conf(value: str) -> str: + """Escape characters that could break wpa_supplicant.conf quoting.""" + return value.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '').replace('\r', '') + + +def format_ssid_value(ssid: str) -> str: + """Render an SSID as hexadecimal bytes for lossless wpa_supplicant parsing.""" + return ssid.encode("utf-8", errors="surrogateescape").hex() + + +def _is_raw_psk(psk: str) -> bool: + """True if psk is a pre-hashed 64-hex WPA PSK. Quoted 64-char values fail as + too-long passphrases, so raw PSKs must be passed unquoted.""" + return len(psk) == 64 and all(c in "0123456789abcdefABCDEF" for c in psk) + + +def is_valid_psk(psk: str) -> bool: + try: + return 8 <= len(psk.encode("utf-8")) <= 63 or _is_raw_psk(psk) + except UnicodeEncodeError: + return False + + +def format_psk_value(psk: str) -> str: + """Render a psk value for wpa_supplicant: raw 64-hex unquoted, else quoted.""" + if _is_raw_psk(psk): + return psk + return f'"{sanitize_for_conf(psk)}"' + + +def generate_wpa_conf(store, path: str = WPA_SUPPLICANT_CONF): + """Write wpa_supplicant.conf from a NetworkStore (STA networks only).""" + lines = [ + WPA_CTRL_INTERFACE, + "update_config=0", + "p2p_disabled=1", + "", + ] + + for ssid, entry in store.get_profiles(): + psk = entry.get("psk", "") + hidden = entry.get("hidden", False) + priority = entry.get("priority", 0) + bssid = entry.get("bssid", "") + ssid_value = format_ssid_value(ssid) + if not ssid_value: + continue + lines.append("network={") + lines.append(f" ssid={ssid_value}") + if psk: + lines.append(f' psk={format_psk_value(psk)}') + lines.append(" key_mgmt=WPA-PSK") + else: + lines.append(" key_mgmt=NONE") + if hidden: + lines.append(" scan_ssid=1") + if bssid: + lines.append(f" bssid={bssid}") + lines.append(f" priority={priority}") + lines.append("}") + lines.append("") + + with atomic_write(path, overwrite=True) as f: + f.write("\n".join(lines)) + + +def try_attach_ctrl() -> WpaCtrl | None: + """Pure attach to a running wpa_supplicant ctrl socket. Never spawns, never kills.""" + try: + ctrl = WpaCtrl() + ctrl.open() + return ctrl + except OSError: + return None + + +def _unmanage_wlan0() -> bool: + """Tell NetworkManager to release wlan0 when it is present. + + An image without NetworkManager has no nmcli and wlan0 is already available, + so skip the compatibility handoff and continue bringup. + """ + nmcli = shutil.which("nmcli") + if nmcli is None: + cloudlog.info("nmcli not found; assuming NetworkManager is absent") + return True + result = subprocess.run(["sudo", nmcli, "dev", "set", "wlan0", "managed", "no"], capture_output=True) + cloudlog.info(f"nmcli dev set wlan0 managed no: rc={result.returncode}") + return result.returncode == 0 + + +def ensure_wpa_supplicant(should_exit: Callable[[], bool], station_reconfigured: Callable[[str], None] | None = None) -> WpaCtrl | None: + """Attach to a wpa_supplicant we own, or spawn one. Never attach to NM's daemon. + Returns the attached WpaCtrl, or None if ownership cannot be acquired.""" + # Wait for wlan0 on cold boot; _unmanage_wlan0 below silently fails if it's missing. + # If shutdown is requested while wlan0 is still absent, bail so stop() can't + # end up triggering _unmanage_wlan0 / pkill / ip flush after teardown. + while not os.path.exists("/sys/class/net/wlan0"): + if should_exit(): + return None + time.sleep(0.5) + + # AP adoption: a hotspot owned by another UI process is still up, and STA cleanup would tear it down. + # Retry on transient ctrl unavailability (UI just restarted, AP socket briefly unbound) + # rather than falling through to STA cleanup, which would kill dnsmasq and flush wlan0. + if wpa_supplicant_running(WPA_AP_CONF): + for _ in range(3): + if should_exit(): + return None + ctrl = try_attach_ctrl() + if ctrl is not None: + return ctrl + time.sleep(0.5) + # AP process is alive but its ctrl socket is unreachable (deleted/wedged). The + # hotspot is unmanageable from our side, so kill it and fall through to STA + # spawn. Otherwise we'd loop forever returning None and the user cannot recover + # via tethering toggle since `_start_tethering` only kills STA-config daemons. + cloudlog.warning("AP daemon present but ctrl attach failed; killing it so STA spawn can recover") + stop_wpa_supplicant(WPA_AP_CONF) + + # Our own STA daemon is still alive, so attach without disturbing NM. + if wpa_supplicant_running(WPA_SUPPLICANT_CONF): + if should_exit(): + return None + ctrl = try_attach_ctrl() + if ctrl is not None: + try: + status = parse_status(ctrl.request("STATUS")) + station_ssid = status.get("ssid") if status.get("wpa_state") == "COMPLETED" and status.get("mode") == "station" else None + response = ctrl.request("RECONFIGURE").strip() + if response.startswith("OK"): + if station_ssid is not None and station_reconfigured is not None: + station_reconfigured(station_ssid) + return ctrl + cloudlog.warning(f"Station configuration reconciliation failed: {response}") + except Exception: + cloudlog.exception("Failed to reconcile running station configuration") + ctrl.close() + + # Honor cancellation before mutating NM / killing daemons / flushing IPs. + if should_exit(): + return None + + if not _unmanage_wlan0(): + cloudlog.warning("NetworkManager handoff failed; deferring station bringup") + return None + + # NM teardown is async (~800ms): wait for NM's ctrl socket to disappear before + # attaching or spawning, otherwise we bind to a socket NM is about to delete. + for _ in range(30): + if should_exit(): + return None + if not os.path.exists("/var/run/wpa_supplicant/wlan0"): + break + time.sleep(0.1) + else: + # Socket still held by NM; the post-spawn pgrep gate below is the fallback. + cloudlog.warning("/var/run/wpa_supplicant/wlan0 still present after NM unmanage; spawn will refuse to attach to foreign daemon") + + # Target only OUR config so a system-managed daemon on another config survives. + stop_wpa_supplicant(WPA_SUPPLICANT_CONF) + stop_tethering_dnsmasq() + subprocess.run(["sudo", "ip", "addr", "flush", "dev", "wlan0"], check=False) + time.sleep(0.5) + + subprocess.run(["sudo", "wpa_supplicant", "-B", "-i", "wlan0", "-c", WPA_SUPPLICANT_CONF, "-D", "nl80211"], check=False) + + # Gate on pgrep matching OUR config so we refuse to attach to NM's daemon if its + # teardown didn't finish above. + for _ in range(30): + if should_exit(): + return None + if wpa_supplicant_running(WPA_SUPPLICANT_CONF): + ctrl = try_attach_ctrl() + if ctrl is not None: + try: + ctrl.request("ENABLE_NETWORK all") + except Exception: + pass + return ctrl + time.sleep(1) + cloudlog.error("wpa_supplicant did not start after 30 attempts") + return None From 53e2dd28e54a7a399a6be02ee5e2fbb5c9923fc4 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 02:16:29 +0300 Subject: [PATCH 02/71] wifi: replace NetworkManager WiFi and tethering --- .../selfdrive/ui/layouts/settings/settings.py | 1 - .../mici/layouts/settings/network/__init__.py | 3 +- .../settings/network/network_layout.py | 21 +- .../mici/layouts/settings/network/wifi_ui.py | 21 +- openpilot/system/ui/lib/networkmanager.py | 64 - .../ui/lib/tests/test_handle_state_change.py | 2346 ++++++++++------- .../ui/lib/tests/test_standalone_wifi.py | 58 + .../ui/lib/tests/test_wifi_manager_bringup.py | 173 ++ .../system/ui/lib/tests/test_wpa_ctrl.py | 4 +- openpilot/system/ui/lib/wifi_manager.py | 1979 +++++++++----- openpilot/system/ui/lib/wifi_network_store.py | 171 +- openpilot/system/ui/lib/wpa_ctrl.py | 8 +- openpilot/system/ui/mici_setup.py | 6 +- openpilot/system/ui/tici_setup.py | 33 +- openpilot/system/ui/tici_updater.py | 4 +- openpilot/system/ui/widgets/network.py | 36 +- pyproject.toml | 1 - uv.lock | 11 - 18 files changed, 3179 insertions(+), 1761 deletions(-) delete mode 100644 openpilot/system/ui/lib/networkmanager.py create mode 100644 openpilot/system/ui/lib/tests/test_standalone_wifi.py create mode 100644 openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py diff --git a/openpilot/selfdrive/ui/layouts/settings/settings.py b/openpilot/selfdrive/ui/layouts/settings/settings.py index 48b75e5dbd97c8..fadc585d0dd100 100644 --- a/openpilot/selfdrive/ui/layouts/settings/settings.py +++ b/openpilot/selfdrive/ui/layouts/settings/settings.py @@ -53,7 +53,6 @@ def __init__(self): # Panel configuration wifi_manager = WifiManager() - wifi_manager.set_active(False) self._panels = { PanelType.DEVICE: PanelInfo(tr_noop("Device"), DeviceLayout()), diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py index ddbab4b478cb24..2224bb732767d9 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -3,7 +3,8 @@ from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiIcon from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.system.ui.lib.application import gui_app -from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus, SecurityType, normalize_ssid +from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus, SecurityType +from openpilot.system.ui.lib.wpa_ctrl import normalize_ssid class WifiNetworkButton(BigButton): diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py index 58b3d0c77dceeb..40bc9fa24f4040 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -14,11 +14,12 @@ def __init__(self): super().__init__() self._wifi_manager = WifiManager() - self._wifi_manager.set_active(False) self._wifi_ui = WifiUIMici(self._wifi_manager) self._wifi_manager.add_callbacks( networks_updated=self._on_network_updated, + activated=lambda: self._on_tethering_finished(), + disconnected=lambda: self._on_tethering_finished(), ) # ******** Tethering ******** @@ -32,9 +33,13 @@ def tethering_toggle_callback(checked: bool): def tethering_password_callback(password: str): if password: - self._tethering_toggle_btn.set_enabled(False) - self._tethering_password_btn.set_enabled(False) self._wifi_manager.set_tethering_password(password) + if self._wifi_manager.is_tethering_active(): + self._tethering_toggle_btn.set_enabled(False) + self._tethering_password_btn.set_enabled(False) + else: + self._tethering_toggle_btn.set_enabled(True) + self._tethering_password_btn.set_enabled(True) def tethering_password_clicked(): tethering_password = self._wifi_manager.tethering_password @@ -123,14 +128,16 @@ def update_apn(apn: str): dlg = BigInputDialog("enter APN...", current_apn, minimum_length=0, confirm_callback=update_apn) gui_app.push_widget(dlg) - def _on_network_updated(self, networks: list[Network]): - # Update tethering state + def _on_tethering_finished(self): tethering_active = self._wifi_manager.is_tethering_active() - # TODO: use real signals (like activated/settings changed, etc.) to speed up re-enabling buttons self._tethering_toggle_btn.set_enabled(True) self._tethering_password_btn.set_enabled(True) - self._network_metered_btn.set_enabled(lambda: not tethering_active and bool(self._wifi_manager.ipv4_address)) self._tethering_toggle_btn.set_checked(tethering_active) + self._on_network_updated(self._wifi_manager.networks) + + def _on_network_updated(self, networks: list[Network]): + tethering_active = self._wifi_manager.is_tethering_active() + self._network_metered_btn.set_enabled(lambda: not tethering_active and bool(self._wifi_manager.ipv4_address)) # Update network metered self._network_metered_btn.set_value( diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py index 8404faf9f7e6cc..f9994288338f9a 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py @@ -4,12 +4,14 @@ from collections.abc import Callable from openpilot.common.swaglog import cloudlog -from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog, BigConfirmationDialog +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog, BigConfirmationDialog from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight +from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.scroller import NavScroller -from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, SecurityType, normalize_ssid +from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, SecurityType +from openpilot.system.ui.lib.wpa_ctrl import normalize_ssid class LoadingAnimation(Widget): @@ -220,7 +222,7 @@ def _update_state(self): elif self._is_connected: self.set_value("tethering" if self._network.is_tethering else "connected") elif self._network_missing: - # after connecting/connected since NM will still attempt to connect/stay connected for a while + # after connecting/connected since wpa_supplicant will still attempt to connect/stay connected for a while self.set_value("not in range") else: self.set_value("unsupported") @@ -286,6 +288,7 @@ def __init__(self, wifi_manager: WifiManager): self._wifi_manager.add_callbacks( need_auth=self._on_need_auth, forgotten=self._on_forgotten, + forget_failed=self._on_forget_failed, networks_updated=self._on_network_updated, ) @@ -301,6 +304,10 @@ def show_event(self): self._networks = {n.ssid: n for n in self._wifi_manager.networks} self._update_buttons(re_sort=True) + def hide_event(self): + super().hide_event() + self._wifi_manager.set_active(False) + def _on_network_updated(self, networks: list[Network]): self._networks = {network.ssid: network for network in networks} self._update_buttons() @@ -339,6 +346,9 @@ def _connect_with_password(self, ssid: str, password: str): self._move_network_to_front(ssid, scroll=True) def _connect_to_network(self, ssid: str): + if self._wifi_manager.is_tethering_active(): + return + network = self._networks.get(ssid) if network is None: cloudlog.warning(f"Trying to connect to unknown network: {ssid}") @@ -360,7 +370,6 @@ def _on_need_auth(self, ssid, incorrect_password=True): if isinstance(btn, WifiButton) and btn.network.ssid == ssid: btn.set_wrong_password() break - return dlg = BigInputDialog("enter password...", "", minimum_length=8, confirm_callback=lambda _password: self._connect_with_password(ssid, _password)) @@ -372,6 +381,10 @@ def _on_forgotten(self, ssid): if isinstance(btn, WifiButton) and btn.network.ssid == ssid: btn.on_forgotten() + def _on_forget_failed(self, ssid): + self._on_forgotten(ssid) + gui_app.push_widget(BigDialog("", tr("Failed to forget Wi-Fi network"))) + def _move_network_to_front(self, ssid: str | None, scroll: bool = False): # Move connecting/connected network to the front with animation front_btn_idx = next((i for i, btn in enumerate(self._scroller.items) diff --git a/openpilot/system/ui/lib/networkmanager.py b/openpilot/system/ui/lib/networkmanager.py deleted file mode 100644 index d2d6b30b1071da..00000000000000 --- a/openpilot/system/ui/lib/networkmanager.py +++ /dev/null @@ -1,64 +0,0 @@ -from enum import IntEnum - - -# NetworkManager device states -class NMDeviceState(IntEnum): - # https://networkmanager.dev/docs/api/1.46/nm-dbus-types.html#NMDeviceState - UNKNOWN = 0 - UNMANAGED = 10 - UNAVAILABLE = 20 - DISCONNECTED = 30 - PREPARE = 40 - CONFIG = 50 - NEED_AUTH = 60 - IP_CONFIG = 70 - IP_CHECK = 80 - SECONDARIES = 90 - ACTIVATED = 100 - DEACTIVATING = 110 - FAILED = 120 - - -class NMDeviceStateReason(IntEnum): - # https://networkmanager.dev/docs/api/1.46/nm-dbus-types.html#NMDeviceStateReason - NONE = 0 - UNKNOWN = 1 - IP_CONFIG_UNAVAILABLE = 5 - NO_SECRETS = 7 - SUPPLICANT_DISCONNECT = 8 - SUPPLICANT_TIMEOUT = 11 - CONNECTION_REMOVED = 38 - USER_REQUESTED = 39 - SSID_NOT_FOUND = 53 - NEW_ACTIVATION = 60 - - -# NetworkManager constants -NM = "org.freedesktop.NetworkManager" -NM_PATH = '/org/freedesktop/NetworkManager' -NM_IFACE = 'org.freedesktop.NetworkManager' -NM_ACCESS_POINT_IFACE = 'org.freedesktop.NetworkManager.AccessPoint' -NM_SETTINGS_PATH = '/org/freedesktop/NetworkManager/Settings' -NM_SETTINGS_IFACE = 'org.freedesktop.NetworkManager.Settings' -NM_CONNECTION_IFACE = 'org.freedesktop.NetworkManager.Settings.Connection' -NM_ACTIVE_CONNECTION_IFACE = 'org.freedesktop.NetworkManager.Connection.Active' -NM_WIRELESS_IFACE = 'org.freedesktop.NetworkManager.Device.Wireless' -NM_PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties' -NM_DEVICE_IFACE = 'org.freedesktop.NetworkManager.Device' -NM_IP4_CONFIG_IFACE = 'org.freedesktop.NetworkManager.IP4Config' - -NM_DEVICE_TYPE_WIFI = 2 -NM_DEVICE_TYPE_MODEM = 8 - -# https://developer.gnome.org/NetworkManager/1.26/nm-dbus-types.html#NM80211ApFlags -NM_802_11_AP_FLAGS_NONE = 0x0 -NM_802_11_AP_FLAGS_PRIVACY = 0x1 -NM_802_11_AP_FLAGS_WPS = 0x2 - -# https://developer.gnome.org/NetworkManager/1.26/nm-dbus-types.html#NM80211ApSecurityFlags -NM_802_11_AP_SEC_PAIR_WEP40 = 0x00000001 -NM_802_11_AP_SEC_PAIR_WEP104 = 0x00000002 -NM_802_11_AP_SEC_GROUP_WEP40 = 0x00000010 -NM_802_11_AP_SEC_GROUP_WEP104 = 0x00000020 -NM_802_11_AP_SEC_KEY_MGMT_PSK = 0x00000100 -NM_802_11_AP_SEC_KEY_MGMT_802_1X = 0x00000200 diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index a7a33834cd6a4f..47ad7f0e561edc 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -1,907 +1,1441 @@ -"""Tests for WifiManager._handle_state_change. - -Tests the state machine in isolation by constructing a WifiManager with mocked -DBus, then calling _handle_state_change directly with NM state transitions. -""" -import unittest -from jeepney.low_level import MessageType - -from openpilot.common.parameterized import parameterized -from openpilot.common.test import Mocker, OpenpilotTestCase -from openpilot.system.ui.lib.networkmanager import NMDeviceState, NMDeviceStateReason -from openpilot.system.ui.lib.wifi_manager import WifiManager, WifiState, ConnectStatus - - -def _make_wm(mocker: Mocker, connections=None): - """Create a WifiManager with only the fields _handle_state_change touches.""" - mocker.patch.object(WifiManager, '_initialize') - wm = WifiManager.__new__(WifiManager) - wm._exit = True # prevent stop() from doing anything in __del__ - wm._conn_monitor = mocker.MagicMock() - wm._connections = dict(connections or {}) - wm._wifi_state = WifiState() - wm._user_epoch = 0 - wm._callback_queue = [] - wm._need_auth = [] - wm._activated = [] - wm._update_networks = mocker.MagicMock() - wm._update_active_connection_info = mocker.MagicMock() - wm._get_active_wifi_connection = mocker.MagicMock(return_value=(None, None)) - return wm - - -def fire(wm: WifiManager, new_state: int, prev_state: int = NMDeviceState.UNKNOWN, - reason: int = NMDeviceStateReason.NONE) -> None: - """Feed a state change into the handler.""" - wm._handle_state_change(new_state, prev_state, reason) - - -def fire_wpa_connect(wm: WifiManager) -> None: - """WPA handshake then IP negotiation through ACTIVATED, as seen on device.""" - fire(wm, NMDeviceState.NEED_AUTH) - fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH) - fire(wm, NMDeviceState.CONFIG) - fire(wm, NMDeviceState.IP_CONFIG) - fire(wm, NMDeviceState.IP_CHECK) - fire(wm, NMDeviceState.SECONDARIES) - fire(wm, NMDeviceState.ACTIVATED) - - -# --------------------------------------------------------------------------- -# Basic transitions -# --------------------------------------------------------------------------- - -class TestDisconnected(OpenpilotTestCase): - def test_generic_disconnect_clears_state(self, mocker): - wm = _make_wm(mocker) - wm._wifi_state = WifiState(ssid="Net", status=ConnectStatus.CONNECTED) - - fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.UNKNOWN) - - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - wm._update_networks.assert_not_called() - - def test_new_activation_is_noop(self, mocker): - """NEW_ACTIVATION means NM is about to connect to another network — don't clear.""" - wm = _make_wm(mocker) - wm._wifi_state = WifiState(ssid="OldNet", status=ConnectStatus.CONNECTED) - - fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.NEW_ACTIVATION) - - assert wm._wifi_state.ssid == "OldNet" - assert wm._wifi_state.status == ConnectStatus.CONNECTED - - def test_connection_removed_keeps_other_connecting(self, mocker): - """Forget A while connecting to B: CONNECTION_REMOVED for A must not clear B.""" - wm = _make_wm(mocker, connections={"B": "/path/B"}) - wm._set_connecting("B") - - fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.CONNECTION_REMOVED) - - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - def test_connection_removed_clears_when_forgotten(self, mocker): - """Forget A: A is no longer in _connections, so state should clear.""" - wm = _make_wm(mocker, connections={}) - wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) - - fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.CONNECTION_REMOVED) - - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - - -class TestDeactivating(OpenpilotTestCase): - def test_deactivating_noop_for_non_connection_removed(self, mocker): - """DEACTIVATING with non-CONNECTION_REMOVED reason is a no-op.""" - wm = _make_wm(mocker) - wm._wifi_state = WifiState(ssid="Net", status=ConnectStatus.CONNECTED) - - fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.USER_REQUESTED) - - assert wm._wifi_state.ssid == "Net" - assert wm._wifi_state.status == ConnectStatus.CONNECTED - - @parameterized.expand([ - (ConnectStatus.CONNECTED, True), - (ConnectStatus.CONNECTING, False), - ], names=("status", "expected_clears")) - def test_deactivating_connection_removed(self, mocker, status, expected_clears): - """DEACTIVATING(CONNECTION_REMOVED) clears CONNECTED but preserves CONNECTING. - - CONNECTED: forgetting the current network. The forgotten callback fires between - DEACTIVATING and DISCONNECTED — must clear here so the UI doesn't flash "connected" - after the eager _network_forgetting flag resets. - - CONNECTING: forget A while connecting to B. DEACTIVATING fires for A's removal, - but B's CONNECTING state must be preserved. - """ - wm = _make_wm(mocker, connections={"B": "/path/B"}) - wm._wifi_state = WifiState(ssid="B" if status == ConnectStatus.CONNECTING else "A", status=status) - - fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.CONNECTION_REMOVED) - - if expected_clears: - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - else: - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - -class TestPrepareConfig(OpenpilotTestCase): - def test_user_initiated_skips_dbus_lookup(self, mocker): - """User called _set_connecting('B') — PREPARE must not overwrite via DBus. - - Reproduced on device: rapidly tap A then B. PREPARE's DBus lookup returns A's - stale conn_path, overwriting ssid to A for 1-2 frames. UI shows the "connecting" - indicator briefly jump to the wrong network row then back. - """ - wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) - wm._set_connecting("B") - wm._get_active_wifi_connection.return_value = ("/path/A", {}) - - fire(wm, NMDeviceState.PREPARE) - - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - wm._get_active_wifi_connection.assert_not_called() - - @parameterized.expand([NMDeviceState.PREPARE, NMDeviceState.CONFIG], names=("state",)) - def test_auto_connect_looks_up_ssid(self, mocker, state): - """Auto-connection (ssid=None): PREPARE and CONFIG must look up ssid from NM.""" - wm = _make_wm(mocker, connections={"AutoNet": "/path/auto"}) - wm._get_active_wifi_connection.return_value = ("/path/auto", {}) - - fire(wm, state) - - assert wm._wifi_state.ssid == "AutoNet" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - def test_auto_connect_dbus_fails(self, mocker): - """Auto-connection but DBus returns None: ssid stays None, status CONNECTING.""" - wm = _make_wm(mocker) - - fire(wm, NMDeviceState.PREPARE) - - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - def test_auto_connect_conn_path_not_in_connections(self, mocker): - """DBus returns a conn_path that doesn't match any known connection.""" - wm = _make_wm(mocker, connections={"Other": "/path/other"}) - wm._get_active_wifi_connection.return_value = ("/path/unknown", {}) - - fire(wm, NMDeviceState.PREPARE) - - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - -class TestNeedAuth(OpenpilotTestCase): - def test_wrong_password_fires_callback(self, mocker): - """NEED_AUTH+SUPPLICANT_DISCONNECT from CONFIG = real wrong password.""" - wm = _make_wm(mocker) - cb = mocker.MagicMock() - wm.add_callbacks(need_auth=cb) - wm._set_connecting("SecNet") - - fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG, - reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT) - - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - assert len(wm._callback_queue) == 1 - wm.process_callbacks() - cb.assert_called_once_with("SecNet") - - def test_failed_no_secrets_fires_callback(self, mocker): - """FAILED+NO_SECRETS = wrong password (weak/gone network). - - Confirmed on device: also fires when a hotspot turns off during connection. - NM can't complete the WPA handshake (AP vanished) and reports NO_SECRETS - rather than SSID_NOT_FOUND. The need_auth callback fires, so the UI shows - "wrong password" — a false positive, but same signal path. - - Real device sequence (new connection, hotspot turned off immediately): - PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG - → NEED_AUTH(CONFIG, NONE) → FAILED(NEED_AUTH, NO_SECRETS) → DISCONNECTED(FAILED, NONE) - """ - wm = _make_wm(mocker) - cb = mocker.MagicMock() - wm.add_callbacks(need_auth=cb) - wm._set_connecting("WeakNet") - - fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NO_SECRETS) - - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - assert len(wm._callback_queue) == 1 - wm.process_callbacks() - cb.assert_called_once_with("WeakNet") - - def test_need_auth_then_failed_no_double_fire(self, mocker): - """Real device sends NEED_AUTH(SUPPLICANT_DISCONNECT) then FAILED(NO_SECRETS) back-to-back. - - The first clears ssid, so the second must not fire a duplicate callback. - Real device sequence: NEED_AUTH(CONFIG, SUPPLICANT_DISCONNECT) → FAILED(NEED_AUTH, NO_SECRETS) - """ - wm = _make_wm(mocker) - cb = mocker.MagicMock() - wm.add_callbacks(need_auth=cb) - wm._set_connecting("BadPass") - - fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG, - reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT) - assert len(wm._callback_queue) == 1 - - fire(wm, NMDeviceState.FAILED, prev_state=NMDeviceState.NEED_AUTH, - reason=NMDeviceStateReason.NO_SECRETS) - assert len(wm._callback_queue) == 1 # no duplicate - - wm.process_callbacks() - cb.assert_called_once_with("BadPass") - - def test_no_ssid_no_callback(self, mocker): - """If ssid is None when NEED_AUTH fires, no callback enqueued.""" - wm = _make_wm(mocker) - cb = mocker.MagicMock() - wm.add_callbacks(need_auth=cb) - - fire(wm, NMDeviceState.NEED_AUTH, reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT) - - assert len(wm._callback_queue) == 0 - - def test_interrupted_auth_ignored(self, mocker): - """Switching A->B: NEED_AUTH from A (prev=DISCONNECTED) must not fire callback. - - Reproduced on device: rapidly switching between two saved networks can trigger a - rare false "wrong password" dialog for the previous network, even though both have - correct passwords. The stale NEED_AUTH has prev_state=DISCONNECTED (not CONFIG). - """ - wm = _make_wm(mocker) - cb = mocker.MagicMock() - wm.add_callbacks(need_auth=cb) - wm._set_connecting("A") - wm._set_connecting("B") - - fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.DISCONNECTED, - reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT) - - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - assert len(wm._callback_queue) == 0 - - -class TestPassthroughStates(OpenpilotTestCase): - """NEED_AUTH (generic), IP_CONFIG, IP_CHECK, SECONDARIES, FAILED (generic) are no-ops.""" - - @parameterized.expand([ - NMDeviceState.NEED_AUTH, - NMDeviceState.IP_CONFIG, - NMDeviceState.IP_CHECK, - NMDeviceState.SECONDARIES, - NMDeviceState.FAILED, - ], names=("state",)) - def test_passthrough_is_noop(self, mocker, state): - wm = _make_wm(mocker) - wm._set_connecting("Net") - - fire(wm, state, reason=NMDeviceStateReason.NONE) - - assert wm._wifi_state.ssid == "Net" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - assert len(wm._callback_queue) == 0 - - -class TestActivated(OpenpilotTestCase): - def test_sets_connected(self, mocker): - """ACTIVATED sets status to CONNECTED and fires callback.""" - wm = _make_wm(mocker, connections={"MyNet": "/path/mynet"}) - cb = mocker.MagicMock() - wm.add_callbacks(activated=cb) - wm._set_connecting("MyNet") - wm._get_active_wifi_connection.return_value = ("/path/mynet", {}) - - fire(wm, NMDeviceState.ACTIVATED) - - assert wm._wifi_state.status == ConnectStatus.CONNECTED - assert wm._wifi_state.ssid == "MyNet" - assert len(wm._callback_queue) == 1 - wm.process_callbacks() - cb.assert_called_once() - - def test_conn_path_none_still_connected(self, mocker): - """ACTIVATED but DBus returns None: status CONNECTED, ssid unchanged.""" - wm = _make_wm(mocker) - wm._set_connecting("MyNet") - - fire(wm, NMDeviceState.ACTIVATED) - - assert wm._wifi_state.status == ConnectStatus.CONNECTED - assert wm._wifi_state.ssid == "MyNet" - - def test_activated_side_effects(self, mocker): - """ACTIVATED persists the volatile connection to disk and updates active connection info.""" - wm = _make_wm(mocker, connections={"Net": "/path/net"}) - wm._set_connecting("Net") - wm._get_active_wifi_connection.return_value = ("/path/net", {}) - - fire(wm, NMDeviceState.ACTIVATED) - - wm._conn_monitor.send_and_get_reply.assert_called_once() - wm._update_active_connection_info.assert_called_once() - wm._update_networks.assert_not_called() - - -# --------------------------------------------------------------------------- -# Thread races: _set_connecting on main thread vs _handle_state_change on monitor thread. -# Uses side_effect on the DBus mock to simulate _set_connecting running mid-handler. -# The epoch counter detects that a user action occurred during the slow DBus call -# and discards the stale update. -# --------------------------------------------------------------------------- -# The deterministic fixes (skip DBus lookup when ssid already set, prev_state guard -# on NEED_AUTH, DEACTIVATING clears CONNECTED on CONNECTION_REMOVED, CONNECTION_REMOVED -# guard) shrink these race windows significantly. The epoch counter closes the -# remaining gaps. - -class TestThreadRaces(OpenpilotTestCase): - def test_prepare_race_user_tap_during_dbus(self, mocker): - """User taps B while PREPARE's DBus call is in flight for auto-connect. - - Monitor thread reads wifi_state (ssid=None), starts DBus call. - Main thread: _set_connecting("B"). Monitor thread writes back stale ssid from DBus. - """ - wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) - - def user_taps_b_during_dbus(*args, **kwargs): - wm._set_connecting("B") - return ("/path/A", {}) - - wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus - - fire(wm, NMDeviceState.PREPARE) - - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - def test_activated_race_user_tap_during_dbus(self, mocker): - """User taps B right as A finishes connecting (ACTIVATED handler running). - - Monitor thread reads wifi_state (A, CONNECTING), starts DBus call. - Main thread: _set_connecting("B"). Monitor thread writes (A, CONNECTED), losing B. - """ - wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) - wm._set_connecting("A") - - def user_taps_b_during_dbus(*args, **kwargs): - wm._set_connecting("B") - return ("/path/A", {}) - - wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus - - fire(wm, NMDeviceState.ACTIVATED) - - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - def test_init_wifi_state_race_user_tap_during_dbus(self, mocker): - """User taps B while _init_wifi_state's DBus calls are in flight. - - _init_wifi_state runs from set_active(True) or worker error paths. It does - 2 DBus calls (device State property + _get_active_wifi_connection) then - unconditionally writes _wifi_state. If the user taps a network during those - calls, _set_connecting("B") is overwritten with stale NM ground truth. - """ - wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) - wm._wifi_device = "/dev/wifi0" - wm._router_main = mocker.MagicMock() - - state_reply = mocker.MagicMock() - state_reply.body = [('u', NMDeviceState.ACTIVATED)] - wm._router_main.send_and_get_reply.return_value = state_reply - - def user_taps_b_during_dbus(*args, **kwargs): - wm._set_connecting("B") - return ("/path/A", {}) - - wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus - - wm._init_wifi_state() - - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - -# --------------------------------------------------------------------------- -# Full sequences (NM signal order from real devices) -# --------------------------------------------------------------------------- - -class TestFullSequences(OpenpilotTestCase): - def test_normal_connect(self, mocker): - """User connects to saved network: full happy path. - - Real device sequence (switching from another connected network): - DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION) - PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH, NONE) → CONFIG - → IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED - """ - wm = _make_wm(mocker, connections={"Home": "/path/home"}) - wm._get_active_wifi_connection.return_value = ("/path/home", {}) - - wm._set_connecting("Home") - fire(wm, NMDeviceState.PREPARE) - fire(wm, NMDeviceState.CONFIG) - fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE) - fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH) - fire(wm, NMDeviceState.CONFIG) - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - fire(wm, NMDeviceState.IP_CONFIG) - fire(wm, NMDeviceState.IP_CHECK) - fire(wm, NMDeviceState.SECONDARIES) - fire(wm, NMDeviceState.ACTIVATED) - - assert wm._wifi_state.status == ConnectStatus.CONNECTED - assert wm._wifi_state.ssid == "Home" - - def test_wrong_password_then_retry(self, mocker): - """Wrong password → NEED_AUTH → FAILED → NM auto-reconnects to saved network. - - Confirmed on device: wrong password for Shane's iPhone, NM auto-connected to unifi. - - Real device sequence (switching from a connected network): - DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION) - → PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) ← WPA handshake - → PREPARE(NEED_AUTH, NONE) → CONFIG - → NEED_AUTH(CONFIG, SUPPLICANT_DISCONNECT) ← wrong password - → FAILED(NEED_AUTH, NO_SECRETS) ← NM gives up - → DISCONNECTED(FAILED, NONE) - → PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG - → IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED ← auto-reconnect to other saved network - """ - wm = _make_wm(mocker, connections={"Sec": "/path/sec"}) - cb = mocker.MagicMock() - wm.add_callbacks(need_auth=cb) - - wm._set_connecting("Sec") - fire(wm, NMDeviceState.PREPARE) - fire(wm, NMDeviceState.CONFIG) - fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE) - fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH) - fire(wm, NMDeviceState.CONFIG) - - fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG, - reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT) - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - assert len(wm._callback_queue) == 1 - - # FAILED(NO_SECRETS) follows but ssid is already cleared — no double-fire - fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NO_SECRETS) - assert len(wm._callback_queue) == 1 - - fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.FAILED) - - # Retry - wm._callback_queue.clear() - wm._set_connecting("Sec") - wm._get_active_wifi_connection.return_value = ("/path/sec", {}) - fire(wm, NMDeviceState.PREPARE) - fire(wm, NMDeviceState.CONFIG) - fire_wpa_connect(wm) - assert wm._wifi_state.status == ConnectStatus.CONNECTED - - def test_switch_saved_networks(self, mocker): - """Switch from A to B (both saved): NM signal sequence from real device. - - Real device sequence: - DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION) - → PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH, NONE) → CONFIG - → IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED - """ - wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) - wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) - wm._get_active_wifi_connection.return_value = ("/path/B", {}) - - wm._set_connecting("B") - - fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED, - reason=NMDeviceStateReason.NEW_ACTIVATION) - fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING, - reason=NMDeviceStateReason.NEW_ACTIVATION) - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - fire(wm, NMDeviceState.PREPARE) - fire(wm, NMDeviceState.CONFIG) - fire_wpa_connect(wm) - assert wm._wifi_state.status == ConnectStatus.CONNECTED - assert wm._wifi_state.ssid == "B" - - def test_rapid_switch_no_false_wrong_password(self, mocker): - """Switch A→B quickly: A's interrupted NEED_AUTH must NOT show wrong password. - - NOTE: The late NEED_AUTH(DISCONNECTED, SUPPLICANT_DISCONNECT) is common when rapidly - switching between networks with wrong/new passwords. Less common when switching between - saved networks with correct passwords. Not guaranteed — some switches skip it and go - straight from DISCONNECTED to PREPARE. The prev_state is consistently DISCONNECTED - for stale signals, so the prev_state guard reliably distinguishes them. - - Worst-case signal sequence this protects against: - DEACTIVATING(NEW_ACTIVATION) → DISCONNECTED(NEW_ACTIVATION) - → NEED_AUTH(DISCONNECTED, SUPPLICANT_DISCONNECT) ← A's stale auth failure - → PREPARE → CONFIG → ... → ACTIVATED ← B connects - """ - wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) - cb = mocker.MagicMock() - wm.add_callbacks(need_auth=cb) - wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) - wm._get_active_wifi_connection.return_value = ("/path/B", {}) - - wm._set_connecting("B") - - fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED, - reason=NMDeviceStateReason.NEW_ACTIVATION) - fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING, - reason=NMDeviceStateReason.NEW_ACTIVATION) - fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.DISCONNECTED, - reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT) - - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - assert len(wm._callback_queue) == 0 - - fire(wm, NMDeviceState.PREPARE) - fire(wm, NMDeviceState.CONFIG) - fire_wpa_connect(wm) - assert wm._wifi_state.status == ConnectStatus.CONNECTED - - def test_forget_while_connecting(self, mocker): - """Forget the network we're currently connecting to (not yet ACTIVATED). - - Confirmed on device: connected to unifi, tapped Shane's iPhone, then forgot - Shane's iPhone while at CONFIG. NM auto-connected to unifi afterward. - - Real device sequence (switching then forgetting mid-connection): - DEACTIVATING(ACTIVATED, NEW_ACTIVATION) → DISCONNECTED(DEACTIVATING, NEW_ACTIVATION) - → PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG - → DEACTIVATING(CONFIG, CONNECTION_REMOVED) ← forget at CONFIG - → DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED) - → PREPARE → CONFIG → ... → ACTIVATED ← NM auto-connects to other saved network - - Note: DEACTIVATING fires from CONFIG (not ACTIVATED). wifi_state.status is - CONNECTING, so the DEACTIVATING handler is a no-op. DISCONNECTED clears state - (ssid removed from _connections by ConnectionRemoved), then PREPARE recovers - via DBus lookup for the auto-connect. - """ - wm = _make_wm(mocker, connections={"A": "/path/A", "Other": "/path/other"}) - wm._get_active_wifi_connection.return_value = ("/path/other", {}) - - wm._set_connecting("A") - - fire(wm, NMDeviceState.PREPARE) - fire(wm, NMDeviceState.CONFIG) - assert wm._wifi_state.ssid == "A" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - # User forgets A: ConnectionRemoved processed first, then state changes - del wm._connections["A"] - - fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.CONFIG, - reason=NMDeviceStateReason.CONNECTION_REMOVED) - assert wm._wifi_state.ssid == "A" - assert wm._wifi_state.status == ConnectStatus.CONNECTING # DEACTIVATING preserves CONNECTING - - fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING, - reason=NMDeviceStateReason.CONNECTION_REMOVED) - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - - # NM auto-connects to another saved network - fire(wm, NMDeviceState.PREPARE) - assert wm._wifi_state.ssid == "Other" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - fire(wm, NMDeviceState.CONFIG) - fire_wpa_connect(wm) - assert wm._wifi_state.status == ConnectStatus.CONNECTED - assert wm._wifi_state.ssid == "Other" - - def test_forget_connected_network(self, mocker): - """Forget the currently connected network (not switching to another). - - Real device sequence: - DEACTIVATING(ACTIVATED, CONNECTION_REMOVED) → DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED) - - ConnectionRemoved signal may or may not have been processed before state changes. - Either way, state must clear — we're forgetting what we're connected to, not switching. - """ - wm = _make_wm(mocker, connections={"A": "/path/A"}) - wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) - - fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED, - reason=NMDeviceStateReason.CONNECTION_REMOVED) - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - - # DISCONNECTED follows — harmless since state is already cleared - fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING, - reason=NMDeviceStateReason.CONNECTION_REMOVED) - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - - def test_forget_A_connect_B(self, mocker): - """Forget A while connecting to B: full signal sequence. - - Real device sequence: - DEACTIVATING(ACTIVATED, CONNECTION_REMOVED) → DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED) - → PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH, NONE) → CONFIG - → IP_CONFIG → IP_CHECK → SECONDARIES → ACTIVATED - - Signal order: - 1. User: _set_connecting("B"), forget("A") removes A from _connections - 2. NewConnection for B arrives → _connections["B"] = ... - 3. DEACTIVATING(CONNECTION_REMOVED) — no-op - 4. DISCONNECTED(CONNECTION_REMOVED) — B is in _connections, must not clear - 5. PREPARE → CONFIG → NEED_AUTH → PREPARE → CONFIG → ... → ACTIVATED - """ - wm = _make_wm(mocker, connections={"A": "/path/A"}) - wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) - - wm._set_connecting("B") - del wm._connections["A"] - wm._connections["B"] = "/path/B" - - fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED, - reason=NMDeviceStateReason.CONNECTION_REMOVED) - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING, - reason=NMDeviceStateReason.CONNECTION_REMOVED) - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - wm._get_active_wifi_connection.return_value = ("/path/B", {}) - fire(wm, NMDeviceState.PREPARE) - fire(wm, NMDeviceState.CONFIG) - fire_wpa_connect(wm) - assert wm._wifi_state.status == ConnectStatus.CONNECTED - assert wm._wifi_state.ssid == "B" - - def test_forget_A_connect_B_late_new_connection(self, mocker): - """Forget A, connect B: NewConnection for B arrives AFTER DISCONNECTED. - - This is the worst-case race: B isn't in _connections when DISCONNECTED fires, - so the guard can't protect it and state clears. PREPARE must recover by doing - the DBus lookup (ssid is None at that point). - - Signal order: - 1. User: _set_connecting("B"), forget("A") removes A from _connections - 2. DEACTIVATING(CONNECTION_REMOVED) — B NOT in _connections, should be no-op - 3. DISCONNECTED(CONNECTION_REMOVED) — B STILL NOT in _connections, clears state - 4. NewConnection for B arrives late → _connections["B"] = ... - 5. PREPARE (ssid=None, so DBus lookup recovers) → CONFIG → ACTIVATED - """ - wm = _make_wm(mocker, connections={"A": "/path/A"}) - wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) - - wm._set_connecting("B") - del wm._connections["A"] - - fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED, - reason=NMDeviceStateReason.CONNECTION_REMOVED) - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING, - reason=NMDeviceStateReason.CONNECTION_REMOVED) - # B not in _connections yet, so state clears — this is the known edge case - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - - # NewConnection arrives late - wm._connections["B"] = "/path/B" - wm._get_active_wifi_connection.return_value = ("/path/B", {}) - - # PREPARE recovers: ssid is None so it looks up from DBus - fire(wm, NMDeviceState.PREPARE) - assert wm._wifi_state.ssid == "B" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - fire(wm, NMDeviceState.CONFIG) - fire_wpa_connect(wm) - assert wm._wifi_state.status == ConnectStatus.CONNECTED - assert wm._wifi_state.ssid == "B" - - def test_auto_connect(self, mocker): - """NM auto-connects (no user action, ssid starts None).""" - wm = _make_wm(mocker, connections={"AutoNet": "/path/auto"}) - wm._get_active_wifi_connection.return_value = ("/path/auto", {}) - - fire(wm, NMDeviceState.PREPARE) - assert wm._wifi_state.ssid == "AutoNet" - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - fire(wm, NMDeviceState.CONFIG) - fire_wpa_connect(wm) - assert wm._wifi_state.status == ConnectStatus.CONNECTED - assert wm._wifi_state.ssid == "AutoNet" - - def test_network_lost_during_connection(self, mocker): - """Hotspot turned off while connecting (before ACTIVATED). - - Confirmed on device: started new connection to Shane's iPhone, immediately - turned off the hotspot. NM can't complete WPA handshake and reports - FAILED(NO_SECRETS) — same signal as wrong password (false positive). - - Real device sequence: - PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG - → NEED_AUTH(CONFIG, NONE) → FAILED(NEED_AUTH, NO_SECRETS) → DISCONNECTED(FAILED, NONE) - - Note: no DEACTIVATING, no SUPPLICANT_DISCONNECT. The NEED_AUTH(CONFIG, NONE) is the - normal WPA handshake (not an error). NM gives up with NO_SECRETS because the AP - vanished mid-handshake. - """ - wm = _make_wm(mocker, connections={"Hotspot": "/path/hs"}) - cb = mocker.MagicMock() - wm.add_callbacks(need_auth=cb) - - wm._set_connecting("Hotspot") - fire(wm, NMDeviceState.PREPARE) - fire(wm, NMDeviceState.CONFIG) - fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE) - fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH) - fire(wm, NMDeviceState.CONFIG) - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - # Second NEED_AUTH(CONFIG, NONE) — NM retries handshake, AP vanishing - fire(wm, NMDeviceState.NEED_AUTH) - assert wm._wifi_state.status == ConnectStatus.CONNECTING - - # NM gives up — reports NO_SECRETS (same as wrong password) - fire(wm, NMDeviceState.FAILED, prev_state=NMDeviceState.NEED_AUTH, - reason=NMDeviceStateReason.NO_SECRETS) - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - assert len(wm._callback_queue) == 1 - - fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.FAILED) - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - - wm.process_callbacks() - cb.assert_called_once_with("Hotspot") - - @unittest.expectedFailure # "TODO: FAILED(SSID_NOT_FOUND) should emit error for UI" - def test_ssid_not_found(self, mocker): - """Network drops off while connected — hotspot turned off. - - NM docs: SSID_NOT_FOUND (53) = "The WiFi network could not be found" - - Confirmed on device: connected to Shane's iPhone, then turned off the hotspot. - No DEACTIVATING fires — NM goes straight from ACTIVATED to FAILED(SSID_NOT_FOUND). - NM retries connecting (PREPARE → CONFIG → ... → FAILED(CONFIG, SSID_NOT_FOUND)) - before finally giving up with DISCONNECTED. - - NOTE: turning off a hotspot during initial connection (before ACTIVATED) typically - produces FAILED(NO_SECRETS) instead of SSID_NOT_FOUND (see test_failed_no_secrets). - - Real device sequence (hotspot turned off while connected): - FAILED(ACTIVATED, SSID_NOT_FOUND) → DISCONNECTED(FAILED, NONE) - → PREPARE → CONFIG → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG - → NEED_AUTH(CONFIG, NONE) → PREPARE(NEED_AUTH) → CONFIG - → FAILED(CONFIG, SSID_NOT_FOUND) → DISCONNECTED(FAILED, NONE) - - The UI error callback mechanism is intentionally deferred — for now just clear state. - """ - wm = _make_wm(mocker, connections={"GoneNet": "/path/gone"}) - cb = mocker.MagicMock() - wm.add_callbacks(need_auth=cb) - - wm._set_connecting("GoneNet") - fire(wm, NMDeviceState.PREPARE) - fire(wm, NMDeviceState.CONFIG) - fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.SSID_NOT_FOUND) - - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - assert wm._wifi_state.ssid is None - - def test_failed_then_disconnected_clears_state(self, mocker): - """After FAILED, NM always transitions to DISCONNECTED to clean up. - - NM docs: FAILED (120) = "failed to connect, cleaning up the connection request" - Full sequence: ... → FAILED(reason) → DISCONNECTED(NONE) - """ - wm = _make_wm(mocker) - wm._set_connecting("Net") - - fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NONE) - assert wm._wifi_state.status == ConnectStatus.CONNECTING # FAILED(NONE) is a no-op - - fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.NONE) - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - - def test_user_requested_disconnect(self, mocker): - """User explicitly disconnects from the network. - - NM docs: USER_REQUESTED (39) = "Device disconnected by user or client" - Expected sequence: DEACTIVATING(USER_REQUESTED) → DISCONNECTED(USER_REQUESTED) - """ - wm = _make_wm(mocker) - wm._wifi_state = WifiState(ssid="MyNet", status=ConnectStatus.CONNECTED) - - fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.USER_REQUESTED) - fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.USER_REQUESTED) - - assert wm._wifi_state.ssid is None - assert wm._wifi_state.status == ConnectStatus.DISCONNECTED - - -# --------------------------------------------------------------------------- -# Worker error recovery: DBus errors in activate/connect re-sync with NM -# --------------------------------------------------------------------------- -# Verified on device: when ActivateConnection returns UnknownConnection error, -# NM emits no state signals. The worker error path is the only recovery point. - -class TestWorkerErrorRecovery(OpenpilotTestCase): - """Worker threads re-sync with NM via _init_wifi_state on DBus errors, - preserving actual NM state instead of blindly clearing to DISCONNECTED.""" - - def _mock_init_restores(self, wm, mocker, ssid, status): - """Replace _init_wifi_state with a mock that simulates NM reporting the given state.""" - mock = mocker.MagicMock( - side_effect=lambda: setattr(wm, '_wifi_state', WifiState(ssid=ssid, status=status)) +import threading +import time +from typing import cast +from unittest import TestCase +from unittest.mock import MagicMock, call, mock_open, patch + +from openpilot.system.ui.lib import wifi_manager as wifi_manager_module +from openpilot.system.ui.lib.wifi_manager import ( + CONNECTING_STALE_TIMEOUT_SECONDS, + ConnectStatus, + MeteredType, + SCAN_PERIOD_SECONDS, + SecurityType, + WifiManager, + WifiState, +) + + +def build_wifi_manager() -> WifiManager: + store = MagicMock() + store.get_metered.return_value = 0 + store.contains.return_value = False + dhcp = MagicMock() + with ( + patch.object(wifi_manager_module, "NetworkStore", return_value=store), + patch.object(wifi_manager_module, "DhcpClient", return_value=dhcp), + patch.object(wifi_manager_module, "Params", None), + patch.object(WifiManager, "_initialize"), + patch.object(wifi_manager_module.atexit, "register"), + ): + manager = WifiManager() + + manager._exit = True + manager._ctrl = MagicMock() + manager._ipv4_forward = True + manager._tethering_ssid = "Hotspot" + manager._tethering_psk = "hotspot-password" + manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + manager._update_active_connection_info = MagicMock() + manager._poll_for_ip = MagicMock() + manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=TestNet\n" + return manager + + +class TestConnectionState(TestCase): + def setUp(self): + self.manager = build_wifi_manager() + + def test_connected_persists_after_auth_and_is_idempotent(self): + activated = MagicMock() + self.manager.add_callbacks(activated=activated) + self.manager._set_connecting("TestNet") + self.manager._set_pending_connection("TestNet", "password123", False) + + with patch.object(wifi_manager_module, "generate_wpa_conf"): + self.manager._handle_connected("TestNet") + self.manager._handle_connected("TestNet") + + self.manager.process_callbacks() + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._store.save_network.assert_called_once_with("TestNet", psk="password123", hidden=False) + self.manager._dhcp.start.assert_called_once() + activated.assert_called_once() + assert call("ENABLE_NETWORK all") in self.manager._ctrl.request.call_args_list + + def test_connected_transitions_are_serialized(self): + self.manager._set_connecting("TestNet") + self.manager._set_pending_connection("TestNet", "password123", False) + first_persist_started = threading.Event() + concurrent_persist_started = threading.Event() + release_persist = threading.Event() + active_persists = 0 + active_persists_lock = threading.Lock() + + def persist(_ssid): + nonlocal active_persists + with active_persists_lock: + active_persists += 1 + if active_persists == 1: + first_persist_started.set() + else: + concurrent_persist_started.set() + assert release_persist.wait(1) + with active_persists_lock: + active_persists -= 1 + + with patch.object(self.manager, "_persist_pending_connection", side_effect=persist): + first = threading.Thread(target=self.manager._handle_connected, args=("TestNet",)) + second = threading.Thread(target=self.manager._handle_connected, args=("TestNet",)) + first.start() + assert first_persist_started.wait(1) + second.start() + try: + assert not concurrent_persist_started.wait(0.1) + finally: + release_persist.set() + first.join(1) + second.join(1) + + assert not first.is_alive() + assert not second.is_alive() + + def test_connect_tap_does_not_wait_for_connected_transition(self): + self.manager._set_connecting("CurrentNet") + self.manager._set_pending_connection("CurrentNet", "current-password", False) + current_epoch = self.manager._user_epoch + persist_started = threading.Event() + release_persist = threading.Event() + connect_returned = threading.Event() + + def save_network(*_args, **_kwargs): + persist_started.set() + assert release_persist.wait(1) + + self.manager._store.save_network.side_effect = save_network + connected = threading.Thread(target=self.manager._handle_connected, args=("CurrentNet",), kwargs={"expected_epoch": current_epoch}) + connected.start() + assert persist_started.wait(1) + + def connect(): + self.manager.connect_to_network("NextNet", "next-password") + connect_returned.set() + + thread_class = threading.Thread + connector = thread_class(target=connect) + with patch.object(wifi_manager_module.threading, "Thread"): + connector.start() + returned_during_persist = connect_returned.wait(0.1) + release_persist.set() + connector.join(1) + connected.join(1) + + assert returned_during_persist + assert not connector.is_alive() + assert not connected.is_alive() + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + assert self.manager._pending_connection is not None + assert self.manager._pending_connection.ssid == "NextNet" + self.manager._dhcp.start.assert_not_called() + + def test_pending_persistence_is_retried_without_restarting_dhcp(self): + for retry in ("connected", "reconcile"): + with self.subTest(retry=retry): + manager = build_wifi_manager() + manager._set_connecting("TestNet") + manager._set_pending_connection("TestNet", "password123", False) + manager._store.save_network.side_effect = [OSError("read-only"), None] + + with patch.object(wifi_manager_module, "generate_wpa_conf"): + manager._handle_connected("TestNet") + if retry == "connected": + manager._handle_connected("TestNet") + else: + manager._reconcile_connecting_state() + + assert manager._store.save_network.call_count == 2 + assert manager._pending_connection is None + manager._dhcp.start.assert_called_once() + + def test_disconnected_event_defers_station_cleanup(self): + self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._ipv4_address = "192.168.1.20" + self.manager._current_network_metered = MeteredType.YES + + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + assert self.manager.ipv4_address == "192.168.1.20" + assert self.manager.current_network_metered == MeteredType.YES + self.manager._dhcp.stop.assert_not_called() + + def test_reconnect_after_disconnected_event_adopts_dhcp(self): + self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._ipv4_address = "192.168.1.20" + self.manager._dhcp.adopt.return_value = True + + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=TestNet\n" + self.manager._handle_event("CTRL-EVENT-CONNECTED") + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + assert self.manager.ipv4_address == "192.168.1.20" + self.manager._dhcp.adopt.assert_called_once() + self.manager._dhcp.stop.assert_not_called() + self.manager._dhcp.start.assert_not_called() + + def test_disconnected_event_cleans_station_after_timeout(self): + self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._ipv4_address = "192.168.1.20" + self.manager._current_network_metered = MeteredType.YES + + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + self.manager._ctrl.request.return_value = "wpa_state=DISCONNECTED\n" + self.manager._reconcile_connecting_state() + + assert self.manager.wifi_state == WifiState() + assert self.manager.ipv4_address == "" + assert self.manager.current_network_metered == MeteredType.UNKNOWN + self.manager._dhcp.stop.assert_called_once() + + def test_disconnected_event_does_not_override_user_connection(self): + self.manager._set_connecting("NextNet") + + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + self.manager._dhcp.stop.assert_not_called() + + def test_connected_event_rejects_unconfirmed_or_previous_network(self): + cases = ( + ("wpa_state=ASSOCIATING\nssid=NextNet\n", "wrong-password"), + ("wpa_state=COMPLETED\nssid=PreviousNet\n", "password123"), + ) + for status, password in cases: + with self.subTest(status=status): + manager = build_wifi_manager() + manager._set_connecting("NextNet") + manager._set_pending_connection("NextNet", password, False) + manager._ctrl.request.return_value = status + + manager._handle_event("CTRL-EVENT-CONNECTED") + + assert manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + assert manager._pending_connection is not None + manager._store.save_network.assert_not_called() + manager._dhcp.start.assert_not_called() + + def test_connected_event_rechecks_epoch_inside_transition_lock(self): + self.manager._set_connecting("FirstNet") + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=FirstNet\n" + lock = threading.Lock() + waiting_for_lock = threading.Event() + lock.acquire() + + class SignalingLock: + def __enter__(self): + waiting_for_lock.set() + lock.acquire() + + def __exit__(self, *_): + lock.release() + + self.manager.__dict__["_connect_lock"] = SignalingLock() + worker = threading.Thread(target=self.manager._handle_event, args=("CTRL-EVENT-CONNECTED",)) + worker.start() + assert waiting_for_lock.wait(1) + + self.manager._set_connecting("NextNet") + lock.release() + worker.join(1) + + assert not worker.is_alive() + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + self.manager._dhcp.start.assert_not_called() + + def test_activate_enables_every_profile_sharing_ssid(self): + self.manager._ctrl.request.return_value = "OK" + + with patch.object(self.manager, "_list_network_ids", return_value=["1", "2"]): + self.manager.activate_connection("Pinned", block=True) + + assert self.manager._ctrl.request.call_args_list == [ + call("DISABLE_NETWORK all"), + call("ENABLE_NETWORK 1"), + call("ENABLE_NETWORK 2"), + call("REASSOCIATE"), + ] + + def test_metered_worker_updates_requested_network_only(self): + self.manager._wifi_state = WifiState("FirstNet", ConnectStatus.CONNECTED) + self.manager._current_network_metered = MeteredType.NO + + with patch.object(wifi_manager_module.threading, "Thread") as thread: + self.manager.set_current_network_metered(MeteredType.YES) + + self.manager._wifi_state = WifiState("NextNet", ConnectStatus.CONNECTED) + thread.call_args.kwargs["target"]() + + self.manager._store.set_metered.assert_called_once_with("FirstNet", int(MeteredType.YES)) + assert self.manager.current_network_metered == MeteredType.NO + + def test_metered_worker_reports_persistence_failure(self): + self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._current_network_metered = MeteredType.NO + self.manager._store.set_metered.side_effect = OSError("read-only") + + with ( + patch.object(wifi_manager_module.threading, "Thread") as thread, + patch.object(wifi_manager_module.cloudlog, "exception") as exception, + ): + self.manager.set_current_network_metered(MeteredType.YES) + thread.call_args.kwargs["target"]() + + exception.assert_called_once_with("Failed to update metered state for TestNet") + assert self.manager.current_network_metered == MeteredType.NO + + def test_activate_restores_saved_profile_constraints(self): + cases = ( + ("Preferred", {"psk": "password123", "hidden": False, "priority": 42}, 42, None), + ("Pinned", {"psk": "password123", "hidden": False, "bssid": "00:11:22:33:44:55"}, 0, "00:11:22:33:44:55"), + ) + for ssid, profile, priority, bssid in cases: + with self.subTest(ssid=ssid): + manager = build_wifi_manager() + manager._store.get.return_value = profile + + with ( + patch.object(manager, "_list_network_ids", return_value=[]), + patch.object(manager, "_add_and_select_network") as add_and_select_network, + ): + manager.activate_connection(ssid, block=True) + + add_and_select_network.assert_called_once_with(ssid, "password123", False, priority, bssid=bssid) + + def test_connect_defers_dhcp_cleanup_to_worker(self): + self.manager._wifi_state = WifiState("CurrentNet", ConnectStatus.CONNECTED) + with ( + patch.object(wifi_manager_module.threading, "Thread") as thread, + patch.object(self.manager, "_list_network_ids", return_value=[]), + patch.object(self.manager, "_add_and_select_network"), + ): + self.manager.connect_to_network("NextNet", "password123") + self.manager._dhcp.stop.assert_not_called() + thread.call_args.kwargs["target"]() + + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + + def test_activate_defers_dhcp_cleanup_to_worker(self): + self.manager._wifi_state = WifiState("CurrentNet", ConnectStatus.CONNECTED) + with ( + patch.object(wifi_manager_module.threading, "Thread") as thread, + patch.object(self.manager, "_list_network_ids", return_value=["1"]), + patch.object(self.manager, "_select_network_ids"), + ): + self.manager.activate_connection("NextNet") + self.manager._dhcp.stop.assert_not_called() + thread.call_args.kwargs["target"]() + + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + + def test_latest_connect_worker_owns_deferred_dhcp_cleanup(self): + self.manager._wifi_state = WifiState("CurrentNet", ConnectStatus.CONNECTED) + events = [] + self.manager._dhcp.stop.side_effect = lambda: events.append("cleanup") + + with ( + patch.object(wifi_manager_module.threading, "Thread") as thread, + patch.object(self.manager, "_list_network_ids", return_value=[]), + patch.object(self.manager, "_add_and_select_network", side_effect=lambda *_: events.append("select") or "1"), + ): + self.manager.connect_to_network("FirstNet", "password123") + first_worker = thread.call_args.kwargs["target"] + self.manager.connect_to_network("NextNet", "password123") + second_worker = thread.call_args.kwargs["target"] + + second_worker() + first_worker() + + assert events == ["cleanup", "select"] + + def test_runtime_network_encodes_control_characters_in_ssid(self): + self.manager._ctrl.request.side_effect = ["0", "OK", "OK", "OK", "OK"] + + self.manager._add_and_select_network("Line\nBreak\r") + + ssid_hex = b"Line\nBreak\r".hex() + assert call(f"SET_NETWORK 0 ssid {ssid_hex}") in self.manager._ctrl.request.call_args_list + + def test_scan_only_reselects_when_disconnected(self): + cases = ( + (WifiState("TestNet", ConnectStatus.CONNECTED), "SCAN TYPE=ONLY"), + (WifiState(), "SCAN"), + ) + for state, command in cases: + with self.subTest(state=state): + manager = build_wifi_manager() + manager._wifi_state = state + + manager._request_scan() + + manager._ctrl.request.assert_called_once_with(command) + + def test_scan_rejects_conflicting_security_variants(self): + self.manager._ctrl.request.return_value = "\n".join(( + "bssid / frequency / signal level / flags / ssid", + "00:11:22:33:44:55\t2437\t-40\t[ESS]\tMixed", + "66:77:88:99:aa:bb\t2437\t-60\t[WPA2-PSK-CCMP][ESS]\tMixed", + )) + + self.manager._update_networks() + + assert len(self.manager.networks) == 1 + assert self.manager.networks[0].ssid == "Mixed" + assert self.manager.networks[0].security_type == SecurityType.UNSUPPORTED + + def test_scan_accepts_psk_bss_among_unsupported_variants(self): + self.manager._ctrl.request.return_value = "\n".join(( + "bssid / frequency / signal level / flags / ssid", + "00:11:22:33:44:55\t2437\t-40\t[RSN-SAE-CCMP][ESS]\tMixed", + "66:77:88:99:aa:bb\t2437\t-60\t[WPA2-PSK-CCMP][ESS]\tMixed", + )) + + self.manager._update_networks() + + assert len(self.manager.networks) == 1 + assert self.manager.networks[0].ssid == "Mixed" + assert self.manager.networks[0].security_type == SecurityType.WPA + + def test_wrong_key_removes_runtime_credentials_and_stops_dhcp(self): + need_auth = MagicMock() + self.manager.add_callbacks(need_auth=need_auth) + self.manager._set_connecting("TestNet") + self.manager._set_pending_connection("TestNet", "wrongpass", False) + self.manager._set_pending_network_id("0", self.manager._user_epoch) + self.manager._last_wrong_key_dispatch[("OldNet", None)] = 0.0 + self.manager._ctrl.request.return_value = "OK" + + with ( + patch.object(self.manager, "_list_network_ids", return_value=["0"]), + patch.object(wifi_manager_module.time, "monotonic", return_value=100), + ): + self.manager._handle_event('CTRL-EVENT-SSID-TEMP-DISABLED id=0 ssid="TestNet" reason=WRONG_KEY') + + self.manager.process_callbacks() + assert self.manager._pending_connection is None + assert self.manager.wifi_state == WifiState() + assert call("REMOVE_NETWORK 0") in self.manager._ctrl.request.call_args_list + assert ("OldNet", None) not in self.manager._last_wrong_key_dispatch + self.manager._dhcp.stop.assert_called_once() + need_auth.assert_called_once_with("TestNet") + + def test_wrong_key_ignores_same_ssid_event_for_other_profile(self): + need_auth = MagicMock() + self.manager.add_callbacks(need_auth=need_auth) + self.manager._set_connecting("TestNet") + self.manager._set_pending_connection("TestNet", "correct-password", False) + self.manager._set_pending_network_id("1", self.manager._user_epoch) + + self.manager._handle_event('CTRL-EVENT-SSID-TEMP-DISABLED id=0 ssid="TestNet" reason=WRONG_KEY') + self.manager.process_callbacks() + + assert self.manager._pending_connection is not None + assert self.manager._pending_connection.password == "correct-password" + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + self.manager._ctrl.request.assert_not_called() + self.manager._dhcp.stop.assert_not_called() + need_auth.assert_not_called() + + def test_wrong_key_exhausts_same_ssid_profiles_before_auth_failure(self): + need_auth = MagicMock() + self.manager.add_callbacks(need_auth=need_auth) + self.manager._set_connecting("TestNet") + + with ( + patch.object(self.manager, "_list_network_ids", side_effect=[["0", "1"], ["1"]]), + patch.object(self.manager, "_remove_wpa_network_id") as remove_network, + patch.object(self.manager, "_select_network_ids") as select_networks, + patch.object(wifi_manager_module.time, "monotonic", return_value=100), + ): + self.manager._handle_event('CTRL-EVENT-SSID-TEMP-DISABLED id=0 ssid="TestNet" reason=WRONG_KEY') + self.manager.process_callbacks() + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + select_networks.assert_called_once_with(["1"]) + need_auth.assert_not_called() + + self.manager._handle_event('CTRL-EVENT-SSID-TEMP-DISABLED id=1 ssid="TestNet" reason=WRONG_KEY') + + self.manager.process_callbacks() + assert remove_network.call_args_list == [call("0"), call("1")] + assert self.manager.wifi_state == WifiState() + self.manager._dhcp.stop.assert_called_once() + need_auth.assert_called_once_with("TestNet") + + def test_connect_allocates_profile_before_removing_matching_ids(self): + requests = [] + + def request(command): + requests.append(command) + if command == "LIST_NETWORKS": + return "network id / ssid / bssid / flags\n0\tTestNet\tany\n" + if command == "ADD_NETWORK": + return "1" + return "OK" + + self.manager._ctrl.request.side_effect = request + real_thread = threading.Thread + worker_threads = [] + + class CapturingThread: + def __init__(self, *args, **kwargs): + self.thread = real_thread(*args, **kwargs) + worker_threads.append(self.thread) + + def start(self): + self.thread.start() + + with patch.object(wifi_manager_module.threading, "Thread", CapturingThread): + self.manager.connect_to_network("TestNet", "correct-password") + worker_threads[0].join() + + assert requests.index("ADD_NETWORK") < requests.index("REMOVE_NETWORK 0") + assert self.manager._pending_connection is not None + assert self.manager._pending_connection.network_id == "1" + + def test_connect_rejects_invalid_passphrases(self): + invalid_passwords = ("short", "x" * 64, "é" * 32) + + for password in invalid_passwords: + with self.subTest(password=password): + manager = build_wifi_manager() + need_auth = MagicMock() + manager.add_callbacks(need_auth=need_auth) + + with patch.object(wifi_manager_module.threading.Thread, "start"): + manager.connect_to_network("TestNet", password) + + manager.process_callbacks() + assert manager.wifi_state == WifiState() + assert manager._pending_connection is None + need_auth.assert_called_once_with("TestNet") + + def test_network_not_found_clears_connecting_state_after_reconciliation(self): + disconnected = MagicMock() + self.manager.add_callbacks(disconnected=disconnected) + self.manager._set_connecting("MissingNet") + self.manager._set_pending_connection("MissingNet", "password123", True) + self.manager._ctrl.request.return_value = "wpa_state=SCANNING\n" + + with patch.object(self.manager, "_remove_wpa_network") as remove_wpa_network: + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + self.manager._handle_event("CTRL-EVENT-NETWORK-NOT-FOUND") + self.manager._handle_event("CTRL-EVENT-NETWORK-NOT-FOUND") + assert self.manager.wifi_state == WifiState("MissingNet", ConnectStatus.CONNECTING) + assert self.manager._pending_connection is not None + remove_wpa_network.assert_not_called() + + self.manager._reconcile_connecting_state() + + self.manager.process_callbacks() + assert self.manager.wifi_state == WifiState() + assert self.manager._pending_connection is None + remove_wpa_network.assert_called_once_with("MissingNet") + assert call("ENABLE_NETWORK all") in self.manager._ctrl.request.call_args_list + self.manager._dhcp.stop.assert_called_once() + disconnected.assert_called_once() + + def test_delayed_network_not_found_does_not_bind_to_fresh_attempt(self): + self.manager._set_connecting("PreviousNet") + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + self.manager._set_connecting("HiddenNet") + self.manager._set_pending_connection("HiddenNet", "password123", True) + self.manager._ctrl.request.return_value = "wpa_state=SCANNING\n" + + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + self.manager._handle_event("CTRL-EVENT-NETWORK-NOT-FOUND") + self.manager._reconcile_connecting_state() + + assert self.manager.wifi_state == WifiState("HiddenNet", ConnectStatus.CONNECTING) + assert self.manager._pending_connection is not None + self.manager._dhcp.stop.assert_not_called() + + def test_reconcile_keeps_saved_runtime_network_after_transient_failure(self): + self.manager._store.contains.return_value = True + self.manager._set_connecting("SavedNet") + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + self.manager._ctrl.request.return_value = "wpa_state=DISCONNECTED\n" + + with patch.object(self.manager, "_remove_wpa_network") as remove_wpa_network: + self.manager._reconcile_connecting_state() + + remove_wpa_network.assert_not_called() + assert call("ENABLE_NETWORK all") in self.manager._ctrl.request.call_args_list + assert self.manager.wifi_state == WifiState() + + def test_reconcile_times_out_stalled_handshake(self): + for wpa_state in ("AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): + with self.subTest(wpa_state=wpa_state): + manager = build_wifi_manager() + manager._store.contains.return_value = True + manager._set_connecting("StalledNet") + manager._set_pending_connection("StalledNet", "password123", False) + manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + manager._ctrl.request.return_value = f"wpa_state={wpa_state}\nssid=StalledNet\n" + + manager._reconcile_connecting_state() + + assert manager.wifi_state == WifiState() + assert manager._pending_connection is None + assert call("ENABLE_NETWORK all") in manager._ctrl.request.call_args_list + manager._dhcp.stop.assert_called_once() + + def test_reconcile_does_not_report_generic_disconnect_as_auth_failure(self): + need_auth = MagicMock() + self.manager.add_callbacks(need_auth=need_auth) + self.manager._store.contains.return_value = True + self.manager._networks = [wifi_manager_module.Network("SavedNet", 100, SecurityType.WPA, False)] + self.manager._set_connecting("SavedNet") + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + self.manager._ctrl.request.return_value = "wpa_state=DISCONNECTED\n" + + self.manager._reconcile_connecting_state() + self.manager.process_callbacks() + + assert self.manager.wifi_state == WifiState() + need_auth.assert_not_called() + + def test_reconcile_clears_ipv6_state_before_adopting_another_network(self): + previous_state = WifiState("PreviousNet", ConnectStatus.CONNECTED) + self.manager._wifi_state = previous_state + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nmode=station\nssid=TestNet\n" + self.manager._dhcp.clear_ipv6_state.side_effect = lambda: self.assertEqual(self.manager.wifi_state, previous_state) + + self.manager._reconcile_connecting_state() + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._dhcp.clear_ipv6_state.assert_called_once() + self.manager._dhcp.start.assert_called_once() + + def test_stale_network_not_found_does_not_clear_fresh_connection(self): + self.manager._set_connecting("TestNet") + self.manager._set_pending_connection("TestNet", "password123", False) + + with ( + patch.object(self.manager, "_remove_wpa_network") as remove_wpa_network, + patch.object(wifi_manager_module, "generate_wpa_conf"), + ): + self.manager._handle_event("CTRL-EVENT-NETWORK-NOT-FOUND") + self.manager._handle_connected("TestNet") + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._store.save_network.assert_called_once_with("TestNet", psk="password123", hidden=False) + remove_wpa_network.assert_not_called() + + def test_forget_cancels_in_flight_connection(self): + runtime_networks = set() + connect_started = threading.Event() + release_connect = threading.Event() + connect_added = threading.Event() + forget_removed = threading.Event() + + def remove_network(ssid): + runtime_networks.discard(ssid) + if connect_added.is_set(): + forget_removed.set() + + def add_network(ssid, *_): + connect_started.set() + assert release_connect.wait(1) + runtime_networks.add(ssid) + connect_added.set() + return "1" + + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = True + + with ( + patch.object(self.manager, "_list_network_ids", return_value=[]), + patch.object(self.manager, "_remove_wpa_network", side_effect=remove_network), + patch.object(self.manager, "_add_and_select_network", side_effect=add_network), + patch.object(wifi_manager_module, "generate_wpa_conf"), + ): + self.manager.connect_to_network("TestNet", "password123") + assert connect_started.wait(1) + + self.manager.forget_connection("TestNet") + assert self.manager.wifi_state == WifiState() + + release_connect.set() + assert connect_added.wait(1) + assert forget_removed.wait(1) + + assert runtime_networks == set() + + def test_forget_does_not_disconnect_fresh_connection(self): + forget_removing = threading.Event() + release_forget = threading.Event() + new_network_selected = threading.Event() + forget_finished = threading.Event() + + def remove_saved_network(ssid): + assert ssid == "TestNet" + forget_removing.set() + assert release_forget.wait(1) + return True + + def select_network(*_): + new_network_selected.set() + return "1" + + self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._store.contains.return_value = True + self.manager._store.remove.side_effect = remove_saved_network + + with ( + patch.object(self.manager, "_list_network_ids", return_value=[]), + patch.object(self.manager, "_remove_wpa_network"), + patch.object(self.manager, "_add_and_select_network", side_effect=select_network), + patch.object(self.manager, "_enqueue_callbacks", side_effect=lambda *_: forget_finished.set()), + patch.object(wifi_manager_module, "generate_wpa_conf"), + ): + self.manager.forget_connection("TestNet") + assert forget_removing.wait(1) + + self.manager.connect_to_network("NextNet", "password123") + assert new_network_selected.wait(1) + + release_forget.set() + assert forget_finished.wait(1) + + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + assert call("ENABLE_NETWORK all") not in self.manager._ctrl.request.call_args_list + assert call("DISCONNECT") not in self.manager._ctrl.request.call_args_list + assert call("REASSOCIATE") not in self.manager._ctrl.request.call_args_list + + def test_forget_withholds_callback_when_runtime_removal_fails(self): + forgotten = MagicMock() + forget_failed = MagicMock() + self.manager.add_callbacks(forgotten=forgotten, forget_failed=forget_failed) + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = True + + def request(command): + if command == "LIST_NETWORKS": + return "network id / ssid / bssid / flags\n0\tSavedNet\tany\t\n" + if command == "REMOVE_NETWORK 0": + return "FAIL\n" + return "OK\n" + + self.manager._ctrl.request.side_effect = request + with patch.object(wifi_manager_module, "generate_wpa_conf"): + self.manager.forget_connection("SavedNet", block=True) + + self.manager.process_callbacks() + forgotten.assert_not_called() + forget_failed.assert_called_once_with("SavedNet") + + def test_forget_allows_fallback_connection_after_disconnect_event(self): + self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = True + + def request(command): + if command == "DISCONNECT": + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + return "OK" + + self.manager._ctrl.request.side_effect = request + with ( + patch.object(wifi_manager_module, "generate_wpa_conf"), + patch.object(self.manager, "_remove_wpa_network"), + ): + self.manager.forget_connection("TestNet", block=True) + + self.manager._ctrl.request.side_effect = None + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=NextNet\n" + self.manager._handle_event("CTRL-EVENT-CONNECTED") + + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTED) + + def test_forget_failure_releases_caller_without_reporting_success(self): + forgotten = MagicMock() + forget_failed = MagicMock() + self.manager.add_callbacks(forgotten=forgotten, forget_failed=forget_failed) + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = False + + self.manager.forget_connection("SavedNet", block=True) + self.manager.process_callbacks() + + forgotten.assert_not_called() + forget_failed.assert_called_once_with("SavedNet") + + def test_failed_connect_worker_does_not_reset_fresh_selection(self): + real_thread = threading.Thread + worker_threads = [] + + class CapturingThread: + def __init__(self, *args, **kwargs): + self.thread = real_thread(*args, **kwargs) + worker_threads.append(self.thread) + + def start(self): + self.thread.start() + + def fail_after_fresh_selection(*_): + self.manager._set_connecting("NextNet") + self.manager._set_pending_connection("NextNet", "new-password", False) + raise OSError("stale request failed") + + with ( + patch.object(wifi_manager_module.threading, "Thread", CapturingThread), + patch.object(self.manager, "_list_network_ids", return_value=[]), + patch.object(self.manager, "_remove_wpa_network"), + patch.object(self.manager, "_add_and_select_network", side_effect=fail_after_fresh_selection), + ): + self.manager.connect_to_network("OldNet", "old-password") + worker_threads[0].join() + + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + assert self.manager._pending_connection is not None + assert self.manager._pending_connection.ssid == "NextNet" + + def test_failed_activate_worker_does_not_reset_fresh_selection(self): + def fail_after_fresh_selection(*_): + self.manager._set_connecting("NextNet") + raise OSError("stale request failed") + + with patch.object(self.manager, "_list_network_ids", side_effect=fail_after_fresh_selection): + self.manager.activate_connection("OldNet", block=True) + + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + + def test_failed_connect_reenables_saved_networks(self): + class ImmediateThread: + def __init__(self, target, **_): + self._target = target + + def start(self): + self._target() + + def request(command): + if command == "LIST_NETWORKS": + return "network id / ssid / bssid / flags\n1\tTestNet\tany\t\n" + if command == "ADD_NETWORK": + return "2" + if command == "REMOVE_NETWORK 1": + return "FAIL" + return "OK" + + self.manager._ctrl.request.side_effect = request + with patch.object(wifi_manager_module.threading, "Thread", ImmediateThread): + self.manager.connect_to_network("TestNet", "password123") + + assert call("ENABLE_NETWORK all") in self.manager._ctrl.request.call_args_list + assert self.manager.wifi_state == WifiState() + + def test_failed_activate_reenables_saved_networks(self): + self.manager._ctrl.request.side_effect = lambda command: "FAIL" if command == "REASSOCIATE" else "OK" + with patch.object(self.manager, "_list_network_ids", return_value=["1"]): + self.manager.activate_connection("TestNet", block=True) + + assert call("ENABLE_NETWORK all") in self.manager._ctrl.request.call_args_list + assert self.manager.wifi_state == WifiState() + + def test_request_error_invalidates_control_socket(self): + self.manager._ctrl.request.side_effect = OSError("socket closed") + epoch = self.manager._monitor_epoch + + with self.assertRaises(OSError): + self.manager._request("SCAN") + + assert self.manager._ctrl is None + assert self.manager._monitor_epoch == epoch + 1 + + +class TestStartupAdoption(TestCase): + def setUp(self): + self.manager = build_wifi_manager() + + def test_station_dhcp_adoption(self): + cases = ( + ("connected", "TestNet", True, True, False), + ("missing-client", "TestNet", False, True, True), + ("reconnecting", "TestNet", True, True, False), + ("different-network", "PreviousNet", False, False, True), ) - wm._init_wifi_state = mock - return mock - - def test_activate_dbus_error_resyncs(self, mocker): - """ActivateConnection returns DBus error while A is connected. - NM rejects the request — no state signals emitted. Worker must re-read NM - state to discover A is still connected, not clear to DISCONNECTED. - """ - wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) - wm._wifi_device = "/dev/wifi0" - wm._nm = mocker.MagicMock() - wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) - wm._router_main = mocker.MagicMock() - - error_reply = mocker.MagicMock() - error_reply.header.message_type = MessageType.error - wm._router_main.send_and_get_reply.return_value = error_reply - - mock_init = self._mock_init_restores(wm, mocker, "A", ConnectStatus.CONNECTED) - - wm.activate_connection("B", block=True) - - mock_init.assert_called_once() - assert wm._wifi_state.ssid == "A" - assert wm._wifi_state.status == ConnectStatus.CONNECTED - - def test_connect_to_network_dbus_error_resyncs(self, mocker): - """AddAndActivateConnection2 returns DBus error while A is connected.""" - wm = _make_wm(mocker, connections={"A": "/path/A"}) - wm._wifi_device = "/dev/wifi0" - wm._nm = mocker.MagicMock() - wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) - wm._router_main = mocker.MagicMock() - wm._forgotten = [] - - error_reply = mocker.MagicMock() - error_reply.header.message_type = MessageType.error - wm._router_main.send_and_get_reply.return_value = error_reply - - mock_init = self._mock_init_restores(wm, mocker, "A", ConnectStatus.CONNECTED) - - # Run worker thread synchronously - workers = [] - mocker.patch('openpilot.system.ui.lib.wifi_manager.threading.Thread', - side_effect=lambda target, **kw: type('T', (), {'start': lambda self: workers.append(target)})()) - - wm.connect_to_network("B", "password123") - workers[-1]() - - mock_init.assert_called_once() - assert wm._wifi_state.ssid == "A" - assert wm._wifi_state.status == ConnectStatus.CONNECTED + for state, adoption_ssid, adoption_result, expect_adopt, expect_start in cases: + with self.subTest(state=state): + manager = build_wifi_manager() + manager._dhcp_adoption_ssid = adoption_ssid + manager._dhcp.adopt.return_value = adoption_result + if state in ("reconnecting", "different-network"): + manager._ctrl.request.side_effect = ( + "wpa_state=ASSOCIATING\nmode=station\nssid=TestNet\n", + "wpa_state=COMPLETED\nmode=station\nssid=TestNet\n", + "OK", + ) + else: + manager._ctrl.request.return_value = "wpa_state=COMPLETED\nmode=station\nssid=TestNet\n" + + manager._init_wifi_state() + if state in ("reconnecting", "different-network"): + manager._handle_event("CTRL-EVENT-CONNECTED") + + assert manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + assert bool(manager._dhcp.adopt.call_count) == expect_adopt + assert bool(manager._dhcp.start.call_count) == expect_start + + def test_hotspot_adopts_with_dhcp_and_nat(self): + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nmode=AP\nssid=Hotspot\n" + + with ( + patch.object(wifi_manager_module, "tethering_dnsmasq_running", return_value=True), + patch.object(wifi_manager_module, "_tethering_firewall_ready", return_value=True), + patch("builtins.open", mock_open(read_data=' psk="hotspot-password"\n')), + ): + self.manager._init_wifi_state() + + assert self.manager.is_tethering_active() + assert self.manager.wifi_state == WifiState("Hotspot", ConnectStatus.CONNECTED) + assert self.manager.ipv4_address == "192.168.43.1" + self.manager._dhcp.start.assert_not_called() + + def test_hotspot_adoption_notifies_callback_registered_after_startup(self): + with ( + patch.object(wifi_manager_module, "tethering_dnsmasq_running", return_value=True), + patch.object(wifi_manager_module, "_tethering_firewall_ready", return_value=True), + patch("builtins.open", mock_open(read_data=' psk="hotspot-password"\n')), + ): + assert self.manager._adopt_ap_state("Hotspot") + + activated = MagicMock() + self.manager.add_callbacks(activated=activated) + self.manager.process_callbacks() + + activated.assert_called_once() + + def test_hotspot_password_mismatch_rebuilds_ap(self): + with ( + patch.object(wifi_manager_module, "tethering_dnsmasq_running", return_value=True), + patch.object(wifi_manager_module, "_tethering_firewall_ready", return_value=True), + patch("builtins.open", mock_open(read_data=' psk="old-password"\n')), + patch.object(self.manager, "_start_tethering") as start_tethering, + ): + assert self.manager._adopt_ap_state("Hotspot") + + start_tethering.assert_called_once() + assert self.manager.is_tethering_active() + + def test_incomplete_hotspot_is_removed(self): + for dnsmasq_running, nat_ready in ((False, True), (True, False)): + with self.subTest(dnsmasq_running=dnsmasq_running, nat_ready=nat_ready): + manager = build_wifi_manager() + manager._ctrl.request.return_value = "wpa_state=COMPLETED\nmode=AP\nssid=Hotspot\n" + with ( + patch.object(wifi_manager_module, "tethering_dnsmasq_running", return_value=dnsmasq_running), + patch.object(wifi_manager_module, "_tethering_firewall_ready", return_value=nat_ready), + patch.object(manager, "_stop_tethering") as stop_tethering, + ): + manager._init_wifi_state() + + assert manager.wifi_state == WifiState() + assert not manager.is_tethering_active() + stop_tethering.assert_called_once() + + def test_reconcile_adopts_missed_connection(self): + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nmode=station\nssid=TestNet\n" + + self.manager._reconcile_connecting_state() + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._dhcp.start.assert_called_once() + + def test_reconcile_stops_hotspot_without_responsive_control_socket(self): + for ctrl in ("unresponsive", None): + with self.subTest(ctrl=ctrl): + manager = build_wifi_manager() + manager._tethering_active = True + manager._wifi_state = WifiState("Hotspot", ConnectStatus.CONNECTED) + if ctrl is None: + manager._ctrl = None + else: + manager._ctrl.request.side_effect = OSError("socket closed") + + with patch.object(manager, "_stop_tethering") as stop_tethering: + manager._reconcile_connecting_state() + + stop_tethering.assert_called_once() + + def test_reconcile_keeps_healthy_hotspot(self): + self.manager._tethering_active = True + self.manager._wifi_state = WifiState("Hotspot", ConnectStatus.CONNECTED) + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nmode=AP\nssid=Hotspot\n" + + with ( + patch.object(wifi_manager_module, "tethering_dnsmasq_running", return_value=True), + patch.object(wifi_manager_module, "_tethering_firewall_ready", return_value=True), + patch.object(self.manager, "_stop_tethering") as stop_tethering, + ): + self.manager._reconcile_connecting_state() + + stop_tethering.assert_not_called() + + def test_reconcile_stops_hotspot_without_nat(self): + self.manager._tethering_active = True + self.manager._wifi_state = WifiState("Hotspot", ConnectStatus.CONNECTED) + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nmode=AP\nssid=Hotspot\n" + + with ( + patch.object(wifi_manager_module, "tethering_dnsmasq_running", return_value=True), + patch.object(wifi_manager_module.subprocess, "run", return_value=MagicMock(returncode=1)), + patch.object(self.manager, "_stop_tethering") as stop_tethering, + ): + self.manager._reconcile_connecting_state() + + stop_tethering.assert_called_once() + + def test_reconcile_clears_state_when_hotspot_cleanup_fails(self): + self.manager._tethering_active = True + self.manager._wifi_state = WifiState("Hotspot", ConnectStatus.CONNECTED) + self.manager._ctrl.request.side_effect = OSError("socket closed") + disconnected = MagicMock() + self.manager.add_callbacks(disconnected=disconnected) + + with patch.object(self.manager, "_stop_tethering", side_effect=OSError("cleanup failed")): + self.manager._reconcile_connecting_state() + + self.manager.process_callbacks() + assert not self.manager.is_tethering_active() + assert self.manager.wifi_state == WifiState() + disconnected.assert_called_once() + + +class TestLifecycle(TestCase): + def test_manager_starts_inactive_until_ui_is_shown(self): + with ( + patch.object(wifi_manager_module, "NetworkStore"), + patch.object(wifi_manager_module, "DhcpClient"), + patch.object(wifi_manager_module, "Params", None), + patch.object(WifiManager, "_initialize"), + ): + manager = WifiManager() + + assert not manager._active + + def test_initial_config_failure_recovers_without_restart(self): + manager = build_wifi_manager() + manager._tethering_ssid = "weedle" + manager._scan_thread = MagicMock() + manager._state_thread = MagicMock() + ctrl = MagicMock() + + with ( + patch("builtins.open", side_effect=FileNotFoundError), + patch.object(wifi_manager_module, "generate_wpa_conf", side_effect=[OSError("read-only"), None]) as generate, + patch.object(wifi_manager_module, "wpa_supplicant_running", return_value=False), + patch.object(wifi_manager_module, "ensure_wpa_supplicant", return_value=ctrl) as ensure, + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager._initialize() + thread.call_args.kwargs["target"]() + manager._ensure_wpa_supplicant() + + manager._scan_thread.start.assert_called_once() + manager._state_thread.start.assert_called_once() + assert generate.call_count == 2 + ensure.assert_called_once() + assert manager._ctrl is ctrl + + def test_station_recovery_cleans_abandoned_ap_services(self): + manager = build_wifi_manager() + ctrl = MagicMock() + + def ensure(_should_exit, _station_reconfigured, on_abandoned_ap): + on_abandoned_ap() + return ctrl + + with ( + patch.object(wifi_manager_module, "wpa_supplicant_running", return_value=True), + patch.object(wifi_manager_module, "ensure_wpa_supplicant", side_effect=ensure), + patch.object(wifi_manager_module, "stop_tethering_dnsmasq") as stop_dnsmasq, + patch.object(wifi_manager_module, "_delete_tethering_firewall_rules") as delete_firewall, + patch.object(wifi_manager_module.subprocess, "run") as run, + ): + manager._ensure_wpa_supplicant() + + run.assert_called_once_with(["sudo", "sysctl", "net.ipv4.ip_forward=0"], check=True) + stop_dnsmasq.assert_called_once() + delete_firewall.assert_called_once() + assert manager._ctrl is ctrl + + def test_hidden_manager_reconciles_without_scanning(self): + manager = build_wifi_manager() + manager._exit = False + manager._active = False + manager._last_network_scan = 0.0 + + with ( + patch.object(manager, "_reconcile_connecting_state") as reconcile, + patch.object(manager, "_request_scan") as request_scan, + patch.object(wifi_manager_module.time, "sleep", side_effect=lambda _: setattr(manager, "_exit", True)), + ): + manager._network_scanner() + + reconcile.assert_called_once() + request_scan.assert_not_called() + + def test_failed_station_bringup_uses_scan_period_retry(self): + manager = build_wifi_manager() + manager._exit = False + manager._ctrl = None + sleeps = [] + + def sleep(duration): + sleeps.append(duration) + manager._exit = True + + with ( + patch.object(wifi_manager_module, "wpa_supplicant_running", return_value=False), + patch.object(manager, "_ensure_wpa_supplicant"), + patch.object(wifi_manager_module.time, "sleep", side_effect=sleep), + ): + manager._monitor_state() + + assert sleeps == [SCAN_PERIOD_SECONDS] + + def test_disconnected_reconciliation_is_rate_limited(self): + manager = build_wifi_manager() + manager._wifi_state = WifiState() + manager._last_connected_recheck = 0.0 + manager._ctrl.request.return_value = "wpa_state=DISCONNECTED\n" + + with patch.object(wifi_manager_module.time, "monotonic", return_value=100.0): + manager._reconcile_connecting_state() + manager._reconcile_connecting_state() + + manager._ctrl.request.assert_called_once_with("STATUS") + + def test_activating_manager_refreshes_state_and_networks(self): + manager = build_wifi_manager() + manager._active = False + + with ( + patch.object(manager, "_init_wifi_state") as init_wifi_state, + patch.object(manager, "_update_networks") as update_networks, + ): + manager.set_active(True) + + assert manager._active + init_wifi_state.assert_called_once_with(block=False) + update_networks.assert_called_once_with(block=False) + + def test_stop_leaves_network_data_plane_running(self): + manager = build_wifi_manager() + manager._exit = False + manager._tethering_active = True + ctrl = manager._ctrl + + with patch.object(manager, "_stop_tethering") as stop_tethering: + manager.stop() + + assert manager._exit + assert ctrl is not None + ctrl.interrupt.assert_called_once() + ctrl.close.assert_called_once() + manager._dhcp.stop.assert_not_called() + stop_tethering.assert_not_called() + + def test_stop_interrupts_control_request_before_join(self): + manager = build_wifi_manager() + manager._exit = False + request_started = threading.Event() + interrupted = threading.Event() + request_finished = threading.Event() + closed = threading.Event() + + class BlockingCtrl: + def request(self, _command): + request_started.set() + interrupted.wait(0.2) + request_finished.set() + + def interrupt(self): + interrupted.set() + + def close(self): + closed.set() + + manager._ctrl = cast(wifi_manager_module.WpaCtrl, BlockingCtrl()) + manager._scan_thread = threading.Thread(target=manager._ctrl.request, args=("STATUS",)) + manager._state_thread = MagicMock() + manager._state_thread.is_alive.return_value = False + manager._scan_thread.start() + assert request_started.wait(1) + + manager.stop() + + assert interrupted.is_set() + assert request_finished.is_set() + assert closed.is_set() + assert not manager._scan_thread.is_alive() + + def test_callbacks_coalesce_network_updates(self): + manager = build_wifi_manager() + updated = MagicMock() + manager.add_callbacks(networks_updated=updated) + + for _ in range(100): + manager._mark_networks_updated() + manager.process_callbacks() + + updated.assert_called_once_with(manager.networks) + + +class TestTetheringTransitions(TestCase): + def test_hotspot_adoption_does_not_overlap_tethering_transition(self): + manager = build_wifi_manager() + start_entered = threading.Event() + release_start = threading.Event() + adoption_entered = threading.Event() + starts = 0 + + def start_tethering(): + nonlocal starts + starts += 1 + if starts == 1: + start_entered.set() + assert release_start.wait(1) + else: + adoption_entered.set() + + with ( + patch.object(manager, "_start_tethering", side_effect=start_tethering), + patch.object(manager, "_ap_config_matches_password", return_value=False), + patch.object(wifi_manager_module, "tethering_dnsmasq_running", return_value=True), + patch.object(wifi_manager_module, "_tethering_firewall_ready", return_value=True), + ): + manager.set_tethering_active(True) + assert start_entered.wait(1) + + adoption = threading.Thread(target=manager._adopt_ap_state, args=("Hotspot",)) + adoption.start() + try: + assert not adoption_entered.wait(0.1) + finally: + release_start.set() + + adoption.join(1) + assert not adoption.is_alive() + assert adoption_entered.is_set() + + def test_reconcile_waits_for_pending_tethering_start(self): + manager = build_wifi_manager() + manager._last_connected_recheck = 0.0 + + with ( + patch.object(manager, "_start_tethering") as start_tethering, + patch.object(manager, "_stop_tethering") as stop_tethering, + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager.set_tethering_active(True) + manager._reconcile_tethering_state() + + stop_tethering.assert_not_called() + thread.call_args.kwargs["target"]() + + start_tethering.assert_called_once() + assert manager.is_tethering_active() + + def test_startup_station_bringup_does_not_overlap_tethering(self): + manager = build_wifi_manager() + manager._tethering_ssid = "weedle" + station_entered = threading.Event() + release_station = threading.Event() + tethering_entered = threading.Event() + + def ensure_station(): + station_entered.set() + assert release_station.wait(1) + + with ( + patch("builtins.open", side_effect=FileNotFoundError), + patch.object(wifi_manager_module, "generate_wpa_conf"), + patch.object(manager, "_ensure_wpa_supplicant", side_effect=ensure_station), + patch.object(manager, "_update_networks"), + patch.object(manager, "_init_wifi_state"), + patch.object(manager, "_start_tethering", side_effect=lambda: tethering_entered.set()), + ): + with patch.object(wifi_manager_module.threading, "Thread") as thread: + manager._initialize() + initialize = thread.call_args.kwargs["target"] + + initialize_thread = threading.Thread(target=initialize) + initialize_thread.start() + assert station_entered.wait(1) + + manager.set_tethering_active(True) + try: + assert not tethering_entered.wait(0.1) + finally: + release_station.set() + + initialize_thread.join(1) + assert not initialize_thread.is_alive() + assert tethering_entered.wait(1) + + def test_tethering_transitions_do_not_overlap(self): + manager = build_wifi_manager() + start_entered = threading.Event() + release_start = threading.Event() + stop_entered = threading.Event() + + def start_tethering(): + start_entered.set() + assert release_start.wait(1) + + with ( + patch.object(manager, "_start_tethering", side_effect=start_tethering), + patch.object(manager, "_stop_tethering", side_effect=lambda: stop_entered.set()), + ): + manager.set_tethering_active(True) + assert start_entered.wait(1) + + manager.set_tethering_active(False) + try: + assert not stop_entered.wait(0.1) + finally: + release_start.set() + assert stop_entered.wait(1) + + def test_latest_tethering_request_wins(self): + manager = build_wifi_manager() + transitions = [] + + def start_tethering(): + transitions.append(True) + manager._tethering_active = True + + def stop_tethering(): + transitions.append(False) + manager._tethering_active = False + + with ( + patch.object(manager, "_start_tethering", side_effect=start_tethering), + patch.object(manager, "_stop_tethering", side_effect=stop_tethering), + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager.set_tethering_active(True) + manager.set_tethering_active(False) + + workers = [item.kwargs["target"] for item in thread.call_args_list] + workers[1]() + workers[0]() + + assert transitions == [False] + assert not manager.is_tethering_active() + + def test_failed_tethering_stop_notifies_disconnected(self): + manager = build_wifi_manager() + manager._tethering_active = True + disconnected = MagicMock() + manager.add_callbacks(disconnected=disconnected) + + with ( + patch.object(manager, "_stop_tethering", side_effect=OSError("cleanup failed")), + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager.set_tethering_active(False) + thread.call_args.kwargs["target"]() + + manager.process_callbacks() + assert not manager.is_tethering_active() + assert manager.wifi_state == WifiState() + assert manager.ipv4_address == "" + disconnected.assert_called_once() + + +class TestTetheringPassword(TestCase): + def test_latest_password_request_wins(self): + manager = build_wifi_manager() + password_file = MagicMock() + password_write = MagicMock() + password_write.__enter__.return_value = password_file + + with ( + patch.object(wifi_manager_module, "atomic_write", return_value=password_write), + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager.set_tethering_password("first-password") + manager.set_tethering_password("second-password") + + workers = [item.kwargs["target"] for item in thread.call_args_list] + workers[1]() + workers[0]() + + assert manager.tethering_password == "second-password" + password_file.write.assert_called_once_with("second-password") + + def test_startup_falls_back_to_existing_hotspot_password(self): + manager = build_wifi_manager() + manager._tethering_ssid = "weedle" + manager._store.get_tethering_password.return_value = "custom-password" + + with ( + patch("builtins.open", side_effect=FileNotFoundError), + patch.object(wifi_manager_module.threading, "Thread"), + ): + manager._initialize() + + assert manager.tethering_password == "custom-password" + + def test_persist_failure_reenables_active_tethering_controls(self): + manager = build_wifi_manager() + manager._tethering_active = True + manager._tethering_psk = "old-password" + activated = MagicMock() + manager.add_callbacks(activated=activated) + manager.process_callbacks() + activated.reset_mock() + + with ( + patch.object(wifi_manager_module, "atomic_write", side_effect=OSError("read-only")), + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager.set_tethering_password("replacement-password") + thread.call_args.kwargs["target"]() + + manager.process_callbacks() + assert manager.tethering_password == "old-password" + activated.assert_called_once() + + def test_teardown_failure_reenables_active_tethering_controls(self): + manager = build_wifi_manager() + manager._tethering_active = True + manager._wifi_state = WifiState(manager._tethering_ssid, ConnectStatus.CONNECTED) + disconnected = MagicMock() + manager.add_callbacks(disconnected=disconnected) + password_file = MagicMock() + password_write = MagicMock() + password_write.__enter__.return_value = password_file + + with ( + patch.object(wifi_manager_module, "atomic_write", return_value=password_write), + patch.object(manager, "_stop_tethering", side_effect=OSError("cleanup failed")) as stop_tethering, + patch.object(manager, "_start_tethering") as start_tethering, + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager.set_tethering_password("replacement-password") + thread.call_args.kwargs["target"]() + + manager.process_callbacks() + assert manager.tethering_password == "replacement-password" + assert not manager.is_tethering_active() + assert manager.wifi_state == WifiState() + assert stop_tethering.call_count == 2 + start_tethering.assert_not_called() + disconnected.assert_called_once() diff --git a/openpilot/system/ui/lib/tests/test_standalone_wifi.py b/openpilot/system/ui/lib/tests/test_standalone_wifi.py new file mode 100644 index 00000000000000..1660b9a87cb4ef --- /dev/null +++ b/openpilot/system/ui/lib/tests/test_standalone_wifi.py @@ -0,0 +1,58 @@ +from importlib import import_module +import os +from unittest import SkipTest, skipUnless, TestCase +from unittest.mock import MagicMock, patch + +previous_scale = os.environ.get("SCALE") +os.environ["SCALE"] = "1" +try: + try: + import_module("pyray") + except ImportError: + pyray_available = False + else: + pyray_available = True + from openpilot.system.ui.widgets.network import UIState, WifiManagerUI +finally: + if previous_scale is None: + del os.environ["SCALE"] + else: + os.environ["SCALE"] = previous_scale + + +@skipUnless(pyray_available, "pyray is unavailable") +class TestStandaloneWifi(TestCase): + def test_forget_failure_releases_wifi_controls(self): + wifi_ui = WifiManagerUI.__new__(WifiManagerUI) + wifi_ui.state = UIState.FORGETTING + + wifi_ui._on_forget_failed("SavedNet") + + assert wifi_ui.state == UIState.IDLE + + def test_mici_wrong_password_opens_password_dialog(self): + try: + from openpilot.selfdrive.ui.mici.layouts.settings.network import wifi_ui as wifi_ui_module + from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici + except ImportError as e: + raise SkipTest("mici UI dependencies are unavailable") from e + + class WifiButton: + def __init__(self): + self.network = MagicMock(ssid="SavedNet") + self.set_wrong_password = MagicMock() + + button = WifiButton() + wifi_ui = WifiUIMici.__new__(WifiUIMici) + wifi_ui._scroller = MagicMock(items=[button]) + dialog = MagicMock() + + with ( + patch.object(wifi_ui_module, "WifiButton", WifiButton), + patch.object(wifi_ui_module, "BigInputDialog", return_value=dialog), + patch.object(wifi_ui_module.gui_app, "push_widget") as push_widget, + ): + wifi_ui._on_need_auth("SavedNet") + + button.set_wrong_password.assert_called_once() + push_widget.assert_called_once_with(dialog) diff --git a/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py b/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py new file mode 100644 index 00000000000000..029588e8392767 --- /dev/null +++ b/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py @@ -0,0 +1,173 @@ +import subprocess +from contextlib import contextmanager +from unittest import TestCase +from unittest.mock import MagicMock, mock_open, patch + +from openpilot.system.ui.lib import wifi_manager as wifi_manager_module +from openpilot.system.ui.lib.wifi_manager import ( + ConnectStatus, + TETHERING_NAT_COMMENT, + TETHERING_SUBNET, + WifiManager, + WifiState, +) + + +def build_tethering_manager() -> WifiManager: + with ( + patch.object(wifi_manager_module, "NetworkStore", return_value=MagicMock()), + patch.object(wifi_manager_module, "DhcpClient", return_value=MagicMock()), + patch.object(wifi_manager_module, "Params", None), + patch.object(WifiManager, "_initialize"), + patch.object(wifi_manager_module.atexit, "register"), + ): + manager = WifiManager() + + manager._exit = True + manager._tethering_ssid = "weedle-test" + manager._tethering_psk = "hotspot-psk-1234" + manager._tethering_active = True + manager._ipv4_forward = True + return manager + + +@contextmanager +def tethering_side_effects(manager: WifiManager, mode: str = "AP"): + ctrl = MagicMock() + ctrl.request.return_value = f"wpa_state=COMPLETED\nmode={mode}\nssid={manager._tethering_ssid}\n" + dnsmasq = MagicMock() + dnsmasq.poll.return_value = None + ap_file = mock_open() + + with ( + patch.object(wifi_manager_module.time, "sleep"), + patch.object(wifi_manager_module, "atomic_write", return_value=ap_file()) as atomic_write, + patch.object(wifi_manager_module, "stop_wpa_supplicant"), + patch.object(wifi_manager_module, "wpa_supplicant_running", return_value=True), + patch.object(wifi_manager_module, "stop_tethering_dnsmasq"), + patch.object(wifi_manager_module, "WpaCtrl", return_value=ctrl), + patch.object(wifi_manager_module.subprocess, "Popen", return_value=dnsmasq), + patch.object(wifi_manager_module.subprocess, "run", return_value=MagicMock(returncode=1)) as run, + ): + yield run, ctrl, ap_file, atomic_write + + +class TestTetheringFirewall(TestCase): + def test_selects_xtables_backend(self): + for discovered, expected in (("/usr/sbin/iptables-legacy", "iptables-legacy"), (None, "iptables")): + with self.subTest(discovered=discovered), patch.object(wifi_manager_module.shutil, "which", return_value=discovered): + assert all(rule[1] == expected for rule in wifi_manager_module._tethering_firewall_rules("-A")) + + def test_installs_uplink_independent_masquerade(self): + manager = build_tethering_manager() + with ( + patch.object(wifi_manager_module.shutil, "which", return_value="/usr/sbin/iptables-legacy"), + tethering_side_effects(manager) as (run, ctrl, _, _), + ): + manager._start_tethering() + + commands = [item.args[0] for item in run.call_args_list] + nat_add = next(command for command in commands if "-A" in command and "MASQUERADE" in command) + assert nat_add[:2] == ["sudo", "iptables-legacy"] + assert "-s" in nat_add and TETHERING_SUBNET in nat_add + assert "!" in nat_add and "-d" in nat_add + assert "-o" not in nat_add + assert TETHERING_NAT_COMMENT in nat_add + assert manager._ctrl is ctrl + assert manager._wifi_state == WifiState("weedle-test", ConnectStatus.CONNECTED) + + def test_installs_client_input_and_forward_rules(self): + manager = build_tethering_manager() + with ( + patch.object(wifi_manager_module.shutil, "which", return_value="/usr/sbin/iptables-legacy"), + tethering_side_effects(manager) as (run, _, _, _), + ): + manager._start_tethering() + + commands = [item.args[0] for item in run.call_args_list] + added = [command for command in commands if "-A" in command] + assert any("INPUT" in command and "udp" in command and "67" in command and "ACCEPT" in command for command in added) + assert any("INPUT" in command and "udp" in command and "53" in command and "ACCEPT" in command for command in added) + assert any("INPUT" in command and "tcp" in command and "53" in command and "ACCEPT" in command for command in added) + assert any("FORWARD" in command and "-i" in command and TETHERING_SUBNET in command and "ACCEPT" in command for command in added) + assert any("FORWARD" in command and "-o" in command and "ESTABLISHED,RELATED" in command and "ACCEPT" in command for command in added) + assert all(TETHERING_NAT_COMMENT in command for command in added) + + def test_start_preserves_untagged_masquerade_rules(self): + manager = build_tethering_manager() + with tethering_side_effects(manager) as (run, _, _, _): + manager._start_tethering() + + commands = [item.args[0] for item in run.call_args_list] + assert not any("-D" in command and "-o" in command and "MASQUERADE" in command for command in commands) + + def test_nat_failure_aborts_bringup(self): + manager = build_tethering_manager() + + def fail_nat_add(command, **_): + if "-A" in command and "MASQUERADE" in command: + raise subprocess.CalledProcessError(1, command) + return MagicMock(returncode=1) + + with ( + patch.object(wifi_manager_module.shutil, "which", return_value="/usr/sbin/iptables-legacy"), + tethering_side_effects(manager) as (run, _, _, _), + ): + run.side_effect = fail_nat_add + with self.assertRaises(subprocess.CalledProcessError): + manager._start_tethering() + + assert manager._ctrl is None + assert manager._wifi_state.status != ConnectStatus.CONNECTED + + def test_non_ap_daemon_aborts_bringup(self): + manager = build_tethering_manager() + with tethering_side_effects(manager, mode="station") as (_, ctrl, _, _): + with self.assertRaisesRegex(RuntimeError, "did not take over wlan0"): + manager._start_tethering() + + ctrl.close.assert_called_once() + assert manager._ctrl is None + + def test_ap_config_uses_wpa2_with_ccmp(self): + manager = build_tethering_manager() + with tethering_side_effects(manager) as (_, _, ap_file, _): + manager._start_tethering() + + config = ap_file().write.call_args.args[0] + assert "ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev\n" in config + assert " proto=RSN\n" in config + assert " pairwise=CCMP\n" in config + assert " group=CCMP\n" in config + + def test_ap_config_is_written_atomically(self): + manager = build_tethering_manager() + with tethering_side_effects(manager) as (_, _, ap_file, atomic_write): + manager._start_tethering() + + atomic_write.assert_called_once_with(wifi_manager_module.WPA_AP_CONF, overwrite=True) + ap_file().write.assert_called_once() + + def test_stop_removes_nat_and_restores_station(self): + manager = build_tethering_manager() + manager._ctrl = MagicMock() + + with ( + patch.object(manager, "_ensure_wpa_supplicant") as ensure_wpa_supplicant, + patch.object(wifi_manager_module.shutil, "which", return_value="/usr/sbin/iptables-legacy"), + patch.object(wifi_manager_module, "stop_tethering_dnsmasq"), + patch.object(wifi_manager_module, "stop_wpa_supplicant"), + patch.object(wifi_manager_module, "generate_wpa_conf"), + patch.object(wifi_manager_module.time, "sleep"), + patch.object(wifi_manager_module.subprocess, "run", return_value=MagicMock(returncode=1)) as run, + ): + manager._stop_tethering() + expected_rules = wifi_manager_module._tethering_firewall_rules("-D") + + commands = [item.args[0] for item in run.call_args_list] + for rule in expected_rules: + assert rule in commands + assert ["sudo", "sysctl", "net.ipv4.ip_forward=0"] in commands + ensure_wpa_supplicant.assert_called_once() + assert not manager._tethering_active + assert manager._wifi_state == WifiState() diff --git a/openpilot/system/ui/lib/tests/test_wpa_ctrl.py b/openpilot/system/ui/lib/tests/test_wpa_ctrl.py index 5382dcecd8faca..9eb5d5a07b2c12 100644 --- a/openpilot/system/ui/lib/tests/test_wpa_ctrl.py +++ b/openpilot/system/ui/lib/tests/test_wpa_ctrl.py @@ -440,6 +440,7 @@ def test_attaches_existing_hotspot_before_station_cleanup(self): def test_unreachable_hotspot_falls_back_to_station_bringup(self): ctrl = MagicMock() + abandoned_ap = MagicMock() ap_running = True station_running = False @@ -473,9 +474,10 @@ def run(command, **_kwargs): patch.object(wpa_ctrl_module.time, "sleep"), patch.object(wpa_ctrl_module.subprocess, "run", side_effect=run), ): - result = wpa_ctrl_module.ensure_wpa_supplicant(lambda: False) + result = wpa_ctrl_module.ensure_wpa_supplicant(lambda: False, on_abandoned_ap=abandoned_ap) assert result is ctrl + abandoned_ap.assert_called_once() assert call(wpa_ctrl_module.WPA_AP_CONF) in kill.call_args_list assert call(wpa_ctrl_module.WPA_SUPPLICANT_CONF) in kill.call_args_list diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 26474be942c1d0..5a17d3d7fd9707 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -1,30 +1,27 @@ import atexit +import shutil +import subprocess import threading import time -import uuid -import subprocess from collections.abc import Callable -from dataclasses import dataclass, replace +from dataclasses import dataclass from enum import IntEnum -from typing import TYPE_CHECKING, Any - -from jeepney import DBusAddress, new_method_call -from jeepney.bus_messages import MatchRule, message_bus -from jeepney.io.blocking import DBusConnection, open_dbus_connection as open_dbus_connection_blocking -from jeepney.io.threading import DBusRouter, open_dbus_connection as open_dbus_connection_threading -from jeepney.low_level import MessageType -from jeepney.wrappers import Properties +from typing import TYPE_CHECKING from openpilot.common.swaglog import cloudlog -from openpilot.system.ui.lib.networkmanager import (NM, NM_WIRELESS_IFACE, NM_802_11_AP_SEC_PAIR_WEP40, - NM_802_11_AP_SEC_PAIR_WEP104, NM_802_11_AP_SEC_GROUP_WEP40, - NM_802_11_AP_SEC_GROUP_WEP104, NM_802_11_AP_SEC_KEY_MGMT_PSK, - NM_802_11_AP_SEC_KEY_MGMT_802_1X, NM_802_11_AP_FLAGS_NONE, - NM_802_11_AP_FLAGS_PRIVACY, NM_802_11_AP_FLAGS_WPS, - NM_PATH, NM_IFACE, NM_ACCESS_POINT_IFACE, NM_SETTINGS_PATH, - NM_SETTINGS_IFACE, NM_CONNECTION_IFACE, NM_DEVICE_IFACE, - NM_DEVICE_TYPE_WIFI, NM_ACTIVE_CONNECTION_IFACE, - NM_IP4_CONFIG_IFACE, NM_PROPERTIES_IFACE, NMDeviceState, NMDeviceStateReason) +from openpilot.common.utils import atomic_write +from openpilot.system.ui.lib.dhcp_client import DhcpClient +from openpilot.system.ui.lib.wifi_network_store import MeteredType, NetworkStore +from openpilot.system.ui.lib.wpa_ctrl import (WpaCtrl, WpaCtrlMonitor, SecurityType, + WPA_SUPPLICANT_CONF, WPA_AP_CONF, + WPA_CTRL_INTERFACE, + stop_wpa_supplicant, wpa_supplicant_running, + sanitize_for_conf, format_psk_value, format_ssid_value, is_valid_psk, + generate_wpa_conf, parse_event_network_id, parse_event_ssid, + parse_scan_results, flags_to_security_type, + parse_status, dbm_to_percent, decode_ssid, + ensure_wpa_supplicant, try_attach_ctrl, + stop_tethering_dnsmasq, tethering_dnsmasq_running) if TYPE_CHECKING: from openpilot.common.params import Params @@ -35,60 +32,15 @@ Params = None TETHERING_IP_ADDRESS = "192.168.43.1" +TETHERING_SUBNET = "192.168.43.0/24" +TETHERING_NAT_COMMENT = "openpilot-tethering" DEFAULT_TETHERING_PASSWORD = "swagswagcomma" -SIGNAL_QUEUE_SIZE = 10 +TETHERING_PASSWORD_FILE = "/data/tethering_password" SCAN_PERIOD_SECONDS = 5 - -DEBUG = False -_dbus_call_idx = 0 - - -def normalize_ssid(ssid: str) -> str: - return ssid.replace("’", "'") # for iPhone hotspots - - -def _wrap_router(router): - def _wrap(orig): - def wrapper(msg, **kw): - global _dbus_call_idx - _dbus_call_idx += 1 - if DEBUG: - h = msg.header.fields - print(f"[DBUS #{_dbus_call_idx}] {h.get(6, '?')} {h.get(3, '?')} {msg.body}") - return orig(msg, **kw) - return wrapper - router.send_and_get_reply = _wrap(router.send_and_get_reply) - router.send = _wrap(router.send) - - -class SecurityType(IntEnum): - OPEN = 0 - WPA = 1 - WPA2 = 2 - WPA3 = 3 - UNSUPPORTED = 4 - - -class MeteredType(IntEnum): - UNKNOWN = 0 - YES = 1 - NO = 2 - - -def get_security_type(flags: int, wpa_flags: int, rsn_flags: int) -> SecurityType: - wpa_props = wpa_flags | rsn_flags - - # obtained by looking at flags of networks in the office as reported by an Android phone - supports_wpa = (NM_802_11_AP_SEC_PAIR_WEP40 | NM_802_11_AP_SEC_PAIR_WEP104 | NM_802_11_AP_SEC_GROUP_WEP40 | - NM_802_11_AP_SEC_GROUP_WEP104 | NM_802_11_AP_SEC_KEY_MGMT_PSK) - - if (flags == NM_802_11_AP_FLAGS_NONE) or ((flags & NM_802_11_AP_FLAGS_WPS) and not (wpa_props & supports_wpa)): - return SecurityType.OPEN - elif (flags & NM_802_11_AP_FLAGS_PRIVACY) and (wpa_props & supports_wpa) and not (wpa_props & NM_802_11_AP_SEC_KEY_MGMT_802_1X): - return SecurityType.WPA - else: - cloudlog.warning(f"Unsupported network! flags: {flags}, wpa_flags: {wpa_flags}, rsn_flags: {rsn_flags}") - return SecurityType.UNSUPPORTED +CONNECTING_STALE_TIMEOUT_SECONDS = 5 +NETWORK_NOT_FOUND_EVENTS_REQUIRED = 2 +# Suppress WRONG_KEY events from prior attempts that can clobber fresh credentials on a fast retry. +WRONG_KEY_DEBOUNCE_SECONDS = 2.0 @dataclass(frozen=True) @@ -98,48 +50,10 @@ class Network: security_type: SecurityType is_tethering: bool - @classmethod - def from_dbus(cls, ssid: str, aps: list["AccessPoint"], is_tethering: bool) -> "Network": - # we only want to show the strongest AP for each Network/SSID - strongest_ap = max(aps, key=lambda ap: ap.strength) - security_type = get_security_type(strongest_ap.flags, strongest_ap.wpa_flags, strongest_ap.rsn_flags) - - return cls( - ssid=ssid, - strength=100 if is_tethering else strongest_ap.strength, - security_type=security_type, - is_tethering=is_tethering, - ) - -@dataclass(frozen=True) -class AccessPoint: - ssid: str - bssid: str - strength: int - flags: int - wpa_flags: int - rsn_flags: int - ap_path: str - - @classmethod - def from_dbus(cls, ap_props: dict[str, tuple[str, Any]], ap_path: str) -> "AccessPoint": - ssid = bytes(ap_props['Ssid'][1]).decode("utf-8", "replace") - bssid = str(ap_props['HwAddress'][1]) - strength = int(ap_props['Strength'][1]) - flags = int(ap_props['Flags'][1]) - wpa_flags = int(ap_props['WpaFlags'][1]) - rsn_flags = int(ap_props['RsnFlags'][1]) - - return cls( - ssid=ssid, - bssid=bssid, - strength=strength, - flags=flags, - wpa_flags=wpa_flags, - rsn_flags=rsn_flags, - ap_path=ap_path, - ) +def sort_networks(networks: list[Network], current_ssid: str | None, saved_ssids: set[str]) -> list[Network]: + """Sort networks: connected first, then saved, then by signal strength.""" + return sorted(networks, key=lambda n: (n.ssid != current_ssid, n.ssid not in saved_ssids, -n.strength, n.ssid.lower())) class ConnectStatus(IntEnum): @@ -154,38 +68,97 @@ class WifiState: status: ConnectStatus = ConnectStatus.DISCONNECTED +@dataclass(frozen=True) +class PendingConnection: + ssid: str + password: str + hidden: bool + epoch: int + network_id: str | None = None + + +def _iptables_executable() -> str: + # AGNOS 18.7 exposes NAT through xtables while /usr/sbin/iptables selects the unsupported nft frontend. + return "iptables-legacy" if shutil.which("iptables-legacy") is not None else "iptables" + + +def _tethering_firewall_rules(op: str) -> list[list[str]]: + # Source-subnet MASQUERADE (no `-o `) so the session survives uplink changes. + # Mirrors NM's nm-firewall-utils.c:_share_iptables_set_masquerade_sync. + command = ["sudo", _iptables_executable()] + tagged = ["-m", "comment", "--comment", TETHERING_NAT_COMMENT] + return [ + [*command, "-t", "nat", op, "POSTROUTING", + "-s", TETHERING_SUBNET, "!", "-d", TETHERING_SUBNET, + *tagged, "-j", "MASQUERADE"], + [*command, op, "INPUT", "-i", "wlan0", "-p", "udp", "--dport", "67", *tagged, "-j", "ACCEPT"], + [*command, op, "INPUT", "-i", "wlan0", "-p", "udp", "--dport", "53", *tagged, "-j", "ACCEPT"], + [*command, op, "INPUT", "-i", "wlan0", "-p", "tcp", "--dport", "53", *tagged, "-j", "ACCEPT"], + [*command, op, "FORWARD", "-i", "wlan0", "-s", TETHERING_SUBNET, *tagged, "-j", "ACCEPT"], + [*command, op, "FORWARD", "-o", "wlan0", "-d", TETHERING_SUBNET, + "-m", "conntrack", "--ctstate", "ESTABLISHED,RELATED", *tagged, "-j", "ACCEPT"], + ] + + +def _tethering_firewall_ready() -> bool: + try: + return all(subprocess.run(rule, capture_output=True, check=False).returncode == 0 + for rule in _tethering_firewall_rules("-C")) + except OSError: + cloudlog.exception("Failed to verify tethering firewall rules") + return False + + +def _delete_tethering_firewall_rules(): + for rule in _tethering_firewall_rules("-D"): + for _ in range(4): + result = subprocess.run(rule, capture_output=True, check=False) + if result.returncode != 0: + break + + class WifiManager: def __init__(self): - self._networks: list[Network] = [] # an unsorted list of available Networks. a Network can be comprised of multiple APs - self._active = True # used to not run when not in settings + self._networks: list[Network] = [] self._exit = False + self._exit_event = threading.Event() + self._active = False - # DBus connections - try: - self._router_main = DBusRouter(open_dbus_connection_threading(bus="SYSTEM")) # used by scanner / general method calls - _wrap_router(self._router_main) - self._conn_monitor = open_dbus_connection_blocking(bus="SYSTEM") # used by state monitor thread - self._nm = DBusAddress(NM_PATH, bus_name=NM, interface=NM_IFACE) - except FileNotFoundError: - cloudlog.exception("Failed to connect to system D-Bus") - self._router_main = None - self._conn_monitor = None - self._exit = True - - # Store wifi device path - self._wifi_device: str | None = None + self._store: NetworkStore | None = None + self._ctrl: WpaCtrl | None = None + self._dhcp = DhcpClient() - # State - self._connections: dict[str, str] = {} # ssid -> connection path, updated via NM signals self._wifi_state: WifiState = WifiState() self._user_epoch: int = 0 self._ipv4_address: str = "" + self._dhcp_adoption_ssid: str | None = None self._current_network_metered: MeteredType = MeteredType.UNKNOWN - self._tethering_password: str = "" self._ipv4_forward = False + self._tethering_active = False + self._tethering_psk = DEFAULT_TETHERING_PASSWORD + self._dnsmasq_proc: subprocess.Popen | None = None + self._pending_connection: PendingConnection | None = None + self._network_not_found_epoch: int | None = None + self._network_not_found_events = 0 self._last_network_scan: float = 0.0 + self._last_connecting_at: float = 0.0 + self._last_scanning_recheck: float = 0.0 + self._last_connected_recheck: float = 0.0 + self._last_wrong_key_dispatch: dict[tuple[str, str | None], float] = {} self._callback_queue: list[Callable] = [] + self._callback_lock = threading.Lock() + self._state_lock = threading.RLock() + # Serializes supplicant network mutations and connected-state transitions. + self._connect_lock = threading.Lock() + self._station_lock = threading.Lock() + self._station_cleanup_pending = False + self._tethering_lock = threading.RLock() + self._tethering_epoch = 0 + self._tethering_transition_pending = False + self._tethering_password_epoch = 0 + # Coalesced so an undrained queue (user on another tab) can't grow unboundedly. + self._networks_updated_pending = False self._tethering_ssid = "weedle" if Params is not None: @@ -193,14 +166,15 @@ def __init__(self): if dongle_id: self._tethering_ssid += "-" + dongle_id[:4] - # Callbacks self._need_auth: list[Callable[[str], None]] = [] self._activated: list[Callable[[], None]] = [] self._forgotten: list[Callable[[str | None], None]] = [] + self._forget_failed: list[Callable[[str | None], None]] = [] self._networks_updated: list[Callable[[list[Network]], None]] = [] self._disconnected: list[Callable[[], None]] = [] self._scan_lock = threading.Lock() + self._monitor_epoch = 0 self._scan_thread = threading.Thread(target=self._network_scanner, daemon=True) self._state_thread = threading.Thread(target=self._monitor_state, daemon=True) self._initialize() @@ -208,50 +182,124 @@ def __init__(self): def _initialize(self): def worker(): - self._wait_for_wifi_device() - - # TODO: wait for state thread to start before adding tethering connection, tiny race currently - self._scan_thread.start() - self._state_thread.start() - - self._init_connections() - if Params is not None and self._tethering_ssid not in self._connections: - self._add_tethering_connection() + try: + store = NetworkStore() + self._store = store + # WPA passphrases may legally include leading or trailing spaces, so only + # trim the file terminator. + try: + with open(TETHERING_PASSWORD_FILE) as f: + raw = f.read() + self._tethering_psk = raw[:-1] if raw.endswith("\n") else raw + except FileNotFoundError: + self._tethering_psk = store.get_tethering_password(self._tethering_ssid) or DEFAULT_TETHERING_PASSWORD + except (OSError, UnicodeError): + cloudlog.exception("Failed to read tethering password") + self._tethering_psk = store.get_tethering_password(self._tethering_ssid) or DEFAULT_TETHERING_PASSWORD + + with self._tethering_lock: + self._ensure_wpa_supplicant() + + # Populate networks before wifi state so the connected SSID's strength is + # known on first render; otherwise it flashes the disconnected icon. + self._update_networks(block=True) + + self._init_wifi_state() + + cloudlog.debug("WifiManager initialized") + except Exception: + cloudlog.exception("WifiManager initialization failed") + finally: + self._scan_thread.start() + self._state_thread.start() - self._init_wifi_state() + threading.Thread(target=worker, daemon=True).start() - self._tethering_password = self._get_tethering_password() - cloudlog.debug("WifiManager initialized") + def _require_store(self) -> NetworkStore: + if self._store is None: + raise RuntimeError("WifiManager is not initialized") + return self._store + + def _ensure_wpa_supplicant(self): + self._dhcp_adoption_ssid = None + if not wpa_supplicant_running(WPA_AP_CONF): + try: + generate_wpa_conf(self._require_store()) + except Exception: + cloudlog.exception("Failed to generate wpa_supplicant configuration") + return - threading.Thread(target=worker, daemon=True).start() + def station_reconfigured(ssid: str): + self._dhcp_adoption_ssid = ssid + ctrl = ensure_wpa_supplicant(lambda: self._exit, station_reconfigured, + on_abandoned_ap=self._clear_tethering_network_state) + if ctrl is not None: + self._ctrl = ctrl + + def _request(self, cmd: str) -> str: + ctrl = self._ctrl + if ctrl is None: + raise OSError("wpa_supplicant ctrl not attached") + try: + return ctrl.request(cmd) + except OSError: + # Monitor recv doesn't raise on daemon SIGKILL; the epoch bump kicks it to respawn. + try: + ctrl.close() + except Exception: + pass + self._ctrl = None + self._monitor_epoch += 1 + raise def _init_wifi_state(self, block: bool = True): def worker(): - if self._wifi_device is None: - cloudlog.warning("No WiFi device found") + if self._ctrl is None: return epoch = self._user_epoch - dev_addr = DBusAddress(self._wifi_device, bus_name=NM, interface=NM_DEVICE_IFACE) - dev_state = self._router_main.send_and_get_reply(Properties(dev_addr).get('State')).body[0][1] + try: + status = parse_status(self._request("STATUS")) + except Exception: + cloudlog.exception("Failed to get wpa_supplicant status") + return - ssid: str | None = None - status = ConnectStatus.DISCONNECTED - if NMDeviceState.PREPARE <= dev_state <= NMDeviceState.SECONDARIES and dev_state != NMDeviceState.NEED_AUTH: - status = ConnectStatus.CONNECTING - elif dev_state == NMDeviceState.ACTIVATED: - status = ConnectStatus.CONNECTED + wpa_state = status.get("wpa_state", "") + ssid = status.get("ssid") - conn_path, _ = self._get_active_wifi_connection() - if conn_path: - ssid = next((s for s, p in self._connections.items() if p == conn_path), None) + if status.get("mode") == "AP": + # Hotspot adoption after UI restart. STATUS reports COMPLETED in AP mode too, + # so the STA path below would flush wlan0 and kill the live hotspot. + if self._user_epoch != epoch: + return + if self._adopt_ap_state(ssid): + return + # dnsmasq is gone, so the surviving AP daemon is half-broken. Stay + # DISCONNECTED rather than letting the COMPLETED branch below treat this + # as a station connect (which would start STA DHCP on wlan0 and clobber + # the hotspot's address). + self._wifi_state = WifiState(ssid=None, status=ConnectStatus.DISCONNECTED) + return + + if wpa_state == "COMPLETED": + connection_status = ConnectStatus.CONNECTED + elif wpa_state in ("SCANNING", "AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): + # Adopt mid-connect state; otherwise a WRONG_KEY event would bypass its current_ssid check. + connection_status = ConnectStatus.CONNECTING + else: + connection_status = ConnectStatus.DISCONNECTED + ssid = None - # Discard if user acted during DBus calls if self._user_epoch != epoch: return - self._wifi_state = WifiState(ssid=ssid, status=status) + if connection_status == ConnectStatus.CONNECTED and ssid is not None: + adopt_dhcp = self._dhcp_adoption_ssid == ssid + self._dhcp_adoption_ssid = None + self._handle_connected(ssid, adopt_dhcp=adopt_dhcp, expected_epoch=epoch) + else: + self._wifi_state = WifiState(ssid=ssid, status=connection_status) if block: worker() @@ -261,14 +309,20 @@ def worker(): def add_callbacks(self, need_auth: Callable[[str], None] | None = None, activated: Callable[[], None] | None = None, forgotten: Callable[[str | None], None] | None = None, + forget_failed: Callable[[str | None], None] | None = None, networks_updated: Callable[[list[Network]], None] | None = None, disconnected: Callable[[], None] | None = None): if need_auth is not None: self._need_auth.append(need_auth) if activated is not None: - self._activated.append(activated) + with self._callback_lock: + self._activated.append(activated) + if self._tethering_active: + self._callback_queue.append(activated) if forgotten is not None: self._forgotten.append(forgotten) + if forget_failed is not None: + self._forget_failed.append(forget_failed) if networks_updated is not None: self._networks_updated.append(networks_updated) if disconnected is not None: @@ -276,8 +330,8 @@ def add_callbacks(self, need_auth: Callable[[str], None] | None = None, @property def networks(self) -> list[Network]: - # Sort by connected/connecting, then known, then strength, then alphabetically. This is a pure UI ordering and should not affect underlying state. - return sorted(self._networks, key=lambda n: (n.ssid != self._wifi_state.ssid, not self.is_connection_saved(n.ssid), -n.strength, n.ssid.lower())) + saved_ssids = self._store.saved_ssids() if self._store is not None else set() + return sort_networks(self._networks, self._wifi_state.ssid, saved_ssids) @property def wifi_state(self) -> WifiState: @@ -303,636 +357,1178 @@ def connected_ssid(self) -> str | None: @property def tethering_password(self) -> str: - return self._tethering_password + return self._tethering_psk def _set_connecting(self, ssid: str | None): - # Called by user action, or sequentially from state change handler - self._user_epoch += 1 - self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.DISCONNECTED if ssid is None else ConnectStatus.CONNECTING) + with self._state_lock: + self._dhcp_adoption_ssid = None + self._user_epoch += 1 + self._network_not_found_epoch = None + self._network_not_found_events = 0 + self._last_connecting_at = time.monotonic() if ssid is not None else 0.0 + self._last_scanning_recheck = 0.0 + self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.DISCONNECTED if ssid is None else ConnectStatus.CONNECTING) + + def _clear_station_state(self): + self._dhcp.stop() + self._dhcp.clear_ipv6_state() + self._ipv4_address = "" + self._current_network_metered = MeteredType.UNKNOWN + + def _prepare_connection(self, epoch: int) -> bool: + with self._station_lock: + with self._state_lock: + if self._user_epoch != epoch: + return False + cleanup_pending = self._station_cleanup_pending + self._station_cleanup_pending = False + if cleanup_pending: + self._clear_station_state() + with self._state_lock: + return self._user_epoch == epoch + + def _set_pending_connection(self, ssid: str, password: str, hidden: bool): + with self._state_lock: + self._pending_connection = PendingConnection(ssid=ssid, password=password, hidden=hidden, epoch=self._user_epoch) + + def _set_pending_network_id(self, net_id: str, epoch: int): + with self._state_lock: + pending = self._pending_connection + if pending is None or pending.epoch != epoch or self._user_epoch != epoch: + return + self._pending_connection = PendingConnection( + ssid=pending.ssid, + password=pending.password, + hidden=pending.hidden, + epoch=pending.epoch, + network_id=net_id, + ) + + def _clear_pending_connection(self, ssid: str | None = None): + with self._state_lock: + if self._pending_connection is None: + return + if ssid is None or self._pending_connection.ssid == ssid: + self._pending_connection = None + + def _persist_pending_connection(self, ssid: str | None): + with self._state_lock: + pending = self._pending_connection + if pending is None or ssid is None: + return + + if ssid != pending.ssid or pending.epoch != self._user_epoch: + return + + # On filesystem error, keep credentials for later retry and swallow so + # _handle_connected can still fire DHCP/activated callbacks. + try: + store = self._require_store() + store.save_network(ssid, psk=pending.password, hidden=pending.hidden) + generate_wpa_conf(store) + except Exception: + cloudlog.exception("Failed to persist pending connection for %s", ssid) + return + with self._state_lock: + if self._pending_connection is pending: + self._pending_connection = None + + def _connected_transition_is_current(self, ssid: str, epoch: int) -> bool: + with self._state_lock: + return self._user_epoch == epoch and self._wifi_state == WifiState(ssid, ConnectStatus.CONNECTED) def _enqueue_callbacks(self, cbs: list[Callable], *args): - for cb in cbs: - self._callback_queue.append(lambda _cb=cb: _cb(*args)) + with self._callback_lock: + for cb in cbs: + self._callback_queue.append(lambda _cb=cb: _cb(*args)) + + def _mark_networks_updated(self): + # Coalesces across scans so the queue stays O(1) when the UI isn't draining. + with self._callback_lock: + self._networks_updated_pending = True def process_callbacks(self): - # Call from UI thread to run any pending callbacks - to_run, self._callback_queue = self._callback_queue, [] + with self._callback_lock: + to_run, self._callback_queue = self._callback_queue, [] + if self._networks_updated_pending: + self._networks_updated_pending = False + networks_cbs = list(self._networks_updated) + else: + networks_cbs = None for cb in to_run: cb() + if networks_cbs: + # Fire with the latest snapshot, not one captured when we were flagged. + snapshot = self.networks + for cb in networks_cbs: + cb(snapshot) def set_active(self, active: bool): self._active = active - - # Update networks and WiFi state (to self-heal) immediately when activating for UI if active: self._init_wifi_state(block=False) self._update_networks(block=False) def _monitor_state(self): - # Filter for signals - rules = ( - MatchRule( - type="signal", - interface=NM_DEVICE_IFACE, - member="StateChanged", - path=self._wifi_device, - ), - MatchRule( - type="signal", - interface=NM_SETTINGS_IFACE, - member="NewConnection", - path=NM_SETTINGS_PATH, - ), - MatchRule( - type="signal", - interface=NM_SETTINGS_IFACE, - member="ConnectionRemoved", - path=NM_SETTINGS_PATH, - ), - MatchRule( - type="signal", - interface=NM_PROPERTIES_IFACE, - member="PropertiesChanged", - path=self._wifi_device, - ), - ) - - for rule in rules: - self._conn_monitor.send_and_get_reply(message_bus.AddMatch(rule)) - - with (self._conn_monitor.filter(rules[0], bufsize=SIGNAL_QUEUE_SIZE) as state_q, - self._conn_monitor.filter(rules[1], bufsize=SIGNAL_QUEUE_SIZE) as new_conn_q, - self._conn_monitor.filter(rules[2], bufsize=SIGNAL_QUEUE_SIZE) as removed_conn_q, - self._conn_monitor.filter(rules[3], bufsize=SIGNAL_QUEUE_SIZE) as props_q): - while not self._exit: - try: - self._conn_monitor.recv_messages(timeout=1) - except TimeoutError: + # If pgrep keeps finding our daemon but try_attach_ctrl keeps returning None, + # the process is alive with a dead/missing ctrl socket. After this many + # consecutive attach failures, force a full respawn instead of looping forever. + ATTACH_FAILURES_BEFORE_RESPAWN = 3 + attach_failures = 0 + while not self._exit: + if self._ctrl is None: + # _start_tethering closes _ctrl and pkills the STA daemon before the AP daemon + # is up. Spawning STA in that gap races AP bringup and can keep the hotspot + # off wlan0. Wait for tethering to finish; _start_tethering will rebind _ctrl. + if self._tethering_active: + self._exit_event.wait(1) continue - - # Connection added/removed - while len(removed_conn_q): - conn_path = removed_conn_q.popleft().body[0] - self._connection_removed(conn_path) - while len(new_conn_q): - conn_path = new_conn_q.popleft().body[0] - self._new_connection(conn_path) - - # PropertiesChanged on wifi device (LastScan = scan complete) - while len(props_q): - iface, changed, _ = props_q.popleft().body - if iface == NM_WIRELESS_IFACE and 'LastScan' in changed: - self._update_networks() - - # Device state changes - while len(state_q): - new_state, previous_state, change_reason = state_q.popleft().body - - self._handle_state_change(new_state, previous_state, change_reason) - - def _handle_state_change(self, new_state: int, prev_state: int, change_reason: int): - # Thread safety: _wifi_state is read/written by both the monitor thread (this handler) - # and the main thread (_set_connecting via connect/activate). PREPARE/CONFIG and ACTIVATED - # have a read-then-write pattern with a slow DBus call in between — if _set_connecting - # runs mid-call, the handler would overwrite the user's newer state with stale data. - # - # The _user_epoch counter solves this without locks. _set_connecting increments the epoch - # on every user action. Handlers snapshot the epoch before their DBus call and compare - # after: if it changed, a user action occurred during the call and the stale result is - # discarded. Combined with deterministic fixes (skip DBus lookup when ssid already set, - # DEACTIVATING clears CONNECTED on CONNECTION_REMOVED, CONNECTION_REMOVED guard), - # all known race windows are closed. - - # TODO: Handle (FAILED, SSID_NOT_FOUND) and emit for UI to show error - # Happens when network drops off after starting connection - - if new_state == NMDeviceState.DISCONNECTED: - if change_reason == NMDeviceStateReason.NEW_ACTIVATION: - return - - # Guard: forget A while connecting to B fires CONNECTION_REMOVED. Don't clear B's state - # if B is still a known connection. If B hasn't arrived in _connections yet (late - # NewConnection), state clears here but PREPARE recovers via DBus lookup. - if (change_reason == NMDeviceStateReason.CONNECTION_REMOVED and self._wifi_state.ssid and - self._wifi_state.ssid in self._connections): + # No owned daemon? Spawn one so wifi doesn't stay dead after a failed + # initial bringup or a crash. Otherwise just attach. + daemon_alive = wpa_supplicant_running(WPA_SUPPLICANT_CONF) or wpa_supplicant_running(WPA_AP_CONF) + stale_daemon = daemon_alive and attach_failures >= ATTACH_FAILURES_BEFORE_RESPAWN + if daemon_alive and not stale_daemon: + ctrl = try_attach_ctrl() + if ctrl is None: + attach_failures += 1 + self._exit_event.wait(SCAN_PERIOD_SECONDS) + continue + self._ctrl = ctrl + attach_failures = 0 + else: + self._ensure_wpa_supplicant() + attach_failures = 0 + if self._ctrl is None: + self._exit_event.wait(SCAN_PERIOD_SECONDS) + continue + monitor = None + try: + epoch = self._monitor_epoch + monitor = WpaCtrlMonitor() + monitor.open() + while not self._exit and self._monitor_epoch == epoch: + event = monitor.recv(timeout=1.0) + if event is None: + continue + self._handle_event(event) + except Exception: + cloudlog.exception("wpa_supplicant monitor error, reconnecting...") + # Drop the ctrl handle so the next iteration re-attaches (or respawns + # if the daemon actually died); otherwise we'd wedge on a dead socket. + if self._ctrl is not None: + try: + self._ctrl.close() + except Exception: + pass + self._ctrl = None + finally: + if monitor is not None: + try: + monitor.close() + except Exception: + pass + if not self._exit: + self._exit_event.wait(SCAN_PERIOD_SECONDS) + + def _adopt_ap_state(self, ssid: str | None) -> bool: + """Adopt a hotspot only when its DHCP and NAT services are ready. On refusal, + tear down its network services so the monitor can recover station mode.""" + with self._tethering_lock: + if not (tethering_dnsmasq_running() and _tethering_firewall_ready()): + cloudlog.warning("AP services are incomplete; refusing adoption and tearing down orphan AP") + self._stop_tethering() + return False + if not self._ap_config_matches_password(): + cloudlog.warning("Persisted tethering password differs from the running AP; rebuilding hotspot") + self._tethering_active = True + try: + self._start_tethering() + return True + except Exception: + cloudlog.exception("Failed to rebuild hotspot with persisted password") + try: + self._stop_tethering() + except Exception: + cloudlog.exception("Hotspot password reconciliation rollback also failed") + self._tethering_active = False + self._wifi_state = WifiState() + self._ipv4_address = "" + self._enqueue_callbacks(self._disconnected) + return False + with self._callback_lock: + self._tethering_active = True + self._wifi_state = WifiState(ssid=ssid or self._tethering_ssid, status=ConnectStatus.CONNECTED) + self._ipv4_address = TETHERING_IP_ADDRESS + self._callback_queue.extend(self._activated) + return True + + def _ap_config_matches_password(self) -> bool: + try: + with open(WPA_AP_CONF) as f: + expected = f"psk={format_psk_value(self._tethering_psk)}" + return any(line.strip() == expected for line in f) + except OSError: + cloudlog.exception("Failed to read running AP configuration") + return False + + def _handle_connected(self, ssid: str, adopt_dhcp: bool = False, expected_epoch: int | None = None): + """Transition to CONNECTED. Idempotent on (ssid, CONNECTED) so the monitor and + reconcile paths can both call in without each one killing the previous udhcpc.""" + with self._connect_lock: + with self._state_lock: + if expected_epoch is not None and self._user_epoch != expected_epoch: + return + if (self._wifi_state.status == ConnectStatus.CONNECTING + and self._wifi_state.ssid is not None + and self._wifi_state.ssid != ssid): + return + already_connected = self._wifi_state == WifiState(ssid, ConnectStatus.CONNECTED) + transition_epoch = self._user_epoch + if not already_connected: + self._last_connecting_at = 0.0 + self._last_scanning_recheck = 0.0 + self._network_not_found_epoch = None + self._network_not_found_events = 0 + self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTED) + + if already_connected: + # If a prior persist hit a transient FS error, _pending_connection is still + # populated for this SSID. Without the retry here, repeat CONNECTED events + # short-circuit and the network is forgotten after restart. + with self._state_lock: + pending = self._pending_connection + if pending is not None and pending.ssid == ssid: + self._persist_pending_connection(ssid) + return + self._persist_pending_connection(ssid) + if not self._connected_transition_is_current(ssid, transition_epoch): + return + # Re-enable saved networks so wpa_supplicant can auto-roam: SELECT_NETWORK disables + # every other network as a side effect. + if self._ctrl is not None: + try: + self._request("ENABLE_NETWORK all") + except Exception: + cloudlog.exception("Failed to re-enable saved networks for auto-roam") + if not self._connected_transition_is_current(ssid, transition_epoch): return + if not adopt_dhcp or not self._dhcp.adopt(): + self._ipv4_address = "" + self._dhcp.start() + if not self._connected_transition_is_current(ssid, transition_epoch): + return + self._enqueue_callbacks(self._activated) + self._poll_for_ip() - self._set_connecting(None) + def _handle_event(self, event: str): + """Dispatch wpa_supplicant event to state machine.""" + if "CTRL-EVENT-SCAN-RESULTS" in event: + self._update_networks(block=False) - elif new_state in (NMDeviceState.PREPARE, NMDeviceState.CONFIG): + elif "CTRL-EVENT-CONNECTED" in event: epoch = self._user_epoch - if self._wifi_state.ssid is not None: - self._wifi_state = replace(self._wifi_state, status=ConnectStatus.CONNECTING) + try: + status = parse_status(self._request("STATUS")) + except Exception: + cloudlog.exception("Failed to verify wpa_supplicant connected state") return - # Auto-connection when NetworkManager connects to known networks on its own (ssid=None): look up ssid from NM - wifi_state = replace(self._wifi_state, status=ConnectStatus.CONNECTING) - - conn_path, _ = self._get_active_wifi_connection(self._conn_monitor) - - # Discard if user acted during DBus call if self._user_epoch != epoch: return - if conn_path is None: - cloudlog.warning("Failed to get active wifi connection during PREPARE/CONFIG state") - else: - wifi_state = replace(wifi_state, ssid=next((s for s, p in self._connections.items() if p == conn_path), None)) - - self._wifi_state = wifi_state - - # BAD PASSWORD - # - strong network rejects with NEED_AUTH+SUPPLICANT_DISCONNECT - # - weak/gone network fails with FAILED+NO_SECRETS - # TODO: sometimes on PC it's observed no future signals are fired if mouse is held down blocking wrong password dialog - elif ((new_state == NMDeviceState.NEED_AUTH and change_reason == NMDeviceStateReason.SUPPLICANT_DISCONNECT - and prev_state == NMDeviceState.CONFIG) or - (new_state == NMDeviceState.FAILED and change_reason == NMDeviceStateReason.NO_SECRETS)): - - # prev_state guard: real auth failures come from CONFIG (supplicant handshake). - # Stale NEED_AUTH from a prior connection during network switching arrives with - # prev_state=DISCONNECTED and must be ignored to avoid a false wrong-password callback. - if self._wifi_state.ssid: - self._enqueue_callbacks(self._need_auth, self._wifi_state.ssid) - self._set_connecting(None) - - elif new_state in (NMDeviceState.NEED_AUTH, NMDeviceState.IP_CONFIG, NMDeviceState.IP_CHECK, - NMDeviceState.SECONDARIES, NMDeviceState.FAILED): - pass - - elif new_state == NMDeviceState.ACTIVATED: - # Note that IP address from Ip4Config may not be propagated immediately and could take until the next scan results + if status.get("wpa_state") != "COMPLETED": + return + + ssid = status.get("ssid") + if ssid: + adopt_dhcp = self._dhcp_adoption_ssid == ssid + self._dhcp_adoption_ssid = None + self._handle_connected(ssid, adopt_dhcp=adopt_dhcp, expected_epoch=epoch) + + elif "CTRL-EVENT-DISCONNECTED" in event: + if self._tethering_active: + return # Ignore disconnects during tethering transitions + epoch = self._user_epoch - wifi_state = replace(self._wifi_state, status=ConnectStatus.CONNECTED) - conn_path, _ = self._get_active_wifi_connection(self._conn_monitor) + # Don't clear state if we're connecting to something (user action in progress) + if self._wifi_state.status == ConnectStatus.CONNECTING: + return - # Discard if user acted during DBus call if self._user_epoch != epoch: return - if conn_path is None: - cloudlog.warning("Failed to get active wifi connection during ACTIVATED state") - else: - wifi_state = replace(wifi_state, ssid=next((s for s, p in self._connections.items() if p == conn_path), None)) + ssid = self._wifi_state.ssid + if self._wifi_state.status == ConnectStatus.CONNECTED and ssid is not None: + self._dhcp_adoption_ssid = ssid + self._last_connecting_at = time.monotonic() + self._last_scanning_recheck = 0.0 + self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTING) + return - self._wifi_state = wifi_state - self._enqueue_callbacks(self._activated) - self._update_active_connection_info() - - # Persist volatile connections (created by AddAndActivateConnection2) to disk - if conn_path is not None: - conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE) - save_reply = self._conn_monitor.send_and_get_reply(new_method_call(conn_addr, 'Save')) - if save_reply.header.message_type == MessageType.error: - cloudlog.warning(f"Failed to persist connection to disk: {save_reply}") - - elif new_state == NMDeviceState.DEACTIVATING: - # Must clear state when forgetting the currently connected network so the UI - # doesn't flash "connected" after the eager "forgetting..." state resets - # (the forgotten callback fires between DEACTIVATING and DISCONNECTED). - # Only clear CONNECTED — CONNECTING must be preserved for forget-A-connect-B. - if change_reason == NMDeviceStateReason.CONNECTION_REMOVED and self._wifi_state.status == ConnectStatus.CONNECTED: - self._set_connecting(None) + if self._wifi_state.status == ConnectStatus.DISCONNECTED: + return + + self._wifi_state = WifiState(ssid=None, status=ConnectStatus.DISCONNECTED) + self._clear_station_state() + self._enqueue_callbacks(self._disconnected) + + elif "TEMP-DISABLED" in event and "reason=WRONG_KEY" in event: + event_ssid = parse_event_ssid(event) + event_network_id = parse_event_network_id(event) + if event_ssid is not None: + with self._connect_lock: + current_ssid = self._wifi_state.ssid + # Auto-connect may leave us in CONNECTING with ssid=None; the event's SSID is authoritative. + connecting_unknown = ( + self._wifi_state.status == ConnectStatus.CONNECTING + and current_ssid is None + ) + if not connecting_unknown and (not current_ssid or event_ssid != current_ssid): + return + + pending = self._pending_connection + if pending is not None and pending.ssid == event_ssid: + if (pending.epoch != self._user_epoch + or pending.network_id is None + or event_network_id != pending.network_id): + return + + # Per-profile debounce suppresses duplicate events without masking a + # legitimate WRONG_KEY from another profile sharing the same SSID. + dispatch_key = (event_ssid, event_network_id) + now = time.monotonic() + self._last_wrong_key_dispatch = { + key: timestamp for key, timestamp in self._last_wrong_key_dispatch.items() + if now - timestamp < WRONG_KEY_DEBOUNCE_SECONDS + } + last_dispatch = self._last_wrong_key_dispatch.get(dispatch_key) + if last_dispatch is not None and now - last_dispatch < WRONG_KEY_DEBOUNCE_SECONDS: + return + self._last_wrong_key_dispatch[dispatch_key] = now + + # Drop only the failed profile. If another profile for this SSID is + # available, keep the current attempt alive and try it before asking + # the user for replacement credentials. + if self._ctrl is not None: + try: + if event_network_id is not None: + matching_ids = self._list_network_ids(event_ssid) + if event_network_id not in matching_ids: + return + self._remove_wpa_network_id(event_network_id) + remaining_ids = [net_id for net_id in matching_ids if net_id != event_network_id] + else: + self._remove_wpa_network(event_ssid) + remaining_ids = [] + if remaining_ids: + self._select_network_ids(remaining_ids) + self._last_connecting_at = now + self._last_scanning_recheck = 0.0 + self._network_not_found_epoch = None + self._network_not_found_events = 0 + return + self._request("ENABLE_NETWORK all") + except Exception: + cloudlog.exception("Failed to re-enable saved networks after WRONG_KEY") + self._clear_pending_connection(event_ssid) + self._enqueue_callbacks(self._need_auth, event_ssid) + self._set_connecting(None) + # CTRL-EVENT-DISCONNECTED is ignored while CONNECTING, so tear down + # DHCP/IP/metered here ourselves in case it arrived before WRONG_KEY. + self._dhcp.stop() + self._ipv4_address = "" + self._current_network_metered = MeteredType.UNKNOWN + self._enqueue_callbacks(self._disconnected) + + elif "CTRL-EVENT-NETWORK-NOT-FOUND" in event: + if self._wifi_state.status != ConnectStatus.CONNECTING: + return + # The event has no network ID or SSID. A delayed event from the previous + # profile can arrive after a fresh SELECT_NETWORK, so let the existing + # stale-connection reconciliation confirm that this attempt also failed. + if time.monotonic() - self._last_connecting_at >= CONNECTING_STALE_TIMEOUT_SECONDS: + self._network_not_found_events += 1 + if self._network_not_found_events >= NETWORK_NOT_FOUND_EVENTS_REQUIRED: + self._network_not_found_epoch = self._user_epoch + + elif "Trying to associate with" in event or "Associated with" in event: + if self._wifi_state.status == ConnectStatus.DISCONNECTED: + epoch = self._user_epoch + ssid = None + if self._ctrl: + try: + status = parse_status(self._request("STATUS")) + ssid = status.get("ssid") + except Exception: + pass + if self._user_epoch != epoch: + return + self._last_connecting_at = time.monotonic() + self._last_scanning_recheck = 0.0 + self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTING) def _network_scanner(self): while not self._exit: - if self._active: + self._reconcile_connecting_state() + if self._active and not self._tethering_active: if time.monotonic() - self._last_network_scan > SCAN_PERIOD_SECONDS: self._request_scan() self._last_network_scan = time.monotonic() time.sleep(1 / 2.) - def _wait_for_wifi_device(self): - while not self._exit: - device_path = self._get_adapter(NM_DEVICE_TYPE_WIFI) - if device_path is not None: - self._wifi_device = device_path - break - time.sleep(1) - - def _get_adapter(self, adapter_type: int) -> str | None: - # Return the first NetworkManager device path matching adapter_type + def _request_scan(self): + if self._ctrl is None: + return try: - reply = self._router_main.send_and_get_reply(new_method_call(self._nm, 'GetDevices')) - if reply.header.message_type == MessageType.error: - # NetworkManager is not available, body holds an error string instead of device paths - return None - for device_path in reply.body[0]: - dev_addr = DBusAddress(device_path, bus_name=NM, interface=NM_DEVICE_IFACE) - dev_type = self._router_main.send_and_get_reply(Properties(dev_addr).get('DeviceType')).body[0][1] - if dev_type == adapter_type: - return str(device_path) - except Exception as e: - cloudlog.exception(f"Error getting adapter type {adapter_type}: {e}") - return None - - def _init_connections(self) -> None: - settings_addr = DBusAddress(NM_SETTINGS_PATH, bus_name=NM, interface=NM_SETTINGS_IFACE) - known_connections = self._router_main.send_and_get_reply(new_method_call(settings_addr, 'ListConnections')).body[0] - - conns: dict[str, str] = {} - for conn_path in known_connections: - settings = self._get_connection_settings(conn_path) - - if len(settings) == 0: - cloudlog.warning(f'Failed to get connection settings for {conn_path}') - continue - - if "802-11-wireless" in settings: - ssid = settings['802-11-wireless']['ssid'][1].decode("utf-8", "replace") - if ssid != "": - conns[ssid] = conn_path - self._connections = conns - - def _new_connection(self, conn_path: str): - settings = self._get_connection_settings(conn_path) - - if "802-11-wireless" in settings: - ssid = settings['802-11-wireless']['ssid'][1].decode("utf-8", "replace") - if ssid != "": - self._connections[ssid] = conn_path - - def _connection_removed(self, conn_path: str): - self._connections = {ssid: path for ssid, path in self._connections.items() if path != conn_path} - - def _get_active_connections(self, router: DBusConnection | DBusRouter | None = None): - # Returns list of ActiveConnection - if router is None: - router = self._router_main - - return router.send_and_get_reply(Properties(self._nm).get('ActiveConnections')).body[0][1] - - def _get_active_wifi_connection(self, router: DBusConnection | DBusRouter | None = None) -> tuple[str | None, dict | None]: - # Returns first Connection settings path and ActiveConnection props from ActiveConnections with Type 802-11-wireless - if router is None: - router = self._router_main - - for active_conn in self._get_active_connections(router): - conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE) - reply = router.send_and_get_reply(Properties(conn_addr).get_all()) - - if reply.header.message_type == MessageType.error: - cloudlog.warning(f"Failed to get active connection properties for {active_conn}: {reply}") - continue - - props = reply.body[0] - - conn_path = props.get('Connection', ('o', '/'))[1] - if props.get('Type', ('s', ''))[1] == '802-11-wireless' and conn_path != '/': - return conn_path, props - - return None, None - - def _get_connection_settings(self, conn_path: str) -> dict: - conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE) - reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'GetSettings')) - if reply.header.message_type == MessageType.error: - cloudlog.warning(f'Failed to get connection settings: {reply}') - return {} - return dict(reply.body[0]) - - def _add_tethering_connection(self): - connection = { - 'connection': { - 'type': ('s', '802-11-wireless'), - 'uuid': ('s', str(uuid.uuid4())), - 'id': ('s', 'Hotspot'), - 'autoconnect-retries': ('i', 0), - 'interface-name': ('s', 'wlan0'), - 'autoconnect': ('b', False), - }, - '802-11-wireless': { - 'band': ('s', 'bg'), - 'mode': ('s', 'ap'), - 'ssid': ('ay', self._tethering_ssid.encode("utf-8")), - }, - '802-11-wireless-security': { - 'group': ('as', ['ccmp']), - 'key-mgmt': ('s', 'wpa-psk'), - 'pairwise': ('as', ['ccmp']), - 'proto': ('as', ['rsn']), - 'psk': ('s', DEFAULT_TETHERING_PASSWORD), - }, - 'ipv4': { - 'method': ('s', 'shared'), - 'address-data': ('aa{sv}', [[ - ('address', ('s', TETHERING_IP_ADDRESS)), - ('prefix', ('u', 24)), - ]]), - 'gateway': ('s', TETHERING_IP_ADDRESS), - 'never-default': ('b', True), - }, - 'ipv6': {'method': ('s', 'ignore')}, - } - - settings_addr = DBusAddress(NM_SETTINGS_PATH, bus_name=NM, interface=NM_SETTINGS_IFACE) - self._router_main.send_and_get_reply(new_method_call(settings_addr, 'AddConnection', 'a{sa{sv}}', (connection,))) + self._request("SCAN TYPE=ONLY" if self._wifi_state.status == ConnectStatus.CONNECTED else "SCAN") + except Exception: + cloudlog.exception("Failed to request scan") - def connect_to_network(self, ssid: str, password: str, hidden: bool = False): - self._set_connecting(ssid) + def _reconcile_tethering_state(self): + now = time.monotonic() + if now - self._last_connected_recheck < SCAN_PERIOD_SECONDS: + return + self._last_connected_recheck = now - def worker(): - # Clear all connections that may already exist to the network we are connecting to - self.forget_connection(ssid, block=True) - - connection = { - 'connection': { - 'type': ('s', '802-11-wireless'), - 'uuid': ('s', str(uuid.uuid4())), - 'id': ('s', f'openpilot connection {ssid}'), - 'autoconnect-retries': ('i', 0), - }, - '802-11-wireless': { - 'ssid': ('ay', ssid.encode("utf-8")), - 'hidden': ('b', hidden), - 'mode': ('s', 'infrastructure'), - }, - 'ipv4': { - 'method': ('s', 'auto'), - 'dns-priority': ('i', 600), - }, - 'ipv6': {'method': ('s', 'ignore')}, - } - - if password: - connection['802-11-wireless-security'] = { - 'key-mgmt': ('s', 'wpa-psk'), - 'auth-alg': ('s', 'open'), - 'psk': ('s', password), - } - - # Volatile connection auto-deletes on disconnect (wrong password, user switches networks) - # Persisted to disk on ACTIVATED via Save() - if self._wifi_device is None: - cloudlog.warning("No WiFi device found") - # TODO: expose a failed connection state in the UI - self._init_wifi_state() - return - - reply = self._router_main.send_and_get_reply(new_method_call(self._nm, 'AddAndActivateConnection2', 'a{sa{sv}}ooa{sv}', - (connection, self._wifi_device, "/", {'persist': ('s', 'volatile')}))) - - if reply.header.message_type == MessageType.error: - cloudlog.warning(f"Failed to add and activate connection for {ssid}: {reply}") - # TODO: expose a failed connection state in the UI - self._init_wifi_state() + with self._tethering_lock: + if self._tethering_transition_pending or not self._tethering_active: + return - threading.Thread(target=worker, daemon=True).start() + try: + status = parse_status(self._request("STATUS")) + if (status.get("mode") == "AP" and status.get("wpa_state") == "COMPLETED" + and tethering_dnsmasq_running() and _tethering_firewall_ready()): + return + except Exception: + cloudlog.exception("Failed to verify tethering state") + + cloudlog.warning("Tethering services stopped unexpectedly, restoring station mode") + try: + self._stop_tethering() + except Exception: + cloudlog.exception("Failed to restore station mode after tethering failure") + self._tethering_active = False + self._wifi_state = WifiState() + self._ipv4_address = "" + self._enqueue_callbacks(self._disconnected) + + def _reconcile_connecting_state(self): + current_state = self._wifi_state + if self._tethering_active: + self._reconcile_tethering_state() + return + if self._ctrl is None: + return - def forget_connection(self, ssid: str, block: bool = False): - def worker(): - conn_path = self._connections.get(ssid, None) - if conn_path is None: - cloudlog.warning(f"Trying to forget unknown connection: {ssid}") - else: - conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE) - self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Delete')) + # Detect missed CONNECTED event (e.g. monitor was reconnecting after tethering stop) + if current_state.status == ConnectStatus.DISCONNECTED: + now = time.monotonic() + if now - self._last_connected_recheck < SCAN_PERIOD_SECONDS: + return + self._last_connected_recheck = now + epoch = self._user_epoch + try: + status = parse_status(self._request("STATUS")) + except Exception: + return + # A user tap during the blocking STATUS bumped the epoch; their CONNECTING + # state is fresh, so don't synthesize a connected from the stale STATUS. + if self._user_epoch != epoch: + return + # wpa_supplicant reports COMPLETED in AP mode too; STA path would flush the hotspot. Re-adopt + # so a missed startup adoption (e.g. transient STATUS failure) doesn't strand us in DISCONNECTED + # while still attached to the AP daemon, which would route station actions to the AP socket. + if status.get("mode") == "AP": + if self._adopt_ap_state(status.get("ssid")): + return + # dnsmasq is missing, so the AP is incomplete. Stay DISCONNECTED so the user can recover via tethering toggle. + return + if status.get("wpa_state") == "COMPLETED" and status.get("ssid"): + self._handle_connected(status["ssid"], expected_epoch=epoch) + return - self._enqueue_callbacks(self._forgotten, ssid) + # Detect missed DISCONNECTED if the monitor dropped an event. Gated at + # SCAN_PERIOD_SECONDS to avoid STATUS spam. + if current_state.status == ConnectStatus.CONNECTED: + now = time.monotonic() + if now - self._last_connected_recheck < SCAN_PERIOD_SECONDS: + return + self._last_connected_recheck = now + epoch = self._user_epoch + try: + status = parse_status(self._request("STATUS")) + except Exception: + return + # User started another connect while we were blocked in STATUS; their current + # CONNECTING state must not be clobbered by stale STATUS results below. + if self._user_epoch != epoch: + return + wpa_state = status.get("wpa_state", "") + status_ssid = status.get("ssid") + if wpa_state == "COMPLETED" and status_ssid is not None and status_ssid == current_state.ssid: + self._handle_connected(status_ssid, expected_epoch=epoch) + return + if wpa_state == "COMPLETED" and status_ssid: + # Roamed while the monitor was down; adopt the current network instead of + # synthesizing a disconnect that would flush the live lease. + self._dhcp.clear_ipv6_state() + self._handle_connected(status_ssid, expected_epoch=epoch) + return + # Normal roam/rekey transits through these states briefly; treating them as + # disconnect would flush the live udhcpc lease for nothing. Wait for the + # next sample to see the terminal state. + if wpa_state in ("SCANNING", "AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", + "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): + return + self._wifi_state = WifiState(ssid=None, status=ConnectStatus.DISCONNECTED) + self._dhcp.stop() + self._dhcp.clear_ipv6_state() + self._ipv4_address = "" + self._current_network_metered = MeteredType.UNKNOWN + self._enqueue_callbacks(self._disconnected) + return - if block: - worker() - else: - threading.Thread(target=worker, daemon=True).start() + # Reconcile even if ssid is None. STATUS below tells us definitively. + if current_state.status != ConnectStatus.CONNECTING: + return + now = time.monotonic() + if now - self._last_connecting_at < CONNECTING_STALE_TIMEOUT_SECONDS: + return - def activate_connection(self, ssid: str, block: bool = False): - self._set_connecting(ssid) + # Snapshot the user epoch so a STATUS reply for a stale connect attempt can't + # clobber a fresh user-initiated one that started while we were blocked below. + epoch = self._user_epoch + if self._network_not_found_epoch != epoch and now - self._last_scanning_recheck < CONNECTING_STALE_TIMEOUT_SECONDS: + return + try: + status = parse_status(self._request("STATUS")) + except Exception: + cloudlog.exception("Failed to reconcile wifi state from STATUS") + return + if self._user_epoch != epoch: + return + + wpa_state = status.get("wpa_state", "") + status_ssid = status.get("ssid") + + if wpa_state == "COMPLETED" and status_ssid: + self._handle_connected(status_ssid, expected_epoch=epoch) + elif wpa_state == "SCANNING" and self._network_not_found_epoch != epoch: + # Hidden-SSID joins can legitimately stay in SCANNING past the stale window; defer, don't fail. + self._last_scanning_recheck = time.monotonic() + elif wpa_state in ("DISCONNECTED", "INACTIVE", "SCANNING", "AUTHENTICATING", "ASSOCIATING", + "ASSOCIATED", "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): + ssid = current_state.ssid + pending = self._pending_connection + temporary_ssid = ssid if ( + pending is not None + and ssid is not None + and pending.ssid == ssid + and not self._require_store().contains(ssid) + ) else None + self._clear_pending_connection(ssid) + # Drop the unsaved runtime network so ENABLE_NETWORK all doesn't re-arm + # the failed credential for another retry. + try: + if temporary_ssid is not None: + self._remove_wpa_network(temporary_ssid) + self._request("ENABLE_NETWORK all") + except Exception: + cloudlog.exception("Failed to re-enable saved networks after stale CONNECTING") + self._set_connecting(None) + self._dhcp.stop() + self._ipv4_address = "" + self._current_network_metered = MeteredType.UNKNOWN + self._enqueue_callbacks(self._disconnected) + def _update_networks(self, block: bool = True): def worker(): - conn_path = self._connections.get(ssid, None) - if conn_path is None or self._wifi_device is None: - cloudlog.warning(f"Failed to activate connection for {ssid}: conn_path={conn_path}, wifi_device={self._wifi_device}") - # TODO: expose a failed connection state in the UI - self._init_wifi_state() - return + with self._scan_lock: + if self._ctrl is None: + return - reply = self._router_main.send_and_get_reply(new_method_call(self._nm, 'ActivateConnection', 'ooo', - (conn_path, self._wifi_device, "/"))) + try: + raw = self._request("SCAN_RESULTS") + except Exception: + cloudlog.exception("Failed to get scan results") + return + + results = parse_scan_results(raw) - if reply.header.message_type == MessageType.error: - cloudlog.warning(f"Failed to activate connection for {ssid}: {reply}") - # TODO: expose a failed connection state in the UI - self._init_wifi_state() + ssid_map: dict[str, list] = {} + for r in results: + if not r.ssid: + continue + if r.ssid not in ssid_map: + ssid_map[r.ssid] = [] + ssid_map[r.ssid].append(r) + + networks = [] + for ssid, aps in ssid_map.items(): + strongest = max(aps, key=lambda a: a.signal) + security_types = {flags_to_security_type(ap.flags) for ap in aps} + if len(security_types) == 1: + security = security_types.pop() + elif SecurityType.WPA in security_types and SecurityType.OPEN not in security_types: + security = SecurityType.WPA + else: + security = SecurityType.UNSUPPORTED + is_tethering = ssid == self._tethering_ssid + strength = 100 if is_tethering else dbm_to_percent(strongest.signal) + networks.append(Network(ssid=ssid, strength=strength, security_type=security, is_tethering=is_tethering)) + + # SCAN_RESULTS command failure already early-returns above, so reaching + # here means the scan succeeded; an empty result is a real "no APs in + # range" signal (drove away, area with no wifi, etc.) and the UI must + # see vanished SSIDs disappear instead of holding a stale snapshot. + self._networks = networks + self._update_active_connection_info() + self._mark_networks_updated() if block: worker() else: threading.Thread(target=worker, daemon=True).start() - def _deactivate_connection(self, ssid: str): - for active_conn in self._get_active_connections(): - conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE) - reply = self._router_main.send_and_get_reply(Properties(conn_addr).get('SpecificObject')) - if reply.header.message_type == MessageType.error: - continue # object gone (e.g. rapid connect/disconnect) - - specific_obj_path = reply.body[0][1] - - if specific_obj_path != "/": - ap_addr = DBusAddress(specific_obj_path, bus_name=NM, interface=NM_ACCESS_POINT_IFACE) - ap_reply = self._router_main.send_and_get_reply(Properties(ap_addr).get('Ssid')) - if ap_reply.header.message_type == MessageType.error: - continue # AP gone (e.g. mode switch) - - ap_ssid = bytes(ap_reply.body[0][1]).decode("utf-8", "replace") - - if ap_ssid == ssid: - self._router_main.send_and_get_reply(new_method_call(self._nm, 'DeactivateConnection', 'o', (active_conn,))) - return - - def is_tethering_active(self) -> bool: - # Check ssid, not connected_ssid, to also catch connecting state - return self._wifi_state.ssid == self._tethering_ssid - - def is_connection_saved(self, ssid: str) -> bool: - return ssid in self._connections + def _poll_for_ip(self): + """Poll for IP address after DHCP starts, then update connection info.""" + epoch = self._user_epoch - def set_tethering_password(self, password: str): def worker(): - conn_path = self._connections.get(self._tethering_ssid, None) - if conn_path is None: - cloudlog.warning('No tethering connection found') - return - - settings = self._get_connection_settings(conn_path) - if len(settings) == 0: - cloudlog.warning(f'Failed to get tethering settings for {conn_path}') - return - - settings['802-11-wireless-security']['psk'] = ('s', password) - - conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE) - reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Update', 'a{sa{sv}}', (settings,))) - if reply.header.message_type == MessageType.error: - cloudlog.warning(f'Failed to update tethering settings: {reply}') - return - - self._tethering_password = password - if self.is_tethering_active(): - self.activate_connection(self._tethering_ssid, block=True) - + for _ in range(50): # 10 seconds max + if self._wifi_state.status != ConnectStatus.CONNECTED or self._user_epoch != epoch: + return + self._update_active_connection_info() + if self._ipv4_address: + return + time.sleep(0.2) threading.Thread(target=worker, daemon=True).start() - def _get_tethering_password(self) -> str: - conn_path = self._connections.get(self._tethering_ssid, None) - if conn_path is None: - cloudlog.warning('No tethering connection found') - return '' - - reply = self._router_main.send_and_get_reply(new_method_call( - DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE), - 'GetSecrets', 's', ('802-11-wireless-security',) - )) + def _update_active_connection_info(self): + ipv4_address = "" + metered = MeteredType.UNKNOWN - if reply.header.message_type == MessageType.error: - cloudlog.warning(f'Failed to get tethering password: {reply}') - return '' + if self._wifi_state.status == ConnectStatus.CONNECTED: + if self._ctrl: + try: + status = parse_status(self._request("STATUS")) + ipv4_address = status.get("ip_address", "") + except Exception: + pass - secrets = reply.body[0] - if '802-11-wireless-security' not in secrets: - return '' + if not ipv4_address: + try: + result = subprocess.run(["ip", "-4", "-o", "addr", "show", "wlan0"], + capture_output=True, text=True, timeout=2) + for line in result.stdout.strip().split("\n"): + if "inet " in line: + parts = line.split() + inet_idx = parts.index("inet") + ipv4_address = parts[inet_idx + 1].split("/")[0] + break + except Exception: + pass + + ssid = self._wifi_state.ssid + if ssid and self._store is not None: + metered = self._store.get_metered(ssid) - return str(secrets['802-11-wireless-security'].get('psk', ('s', ''))[1]) + self._ipv4_address = ipv4_address + self._current_network_metered = metered - def set_ipv4_forward(self, enabled: bool): - self._ipv4_forward = enabled + def connect_to_network(self, ssid: str, password: str, hidden: bool = False): + # Backend guard: non-UI entry points (hidden-network dialog, automation) can still reach here. + if self._tethering_active: + cloudlog.warning(f"Ignoring connect to {ssid!r} while tethering is active") + return + if password and not is_valid_psk(password): + cloudlog.warning(f"Ignoring connect to {ssid!r} with invalid passphrase") + self._enqueue_callbacks(self._need_auth, ssid) + return + with self._state_lock: + self._station_cleanup_pending |= self._wifi_state.status == ConnectStatus.CONNECTED or self._dhcp_adoption_ssid is not None + self._set_connecting(ssid) + self._set_pending_connection(ssid, password, hidden) + epoch = self._user_epoch - def set_tethering_active(self, active: bool): def worker(): - if active: - self.activate_connection(self._tethering_ssid, block=True) + with self._connect_lock: + if not self._prepare_connection(epoch): + return + if self._ctrl is None: + cloudlog.warning("No wpa_supplicant connection") + # If a fresher attempt landed during the supplicant-restart window, don't + # let this stale worker emit a false disconnect for it. + if self._user_epoch != epoch: + return + self._clear_pending_connection(ssid) + # _init_wifi_state is a no-op while _ctrl is None, so reset CONNECTING inline. + self._set_connecting(None) + self._enqueue_callbacks(self._disconnected) + return - if not self._ipv4_forward: - time.sleep(5) - cloudlog.warning("net.ipv4.ip_forward = 0") - subprocess.run(["sudo", "sysctl", "net.ipv4.ip_forward=0"], check=False) - else: - self._deactivate_connection(self._tethering_ssid) + # Recheck inside the serialization lock so a stale worker cannot remove + # the runtime network a fresher worker just added. + if self._user_epoch != epoch: + return + try: + existing_ids = self._list_network_ids(ssid) + net_id = self._add_and_select_network(ssid, password, hidden) + for existing_id in existing_ids: + self._remove_wpa_network_id(existing_id) + self._set_pending_network_id(net_id, epoch) + except Exception: + cloudlog.exception(f"Failed to connect to {ssid}") + if self._user_epoch != epoch: + return + try: + self._request("ENABLE_NETWORK all") + except Exception: + cloudlog.exception("Failed to re-enable saved networks after connect failure") + # Setup failed before SELECT_NETWORK could land; STATUS won't tell us + # anything useful and _init_wifi_state would silently set DISCONNECTED + # without notifying the UI. Reset CONNECTING and fire disconnected + # ourselves so the UI unsticks. + self._clear_pending_connection(ssid) + self._set_connecting(None) + self._enqueue_callbacks(self._disconnected) threading.Thread(target=worker, daemon=True).start() - def set_current_network_metered(self, metered: MeteredType): + def forget_connection(self, ssid: str, block: bool = False): + if self._wifi_state.status == ConnectStatus.CONNECTING and self._wifi_state.ssid == ssid: + self._set_connecting(None) + def worker(): - if self.is_tethering_active(): - return + self._clear_pending_connection(ssid) - conn_path, _ = self._get_active_wifi_connection() - if conn_path is None: - cloudlog.warning('No active WiFi connection found') + try: + store = self._require_store() + except RuntimeError: + cloudlog.exception(f"forget_connection: manager not initialized for {ssid}") + self._enqueue_callbacks(self._forget_failed, ssid) return - settings = self._get_connection_settings(conn_path) - - if len(settings) == 0: - cloudlog.warning(f'Failed to get connection settings for {conn_path}') + existed = store.contains(ssid) + removed = store.remove(ssid) + if existed and not removed: + # rm failed, so the on-disk file survives and _load will restore the entry + # at next start. Don't tear down the runtime/regenerate config or fire + # `forgotten`, or the UI will lie about state until the file gets restored. + cloudlog.warning(f"forget_connection: failed to remove {ssid} from disk; leaving runtime intact") + self._enqueue_callbacks(self._forget_failed, ssid) return + if not removed: + cloudlog.warning(f"Trying to forget unknown connection: {ssid}") - settings['connection']['metered'] = ('i', int(metered)) - - conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE) - reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Update', 'a{sa{sv}}', (settings,))) - if reply.header.message_type == MessageType.error: - cloudlog.warning(f'Failed to update metered settings: {reply}') - - threading.Thread(target=worker, daemon=True).start() - - def _request_scan(self): - if self._wifi_device is None: - cloudlog.warning("No WiFi device found") - return + with self._connect_lock: + try: + generate_wpa_conf(store) + if self._ctrl: + with self._state_lock: + was_connected = self._wifi_state.ssid == ssid and self._wifi_state.status == ConnectStatus.CONNECTED + preserve_selection = self._wifi_state.status == ConnectStatus.CONNECTING and self._wifi_state.ssid != ssid + if was_connected: + self._set_connecting(None) + self._clear_station_state() + self._request("DISCONNECT") + self._remove_wpa_network(ssid) + if not preserve_selection: + self._request("ENABLE_NETWORK all") + # Reassociate only when the forgotten profile was the live link, so the + # device falls back to the next saved network. Otherwise REASSOCIATE + # would briefly drop an unrelated active connection. + if was_connected: + self._request("REASSOCIATE") + except Exception: + cloudlog.exception(f"Failed to reconfigure after forgetting {ssid}") + self._enqueue_callbacks(self._forget_failed, ssid) + return - wifi_addr = DBusAddress(self._wifi_device, bus_name=NM, interface=NM_WIRELESS_IFACE) - reply = self._router_main.send_and_get_reply(new_method_call(wifi_addr, 'RequestScan', 'a{sv}', ({},))) + self._enqueue_callbacks(self._forgotten, ssid) - if reply.header.message_type == MessageType.error: - cloudlog.warning(f"Failed to request scan: {reply}") + if block: + worker() + else: + threading.Thread(target=worker, daemon=True).start() - def _update_networks(self, block: bool = True): - if not self._active: + def activate_connection(self, ssid: str, block: bool = False): + if self._tethering_active: + cloudlog.warning(f"Ignoring activate {ssid!r} while tethering is active") return + with self._state_lock: + self._station_cleanup_pending |= self._wifi_state.status == ConnectStatus.CONNECTED or self._dhcp_adoption_ssid is not None + self._set_connecting(ssid) + self._clear_pending_connection() + epoch = self._user_epoch def worker(): - with self._scan_lock: - if self._wifi_device is None: - cloudlog.warning("No WiFi device found") + with self._connect_lock: + if not self._prepare_connection(epoch): + return + if self._ctrl is None: + cloudlog.warning(f"No wpa_supplicant connection for activate {ssid}") + # Skip the reset if a fresher attempt has already moved on, otherwise + # this stale worker would emit a false disconnect for the current attempt. + if self._user_epoch != epoch: + return + # _init_wifi_state is a no-op while _ctrl is None, so reset CONNECTING inline. + self._set_connecting(None) + self._enqueue_callbacks(self._disconnected) return - # NOTE: AccessPoints property may exclude hidden APs (use GetAllAccessPoints method if needed) - wifi_addr = DBusAddress(self._wifi_device, NM, interface=NM_WIRELESS_IFACE) - wifi_props_reply = self._router_main.send_and_get_reply(Properties(wifi_addr).get_all()) - if wifi_props_reply.header.message_type == MessageType.error: - cloudlog.warning(f"Failed to get WiFi properties: {wifi_props_reply}") + def reset_to_disconnected(): + if self._user_epoch != epoch: + return + try: + self._request("ENABLE_NETWORK all") + except Exception: + cloudlog.exception("Failed to re-enable saved networks after activation failure") + # Mirror the _ctrl is None recovery: _init_wifi_state silently sets DISCONNECTED + # without firing the callback, which leaves the UI wedged at CONNECTING. + self._set_connecting(None) + self._enqueue_callbacks(self._disconnected) + + # Recheck inside the serialization lock so a stale worker cannot mutate + # networks added by a fresher one. + if self._user_epoch != epoch: return + try: + ids = self._list_network_ids(ssid) + if ids: + self._select_network_ids(ids) + else: + # Network not in wpa_supplicant's runtime list, so add it from the store. + entry = self._require_store().get(ssid) + if entry: + self._add_and_select_network( + ssid, + entry.get("psk", ""), + entry.get("hidden", False), + entry.get("priority", 0), + bssid=entry.get("bssid") or None, + ) + else: + cloudlog.warning(f"Network {ssid} not found for activation") + reset_to_disconnected() + except Exception: + cloudlog.exception(f"Failed to activate {ssid}") + reset_to_disconnected() - ap_paths = wifi_props_reply.body[0].get('AccessPoints', ('ao', []))[1] + if block: + worker() + else: + threading.Thread(target=worker, daemon=True).start() - aps: dict[str, list[AccessPoint]] = {} + def _select_network_ids(self, net_ids: list[str]): + """Make only the supplied runtime profiles eligible, then reassociate.""" + commands = ["DISABLE_NETWORK all", *(f"ENABLE_NETWORK {net_id}" for net_id in net_ids), "REASSOCIATE"] + for command in commands: + resp = self._request(command).strip() + if not resp.startswith("OK"): + raise RuntimeError(f"{command} failed: {resp}") + + def _add_and_select_network(self, ssid: str, psk: str = "", hidden: bool = False, + priority: int = 0, bssid: str | None = None) -> str: + """Add a network and select it. Every SET_NETWORK is checked so a bad PSK/key_mgmt + surfaces an immediate error instead of a delayed WRONG_KEY; orphans get REMOVE_NETWORK'd.""" + net_id = self._request("ADD_NETWORK").strip() + if not net_id.isdigit(): + raise RuntimeError(f"ADD_NETWORK failed: {net_id}") - for ap_path in ap_paths: - ap_addr = DBusAddress(ap_path, NM, interface=NM_ACCESS_POINT_IFACE) - ap_props = self._router_main.send_and_get_reply(Properties(ap_addr).get_all()) + try: + self._wpa_set_network(net_id, "ssid", format_ssid_value(ssid)) + if psk: + self._wpa_set_network(net_id, "psk", format_psk_value(psk)) + else: + self._wpa_set_network(net_id, "key_mgmt", "NONE") + if hidden: + self._wpa_set_network(net_id, "scan_ssid", "1") + if bssid: + self._wpa_set_network(net_id, "bssid", bssid) + self._wpa_set_network(net_id, "priority", str(priority)) + resp = self._request(f"SELECT_NETWORK {net_id}").strip() + if not resp.startswith("OK"): + raise RuntimeError(f"SELECT_NETWORK {net_id} failed: {resp}") + return net_id + except Exception: + try: + self._request(f"REMOVE_NETWORK {net_id}") + except Exception: + cloudlog.exception(f"Failed to clean up orphaned network {net_id}") + raise + + def _wpa_set_network(self, net_id: str, key: str, value: str): + resp = self._request(f"SET_NETWORK {net_id} {key} {value}").strip() + if not resp.startswith("OK"): + raise RuntimeError(f"SET_NETWORK {net_id} {key} failed: {resp}") + + def _list_network_ids(self, ssid: str) -> list[str]: + """Return all wpa_supplicant network ids matching SSID. LIST_NETWORKS emits + printf_encode'd SSIDs. Decode before comparing or non-ASCII SSIDs silently miss. + Don't .strip() the whole reply: SSIDs may end with spaces, so a trailing-space + SSID on the last line would be clipped and miss the match.""" + if self._ctrl is None: + raise OSError("wpa_supplicant ctrl not attached") + raw = self._request("LIST_NETWORKS") + lines = raw.splitlines() + if not lines or not lines[0].startswith("network id"): + raise RuntimeError(f"LIST_NETWORKS failed: {raw.strip()}") + return [parts[0] for line in lines[1:] + if len(parts := line.split("\t")) >= 2 and decode_ssid(parts[1]) == ssid] + + def _remove_wpa_network(self, ssid: str): + for net_id in self._list_network_ids(ssid): + self._remove_wpa_network_id(net_id) + + def _remove_wpa_network_id(self, net_id: str): + resp = self._request(f"REMOVE_NETWORK {net_id}").strip() + if not resp.startswith("OK"): + raise RuntimeError(f"REMOVE_NETWORK {net_id} failed: {resp}") - # some APs have been seen dropping off during iteration - if ap_props.header.message_type == MessageType.error: - cloudlog.warning(f"Failed to get AP properties for {ap_path}") - continue + def is_tethering_active(self) -> bool: + return self._tethering_active + def is_connection_saved(self, ssid: str) -> bool: + return self._store.contains(ssid) if self._store is not None else False + + def set_tethering_password(self, password: str): + # WPA PSKs are 8-63 UTF-8 bytes or exactly 64 hexadecimal characters. + pw_bytes = len(password.encode("utf-8")) + if not is_valid_psk(password): + cloudlog.warning(f"set_tethering_password: rejecting invalid password (bytes={pw_bytes})") + # Notify the UI so it re-enables the tethering controls. + self._enqueue_callbacks(self._activated if self._tethering_active else self._disconnected) + return + self._tethering_password_epoch += 1 + epoch = self._tethering_password_epoch + def transition(): + try: + with atomic_write(TETHERING_PASSWORD_FILE, overwrite=True) as f: + f.write(password) + except Exception: + cloudlog.exception("Failed to persist tethering password; runtime state unchanged") + self._enqueue_callbacks(self._activated if self._tethering_active else self._disconnected) + return + try: + if self._store is not None: + self._store.set_tethering_password(self._tethering_ssid, password) + except Exception: + cloudlog.exception("Failed to update NetworkManager tethering profile") + self._tethering_psk = password + if self._tethering_active: + try: + # Keep the hotspot active while the password restart is in progress. + self._stop_tethering() + self._start_tethering() + except Exception: + cloudlog.exception("Failed to restart tethering after password change") try: - ap = AccessPoint.from_dbus(ap_props.body[0], ap_path) - if ap.ssid == "": - continue + self._stop_tethering() + except Exception: + cloudlog.exception("Tethering rollback also failed") + self._tethering_active = False + self._wifi_state = WifiState() + self._enqueue_callbacks(self._disconnected) + + def worker(): + with self._tethering_lock: + if self._tethering_password_epoch == epoch: + transition() + threading.Thread(target=worker, daemon=True).start() - if ap.ssid not in aps: - aps[ap.ssid] = [] + def set_ipv4_forward(self, enabled: bool): + self._ipv4_forward = enabled - aps[ap.ssid].append(ap) + def set_tethering_active(self, active: bool): + # Enabling is visible immediately; disabling completes after station mode is restored. + self._tethering_epoch += 1 + epoch = self._tethering_epoch + self._tethering_transition_pending = True + if active: + self._tethering_active = True + def transition(): + if active: + try: + self._start_tethering() + if not self._ipv4_forward: + time.sleep(5) + cloudlog.warning("net.ipv4.ip_forward = 0") + subprocess.run(["sudo", "sysctl", "net.ipv4.ip_forward=0"], check=False) + except Exception: + cloudlog.exception("Failed to start tethering, rolling back") + try: + # Safe on a partial bringup. + self._stop_tethering() except Exception: - # catch all for parsing errors - cloudlog.exception(f"Failed to parse AP properties for {ap_path}") + cloudlog.exception("Tethering rollback also failed") + self._tethering_active = False + self._wifi_state = WifiState() + self._enqueue_callbacks(self._disconnected) + else: + try: + self._stop_tethering() + except Exception: + cloudlog.exception("Failed to stop tethering") + # Force-clear so the UI isn't stuck reporting tethering active. + self._tethering_active = False + self._wifi_state = WifiState() + self._ipv4_address = "" + self._enqueue_callbacks(self._disconnected) - self._networks = [Network.from_dbus(ssid, ap_list, ssid == self._tethering_ssid) for ssid, ap_list in aps.items()] - self._update_active_connection_info() - self._enqueue_callbacks(self._networks_updated, self.networks) # sorted + def worker(): + with self._tethering_lock: + if self._tethering_epoch == epoch: + try: + transition() + finally: + self._tethering_transition_pending = False + threading.Thread(target=worker, daemon=True).start() - if block: - worker() - else: - threading.Thread(target=worker, daemon=True).start() + def set_current_network_metered(self, metered: MeteredType): + if self._tethering_active: + return + ssid = self.connected_ssid + if ssid is None: + return - def _update_active_connection_info(self): - ipv4_address = "" - metered = MeteredType.UNKNOWN + def worker(): + try: + self._require_store().set_metered(ssid, int(metered)) + except Exception: + cloudlog.exception(f"Failed to update metered state for {ssid}") + return + if self.connected_ssid == ssid: + self._current_network_metered = metered + threading.Thread(target=worker, daemon=True).start() - conn_path, props = self._get_active_wifi_connection() + def _start_tethering(self): + self._tethering_active = True + self._set_connecting(self._tethering_ssid) + + psk = self._tethering_psk + + if self._ctrl: + self._ctrl.close() + self._ctrl = None + + # Target only openpilot-owned daemons, including surviving AP instances. + self._monitor_epoch += 1 + stop_wpa_supplicant(WPA_SUPPLICANT_CONF) + stop_wpa_supplicant(WPA_AP_CONF) + self._dhcp.stop() + time.sleep(0.5) + + safe_tether_ssid = sanitize_for_conf(self._tethering_ssid) + lines = [WPA_CTRL_INTERFACE, "ap_scan=2", "", + "network={", f' ssid="{safe_tether_ssid}"', " mode=2", + " frequency=2437", " key_mgmt=WPA-PSK", " proto=RSN", + " pairwise=CCMP", " group=CCMP", f' psk={format_psk_value(psk)}', "}", ""] + ap_conf = "\n".join(lines) + with atomic_write(WPA_AP_CONF, overwrite=True) as f: + f.write(ap_conf) + + subprocess.run(["sudo", "wpa_supplicant", "-B", "-i", "wlan0", "-c", WPA_AP_CONF, "-D", "nl80211"], check=False) + time.sleep(1) + + # Treat interface configuration failures as incomplete AP bringup. + subprocess.run(["sudo", "ip", "addr", "flush", "dev", "wlan0"], check=False) + subprocess.run(["sudo", "ip", "addr", "add", f"{TETHERING_IP_ADDRESS}/24", "dev", "wlan0"], check=True) + subprocess.run(["sudo", "ip", "link", "set", "wlan0", "up"], check=True) + + stop_tethering_dnsmasq() + self._dnsmasq_proc = subprocess.Popen([ + "sudo", "dnsmasq", + "--interface=wlan0", + "--bind-interfaces", + "--dhcp-range=192.168.43.2,192.168.43.254,24h", + "--dhcp-leasefile=/tmp/dnsmasq.leases", + "--no-daemon", "--log-queries", + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + # Fail bringup if clients cannot obtain leases. + time.sleep(0.2) + if self._dnsmasq_proc.poll() is not None: + rc = self._dnsmasq_proc.returncode + self._dnsmasq_proc = None + raise RuntimeError(f"dnsmasq exited during tethering bringup (rc={rc})") + + # Flush tagged copies so repeated starts remain idempotent. + _delete_tethering_firewall_rules() + # Firewall and forwarding failures must roll back the hotspot. + for rule in _tethering_firewall_rules("-A"): + subprocess.run(rule, check=True) + if self._ipv4_forward: + subprocess.run(["sudo", "sysctl", "net.ipv4.ip_forward=1"], check=True) + + # Verify that the owned daemon has control of wlan0 in AP mode. + if not wpa_supplicant_running(WPA_AP_CONF): + raise RuntimeError("AP wpa_supplicant did not start with our config; another daemon likely still owns wlan0") + try: + ctrl = WpaCtrl() + ctrl.open() + except Exception as e: + raise RuntimeError(f"AP wpa_supplicant bringup failed: {e}") from e + try: + status = parse_status(ctrl.request("STATUS")) + except Exception as e: + ctrl.close() + raise RuntimeError(f"AP wpa_supplicant STATUS failed: {e}") from e + if status.get("mode") != "AP": + actual_mode = status.get("mode") + ctrl.close() + raise RuntimeError(f"AP wpa_supplicant bringup did not take over wlan0 (mode={actual_mode!r}); another daemon likely owns the interface") + self._ctrl = ctrl + + self._wifi_state = WifiState(ssid=self._tethering_ssid, status=ConnectStatus.CONNECTED) + self._ipv4_address = TETHERING_IP_ADDRESS + self._enqueue_callbacks(self._activated) + + def _clear_tethering_network_state(self): + try: + subprocess.run(["sudo", "sysctl", "net.ipv4.ip_forward=0"], check=True) + except (OSError, subprocess.CalledProcessError): + cloudlog.exception("Failed to disable IPv4 forwarding during tethering teardown") + + try: + stop_tethering_dnsmasq() + except OSError: + cloudlog.exception("Failed to stop tethering dnsmasq") + if self._dnsmasq_proc is not None: + try: + self._dnsmasq_proc.wait(timeout=3) + except Exception: + cloudlog.exception("Failed waiting for tethering dnsmasq to stop") + self._dnsmasq_proc = None - if conn_path is not None and props is not None: - # IPv4 address - ip4config_path = props.get('Ip4Config', ('o', '/'))[1] + try: + _delete_tethering_firewall_rules() + except OSError: + cloudlog.exception("Failed to remove tethering firewall rules") - if ip4config_path != "/": - ip4config_addr = DBusAddress(ip4config_path, bus_name=NM, interface=NM_IP4_CONFIG_IFACE) - address_data = self._router_main.send_and_get_reply(Properties(ip4config_addr).get('AddressData')).body[0][1] + def _stop_tethering(self): + self._clear_tethering_network_state() - for entry in address_data: - if 'address' in entry: - ipv4_address = entry['address'][1] - break + if self._ctrl: + self._ctrl.close() + self._ctrl = None - # Metered status - settings = self._get_connection_settings(conn_path) + # Stop AP wpa_supplicant (only the one running our AP config). + self._monitor_epoch += 1 + stop_wpa_supplicant(WPA_AP_CONF) + time.sleep(0.5) - if len(settings) > 0: - metered_prop = settings['connection'].get('metered', ('i', 0))[1] + subprocess.run(["sudo", "ip", "addr", "flush", "dev", "wlan0"], check=False) - if metered_prop == MeteredType.YES: - metered = MeteredType.YES - elif metered_prop == MeteredType.NO: - metered = MeteredType.NO + generate_wpa_conf(self._require_store()) + self._ensure_wpa_supplicant() - self._ipv4_address = ipv4_address - self._current_network_metered = metered + self._tethering_active = False + self._wifi_state = WifiState(ssid=None, status=ConnectStatus.DISCONNECTED) + self._ipv4_address = "" + self._enqueue_callbacks(self._disconnected) def __del__(self): self.stop() @@ -940,13 +1536,14 @@ def __del__(self): def stop(self): if not self._exit: self._exit = True + self._exit_event.set() + ctrl, self._ctrl = self._ctrl, None + if ctrl is not None: + ctrl.interrupt() if self._scan_thread.is_alive(): self._scan_thread.join() if self._state_thread.is_alive(): self._state_thread.join() - - if self._router_main is not None: - self._router_main.close() - self._router_main.conn.close() - if self._conn_monitor is not None: - self._conn_monitor.close() + if ctrl is not None: + ctrl.close() + # Network daemons outlive the UI and are adopted by the next controller. diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index 929c0b819a5091..c4e5023d4c2b95 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -126,6 +126,14 @@ def _normalize_keyfile_sections(cp: configparser.ConfigParser): cp[alias] = dict(cp[canonical]) +def _keyfile_section(cp: configparser.ConfigParser, alias: str, canonical: str) -> str | None: + if cp.has_section(alias): + return alias + if cp.has_section(canonical): + return canonical + return None + + class NetworkStore: """Persistent storage for saved WiFi networks using .nmconnection files.""" @@ -173,14 +181,18 @@ def _find_netplan_filename(self, file_uuid: str) -> str | None: return None pattern = re.compile(rf"^\s*uuid\s*:\s*['\"]?{re.escape(file_uuid)}['\"]?\s*(?:#.*)?$", re.MULTILINE) yaml_filenames = [fname for fname in filenames if fname.endswith(".yaml")] + read_failed = False for fname in yaml_filenames: try: raw = sudo_read(os.path.join(self._netplan_directory, fname)) except OSError: + read_failed = True continue - if raw and pattern.search(raw): + if not raw: + read_failed = True + elif pattern.search(raw): return fname - return expected if yaml_filenames else None + return expected if read_failed else None def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_ssids: set[str]): if not fname.endswith(".nmconnection"): @@ -203,7 +215,7 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s return if not imported: persistent_ssids.add(ssid) - if set(cp.options("wifi")) - _SUPPORTED_WIFI_OPTIONS: + if {key for key, value in cp.items("wifi") if value} - _SUPPORTED_WIFI_OPTIONS: cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported Wi-Fi options") return file_uuid = cp.get("connection", "uuid", fallback="") @@ -231,7 +243,7 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s if key_mgmt == "none" and any(cp.has_option("wifi-security", k) for k in wep_keys): cloudlog.warning(f"NetworkStore: skipping {ssid!r} (WEP profile, unsupported)") return - unsupported_security_options = set(cp.options("wifi-security")) - _SUPPORTED_SECURITY_OPTIONS + unsupported_security_options = {key for key, value in cp.items("wifi-security") if value} - _SUPPORTED_SECURITY_OPTIONS auth_alg = cp.get("wifi-security", "auth-alg", fallback="").lower() psk_flags = cp.getint("wifi-security", "psk-flags", fallback=0) if unsupported_security_options or auth_alg not in ("", "open") or psk_flags != 0: @@ -255,10 +267,10 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s ipv6 = dict(cp["ipv6"]) if cp.has_section("ipv6") else {"method": "auto"} ipv4_method = ipv4.get("method", "auto").lower() ipv6_method = ipv6.get("method", "auto").lower() - unsupported_ipv4_options = set(ipv4) - _SUPPORTED_IPV4_OPTIONS - unsupported_ipv6_options = set(ipv6) - _SUPPORTED_IPV6_OPTIONS - ipv4_dns_priority = ipv4.get("dns-priority") - ipv6_addr_gen_mode = ipv6.get("addr-gen-mode", "default").lower() + unsupported_ipv4_options = {key for key, value in ipv4.items() if value} - _SUPPORTED_IPV4_OPTIONS + unsupported_ipv6_options = {key for key, value in ipv6.items() if value} - _SUPPORTED_IPV6_OPTIONS + ipv4_dns_priority = ipv4.get("dns-priority") or None + ipv6_addr_gen_mode = (ipv6.get("addr-gen-mode") or "default").lower() if (ipv4_method not in _SUPPORTED_IPV4_METHODS or ipv6_method not in _SUPPORTED_IPV6_METHODS or unsupported_ipv4_options @@ -291,6 +303,21 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s except (configparser.Error, ValueError): return + def _install_keyfile(self, cp: configparser.ConfigParser, path: str): + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + cp.write(f) + temp_path = f.name + + try: + os.chmod(temp_path, 0o600) + subprocess.run(["sudo", "install", "-d", "-m", "755", self._directory], check=True) + subprocess.run(["sudo", "install", "-o", "root", "-g", "root", "-m", "600", temp_path, path], check=True) + finally: + try: + os.unlink(temp_path) + except FileNotFoundError: + pass + def _render_nmconnection(self, ssid: str, entry: dict) -> tuple[str, dict]: file_uuid = entry.get("uuid") if not file_uuid: @@ -304,6 +331,7 @@ def _render_nmconnection(self, ssid: str, entry: dict) -> tuple[str, dict]: canonical_fname = _canonical_filename(file_uuid, ssid) canonical_path = os.path.join(self._directory, canonical_fname) + canonical_existed = os.path.exists(canonical_path) stored_fname = entry.get("_filename") entry["_filename"] = canonical_fname @@ -337,42 +365,19 @@ def _render_nmconnection(self, ssid: str, entry: dict) -> tuple[str, dict]: cp["ipv4"] = ipv4 cp["ipv6"] = entry.get("_ipv6", {"method": "auto"}) - with tempfile.NamedTemporaryFile(mode="w", dir="/tmp", delete=False) as f: - cp.write(f) - temp_path = f.name + self._install_keyfile(cp, canonical_path) - try: - os.chmod(temp_path, 0o600) - subprocess.run(["sudo", "install", "-d", "-m", "755", self._directory], check=True) - subprocess.run(["sudo", "install", "-o", "root", "-g", "root", "-m", "600", temp_path, canonical_path], check=True) - finally: - try: - os.unlink(temp_path) - except FileNotFoundError: - pass - - # Keep one canonical filename even when the tracked profile uses another name. - if stored_fname and stored_fname != canonical_fname: - stored_path = os.path.join(self._directory, stored_fname) - result = subprocess.run(["sudo", "rm", "-f", stored_path], check=False) - # If cleanup fails (FS read-only, etc.) both files survive. Make both files - # hold the same content so they remain one UUID-equivalent profile. Pin - # _filename to the stored name so each update retries the cleanup. - if result.returncode != 0: - cloudlog.warning(f"NetworkStore: cleanup of noncanonical {stored_fname} failed; mirroring content to keep both files in sync") - try: - subprocess.run(["sudo", "install", "-o", "root", "-g", "root", "-m", "600", os.path.join(self._directory, canonical_fname), stored_path], check=True) - except Exception: - cloudlog.exception("NetworkStore: failed to mirror keyfile to noncanonical path") - entry["_filename"] = stored_fname + def cleanup_canonical_after_failure() -> bool: + if canonical_existed: + return True + return subprocess.run(["sudo", "rm", "-f", canonical_path], check=False).returncode == 0 runtime_filename = entry.get("_runtime_filename") if self._runtime_directory is not None and runtime_filename: runtime_path = os.path.join(self._runtime_directory, runtime_filename) result = subprocess.run(["sudo", "rm", "-f", runtime_path], check=False) if result.returncode != 0: - cleanup_result = subprocess.run(["sudo", "rm", "-f", canonical_path], check=False) - if cleanup_result.returncode != 0: + if not cleanup_canonical_after_failure(): raise OSError(f"failed to remove {runtime_path} and roll back {canonical_path}") raise OSError(f"failed to remove {runtime_path}") entry["_runtime_filename"] = None @@ -381,18 +386,31 @@ def _render_nmconnection(self, ssid: str, entry: dict) -> tuple[str, dict]: if self._netplan_directory is not None and netplan_filename: netplan_path = os.path.join(self._netplan_directory, netplan_filename) if not os.path.exists(netplan_path): - cleanup_result = subprocess.run(["sudo", "rm", "-f", canonical_path], check=False) - if cleanup_result.returncode != 0: + if not cleanup_canonical_after_failure(): raise OSError(f"failed to find {netplan_path} and roll back {canonical_path}") raise OSError(f"failed to find {netplan_path}") result = subprocess.run(["sudo", "rm", "-f", netplan_path], check=False) if result.returncode != 0: - cleanup_result = subprocess.run(["sudo", "rm", "-f", canonical_path], check=False) - if cleanup_result.returncode != 0: + if not cleanup_canonical_after_failure(): raise OSError(f"failed to remove {netplan_path} and roll back {canonical_path}") raise OSError(f"failed to remove {netplan_path}") entry["_netplan_filename"] = None + # Keep one canonical filename even when the tracked profile uses another name. + if stored_fname and stored_fname != canonical_fname: + stored_path = os.path.join(self._directory, stored_fname) + result = subprocess.run(["sudo", "rm", "-f", stored_path], check=False) + # If cleanup fails (FS read-only, etc.) both files survive. Make both files + # hold the same content so they remain one UUID-equivalent profile. Pin + # _filename to the stored name so each update retries the cleanup. + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: cleanup of noncanonical {stored_fname} failed; mirroring content to keep both files in sync") + try: + subprocess.run(["sudo", "install", "-o", "root", "-g", "root", "-m", "600", os.path.join(self._directory, canonical_fname), stored_path], check=True) + except Exception: + cloudlog.exception("NetworkStore: failed to mirror keyfile to noncanonical path") + entry["_filename"] = stored_fname + return file_uuid, entry def get_all(self) -> dict[str, dict]: @@ -413,6 +431,16 @@ def get(self, ssid: str) -> dict | None: return dict(entry) if entry else None def get_tethering_password(self, ssid: str) -> str | None: + for cp, _, _, _ in self._tethering_profiles(ssid): + security_section = _keyfile_section(cp, "wifi-security", "802-11-wireless-security") + assert security_section is not None + password = _decode_keyfile_string(cp.get(security_section, "psk", fallback="")) + if password: + return password + return None + + def _tethering_profiles(self, ssid: str) -> list[tuple[configparser.ConfigParser, str, str, str]]: + profiles = [] directories = [self._directory] if self._runtime_directory is not None: directories.append(self._runtime_directory) @@ -431,18 +459,63 @@ def get_tethering_password(self, ssid: str) -> str | None: continue cp = configparser.ConfigParser(interpolation=None) cp.read_string(raw) - _normalize_keyfile_sections(cp) - profile_ssid = _decode_keyfile_ssid(cp.get("wifi", "ssid", fallback="")) - if cp.get("wifi", "mode", fallback="infrastructure") != "ap" or profile_ssid != ssid: + wifi_section = _keyfile_section(cp, "wifi", "802-11-wireless") + security_section = _keyfile_section(cp, "wifi-security", "802-11-wireless-security") + if wifi_section is None or security_section is None: + continue + profile_ssid = _decode_keyfile_ssid(cp.get(wifi_section, "ssid", fallback="")) + if cp.get(wifi_section, "mode", fallback="infrastructure") != "ap" or profile_ssid != ssid: continue - if cp.get("wifi-security", "key-mgmt", fallback="").lower() != "wpa-psk": + if cp.get(security_section, "key-mgmt", fallback="").lower() != "wpa-psk": continue - password = _decode_keyfile_string(cp.get("wifi-security", "psk", fallback="")) - if password: - return password + profiles.append((cp, directory, fname, cp.get("connection", "uuid", fallback=""))) except (configparser.Error, OSError, ValueError): continue - return None + return profiles + + def set_tethering_password(self, ssid: str, password: str) -> bool: + with self._mutation_lock: + profiles = self._tethering_profiles(ssid) + if not profiles: + return False + + cp, source_directory, source_filename, file_uuid = profiles[0] + security_section = _keyfile_section(cp, "wifi-security", "802-11-wireless-security") + assert security_section is not None + cp[security_section]["psk"] = _encode_keyfile_string(password) + + if source_directory == self._directory: + target_path = os.path.join(self._directory, source_filename) + else: + if not file_uuid: + return False + target_path = os.path.join(self._directory, _canonical_filename(file_uuid, ssid)) + target_existed = os.path.exists(target_path) + + runtime_profile = next((profile for profile in profiles + if profile[1] == self._runtime_directory and profile[3] == file_uuid), None) + runtime_path = os.path.join(self._runtime_directory, runtime_profile[2]) if self._runtime_directory is not None and runtime_profile is not None else None + netplan_filename = self._find_netplan_filename(file_uuid) if runtime_path is not None else None + netplan_path = os.path.join(self._netplan_directory, netplan_filename) if self._netplan_directory is not None and netplan_filename else None + if netplan_path is not None and not os.path.exists(netplan_path): + return False + + self._install_keyfile(cp, target_path) + + def cleanup_target_after_failure() -> bool: + if target_existed: + return True + return subprocess.run(["sudo", "rm", "-f", target_path], check=False).returncode == 0 + + for source_path in (runtime_path, netplan_path): + if source_path is None: + continue + result = subprocess.run(["sudo", "rm", "-f", source_path], check=False) + if result.returncode != 0: + if not cleanup_target_after_failure(): + raise OSError(f"failed to remove {source_path} and roll back {target_path}") + raise OSError(f"failed to remove {source_path}") + return True def save_network(self, ssid: str, psk: str | None = None, metered: int | None = None, hidden: bool | None = None): with self._mutation_lock: diff --git a/openpilot/system/ui/lib/wpa_ctrl.py b/openpilot/system/ui/lib/wpa_ctrl.py index 80de0661c7648b..6742ca8fbf9d8d 100644 --- a/openpilot/system/ui/lib/wpa_ctrl.py +++ b/openpilot/system/ui/lib/wpa_ctrl.py @@ -462,7 +462,8 @@ def _unmanage_wlan0() -> bool: return result.returncode == 0 -def ensure_wpa_supplicant(should_exit: Callable[[], bool], station_reconfigured: Callable[[str], None] | None = None) -> WpaCtrl | None: +def ensure_wpa_supplicant(should_exit: Callable[[], bool], station_reconfigured: Callable[[str], None] | None = None, + on_abandoned_ap: Callable[[], None] | None = None) -> WpaCtrl | None: """Attach to a wpa_supplicant we own, or spawn one. Never attach to NM's daemon. Returns the attached WpaCtrl, or None if ownership cannot be acquired.""" # Wait for wlan0 on cold boot; _unmanage_wlan0 below silently fails if it's missing. @@ -490,6 +491,11 @@ def ensure_wpa_supplicant(should_exit: Callable[[], bool], station_reconfigured: # via tethering toggle since `_start_tethering` only kills STA-config daemons. cloudlog.warning("AP daemon present but ctrl attach failed; killing it so STA spawn can recover") stop_wpa_supplicant(WPA_AP_CONF) + if on_abandoned_ap is not None: + try: + on_abandoned_ap() + except Exception: + cloudlog.exception("Failed to clean up abandoned AP services") # Our own STA daemon is still alive, so attach without disturbing NM. if wpa_supplicant_running(WPA_SUPPLICANT_CONF): diff --git a/openpilot/system/ui/mici_setup.py b/openpilot/system/ui/mici_setup.py index e4977ffdf430ca..63224b2131ed2d 100755 --- a/openpilot/system/ui/mici_setup.py +++ b/openpilot/system/ui/mici_setup.py @@ -296,7 +296,6 @@ def __init__(self, network_monitor: NetworkConnectivityMonitor, continue_callbac super().__init__() self._wifi_manager = WifiManager() - self._wifi_manager.set_active(True) self._network_monitor = network_monitor self._custom_software = False self._wifi_ui = WifiUIMici(self._wifi_manager) @@ -336,7 +335,6 @@ def on_waiting_click(): def show_event(self): super().show_event() - # make sure we populate strength and ip immediately if already have wifi self._wifi_manager.set_active(True) self._prev_has_internet = self._has_internet self._prev_wifi_connected = self._wifi_manager.wifi_state.status == ConnectStatus.CONNECTED @@ -347,6 +345,10 @@ def show_event(self): if self._prev_has_internet or self._prev_wifi_connected: self.set_shown_callback(lambda: self._scroll_to_end_and_grow()) + def hide_event(self): + super().hide_event() + self._wifi_manager.set_active(False) + @property def _has_internet(self) -> bool: network_changing = self._wifi_ui.any_network_forgetting or self._wifi_manager.wifi_state.status == ConnectStatus.CONNECTING diff --git a/openpilot/system/ui/tici_setup.py b/openpilot/system/ui/tici_setup.py index 36f47ccf1bba2b..9cfc32091ca843 100755 --- a/openpilot/system/ui/tici_setup.py +++ b/openpilot/system/ui/tici_setup.py @@ -61,7 +61,8 @@ def __init__(self): self.download_url = "" self.download_progress = 0 self.download_thread = None - self.wifi_ui = WifiManagerUI(WifiManager()) + self.wifi_manager = WifiManager() + self.wifi_ui = WifiManagerUI(self.wifi_manager) self.keyboard = Keyboard() self.selected_radio = None self.warning = gui_app.texture("icons/warning.png", 150, 150) @@ -151,43 +152,47 @@ def _render(self, rect: rl.Rectangle): elif self.state == SetupState.DOWNLOAD_FAILED: self.render_download_failed(rect) + def set_state(self, state: SetupState): + self.state = state + self.wifi_manager.set_active(state == SetupState.NETWORK_SETUP) + def _low_voltage_continue_button_callback(self): - self.state = SetupState.GETTING_STARTED + self.set_state(SetupState.GETTING_STARTED) def _custom_software_warning_back_button_callback(self): - self.state = SetupState.SOFTWARE_SELECTION + self.set_state(SetupState.SOFTWARE_SELECTION) def _custom_software_warning_continue_button_callback(self): - self.state = SetupState.NETWORK_SETUP + self.set_state(SetupState.NETWORK_SETUP) self.stop_network_check_thread.clear() self.start_network_check() def _getting_started_button_callback(self): - self.state = SetupState.SOFTWARE_SELECTION + self.set_state(SetupState.SOFTWARE_SELECTION) def _software_selection_back_button_callback(self): - self.state = SetupState.GETTING_STARTED + self.set_state(SetupState.GETTING_STARTED) def _software_selection_continue_button_callback(self): if self._software_selection_openpilot_button.selected: - self.state = SetupState.NETWORK_SETUP + self.set_state(SetupState.NETWORK_SETUP) self.stop_network_check_thread.clear() self.start_network_check() else: - self.state = SetupState.CUSTOM_SOFTWARE_WARNING + self.set_state(SetupState.CUSTOM_SOFTWARE_WARNING) def _download_failed_startover_button_callback(self): - self.state = SetupState.GETTING_STARTED + self.set_state(SetupState.GETTING_STARTED) def _network_setup_back_button_callback(self): - self.state = SetupState.SOFTWARE_SELECTION + self.set_state(SetupState.SOFTWARE_SELECTION) def _network_setup_continue_button_callback(self): self.stop_network_check_thread.set() if self._software_selection_openpilot_button.selected: self.download(OPENPILOT_URL) else: - self.state = SetupState.CUSTOM_SOFTWARE + self.set_state(SetupState.CUSTOM_SOFTWARE) def render_low_voltage(self, rect: rl.Rectangle): rl.draw_texture_ex(self.warning, rl.Vector2(rect.x + 150, rect.y + 110), 0.0, 1.0, rl.WHITE) @@ -336,7 +341,7 @@ def handle_keyboard_result(result): # Cancel pressed elif result == DialogResult.CANCEL: - self.state = SetupState.SOFTWARE_SELECTION + self.set_state(SetupState.SOFTWARE_SELECTION) self.keyboard.reset(min_text_size=1) self.keyboard.set_title("Enter URL", "for Custom Software") @@ -351,7 +356,7 @@ def download(self, url: str): parsed = urlparse(url, scheme='https') self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl() - self.state = SetupState.DOWNLOADING + self.set_state(SetupState.DOWNLOADING) self.download_thread = threading.Thread(target=self._download_thread, daemon=True) self.download_thread.start() @@ -415,7 +420,7 @@ def _download_thread(self): def download_failed(self, url: str, reason: str): self.failed_url = url self.failed_reason = reason - self.state = SetupState.DOWNLOAD_FAILED + self.set_state(SetupState.DOWNLOAD_FAILED) def main(): diff --git a/openpilot/system/ui/tici_updater.py b/openpilot/system/ui/tici_updater.py index 27cf6579a8b322..7847c454852d90 100755 --- a/openpilot/system/ui/tici_updater.py +++ b/openpilot/system/ui/tici_updater.py @@ -43,7 +43,8 @@ def __init__(self, updater_path, manifest_path): self.show_reboot_button = False self.process = None self.update_thread = None - self.wifi_manager_ui = WifiManagerUI(WifiManager()) + self.wifi_manager = WifiManager() + self.wifi_manager_ui = WifiManagerUI(self.wifi_manager) # Buttons self._wifi_button = Button("Connect to Wi-Fi", click_callback=lambda: self.set_current_screen(Screen.WIFI)) @@ -53,6 +54,7 @@ def __init__(self, updater_path, manifest_path): def set_current_screen(self, screen: Screen): self.current_screen = screen + self.wifi_manager.set_active(screen == Screen.WIFI) def install_update(self): self.set_current_screen(Screen.PROGRESS) diff --git a/openpilot/system/ui/widgets/network.py b/openpilot/system/ui/widgets/network.py index 4068d552b9bd22..1d6430974f4d42 100644 --- a/openpilot/system/ui/widgets/network.py +++ b/openpilot/system/ui/widgets/network.py @@ -6,16 +6,16 @@ from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel -from openpilot.system.ui.lib.wifi_manager import WifiManager, SecurityType, Network, MeteredType, normalize_ssid +from openpilot.system.ui.lib.wifi_manager import WifiManager, SecurityType, Network, MeteredType +from openpilot.system.ui.lib.wpa_ctrl import normalize_ssid from openpilot.system.ui.widgets import DialogResult, Widget from openpilot.system.ui.widgets.button import ButtonStyle, Button -from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog +from openpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDialog from openpilot.system.ui.widgets.keyboard import Keyboard from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.widgets.scroller_tici import Scroller from openpilot.system.ui.widgets.list_view import ButtonAction, ListItem, MultipleButtonAction, ToggleAction, button_item, text_item -NM_DEVICE_STATE_NEED_AUTH = 60 MIN_PASSWORD_LENGTH = 8 MAX_PASSWORD_LENGTH = 64 ITEM_HEIGHT = 160 @@ -101,7 +101,11 @@ def __init__(self, wifi_manager: WifiManager): from openpilot.selfdrive.ui.lib.prime_state import PrimeType super().__init__() self._wifi_manager = wifi_manager - self._wifi_manager.add_callbacks(networks_updated=self._on_network_updated) + self._wifi_manager.add_callbacks( + networks_updated=self._on_network_updated, + activated=lambda: self._on_tethering_finished(), + disconnected=lambda: self._on_tethering_finished(), + ) self._params = Params() self._prime_state = ui_state.prime_state self._cell_prime_types = (PrimeType.NONE, PrimeType.LITE) @@ -150,11 +154,13 @@ def __init__(self, wifi_manager: WifiManager): self._scroller = Scroller(items, line_separator=True, spacing=0) - def _on_network_updated(self, networks: list[Network]): + def _on_tethering_finished(self): self._tethering_action.set_enabled(True) self._tethering_action.set_state(self._wifi_manager.is_tethering_active()) self._tethering_password_action.set_enabled(True) + self._on_network_updated(self._wifi_manager.networks) + def _on_network_updated(self, networks: list[Network]): if self._wifi_manager.is_tethering_active() or self._wifi_manager.ipv4_address == "": self._wifi_metered_action.set_enabled(False) self._wifi_metered_action.selected_button = 0 @@ -237,7 +243,10 @@ def update_password(result: DialogResult): password = self._keyboard.text self._wifi_manager.set_tethering_password(password) - self._tethering_password_action.set_enabled(False) + # Only debounce while tethering is actually bouncing. When tethering + # is off, set_tethering_password doesn't emit activated/disconnected. + if self._wifi_manager.is_tethering_active(): + self._tethering_password_action.set_enabled(False) self._keyboard.reset(min_text_size=MIN_PASSWORD_LENGTH) self._keyboard.set_title(tr("Enter new tethering password"), "") @@ -278,12 +287,12 @@ def __init__(self, wifi_manager: WifiManager): self._wifi_manager.add_callbacks(need_auth=self._on_need_auth, activated=self._on_activated, forgotten=self._on_forgotten, + forget_failed=self._on_forget_failed, networks_updated=self._on_network_updated, disconnected=self._on_disconnected) def show_event(self): super().show_event() - # start/stop scanning when widget is visible self._wifi_manager.set_active(True) def hide_event(self): @@ -389,6 +398,9 @@ def _draw_network_item(self, rect, network: Network): self._draw_signal_strength_icon(signal_icon_rect, network) def _networks_buttons_callback(self, network): + if self._wifi_manager.is_tethering_active(): + return + if not self._wifi_manager.is_connection_saved(network.ssid) and network.security_type != SecurityType.OPEN: self.state = UIState.NEEDS_AUTH self._state_network = network @@ -423,6 +435,9 @@ def _draw_signal_strength_icon(self, rect: rl.Rectangle, network: Network): rl.draw_texture_v(gui_app.texture(STRENGTH_ICONS[strength_level], ICON_SIZE, ICON_SIZE), rl.Vector2(rect.x, rect.y), rl.WHITE) def connect_to_network(self, network: Network, password=''): + if self._wifi_manager.is_tethering_active(): + return + self.state = UIState.CONNECTING self._state_network = network if self._wifi_manager.is_connection_saved(network.ssid) and not password: @@ -447,6 +462,8 @@ def _on_network_updated(self, networks: list[Network]): def _on_need_auth(self, ssid): network = next((n for n in self._networks if n.ssid == ssid), None) + if network is None and self._state_network is not None and self._state_network.ssid == ssid: + network = self._state_network if network: self.state = UIState.NEEDS_AUTH self._state_network = network @@ -460,6 +477,11 @@ def _on_forgotten(self, _): if self.state == UIState.FORGETTING: self.state = UIState.IDLE + def _on_forget_failed(self, _): + if self.state == UIState.FORGETTING: + self.state = UIState.IDLE + gui_app.push_widget(alert_dialog(tr("Failed to forget Wi-Fi network"))) + def _on_disconnected(self): if self.state == UIState.CONNECTING: self.state = UIState.IDLE diff --git a/pyproject.toml b/pyproject.toml index 78fb53ed1d7c25..c644d9cf134fdb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,6 @@ dependencies = [ "pyzmq", "sentry-sdk", "setproctitle", - "jeepney", "zstandard", # this can go once we're on Python 3.14+ # ui diff --git a/uv.lock b/uv.lock index b9af0d6adf9051..3ef5e466ff4430 100644 --- a/uv.lock +++ b/uv.lock @@ -398,15 +398,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/94/040a0d9c81f018c39bd887b7b825013b024deb0a6c795f9524797e2cd41b/inputs-0.5-py2.py3-none-any.whl", hash = "sha256:13f894564e52134cf1e3862b1811da034875eb1f2b62e6021e3776e9669a96ec", size = 33630, upload-time = "2018-10-05T22:38:28.28Z" }, ] -[[package]] -name = "jeepney" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, -] - [[package]] name = "kiwisolver" version = "1.5.0" @@ -602,7 +593,6 @@ dependencies = [ { name = "comma-deps-zeromq" }, { name = "comma-deps-zstd" }, { name = "inputs" }, - { name = "jeepney" }, { name = "numpy" }, { name = "pycapnp" }, { name = "pyjwt", extra = ["crypto"] }, @@ -663,7 +653,6 @@ requires-dist = [ { name = "comma-deps-zstd" }, { name = "coverage", marker = "extra == 'testing'" }, { name = "inputs" }, - { name = "jeepney" }, { name = "matplotlib", marker = "extra == 'tools'" }, { name = "msgq", marker = "extra == 'submodules'", editable = "msgq_repo" }, { name = "numpy", specifier = ">=2.0" }, From e9299c42a845181087c2de424cab6120730933ce Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 02:16:37 +0300 Subject: [PATCH 03/71] wifi: test profile interoperability --- .../ui/lib/tests/test_handle_state_change.py | 102 ++- .../system/ui/lib/tests/test_network_store.py | 823 ++++++++++++++++++ .../ui/lib/tests/test_standalone_wifi.py | 36 +- .../ui/lib/tests/test_wifi_manager_bringup.py | 13 +- openpilot/system/ui/lib/wifi_network_store.py | 5 +- 5 files changed, 970 insertions(+), 9 deletions(-) create mode 100644 openpilot/system/ui/lib/tests/test_network_store.py diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 47ad7f0e561edc..1301cd332aeba1 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -30,6 +30,7 @@ def build_wifi_manager() -> WifiManager: ): manager = WifiManager() + manager._store = store manager._exit = True manager._ctrl = MagicMock() manager._ipv4_forward = True @@ -548,6 +549,20 @@ def test_network_not_found_clears_connecting_state_after_reconciliation(self): self.manager._dhcp.stop.assert_called_once() disconnected.assert_called_once() + def test_network_not_found_ends_scanning_after_reconciliation_deferred(self): + self.manager._set_connecting("MissingNet") + self.manager._set_pending_connection("MissingNet", "password123", True) + self.manager._ctrl.request.return_value = "wpa_state=SCANNING\n" + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + + self.manager._reconcile_connecting_state() + self.manager._handle_event("CTRL-EVENT-NETWORK-NOT-FOUND") + self.manager._handle_event("CTRL-EVENT-NETWORK-NOT-FOUND") + self.manager._reconcile_connecting_state() + + assert self.manager.wifi_state == WifiState() + assert self.manager._pending_connection is None + def test_delayed_network_not_found_does_not_bind_to_fresh_attempt(self): self.manager._set_connecting("PreviousNet") self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 @@ -1018,7 +1033,7 @@ def test_reconcile_clears_state_when_hotspot_cleanup_fails(self): class TestLifecycle(TestCase): def test_manager_starts_inactive_until_ui_is_shown(self): with ( - patch.object(wifi_manager_module, "NetworkStore"), + patch.object(wifi_manager_module, "NetworkStore") as network_store, patch.object(wifi_manager_module, "DhcpClient"), patch.object(wifi_manager_module, "Params", None), patch.object(WifiManager, "_initialize"), @@ -1026,6 +1041,32 @@ def test_manager_starts_inactive_until_ui_is_shown(self): manager = WifiManager() assert not manager._active + assert manager._store is None + network_store.assert_not_called() + + def test_initialization_loads_network_store_in_worker(self): + manager = build_wifi_manager() + manager._store = None + manager._scan_thread = MagicMock() + manager._state_thread = MagicMock() + store = MagicMock() + store.get_tethering_password.return_value = "custom-password" + + with ( + patch.object(wifi_manager_module, "NetworkStore", return_value=store) as network_store, + patch("builtins.open", side_effect=FileNotFoundError), + patch.object(wifi_manager_module, "generate_wpa_conf"), + patch.object(manager, "_ensure_wpa_supplicant"), + patch.object(manager, "_update_networks"), + patch.object(manager, "_init_wifi_state"), + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager._initialize() + thread.call_args.kwargs["target"]() + + network_store.assert_called_once() + assert manager._store is store + assert manager.tethering_password == "custom-password" def test_initial_config_failure_recovers_without_restart(self): manager = build_wifi_manager() @@ -1095,19 +1136,33 @@ def test_failed_station_bringup_uses_scan_period_retry(self): manager._ctrl = None sleeps = [] - def sleep(duration): + def wait(duration): sleeps.append(duration) manager._exit = True with ( patch.object(wifi_manager_module, "wpa_supplicant_running", return_value=False), patch.object(manager, "_ensure_wpa_supplicant"), - patch.object(wifi_manager_module.time, "sleep", side_effect=sleep), + patch.object(manager._exit_event, "wait", side_effect=wait), ): manager._monitor_state() assert sleeps == [SCAN_PERIOD_SECONDS] + def test_monitor_exit_skips_retry_sleep(self): + manager = build_wifi_manager() + manager._exit = False + monitor = MagicMock() + monitor.recv.side_effect = lambda **_: setattr(manager, "_exit", True) + + with ( + patch.object(wifi_manager_module, "WpaCtrlMonitor", return_value=monitor), + patch.object(wifi_manager_module.time, "sleep") as sleep, + ): + manager._monitor_state() + + sleep.assert_not_called() + def test_disconnected_reconciliation_is_rate_limited(self): manager = build_wifi_manager() manager._wifi_state = WifiState() @@ -1144,6 +1199,7 @@ def test_stop_leaves_network_data_plane_running(self): manager.stop() assert manager._exit + assert manager._exit_event.is_set() assert ctrl is not None ctrl.interrupt.assert_called_once() ctrl.close.assert_called_once() @@ -1379,20 +1435,56 @@ def test_latest_password_request_wins(self): assert manager.tethering_password == "second-password" password_file.write.assert_called_once_with("second-password") + manager._store.set_tethering_password.assert_called_once_with("Hotspot", "second-password") def test_startup_falls_back_to_existing_hotspot_password(self): manager = build_wifi_manager() manager._tethering_ssid = "weedle" - manager._store.get_tethering_password.return_value = "custom-password" + store = manager._store + assert store is not None + store.get_tethering_password.return_value = "custom-password" + manager._scan_thread = MagicMock() + manager._state_thread = MagicMock() with ( + patch.object(wifi_manager_module, "NetworkStore", return_value=store), patch("builtins.open", side_effect=FileNotFoundError), - patch.object(wifi_manager_module.threading, "Thread"), + patch.object(wifi_manager_module, "generate_wpa_conf"), + patch.object(manager, "_ensure_wpa_supplicant"), + patch.object(manager, "_update_networks"), + patch.object(manager, "_init_wifi_state"), + patch.object(wifi_manager_module.threading, "Thread") as thread, ): manager._initialize() + thread.call_args.kwargs["target"]() assert manager.tethering_password == "custom-password" + def test_startup_falls_back_when_password_file_is_unreadable(self): + for error in (PermissionError("denied"), UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid")): + with self.subTest(error=type(error).__name__): + manager = build_wifi_manager() + manager._tethering_ssid = "weedle" + store = manager._store + assert store is not None + store.get_tethering_password.return_value = "custom-password" + manager._scan_thread = MagicMock() + manager._state_thread = MagicMock() + + with ( + patch.object(wifi_manager_module, "NetworkStore", return_value=store), + patch("builtins.open", side_effect=error), + patch.object(wifi_manager_module, "generate_wpa_conf"), + patch.object(manager, "_ensure_wpa_supplicant"), + patch.object(manager, "_update_networks"), + patch.object(manager, "_init_wifi_state"), + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager._initialize() + thread.call_args.kwargs["target"]() + + assert manager.tethering_password == "custom-password" + def test_persist_failure_reenables_active_tethering_controls(self): manager = build_wifi_manager() manager._tethering_active = True diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py new file mode 100644 index 00000000000000..56a4c2bec5b46f --- /dev/null +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -0,0 +1,823 @@ +import os +import shutil +import tempfile +import threading +from pathlib import Path +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from openpilot.system.ui.lib import wifi_network_store as store_module +from openpilot.system.ui.lib.wifi_network_store import NetworkStore +from openpilot.system.ui.lib.wpa_ctrl import generate_wpa_conf + + +def write_profile(directory: str, filename: str, ssid: str, *, + file_uuid: str | None = None, psk: str | None = "password123", + key_mgmt: str = "wpa-psk", mode: str = "infrastructure", + autoconnect: bool = True, autoconnect_priority: int = 0, + bssid: str | None = None, extra_wifi: str = "", + extra_security: str = "") -> str: + security = "" + if psk is not None or extra_security: + security = f""" +[wifi-security] +key-mgmt={key_mgmt} +{f"psk={psk}" if psk is not None else ""} +{extra_security} +""" + content = f"""\ +[connection] +id={ssid} +uuid={file_uuid or ssid.lower() + "-uuid"} +type=wifi +autoconnect={str(autoconnect).lower()} +autoconnect-priority={autoconnect_priority} + +[wifi] +ssid={ssid} +mode={mode} +{f"bssid={bssid}" if bssid is not None else ""} +{extra_wifi} +{security} +""" + path = os.path.join(directory, filename) + Path(path).write_text(content) + return path + + +def require_entry(store: NetworkStore, ssid: str) -> dict: + entry = store.get(ssid) + assert entry is not None + return entry + + +class TestNetworkStore(TestCase): + def setUp(self): + self.root = tempfile.mkdtemp() + self.persistent = os.path.join(self.root, "persistent") + self.runtime = os.path.join(self.root, "runtime") + self.netplan = os.path.join(self.root, "netplan") + for directory in (self.persistent, self.runtime, self.netplan): + os.mkdir(directory) + + def tearDown(self): + shutil.rmtree(self.root) + + def patch_reads(self): + return patch.object(store_module, "sudo_read", side_effect=lambda path: Path(path).read_text()) + + def make_store(self) -> NetworkStore: + return NetworkStore(self.persistent, self.runtime, self.netplan) + + def run_file_command(self, command, **_): + if command[:2] == ["sudo", "install"] and "-d" not in command: + shutil.copyfile(command[-2], command[-1]) + elif command[:3] == ["sudo", "rm", "-f"]: + Path(command[-1]).unlink(missing_ok=True) + return MagicMock(returncode=0) + + def test_loads_persistent_and_open_profiles(self): + write_profile(self.persistent, "secure.nmconnection", "Secure") + write_profile(self.persistent, "open.nmconnection", "Open", psk=None) + + with self.patch_reads(): + store = self.make_store() + + assert require_entry(store, "Secure")["psk"] == "password123" + assert require_entry(store, "Open")["psk"] == "" + + def test_loads_canonical_networkmanager_sections(self): + path = Path(write_profile(self.persistent, "canonical.nmconnection", "Canonical")) + raw = path.read_text().replace("[wifi]", "[802-11-wireless]").replace("[wifi-security]", "[802-11-wireless-security]") + path.write_text(raw) + + with self.patch_reads(): + store = self.make_store() + + assert require_entry(store, "Canonical")["psk"] == "password123" + + def test_loads_current_networkmanager_profile_defaults(self): + Path(self.persistent, "saved.nmconnection").write_text("""\ +[connection] +id=openpilot connection SavedNet +uuid=saved-uuid +type=wifi +autoconnect-retries=0 +timestamp=1775127802 + +[wifi] +mode=infrastructure +ssid=83;97;118;101;100;78;101;116; +hidden=false +mac-address-blacklist= + +[wifi-security] +auth-alg=open +key-mgmt=wpa-psk +psk=password123 + +[ipv4] +dns-priority=600 +dns-search= +method=auto + +[ipv6] +addr-gen-mode=default +dns-search= +method=ignore + +[proxy] +""") + + with self.patch_reads(): + store = self.make_store() + + assert require_entry(store, "SavedNet")["psk"] == "password123" + + def test_skips_profiles_with_invalid_psks(self): + write_profile(self.persistent, "short.nmconnection", "Short", psk="short") + write_profile(self.persistent, "nonhex.nmconnection", "NonHex", psk="x" * 64) + write_profile(self.persistent, "oversized.nmconnection", "Oversized", psk="é" * 32) + write_profile(self.persistent, "raw.nmconnection", "Raw", psk="a" * 64) + + with self.patch_reads(): + store = self.make_store() + + assert store.get("Short") is None + assert store.get("NonHex") is None + assert store.get("Oversized") is None + assert require_entry(store, "Raw")["psk"] == "a" * 64 + + def test_loads_autoconnect_priority(self): + write_profile(self.persistent, "preferred.nmconnection", "Preferred", autoconnect_priority=42) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.set_metered("Preferred", 1) + + assert require_entry(store, "Preferred")["priority"] == 42 + raw = Path(self.persistent, "preferred-uuid-Preferred.nmconnection").read_text() + assert "autoconnect-priority = 42" in raw + + def test_preserves_bssid_restriction(self): + write_profile(self.persistent, "pinned.nmconnection", "Pinned", bssid="00:11:22:33:44:55") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.set_metered("Pinned", 1) + + assert require_entry(store, "Pinned")["bssid"] == "00:11:22:33:44:55" + raw = Path(self.persistent, "pinned-uuid-Pinned.nmconnection").read_text() + assert "bssid = 00:11:22:33:44:55" in raw + + def test_loads_printable_decimal_list_ssid(self): + path = write_profile(self.persistent, "decimal.nmconnection", "placeholder") + raw = Path(path).read_text().replace("ssid=placeholder", "ssid=65;66;67;") + Path(path).write_text(raw) + + with self.patch_reads(): + store = self.make_store() + + assert require_entry(store, "ABC")["psk"] == "password123" + + def test_preserves_non_utf8_decimal_list_ssid(self): + path = write_profile(self.persistent, "binary.nmconnection", "placeholder") + raw = Path(path).read_text().replace("ssid=placeholder", "ssid=255;65;") + Path(path).write_text(raw) + ssid = b"\xffA".decode("utf-8", errors="surrogateescape") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.set_metered(ssid, 1) + config_path = os.path.join(self.root, "wpa_supplicant.conf") + generate_wpa_conf(store, config_path) + + assert require_entry(store, ssid)["psk"] == "password123" + assert "ssid=ff41" in Path(config_path).read_text() + keyfile = next(Path(self.persistent).glob("*.nmconnection")) + assert "ssid = 255;65;" in keyfile.read_text() + + def test_skips_profiles_that_cannot_be_reproduced_safely(self): + write_profile(self.persistent, "enterprise.nmconnection", "Enterprise", psk=None, key_mgmt="wpa-eap", extra_security="identity=user") + write_profile(self.persistent, "agent-secret.nmconnection", "AgentSecret", psk=None, extra_security="psk-flags=1") + write_profile(self.persistent, "wep.nmconnection", "Wep", psk=None, key_mgmt="none", extra_security="wep-key0=abcde") + write_profile(self.persistent, "disabled.nmconnection", "Disabled", autoconnect=False) + write_profile(self.persistent, "hotspot.nmconnection", "Hotspot", psk=None, mode="ap") + + with self.patch_reads(): + store = self.make_store() + + assert store.get_all() == {} + + def test_skips_non_infrastructure_profiles(self): + for mode in ("adhoc", "mesh"): + write_profile(self.persistent, f"{mode}.nmconnection", mode.title(), mode=mode) + + with self.patch_reads(): + store = self.make_store() + + assert store.get_all() == {} + + def test_skips_profile_with_unsupported_wifi_options(self): + write_profile( + self.persistent, + "randomized.nmconnection", + "Randomized", + extra_wifi="cloned-mac-address=stable", + ) + + with self.patch_reads(): + store = self.make_store() + + assert store.get("Randomized") is None + + def test_skips_profile_with_unsupported_security_constraints(self): + write_profile( + self.persistent, + "constrained.nmconnection", + "Constrained", + extra_security="pmf=3\nproto=rsn;\npairwise=ccmp;", + ) + + with self.patch_reads(): + store = self.make_store() + + assert store.get("Constrained") is None + + def test_reads_existing_tethering_password_without_importing_profile(self): + write_profile(self.persistent, "hotspot.nmconnection", "weedle", psk="custom-password", mode="ap") + + with self.patch_reads(): + store = self.make_store() + assert store.get_tethering_password("weedle") == "custom-password" + assert store.get("weedle") is None + + def test_updates_persistent_tethering_password(self): + path = Path(write_profile(self.persistent, "hotspot.nmconnection", "weedle", psk="old-password", mode="ap")) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert store.set_tethering_password("weedle", "new-password") + + assert "psk = new-password" in path.read_text() + + def test_persists_runtime_tethering_profile_for_rollback(self): + runtime_path = Path(write_profile( + self.runtime, "netplan-hotspot.nmconnection", "weedle", file_uuid="hotspot-uuid", psk="old-password", mode="ap", + )) + netplan_path = Path(self.netplan, "90-NM-hotspot-uuid.yaml") + netplan_path.write_text("network:\n version: 2\n networkmanager:\n uuid: hotspot-uuid\n") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert store.set_tethering_password("weedle", "new-password") + + persistent_path = Path(self.persistent, "hotspot-uuid-weedle.nmconnection") + assert "psk = new-password" in persistent_path.read_text() + assert not runtime_path.exists() + assert not netplan_path.exists() + + def test_runtime_profiles_remain_live_sources_despite_stale_marker(self): + runtime_path = write_profile(self.runtime, "netplan.nmconnection", "Runtime") + Path(self.persistent, ".wpa_supplicant-import-complete").write_text("complete\n") + + with self.patch_reads(), patch.object(store_module.subprocess, "run") as run: + store = self.make_store() + + assert require_entry(store, "Runtime")["psk"] == "password123" + assert os.path.exists(runtime_path) + assert os.listdir(self.persistent) == [".wpa_supplicant-import-complete"] + run.assert_not_called() + + def test_persistent_profile_wins_runtime_duplicate(self): + write_profile(self.persistent, "persistent.nmconnection", "Duplicate", psk="persistent") + write_profile(self.runtime, "runtime.nmconnection", "Duplicate", psk="runtime") + + with self.patch_reads(): + store = self.make_store() + + assert require_entry(store, "Duplicate")["psk"] == "persistent" + + def test_edit_persistent_profile_removes_shadowed_runtime_copy(self): + write_profile(self.persistent, "persistent.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="persistent") + runtime_path = Path(write_profile(self.runtime, "runtime.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="runtime")) + netplan_path = Path(self.netplan, "90-NM-shared-uuid.yaml") + netplan_path.write_text("network:\n version: 2\n") + + with ( + self.patch_reads(), + patch.object(store_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run, + ): + store = self.make_store() + assert require_entry(store, "Duplicate")["_runtime_filename"] == "runtime.nmconnection" + store.set_metered("Duplicate", 1) + + removed = [item.args[0][-1] for item in run.call_args_list + if item.args[0][:3] == ["sudo", "rm", "-f"]] + assert str(runtime_path) in removed + assert str(netplan_path) in removed + + def test_failed_noncanonical_cleanup_keeps_profile_copies_equivalent(self): + stored_path = Path(write_profile(self.persistent, "stored.nmconnection", "Stored", file_uuid="stored-uuid")) + + def run(command, **kwargs): + if command[:3] == ["sudo", "rm", "-f"] and command[-1] == str(stored_path): + return MagicMock(returncode=1) + return self.run_file_command(command, **kwargs) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run): + store = self.make_store() + store.set_metered("Stored", 1) + + canonical_path = Path(self.persistent, "stored-uuid-Stored.nmconnection") + assert canonical_path.read_text() == stored_path.read_text() + assert require_entry(store, "Stored")["_filename"] == "stored.nmconnection" + + def test_emits_multiple_profiles_with_the_same_ssid(self): + write_profile( + self.persistent, + "first.nmconnection", + "Pinned", + file_uuid="first-uuid", + bssid="00:11:22:33:44:55", + ) + write_profile( + self.persistent, + "second.nmconnection", + "Pinned", + file_uuid="second-uuid", + bssid="66:77:88:99:aa:bb", + ) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.set_metered("Pinned", 1) + config_path = os.path.join(self.root, "wpa_supplicant.conf") + generate_wpa_conf(store, config_path) + config = Path(config_path).read_text() + + assert config.count("network={") == 2 + assert "bssid=00:11:22:33:44:55" in config + assert "bssid=66:77:88:99:aa:bb" in config + + def test_metered_updates_every_profile_with_the_same_ssid(self): + write_profile(self.persistent, "first.nmconnection", "Duplicate", file_uuid="first-uuid") + write_profile(self.persistent, "second.nmconnection", "Duplicate", file_uuid="second-uuid") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.set_metered("Duplicate", 1) + + profiles = [entry for ssid, entry in store.get_profiles() if ssid == "Duplicate"] + assert len(profiles) == 2 + assert all(entry["metered"] == 1 for entry in profiles) + for file_uuid in ("first-uuid", "second-uuid"): + raw = Path(self.persistent, f"{file_uuid}-Duplicate.nmconnection").read_text() + assert "metered = 1" in raw + + def test_replacement_psk_preserves_other_profile_credentials(self): + write_profile( + self.persistent, + "first.nmconnection", + "Duplicate", + file_uuid="first-uuid", + psk="stale-password", + bssid="00:11:22:33:44:55", + ) + write_profile( + self.persistent, + "second.nmconnection", + "Duplicate", + file_uuid="second-uuid", + psk="alternate-password", + bssid="66:77:88:99:aa:bb", + ) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.save_network("Duplicate", psk="replacement-password") + + profiles = [entry for ssid, entry in store.get_profiles() if ssid == "Duplicate"] + assert len(profiles) == 2 + assert {entry["bssid"] for entry in profiles} == {"00:11:22:33:44:55", "66:77:88:99:aa:bb"} + assert {entry["bssid"]: entry["psk"] for entry in profiles} == { + "00:11:22:33:44:55": "replacement-password", + "66:77:88:99:aa:bb": "alternate-password", + } + assert "psk = replacement-password" in Path(self.persistent, "first-uuid-Duplicate.nmconnection").read_text() + assert "psk=alternate-password" in Path(self.persistent, "second.nmconnection").read_text() + + def test_replacement_psk_updates_every_unpinned_profile(self): + write_profile(self.persistent, "first.nmconnection", "Duplicate", file_uuid="first-uuid", psk="stale-password") + write_profile(self.persistent, "second.nmconnection", "Duplicate", file_uuid="second-uuid", psk="alternate-password") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.save_network("Duplicate", psk="replacement-password") + + profiles = [entry for ssid, entry in store.get_profiles() if ssid == "Duplicate"] + assert len(profiles) == 2 + assert all(entry["psk"] == "replacement-password" for entry in profiles) + for file_uuid in ("first-uuid", "second-uuid"): + raw = Path(self.persistent, f"{file_uuid}-Duplicate.nmconnection").read_text() + assert "psk = replacement-password" in raw + + def test_readers_do_not_wait_for_profile_writes(self): + write_profile(self.persistent, "saved.nmconnection", "Saved") + render_started = threading.Event() + release_render = threading.Event() + read_finished = threading.Event() + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + render = store._render_nmconnection + + def blocking_render(*args, **kwargs): + render_started.set() + assert release_render.wait(1) + return render(*args, **kwargs) + + with patch.object(store, "_render_nmconnection", side_effect=blocking_render): + writer = threading.Thread(target=store.save_network, args=("Saved",), kwargs={"psk": "replacement-password"}) + reader = threading.Thread(target=lambda: (store.contains("Saved"), read_finished.set())) + writer.start() + assert render_started.wait(1) + reader.start() + read_completed_during_write = read_finished.wait(0.1) + release_render.set() + writer.join(1) + reader.join(1) + + assert read_completed_during_write + assert not writer.is_alive() + assert not reader.is_alive() + + def test_unsupported_persistent_profile_blocks_runtime_duplicate(self): + write_profile(self.persistent, "persistent.nmconnection", "Enterprise", psk=None, key_mgmt="wpa-eap", extra_security="identity=user") + write_profile(self.runtime, "runtime.nmconnection", "Enterprise") + + with self.patch_reads(): + store = self.make_store() + + assert store.get("Enterprise") is None + + def test_persistent_profile_with_unsupported_wifi_options_blocks_runtime_duplicate(self): + write_profile(self.persistent, "persistent.nmconnection", "Randomized", extra_wifi="cloned-mac-address=stable") + write_profile(self.runtime, "runtime.nmconnection", "Randomized") + + with self.patch_reads(): + store = self.make_store() + + assert store.get("Randomized") is None + + def test_forget_runtime_profile_removes_netplan_source(self): + write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") + netplan_path = Path(self.netplan, "90-NM-runtime-uuid.yaml") + netplan_path.write_text("network:\n version: 2\n") + + with ( + self.patch_reads(), + patch.object(store_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run, + ): + store = self.make_store() + + assert store.remove("Runtime") + + removed = [args.args[0][-1] for args in run.call_args_list if args.args[0][:3] == ["sudo", "rm", "-f"]] + assert str(netplan_path) in removed + assert str(Path(self.runtime, "netplan.nmconnection")) in removed + assert store.get("Runtime") is None + + def test_edit_runtime_profile_without_netplan_source(self): + write_profile(self.runtime, "runtime.nmconnection", "Runtime", file_uuid="runtime-uuid") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.save_network("Runtime", psk="replacement-password") + + assert not Path(self.runtime, "runtime.nmconnection").exists() + assert Path(self.persistent, "runtime-uuid-Runtime.nmconnection").exists() + assert require_entry(store, "Runtime")["_netplan_filename"] is None + + def test_forget_runtime_profile_without_netplan_source(self): + write_profile(self.runtime, "runtime.nmconnection", "Runtime", file_uuid="runtime-uuid") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert store.remove("Runtime") + + assert not Path(self.runtime, "runtime.nmconnection").exists() + assert not store.contains("Runtime") + + def test_forget_finds_renamed_netplan_source_by_uuid(self): + write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") + netplan_path = Path(self.netplan, "provisioned-wifi.yaml") + netplan_path.write_text("network:\n version: 2\n networkmanager:\n uuid: runtime-uuid\n") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert store.remove("Runtime") + + assert not netplan_path.exists() + + def test_forget_ignores_unrelated_netplan_source(self): + runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) + netplan_path = Path(self.netplan, "provisioned-wifi.yaml") + netplan_path.write_text("network:\n version: 2\n wifis:\n wlan0:\n access-points:\n Runtime: {}\n") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert store.remove("Runtime") + + assert not runtime_path.exists() + assert netplan_path.exists() + + def test_forget_refuses_unreadable_netplan_source(self): + runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) + netplan_path = Path(self.netplan, "provisioned-wifi.yaml") + netplan_path.write_text("network:\n version: 2\n") + + def read(path): + return "" if path == str(netplan_path) else Path(path).read_text() + + with ( + patch.object(store_module, "sudo_read", side_effect=read), + patch.object(store_module.subprocess, "run", side_effect=self.run_file_command), + ): + store = self.make_store() + assert not store.remove("Runtime") + + assert runtime_path.exists() + assert netplan_path.exists() + + def test_forget_duplicate_runtime_profiles_removes_every_netplan_source(self): + write_profile(self.runtime, "first.nmconnection", "Duplicate", file_uuid="first-uuid") + write_profile(self.runtime, "second.nmconnection", "Duplicate", file_uuid="second-uuid") + netplan_paths = { + Path(self.netplan, "90-NM-first-uuid.yaml"), + Path(self.netplan, "90-NM-second-uuid.yaml"), + } + for path in netplan_paths: + path.write_text("network:\n version: 2\n") + + with ( + self.patch_reads(), + patch.object(store_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run, + ): + store = self.make_store() + assert store.remove("Duplicate") + + removed = {Path(item.args[0][-1]) for item in run.call_args_list + if item.args[0][:3] == ["sudo", "rm", "-f"]} + assert netplan_paths <= removed + + def test_forget_preserves_unsupported_profile_with_same_ssid(self): + unsupported = Path(write_profile( + self.persistent, + "enterprise.nmconnection", + "Duplicate", + file_uuid="enterprise-uuid", + psk=None, + key_mgmt="wpa-eap", + extra_security="identity=user", + )) + managed = Path(write_profile(self.persistent, "managed.nmconnection", "Duplicate", file_uuid="managed-uuid")) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert store.remove("Duplicate") + + assert unsupported.exists() + assert not managed.exists() + + def test_forget_keeps_profile_when_disk_removal_fails(self): + write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") + + with ( + self.patch_reads(), + patch.object(store_module.subprocess, "run", return_value=MagicMock(returncode=1)), + ): + store = self.make_store() + + assert not store.remove("Runtime") + assert store.get("Runtime") is not None + + def test_edit_runtime_profile_installs_keyfile_before_removing_netplan(self): + write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") + netplan_path = Path(self.netplan, "90-NM-runtime-uuid.yaml") + netplan_path.write_text("network:\n version: 2\n") + + with ( + self.patch_reads(), + patch.object(store_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run, + ): + store = self.make_store() + + store.save_network("Runtime", psk="replacement") + + commands = [item.args[0] for item in run.call_args_list] + install_index = next(i for i, command in enumerate(commands) + if command[:2] == ["sudo", "install"] and command[-1].endswith("runtime-uuid-Runtime.nmconnection")) + runtime_remove_index = next(i for i, command in enumerate(commands) + if command[:3] == ["sudo", "rm", "-f"] and command[-1] == str(Path(self.runtime, "netplan.nmconnection"))) + remove_index = next(i for i, command in enumerate(commands) + if command[:3] == ["sudo", "rm", "-f"] and command[-1] == str(netplan_path)) + assert install_index < runtime_remove_index < remove_index + assert require_entry(store, "Runtime")["psk"] == "replacement" + assert require_entry(store, "Runtime")["_runtime_filename"] is None + assert require_entry(store, "Runtime")["_netplan_filename"] is None + + def test_edit_runtime_profile_rolls_back_when_runtime_remove_fails(self): + runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) + netplan_path = Path(self.netplan, "90-NM-runtime-uuid.yaml") + netplan_path.write_text("network:\n version: 2\n") + + def run(command, **_): + return MagicMock(returncode=1 if command[-1] == str(runtime_path) else 0) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run) as process: + store = self.make_store() + keyfile_path = os.path.join(self.persistent, "runtime-uuid-Runtime.nmconnection") + + with self.assertRaises(OSError): + store.set_metered("Runtime", 1) + + commands = [item.args[0] for item in process.call_args_list] + assert ["sudo", "rm", "-f", keyfile_path] in commands + assert ["sudo", "rm", "-f", str(netplan_path)] not in commands + assert require_entry(store, "Runtime")["metered"] == 0 + assert require_entry(store, "Runtime")["_runtime_filename"] == "netplan.nmconnection" + + def test_runtime_cleanup_failure_preserves_existing_persistent_profile(self): + persistent_path = Path(write_profile( + self.persistent, "shared-uuid-Duplicate.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="original-password", + )) + runtime_path = Path(write_profile(self.runtime, "runtime.nmconnection", "Duplicate", file_uuid="shared-uuid")) + + def run(command, **kwargs): + if command[:3] == ["sudo", "rm", "-f"] and command[-1] == str(runtime_path): + return MagicMock(returncode=1) + return self.run_file_command(command, **kwargs) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run): + store = self.make_store() + + with self.assertRaises(OSError): + store.save_network("Duplicate", psk="replacement-password") + + assert persistent_path.exists() + + def test_runtime_cleanup_failure_preserves_noncanonical_persistent_profile(self): + persistent_path = Path(write_profile( + self.persistent, "saved.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="original-password", + )) + runtime_path = Path(write_profile(self.runtime, "runtime.nmconnection", "Duplicate", file_uuid="shared-uuid")) + + def run(command, **kwargs): + if command[:3] == ["sudo", "rm", "-f"] and command[-1] == str(runtime_path): + return MagicMock(returncode=1) + return self.run_file_command(command, **kwargs) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run): + store = self.make_store() + + with self.assertRaises(OSError): + store.set_metered("Duplicate", 1) + + assert persistent_path.exists() + + def test_edit_runtime_profile_rolls_back_when_netplan_remove_fails(self): + write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") + netplan_path = Path(self.netplan, "90-NM-runtime-uuid.yaml") + netplan_path.write_text("network:\n version: 2\n") + + def run(command, **_): + return MagicMock(returncode=1 if command[-1] == str(netplan_path) else 0) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run) as process: + store = self.make_store() + keyfile_path = os.path.join(self.persistent, "runtime-uuid-Runtime.nmconnection") + + with self.assertRaises(OSError): + store.save_network("Runtime", psk="replacement") + + commands = [item.args[0] for item in process.call_args_list] + assert ["sudo", "rm", "-f", keyfile_path] in commands + assert require_entry(store, "Runtime")["psk"] == "password123" + assert require_entry(store, "Runtime")["_netplan_filename"] == "90-NM-runtime-uuid.yaml" + + def test_accepts_only_networkmanager_dns_priority(self): + rollback_path = write_profile(self.persistent, "rollback.nmconnection", "Rollback") + custom_path = write_profile(self.persistent, "custom.nmconnection", "Custom") + for path, priority in ((rollback_path, 600), (custom_path, 100)): + with Path(path).open("a") as f: + f.write(f"""\ +[ipv4] +method=auto +dns-priority={priority} + +[ipv6] +method=auto +""") + + with self.patch_reads(): + store = self.make_store() + + assert require_entry(store, "Rollback")["_ipv4"]["dns-priority"] == "600" + assert store.get("Custom") is None + + def test_skips_profile_with_unsupported_automatic_ip_options(self): + path = write_profile(self.persistent, "static.nmconnection", "Static") + with Path(path).open("a") as f: + f.write("""\ +[ipv4] +method=auto +dns=1.1.1.1;9.9.9.9; + +[ipv6] +method=auto +addr-gen-mode=stable-privacy +""") + + with self.patch_reads(): + store = self.make_store() + + assert store.get("Static") is None + assert "dns=1.1.1.1;9.9.9.9;" in Path(path).read_text() + assert "addr-gen-mode=stable-privacy" in Path(path).read_text() + + def test_skips_profile_with_unsupported_addressing(self): + path = write_profile(self.persistent, "static.nmconnection", "Static") + with Path(path).open("a") as f: + f.write("""\ +[ipv4] +method=manual +address1=192.168.50.10/24,192.168.50.1 + +[ipv6] +method=auto +""") + + with self.patch_reads(): + store = self.make_store() + + assert store.get("Static") is None + assert "address1=192.168.50.10/24,192.168.50.1" in Path(path).read_text() + + def test_saved_profile_uses_nm_keyfile_compatible_name_and_mode(self): + with patch.object(store_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run: + store = self.make_store() + + store.save_network("Cafe/Wifi", psk="password123") + + install = next(item.args[0] for item in run.call_args_list + if item.args[0][:2] == ["sudo", "install"] and "-d" not in item.args[0]) + assert install[-1].endswith("-Cafe_Wifi.nmconnection") + assert install[install.index("-m") + 1] == "600" + + def test_new_profile_writes_rollback_dns_priority(self): + with patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.save_network("Rollback", psk="password123") + + raw = next(Path(self.persistent).glob("*.nmconnection")).read_text() + assert "dns-priority = 600" in raw + + def test_round_trips_boundary_whitespace_with_keyfile_escaping(self): + with patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + + store.save_network(" Cafe ", psk=" password123 ") + with self.patch_reads(): + reloaded = self.make_store() + + assert require_entry(reloaded, " Cafe ")["psk"] == " password123 " + raw = next(Path(self.persistent).glob("*.nmconnection")).read_text() + assert "ssid = 32;67;97;102;101;32;" in raw + assert r"psk = \spassword123\s" in raw + + def test_writes_non_ascii_ssid_as_utf8_byte_list(self): + with patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.save_network("café", psk="password123", metered=1) + + raw = next(Path(self.persistent).glob("*.nmconnection")).read_text() + assert "ssid = 99;97;102;195;169;" in raw + + def test_round_trips_decimal_list_like_literal_ssid(self): + with patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.save_network("65;66;67;", psk="password123") + with self.patch_reads(): + reloaded = self.make_store() + + assert require_entry(reloaded, "65;66;67;")["psk"] == "password123" + + def test_get_returns_copy(self): + store = self.make_store() + store._networks["Test"] = {"psk": "password123"} + + entry = require_entry(store, "Test") + entry["psk"] = "changed" + + assert require_entry(store, "Test")["psk"] == "password123" diff --git a/openpilot/system/ui/lib/tests/test_standalone_wifi.py b/openpilot/system/ui/lib/tests/test_standalone_wifi.py index 1660b9a87cb4ef..bc07a62ef029f0 100644 --- a/openpilot/system/ui/lib/tests/test_standalone_wifi.py +++ b/openpilot/system/ui/lib/tests/test_standalone_wifi.py @@ -12,6 +12,7 @@ pyray_available = False else: pyray_available = True + from openpilot.system.ui.widgets import network as network_module from openpilot.system.ui.widgets.network import UIState, WifiManagerUI finally: if previous_scale is None: @@ -25,10 +26,43 @@ class TestStandaloneWifi(TestCase): def test_forget_failure_releases_wifi_controls(self): wifi_ui = WifiManagerUI.__new__(WifiManagerUI) wifi_ui.state = UIState.FORGETTING + dialog = MagicMock() - wifi_ui._on_forget_failed("SavedNet") + with ( + patch.object(network_module, "alert_dialog", return_value=dialog), + patch.object(network_module.gui_app, "push_widget") as push_widget, + ): + wifi_ui._on_forget_failed("SavedNet") assert wifi_ui.state == UIState.IDLE + push_widget.assert_called_once_with(dialog) + + def test_mici_forget_failure_restores_button_and_opens_dialog(self): + try: + from openpilot.selfdrive.ui.mici.layouts.settings.network import wifi_ui as wifi_ui_module + from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici + except ImportError as e: + raise SkipTest("mici UI dependencies are unavailable") from e + + class WifiButton: + def __init__(self): + self.network = MagicMock(ssid="SavedNet") + self.on_forgotten = MagicMock() + + button = WifiButton() + wifi_ui = WifiUIMici.__new__(WifiUIMici) + wifi_ui._scroller = MagicMock(items=[button]) + dialog = MagicMock() + + with ( + patch.object(wifi_ui_module, "WifiButton", WifiButton), + patch.object(wifi_ui_module, "BigDialog", return_value=dialog), + patch.object(wifi_ui_module.gui_app, "push_widget") as push_widget, + ): + wifi_ui._on_forget_failed("SavedNet") + + button.on_forgotten.assert_called_once() + push_widget.assert_called_once_with(dialog) def test_mici_wrong_password_opens_password_dialog(self): try: diff --git a/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py b/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py index 029588e8392767..2b2aa2b3969a1d 100644 --- a/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py +++ b/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py @@ -14,8 +14,9 @@ def build_tethering_manager() -> WifiManager: + store = MagicMock() with ( - patch.object(wifi_manager_module, "NetworkStore", return_value=MagicMock()), + patch.object(wifi_manager_module, "NetworkStore", return_value=store), patch.object(wifi_manager_module, "DhcpClient", return_value=MagicMock()), patch.object(wifi_manager_module, "Params", None), patch.object(WifiManager, "_initialize"), @@ -23,6 +24,7 @@ def build_tethering_manager() -> WifiManager: ): manager = WifiManager() + manager._store = store manager._exit = True manager._tethering_ssid = "weedle-test" manager._tethering_psk = "hotspot-psk-1234" @@ -53,6 +55,15 @@ def tethering_side_effects(manager: WifiManager, mode: str = "AP"): class TestTetheringFirewall(TestCase): + def test_start_owns_tethering_active_state(self): + manager = build_tethering_manager() + manager._tethering_active = False + + with tethering_side_effects(manager): + manager._start_tethering() + + assert manager.is_tethering_active() + def test_selects_xtables_backend(self): for discovered, expected in (("/usr/sbin/iptables-legacy", "iptables-legacy"), (None, "iptables")): with self.subTest(discovered=discovered), patch.object(wifi_manager_module.shutil, "which", return_value=discovered): diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index c4e5023d4c2b95..a93e068a4519ea 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -11,6 +11,7 @@ from openpilot.common.swaglog import cloudlog from openpilot.common.utils import sudo_read +from openpilot.system.ui.lib.wpa_ctrl import is_valid_psk NM_CONNECTIONS_DIR = "/data/etc/NetworkManager/system-connections" @@ -253,8 +254,8 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s # NM agent-managed secrets (psk-flags=1) live outside the keyfile. We can't # drive them via wpa_supplicant, and loading with psk="" would render as # key_mgmt=NONE, silently demoting a secure profile to open and inviting spoofs. - if key_mgmt == "wpa-psk" and not psk: - cloudlog.warning(f"NetworkStore: skipping {ssid!r} (wpa-psk with no inline secret)") + if key_mgmt == "wpa-psk" and not is_valid_psk(psk): + cloudlog.warning(f"NetworkStore: skipping {ssid!r} (wpa-psk with invalid inline secret)") return # connection.autoconnect=false is user/provisioning intent. Do not load it From f76390d6e3da97bd5c39fa3e2352cb56c835dfec Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 17:57:41 +0300 Subject: [PATCH 04/71] wifi: validate imported profile UUIDs --- .../system/ui/lib/tests/test_network_store.py | 73 ++++++++++++------- openpilot/system/ui/lib/wifi_network_store.py | 18 ++++- 2 files changed, 62 insertions(+), 29 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index 56a4c2bec5b46f..5e26f594c4ea3c 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -2,6 +2,7 @@ import shutil import tempfile import threading +import uuid from pathlib import Path from unittest import TestCase from unittest.mock import MagicMock, patch @@ -11,12 +12,19 @@ from openpilot.system.ui.lib.wpa_ctrl import generate_wpa_conf +def profile_uuid(name: str) -> str: + return str(uuid.uuid5(uuid.NAMESPACE_DNS, name)) + + def write_profile(directory: str, filename: str, ssid: str, *, file_uuid: str | None = None, psk: str | None = "password123", key_mgmt: str = "wpa-psk", mode: str = "infrastructure", autoconnect: bool = True, autoconnect_priority: int = 0, bssid: str | None = None, extra_wifi: str = "", - extra_security: str = "") -> str: + extra_security: str = "", valid_uuid: bool = True) -> str: + file_uuid = file_uuid or ssid + if valid_uuid: + file_uuid = profile_uuid(file_uuid) security = "" if psk is not None or extra_security: security = f""" @@ -28,7 +36,7 @@ def write_profile(directory: str, filename: str, ssid: str, *, content = f"""\ [connection] id={ssid} -uuid={file_uuid or ssid.lower() + "-uuid"} +uuid={file_uuid} type=wifi autoconnect={str(autoconnect).lower()} autoconnect-priority={autoconnect_priority} @@ -97,10 +105,10 @@ def test_loads_canonical_networkmanager_sections(self): assert require_entry(store, "Canonical")["psk"] == "password123" def test_loads_current_networkmanager_profile_defaults(self): - Path(self.persistent, "saved.nmconnection").write_text("""\ + Path(self.persistent, "saved.nmconnection").write_text(f"""\ [connection] id=openpilot connection SavedNet -uuid=saved-uuid +uuid={profile_uuid("SavedNet")} type=wifi autoconnect-retries=0 timestamp=1775127802 @@ -134,6 +142,16 @@ def test_loads_current_networkmanager_profile_defaults(self): assert require_entry(store, "SavedNet")["psk"] == "password123" + def test_skips_profiles_with_invalid_uuids(self): + write_profile(self.persistent, "station.nmconnection", "Station", file_uuid="../../station", valid_uuid=False) + write_profile(self.runtime, "hotspot.nmconnection", "weedle", file_uuid="/tmp/hotspot", mode="ap", valid_uuid=False) + + with self.patch_reads(): + store = self.make_store() + + assert store.get("Station") is None + assert store.get_tethering_password("weedle") is None + def test_skips_profiles_with_invalid_psks(self): write_profile(self.persistent, "short.nmconnection", "Short", psk="short") write_profile(self.persistent, "nonhex.nmconnection", "NonHex", psk="x" * 64) @@ -156,7 +174,7 @@ def test_loads_autoconnect_priority(self): store.set_metered("Preferred", 1) assert require_entry(store, "Preferred")["priority"] == 42 - raw = Path(self.persistent, "preferred-uuid-Preferred.nmconnection").read_text() + raw = Path(self.persistent, f"{profile_uuid('Preferred')}-Preferred.nmconnection").read_text() assert "autoconnect-priority = 42" in raw def test_preserves_bssid_restriction(self): @@ -167,7 +185,7 @@ def test_preserves_bssid_restriction(self): store.set_metered("Pinned", 1) assert require_entry(store, "Pinned")["bssid"] == "00:11:22:33:44:55" - raw = Path(self.persistent, "pinned-uuid-Pinned.nmconnection").read_text() + raw = Path(self.persistent, f"{profile_uuid('Pinned')}-Pinned.nmconnection").read_text() assert "bssid = 00:11:22:33:44:55" in raw def test_loads_printable_decimal_list_ssid(self): @@ -265,14 +283,15 @@ def test_persists_runtime_tethering_profile_for_rollback(self): runtime_path = Path(write_profile( self.runtime, "netplan-hotspot.nmconnection", "weedle", file_uuid="hotspot-uuid", psk="old-password", mode="ap", )) - netplan_path = Path(self.netplan, "90-NM-hotspot-uuid.yaml") - netplan_path.write_text("network:\n version: 2\n networkmanager:\n uuid: hotspot-uuid\n") + hotspot_uuid = profile_uuid("hotspot-uuid") + netplan_path = Path(self.netplan, f"90-NM-{hotspot_uuid}.yaml") + netplan_path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {hotspot_uuid}\n") with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): store = self.make_store() assert store.set_tethering_password("weedle", "new-password") - persistent_path = Path(self.persistent, "hotspot-uuid-weedle.nmconnection") + persistent_path = Path(self.persistent, f"{hotspot_uuid}-weedle.nmconnection") assert "psk = new-password" in persistent_path.read_text() assert not runtime_path.exists() assert not netplan_path.exists() @@ -301,7 +320,7 @@ def test_persistent_profile_wins_runtime_duplicate(self): def test_edit_persistent_profile_removes_shadowed_runtime_copy(self): write_profile(self.persistent, "persistent.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="persistent") runtime_path = Path(write_profile(self.runtime, "runtime.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="runtime")) - netplan_path = Path(self.netplan, "90-NM-shared-uuid.yaml") + netplan_path = Path(self.netplan, f"90-NM-{profile_uuid('shared-uuid')}.yaml") netplan_path.write_text("network:\n version: 2\n") with ( @@ -329,7 +348,7 @@ def run(command, **kwargs): store = self.make_store() store.set_metered("Stored", 1) - canonical_path = Path(self.persistent, "stored-uuid-Stored.nmconnection") + canonical_path = Path(self.persistent, f"{profile_uuid('stored-uuid')}-Stored.nmconnection") assert canonical_path.read_text() == stored_path.read_text() assert require_entry(store, "Stored")["_filename"] == "stored.nmconnection" @@ -371,7 +390,7 @@ def test_metered_updates_every_profile_with_the_same_ssid(self): profiles = [entry for ssid, entry in store.get_profiles() if ssid == "Duplicate"] assert len(profiles) == 2 assert all(entry["metered"] == 1 for entry in profiles) - for file_uuid in ("first-uuid", "second-uuid"): + for file_uuid in (profile_uuid("first-uuid"), profile_uuid("second-uuid")): raw = Path(self.persistent, f"{file_uuid}-Duplicate.nmconnection").read_text() assert "metered = 1" in raw @@ -404,7 +423,7 @@ def test_replacement_psk_preserves_other_profile_credentials(self): "00:11:22:33:44:55": "replacement-password", "66:77:88:99:aa:bb": "alternate-password", } - assert "psk = replacement-password" in Path(self.persistent, "first-uuid-Duplicate.nmconnection").read_text() + assert "psk = replacement-password" in Path(self.persistent, f"{profile_uuid('first-uuid')}-Duplicate.nmconnection").read_text() assert "psk=alternate-password" in Path(self.persistent, "second.nmconnection").read_text() def test_replacement_psk_updates_every_unpinned_profile(self): @@ -418,7 +437,7 @@ def test_replacement_psk_updates_every_unpinned_profile(self): profiles = [entry for ssid, entry in store.get_profiles() if ssid == "Duplicate"] assert len(profiles) == 2 assert all(entry["psk"] == "replacement-password" for entry in profiles) - for file_uuid in ("first-uuid", "second-uuid"): + for file_uuid in (profile_uuid("first-uuid"), profile_uuid("second-uuid")): raw = Path(self.persistent, f"{file_uuid}-Duplicate.nmconnection").read_text() assert "psk = replacement-password" in raw @@ -472,7 +491,7 @@ def test_persistent_profile_with_unsupported_wifi_options_blocks_runtime_duplica def test_forget_runtime_profile_removes_netplan_source(self): write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") - netplan_path = Path(self.netplan, "90-NM-runtime-uuid.yaml") + netplan_path = Path(self.netplan, f"90-NM-{profile_uuid('runtime-uuid')}.yaml") netplan_path.write_text("network:\n version: 2\n") with ( @@ -496,7 +515,7 @@ def test_edit_runtime_profile_without_netplan_source(self): store.save_network("Runtime", psk="replacement-password") assert not Path(self.runtime, "runtime.nmconnection").exists() - assert Path(self.persistent, "runtime-uuid-Runtime.nmconnection").exists() + assert Path(self.persistent, f"{profile_uuid('runtime-uuid')}-Runtime.nmconnection").exists() assert require_entry(store, "Runtime")["_netplan_filename"] is None def test_forget_runtime_profile_without_netplan_source(self): @@ -512,7 +531,7 @@ def test_forget_runtime_profile_without_netplan_source(self): def test_forget_finds_renamed_netplan_source_by_uuid(self): write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") netplan_path = Path(self.netplan, "provisioned-wifi.yaml") - netplan_path.write_text("network:\n version: 2\n networkmanager:\n uuid: runtime-uuid\n") + netplan_path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {profile_uuid('runtime-uuid')}\n") with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): store = self.make_store() @@ -554,8 +573,8 @@ def test_forget_duplicate_runtime_profiles_removes_every_netplan_source(self): write_profile(self.runtime, "first.nmconnection", "Duplicate", file_uuid="first-uuid") write_profile(self.runtime, "second.nmconnection", "Duplicate", file_uuid="second-uuid") netplan_paths = { - Path(self.netplan, "90-NM-first-uuid.yaml"), - Path(self.netplan, "90-NM-second-uuid.yaml"), + Path(self.netplan, f"90-NM-{profile_uuid('first-uuid')}.yaml"), + Path(self.netplan, f"90-NM-{profile_uuid('second-uuid')}.yaml"), } for path in netplan_paths: path.write_text("network:\n version: 2\n") @@ -604,7 +623,7 @@ def test_forget_keeps_profile_when_disk_removal_fails(self): def test_edit_runtime_profile_installs_keyfile_before_removing_netplan(self): write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") - netplan_path = Path(self.netplan, "90-NM-runtime-uuid.yaml") + netplan_path = Path(self.netplan, f"90-NM-{profile_uuid('runtime-uuid')}.yaml") netplan_path.write_text("network:\n version: 2\n") with ( @@ -617,7 +636,7 @@ def test_edit_runtime_profile_installs_keyfile_before_removing_netplan(self): commands = [item.args[0] for item in run.call_args_list] install_index = next(i for i, command in enumerate(commands) - if command[:2] == ["sudo", "install"] and command[-1].endswith("runtime-uuid-Runtime.nmconnection")) + if command[:2] == ["sudo", "install"] and command[-1].endswith(f"{profile_uuid('runtime-uuid')}-Runtime.nmconnection")) runtime_remove_index = next(i for i, command in enumerate(commands) if command[:3] == ["sudo", "rm", "-f"] and command[-1] == str(Path(self.runtime, "netplan.nmconnection"))) remove_index = next(i for i, command in enumerate(commands) @@ -629,7 +648,7 @@ def test_edit_runtime_profile_installs_keyfile_before_removing_netplan(self): def test_edit_runtime_profile_rolls_back_when_runtime_remove_fails(self): runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) - netplan_path = Path(self.netplan, "90-NM-runtime-uuid.yaml") + netplan_path = Path(self.netplan, f"90-NM-{profile_uuid('runtime-uuid')}.yaml") netplan_path.write_text("network:\n version: 2\n") def run(command, **_): @@ -637,7 +656,7 @@ def run(command, **_): with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run) as process: store = self.make_store() - keyfile_path = os.path.join(self.persistent, "runtime-uuid-Runtime.nmconnection") + keyfile_path = os.path.join(self.persistent, f"{profile_uuid('runtime-uuid')}-Runtime.nmconnection") with self.assertRaises(OSError): store.set_metered("Runtime", 1) @@ -650,7 +669,7 @@ def run(command, **_): def test_runtime_cleanup_failure_preserves_existing_persistent_profile(self): persistent_path = Path(write_profile( - self.persistent, "shared-uuid-Duplicate.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="original-password", + self.persistent, f"{profile_uuid('shared-uuid')}-Duplicate.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="original-password", )) runtime_path = Path(write_profile(self.runtime, "runtime.nmconnection", "Duplicate", file_uuid="shared-uuid")) @@ -688,7 +707,7 @@ def run(command, **kwargs): def test_edit_runtime_profile_rolls_back_when_netplan_remove_fails(self): write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") - netplan_path = Path(self.netplan, "90-NM-runtime-uuid.yaml") + netplan_path = Path(self.netplan, f"90-NM-{profile_uuid('runtime-uuid')}.yaml") netplan_path.write_text("network:\n version: 2\n") def run(command, **_): @@ -696,7 +715,7 @@ def run(command, **_): with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run) as process: store = self.make_store() - keyfile_path = os.path.join(self.persistent, "runtime-uuid-Runtime.nmconnection") + keyfile_path = os.path.join(self.persistent, f"{profile_uuid('runtime-uuid')}-Runtime.nmconnection") with self.assertRaises(OSError): store.save_network("Runtime", psk="replacement") @@ -704,7 +723,7 @@ def run(command, **_): commands = [item.args[0] for item in process.call_args_list] assert ["sudo", "rm", "-f", keyfile_path] in commands assert require_entry(store, "Runtime")["psk"] == "password123" - assert require_entry(store, "Runtime")["_netplan_filename"] == "90-NM-runtime-uuid.yaml" + assert require_entry(store, "Runtime")["_netplan_filename"] == f"90-NM-{profile_uuid('runtime-uuid')}.yaml" def test_accepts_only_networkmanager_dns_priority(self): rollback_path = write_profile(self.persistent, "rollback.nmconnection", "Rollback") diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index a93e068a4519ea..d9e76292c6e219 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -55,6 +55,13 @@ def _canonical_filename(file_uuid: str, ssid: str) -> str: return f"{file_uuid}-{ssid_safe}.nmconnection" +def _parse_uuid(value: str) -> str | None: + try: + return str(uuid.UUID(value)) + except ValueError: + return None + + def _decode_keyfile_string(value: str) -> str: """Decode GLib keyfile string escapes.""" decoded = [] @@ -219,7 +226,11 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s if {key for key, value in cp.items("wifi") if value} - _SUPPORTED_WIFI_OPTIONS: cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported Wi-Fi options") return - file_uuid = cp.get("connection", "uuid", fallback="") + raw_uuid = cp.get("connection", "uuid", fallback="") + file_uuid = _parse_uuid(raw_uuid) + if file_uuid is None: + cloudlog.warning(f"NetworkStore: skipping {ssid!r} with invalid uuid={raw_uuid!r}") + return # Persistent /data profiles are authoritative over netplan's runtime # copies, including unsupported or disabled persistent profiles. if imported and ssid in persistent_ssids: @@ -469,7 +480,10 @@ def _tethering_profiles(self, ssid: str) -> list[tuple[configparser.ConfigParser continue if cp.get(security_section, "key-mgmt", fallback="").lower() != "wpa-psk": continue - profiles.append((cp, directory, fname, cp.get("connection", "uuid", fallback=""))) + file_uuid = _parse_uuid(cp.get("connection", "uuid", fallback="")) + if file_uuid is None: + continue + profiles.append((cp, directory, fname, file_uuid)) except (configparser.Error, OSError, ValueError): continue return profiles From c8c372e2af80222066c73a158b317a7faa65b2fd Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 17:59:08 +0300 Subject: [PATCH 05/71] wifi: enforce imported connection constraints --- .../system/ui/lib/tests/test_network_store.py | 15 +++++++++++++- openpilot/system/ui/lib/wifi_network_store.py | 20 +++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index 5e26f594c4ea3c..59a2a8920a94de 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -21,7 +21,7 @@ def write_profile(directory: str, filename: str, ssid: str, *, key_mgmt: str = "wpa-psk", mode: str = "infrastructure", autoconnect: bool = True, autoconnect_priority: int = 0, bssid: str | None = None, extra_wifi: str = "", - extra_security: str = "", valid_uuid: bool = True) -> str: + extra_security: str = "", extra_connection: str = "", valid_uuid: bool = True) -> str: file_uuid = file_uuid or ssid if valid_uuid: file_uuid = profile_uuid(file_uuid) @@ -40,6 +40,7 @@ def write_profile(directory: str, filename: str, ssid: str, *, type=wifi autoconnect={str(autoconnect).lower()} autoconnect-priority={autoconnect_priority} +{extra_connection} [wifi] ssid={ssid} @@ -152,6 +153,18 @@ def test_skips_profiles_with_invalid_uuids(self): assert store.get("Station") is None assert store.get_tethering_password("weedle") is None + def test_enforces_connection_interface_constraint(self): + write_profile(self.persistent, "wlan0.nmconnection", "Wlan0", extra_connection="interface-name=wlan0") + write_profile(self.persistent, "wlan1.nmconnection", "Wlan1", extra_connection="interface-name=wlan1") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.set_metered("Wlan0", 1) + + assert store.get("Wlan1") is None + raw = Path(self.persistent, f"{profile_uuid('Wlan0')}-Wlan0.nmconnection").read_text() + assert "interface-name = wlan0" in raw + def test_skips_profiles_with_invalid_psks(self): write_profile(self.persistent, "short.nmconnection", "Short", psk="short") write_profile(self.persistent, "nonhex.nmconnection", "NonHex", psk="x" * 64) diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index d9e76292c6e219..f5a1a12c568bb2 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -23,6 +23,9 @@ # psk="" would render as key_mgmt=NONE in wpa_supplicant.conf, silently turning # a secure profile into an open one for the same SSID and inviting open spoofing. _SUPPORTED_KEY_MGMT = {"wpa-psk", "none"} +_SUPPORTED_CONNECTION_OPTIONS = { + "id", "uuid", "type", "autoconnect", "autoconnect-priority", "autoconnect-retries", "timestamp", "metered", "interface-name", +} _SUPPORTED_WIFI_OPTIONS = {"ssid", "mode", "hidden", "bssid"} _SUPPORTED_SECURITY_OPTIONS = {"key-mgmt", "psk", "psk-flags", "auth-alg"} _SUPPORTED_IPV4_METHODS = {"auto"} @@ -231,6 +234,16 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s if file_uuid is None: cloudlog.warning(f"NetworkStore: skipping {ssid!r} with invalid uuid={raw_uuid!r}") return + connection = dict(cp["connection"]) + connection_type = connection.get("type", "wifi").lower() + interface_name = connection.get("interface-name", "") + unsupported_connection_options = {key for key, value in connection.items() if value} - _SUPPORTED_CONNECTION_OPTIONS + if (connection_type not in ("wifi", "802-11-wireless") + or interface_name not in ("", "wlan0") + or cp.getint("connection", "autoconnect-retries", fallback=0) != 0 + or unsupported_connection_options): + cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported connection constraints") + return # Persistent /data profiles are authoritative over netplan's runtime # copies, including unsupported or disabled persistent profiles. if imported and ssid in persistent_ssids: @@ -300,6 +313,7 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s "hidden": cp.getboolean("wifi", "hidden", fallback=False), "bssid": cp.get("wifi", "bssid", fallback=""), "uuid": file_uuid, + "_connection": connection, "_ipv4": ipv4, "_ipv6": ipv6, # Remember the on-disk filename so save/remove stay consistent with noncanonical files. @@ -349,13 +363,15 @@ def _render_nmconnection(self, ssid: str, entry: dict) -> tuple[str, dict]: cp = configparser.ConfigParser(interpolation=None) connection_id = ssid.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace") - cp["connection"] = { + connection = dict(entry.get("_connection", {})) + connection.update({ "id": _encode_keyfile_string(connection_id), "uuid": file_uuid, "type": "wifi", "metered": str(entry.get("metered", 0)), "autoconnect-priority": str(entry.get("priority", 0)), - } + }) + cp["connection"] = connection wifi = { "ssid": _encode_keyfile_ssid(ssid), "mode": "infrastructure", From 5b3af2a86297c07f9a622229914f4f995b5f0ecb Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 18:00:00 +0300 Subject: [PATCH 06/71] wifi: match runtime profile shadows by UUID --- openpilot/system/ui/lib/tests/test_network_store.py | 13 +++++++++++++ openpilot/system/ui/lib/wifi_network_store.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index 59a2a8920a94de..870e933984d023 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -349,6 +349,19 @@ def test_edit_persistent_profile_removes_shadowed_runtime_copy(self): assert str(runtime_path) in removed assert str(netplan_path) in removed + def test_edit_persistent_profile_preserves_runtime_profile_with_different_uuid(self): + write_profile(self.persistent, "persistent.nmconnection", "Duplicate", file_uuid="persistent-uuid", psk="persistent") + runtime_path = Path(write_profile(self.runtime, "runtime.nmconnection", "Duplicate", file_uuid="runtime-uuid", psk="runtime")) + netplan_path = Path(self.netplan, f"90-NM-{profile_uuid('runtime-uuid')}.yaml") + netplan_path.write_text("network:\n version: 2\n") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.set_metered("Duplicate", 1) + + assert runtime_path.exists() + assert netplan_path.exists() + def test_failed_noncanonical_cleanup_keeps_profile_copies_equivalent(self): stored_path = Path(write_profile(self.persistent, "stored.nmconnection", "Stored", file_uuid="stored-uuid")) diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index f5a1a12c568bb2..549ef90aa2cad7 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -248,7 +248,7 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s # copies, including unsupported or disabled persistent profiles. if imported and ssid in persistent_ssids: primary = self._networks.get(ssid) - if primary is not None and primary.get("_runtime_filename") is None: + if primary is not None and file_uuid == primary.get("uuid") and primary.get("_runtime_filename") is None: primary["_runtime_filename"] = fname primary["_netplan_filename"] = self._find_netplan_filename(file_uuid) return From 53a8c81c469248bef8cae12e29cf3fa0d443a333 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 18:35:19 +0300 Subject: [PATCH 07/71] wifi: adopt automatic fallback connections --- .../system/ui/lib/tests/test_handle_state_change.py | 11 +++++++++++ openpilot/system/ui/lib/wifi_manager.py | 11 ++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 1301cd332aeba1..14065d3fa3ff5a 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -185,6 +185,17 @@ def test_reconnect_after_disconnected_event_adopts_dhcp(self): self.manager._dhcp.stop.assert_not_called() self.manager._dhcp.start.assert_not_called() + def test_disconnected_event_allows_fallback_to_different_saved_network(self): + self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) + + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=NextNet\n" + self.manager._handle_event("CTRL-EVENT-CONNECTED") + + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTED) + self.manager._dhcp.adopt.assert_not_called() + self.manager._dhcp.start.assert_called_once() + def test_disconnected_event_cleans_station_after_timeout(self): self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) self.manager._ipv4_address = "192.168.1.20" diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 5a17d3d7fd9707..8582da7da7065d 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -138,6 +138,7 @@ def __init__(self): self._tethering_psk = DEFAULT_TETHERING_PASSWORD self._dnsmasq_proc: subprocess.Popen | None = None self._pending_connection: PendingConnection | None = None + self._requested_ssid: str | None = None self._network_not_found_epoch: int | None = None self._network_not_found_events = 0 @@ -359,10 +360,11 @@ def connected_ssid(self) -> str | None: def tethering_password(self) -> str: return self._tethering_psk - def _set_connecting(self, ssid: str | None): + def _set_connecting(self, ssid: str | None, requested: bool = True): with self._state_lock: self._dhcp_adoption_ssid = None self._user_epoch += 1 + self._requested_ssid = ssid if requested else None self._network_not_found_epoch = None self._network_not_found_events = 0 self._last_connecting_at = time.monotonic() if ssid is not None else 0.0 @@ -578,13 +580,12 @@ def _handle_connected(self, ssid: str, adopt_dhcp: bool = False, expected_epoch: with self._state_lock: if expected_epoch is not None and self._user_epoch != expected_epoch: return - if (self._wifi_state.status == ConnectStatus.CONNECTING - and self._wifi_state.ssid is not None - and self._wifi_state.ssid != ssid): + if self._requested_ssid is not None and self._requested_ssid != ssid: return already_connected = self._wifi_state == WifiState(ssid, ConnectStatus.CONNECTED) transition_epoch = self._user_epoch if not already_connected: + self._requested_ssid = None self._last_connecting_at = 0.0 self._last_scanning_recheck = 0.0 self._network_not_found_epoch = None @@ -1406,7 +1407,7 @@ def worker(): def _start_tethering(self): self._tethering_active = True - self._set_connecting(self._tethering_ssid) + self._set_connecting(self._tethering_ssid, requested=False) psk = self._tethering_psk From d703805e0ade25abc1d850ace6df7c0b76f8ad3d Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 18:36:28 +0300 Subject: [PATCH 08/71] wifi: time adopted connection attempts --- .../ui/lib/tests/test_handle_state_change.py | 19 +++++++++++++++++++ openpilot/system/ui/lib/wifi_manager.py | 2 ++ 2 files changed, 21 insertions(+) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 14065d3fa3ff5a..625eff760b5ac2 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -916,6 +916,25 @@ def test_station_dhcp_adoption(self): assert bool(manager._dhcp.adopt.call_count) == expect_adopt assert bool(manager._dhcp.start.call_count) == expect_start + def test_mid_association_adoption_starts_fresh_timeout(self): + self.manager._dhcp_adoption_ssid = "TestNet" + self.manager._last_connecting_at = 0.0 + self.manager._ctrl.request.return_value = "wpa_state=ASSOCIATING\nmode=station\nssid=TestNet\n" + + with patch.object(wifi_manager_module.time, "monotonic", return_value=100.0): + self.manager._init_wifi_state() + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + assert self.manager._last_connecting_at == 100.0 + assert self.manager._dhcp_adoption_ssid == "TestNet" + + self.manager._ctrl.request.reset_mock() + with patch.object(wifi_manager_module.time, "monotonic", return_value=104.0): + self.manager._reconcile_connecting_state() + + self.manager._ctrl.request.assert_not_called() + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + def test_hotspot_adopts_with_dhcp_and_nat(self): self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nmode=AP\nssid=Hotspot\n" diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 8582da7da7065d..9a7e787df17d33 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -300,6 +300,8 @@ def worker(): self._dhcp_adoption_ssid = None self._handle_connected(ssid, adopt_dhcp=adopt_dhcp, expected_epoch=epoch) else: + if connection_status == ConnectStatus.CONNECTING and self._last_connecting_at == 0.0: + self._last_connecting_at = time.monotonic() self._wifi_state = WifiState(ssid=ssid, status=connection_status) if block: From 753e098e526ec85b324a92565dc7556b92a740fe Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 18:37:29 +0300 Subject: [PATCH 09/71] wifi: reject malformed BSSID constraints --- openpilot/system/ui/lib/tests/test_network_store.py | 13 +++++++++++++ openpilot/system/ui/lib/wifi_network_store.py | 6 +++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index 870e933984d023..392fee13c5fef2 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -201,6 +201,19 @@ def test_preserves_bssid_restriction(self): raw = Path(self.persistent, f"{profile_uuid('Pinned')}-Pinned.nmconnection").read_text() assert "bssid = 00:11:22:33:44:55" in raw + def test_skips_profile_with_invalid_bssid(self): + write_profile(self.persistent, "valid.nmconnection", "Valid") + write_profile(self.persistent, "invalid.nmconnection", "Invalid", bssid="not-a-mac") + + with self.patch_reads(): + store = self.make_store() + + config_path = os.path.join(self.root, "wpa_supplicant.conf") + generate_wpa_conf(store, config_path) + + assert store.get("Invalid") is None + assert "ssid=56616c6964" in Path(config_path).read_text() + def test_loads_printable_decimal_list_ssid(self): path = write_profile(self.persistent, "decimal.nmconnection", "placeholder") raw = Path(path).read_text().replace("ssid=placeholder", "ssid=65;66;67;") diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index 549ef90aa2cad7..4e321255f8b8ec 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -229,6 +229,10 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s if {key for key, value in cp.items("wifi") if value} - _SUPPORTED_WIFI_OPTIONS: cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported Wi-Fi options") return + bssid = cp.get("wifi", "bssid", fallback="") + if bssid and re.fullmatch(r"(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}", bssid) is None: + cloudlog.warning(f"NetworkStore: skipping {ssid!r} with invalid bssid={bssid!r}") + return raw_uuid = cp.get("connection", "uuid", fallback="") file_uuid = _parse_uuid(raw_uuid) if file_uuid is None: @@ -311,7 +315,7 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s "metered": cp.getint("connection", "metered", fallback=0), "priority": cp.getint("connection", "autoconnect-priority", fallback=0), "hidden": cp.getboolean("wifi", "hidden", fallback=False), - "bssid": cp.get("wifi", "bssid", fallback=""), + "bssid": bssid, "uuid": file_uuid, "_connection": connection, "_ipv4": ipv4, From de474987dc4c1ee5d712abfcdc017badd5cacbb1 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 18:38:36 +0300 Subject: [PATCH 10/71] hardwared: read canonical WiFi keyfiles --- openpilot/common/hardware/comma/hardware.py | 3 ++- .../hardware/comma/tests/test_hardware.py | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 openpilot/common/hardware/comma/tests/test_hardware.py diff --git a/openpilot/common/hardware/comma/hardware.py b/openpilot/common/hardware/comma/hardware.py index effb4d9dbee598..32711a474435dd 100644 --- a/openpilot/common/hardware/comma/hardware.py +++ b/openpilot/common/hardware/comma/hardware.py @@ -225,7 +225,8 @@ def get_network_metered(self, network_type) -> bool: cp = configparser.ConfigParser(interpolation=None) try: cp.read_string(raw) - keyfile_ssid = cp.get("wifi", "ssid", fallback="") + wifi_section = "wifi" if cp.has_section("wifi") else "802-11-wireless" + keyfile_ssid = cp.get(wifi_section, "ssid", fallback="") if keyfile_ssid != ssid and keyfile_ssid != ssid_keyfile_list: continue metered = cp.getint("connection", "metered", fallback=0) diff --git a/openpilot/common/hardware/comma/tests/test_hardware.py b/openpilot/common/hardware/comma/tests/test_hardware.py new file mode 100644 index 00000000000000..6ab916bc2c7864 --- /dev/null +++ b/openpilot/common/hardware/comma/tests/test_hardware.py @@ -0,0 +1,24 @@ +from pathlib import Path +from unittest import TestCase +from unittest.mock import patch + +from openpilot.common.hardware.comma import hardware as hardware_module +from openpilot.common.hardware.comma.hardware import HardwareComma, NetworkType + + +class TestHardwareComma(TestCase): + def test_canonical_wifi_profile_metered(self): + profile = """\ +[connection] +metered=1 + +[802-11-wireless] +ssid=TestNet +""" + + with ( + patch.object(hardware_module, "wpa_supplicant_cmd", return_value={"ssid": "TestNet"}), + patch.object(Path, "glob", return_value=[Path("profile.nmconnection")]), + patch.object(hardware_module, "sudo_read", return_value=profile), + ): + assert HardwareComma().get_network_metered(NetworkType.wifi) From a5279e55beae4c1759a803e9483343c94b23115f Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 18:41:58 +0300 Subject: [PATCH 11/71] wifi: make multi-profile forget atomic --- .../system/ui/lib/tests/test_network_store.py | 44 ++++++++++++++++--- openpilot/system/ui/lib/wifi_network_store.py | 32 +++++++++++--- 2 files changed, 64 insertions(+), 12 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index 392fee13c5fef2..de7434f2fbce8f 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -81,6 +81,8 @@ def make_store(self) -> NetworkStore: def run_file_command(self, command, **_): if command[:2] == ["sudo", "install"] and "-d" not in command: shutil.copyfile(command[-2], command[-1]) + elif command[:3] == ["sudo", "mv", "-f"]: + Path(command[-2]).replace(command[-1]) elif command[:3] == ["sudo", "rm", "-f"]: Path(command[-1]).unlink(missing_ok=True) return MagicMock(returncode=0) @@ -541,9 +543,9 @@ def test_forget_runtime_profile_removes_netplan_source(self): assert store.remove("Runtime") - removed = [args.args[0][-1] for args in run.call_args_list if args.args[0][:3] == ["sudo", "rm", "-f"]] - assert str(netplan_path) in removed - assert str(Path(self.runtime, "netplan.nmconnection")) in removed + staged = [args.args[0][-2] for args in run.call_args_list if args.args[0][:3] == ["sudo", "mv", "-f"]] + assert str(netplan_path) in staged + assert str(Path(self.runtime, "netplan.nmconnection")) in staged assert store.get("Runtime") is None def test_edit_runtime_profile_without_netplan_source(self): @@ -625,9 +627,9 @@ def test_forget_duplicate_runtime_profiles_removes_every_netplan_source(self): store = self.make_store() assert store.remove("Duplicate") - removed = {Path(item.args[0][-1]) for item in run.call_args_list - if item.args[0][:3] == ["sudo", "rm", "-f"]} - assert netplan_paths <= removed + staged = {Path(item.args[0][-2]) for item in run.call_args_list + if item.args[0][:3] == ["sudo", "mv", "-f"]} + assert netplan_paths <= staged def test_forget_preserves_unsupported_profile_with_same_ssid(self): unsupported = Path(write_profile( @@ -660,6 +662,36 @@ def test_forget_keeps_profile_when_disk_removal_fails(self): assert not store.remove("Runtime") assert store.get("Runtime") is not None + def test_forget_rolls_back_earlier_removals(self): + first_path = Path(write_profile(self.persistent, "a.nmconnection", "Duplicate", file_uuid="first-uuid")) + second_path = Path(write_profile(self.persistent, "b.nmconnection", "Duplicate", file_uuid="second-uuid")) + originals = {first_path, second_path} + mutations = 0 + + def run(command, **kwargs): + nonlocal mutations + if command[:3] == ["sudo", "rm", "-f"] and Path(command[-1]) in originals: + if mutations == 1: + return MagicMock(returncode=1) + mutations += 1 + if command[:3] == ["sudo", "mv", "-f"]: + source = Path(command[-2]) + if source in originals: + if mutations == 1: + return MagicMock(returncode=1) + mutations += 1 + source.replace(command[-1]) + return MagicMock(returncode=0) + return self.run_file_command(command, **kwargs) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run): + store = self.make_store() + assert not store.remove("Duplicate") + + assert first_path.exists() + assert second_path.exists() + assert store.contains("Duplicate") + def test_edit_runtime_profile_installs_keyfile_before_removing_netplan(self): write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") netplan_path = Path(self.netplan, f"90-NM-{profile_uuid('runtime-uuid')}.yaml") diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index 4e321255f8b8ec..d20a35e1cc33be 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -624,12 +624,32 @@ def remove(self, ssid: str) -> bool: cloudlog.warning(f"NetworkStore: failed to find netplan source {p}") return False paths.update(netplan_paths) - for p in paths: - result = subprocess.run(["sudo", "rm", "-f", p], check=False) - # Keep the in-memory entry when disk removal fails. - if result.returncode != 0: - cloudlog.warning(f"NetworkStore: failed to remove {p} (rc={result.returncode})") - return False + existing_paths = sorted(p for p in paths if os.path.exists(p)) + if len(existing_paths) > 1: + token = uuid.uuid4().hex + staged_paths = [] + for p in existing_paths: + staged_path = f"{p}.openpilot-forget-{token}" + result = subprocess.run(["sudo", "mv", "-f", p, staged_path], check=False) + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to stage {p} for removal (rc={result.returncode})") + for original_path, rollback_path in reversed(staged_paths): + rollback = subprocess.run(["sudo", "mv", "-f", rollback_path, original_path], check=False) + if rollback.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to roll back {original_path} (rc={rollback.returncode})") + return False + staged_paths.append((p, staged_path)) + for _, staged_path in staged_paths: + result = subprocess.run(["sudo", "rm", "-f", staged_path], check=False) + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to clean up staged profile {staged_path} (rc={result.returncode})") + else: + for p in existing_paths: + result = subprocess.run(["sudo", "rm", "-f", p], check=False) + # Keep the in-memory entry when disk removal fails. + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to remove {p} (rc={result.returncode})") + return False with self._lock: self._networks.pop(ssid, None) self._profiles.pop(ssid, None) From 8f2191832acae86295cb921980cbffefaf57632c Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 18:43:18 +0300 Subject: [PATCH 12/71] ui: keep parent WiFi scanning active --- .../mici/layouts/settings/network/wifi_ui.py | 2 -- .../ui/lib/tests/test_standalone_wifi.py | 20 +++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py index f9994288338f9a..377060711d4381 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py @@ -300,13 +300,11 @@ def any_network_forgetting(self) -> bool: def show_event(self): # Re-sort scroller items and update from latest scan results super().show_event() - self._wifi_manager.set_active(True) self._networks = {n.ssid: n for n in self._wifi_manager.networks} self._update_buttons(re_sort=True) def hide_event(self): super().hide_event() - self._wifi_manager.set_active(False) def _on_network_updated(self, networks: list[Network]): self._networks = {network.ssid: network for network in networks} diff --git a/openpilot/system/ui/lib/tests/test_standalone_wifi.py b/openpilot/system/ui/lib/tests/test_standalone_wifi.py index bc07a62ef029f0..98b73cafe07d67 100644 --- a/openpilot/system/ui/lib/tests/test_standalone_wifi.py +++ b/openpilot/system/ui/lib/tests/test_standalone_wifi.py @@ -90,3 +90,23 @@ def __init__(self): button.set_wrong_password.assert_called_once() push_widget.assert_called_once_with(dialog) + + def test_mici_wifi_page_leaves_manager_active_for_parent(self): + try: + from openpilot.selfdrive.ui.mici.layouts.settings.network import wifi_ui as wifi_ui_module + from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici + except ImportError as e: + raise SkipTest("mici UI dependencies are unavailable") from e + + wifi_ui = WifiUIMici.__new__(WifiUIMici) + wifi_ui._wifi_manager = MagicMock(networks=[]) + wifi_ui._update_buttons = MagicMock() + + with ( + patch.object(wifi_ui_module.NavScroller, "show_event"), + patch.object(wifi_ui_module.NavScroller, "hide_event"), + ): + wifi_ui.show_event() + wifi_ui.hide_event() + + wifi_ui._wifi_manager.set_active.assert_not_called() From f81ae0a331ce0c7b396ec30ddd5308c83f4caf7c Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 20:44:09 +0300 Subject: [PATCH 13/71] wifi: clear IPv6 state across fallback networks --- .../system/ui/lib/tests/test_handle_state_change.py | 12 +++++++----- openpilot/system/ui/lib/wifi_manager.py | 13 +++++++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 625eff760b5ac2..fde3b56cbbdf83 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -194,6 +194,7 @@ def test_disconnected_event_allows_fallback_to_different_saved_network(self): assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTED) self.manager._dhcp.adopt.assert_not_called() + self.manager._dhcp.clear_ipv6_state.assert_called_once() self.manager._dhcp.start.assert_called_once() def test_disconnected_event_cleans_station_after_timeout(self): @@ -889,12 +890,12 @@ def setUp(self): def test_station_dhcp_adoption(self): cases = ( - ("connected", "TestNet", True, True, False), - ("missing-client", "TestNet", False, True, True), - ("reconnecting", "TestNet", True, True, False), - ("different-network", "PreviousNet", False, False, True), + ("connected", "TestNet", True, True, False, False), + ("missing-client", "TestNet", False, True, True, False), + ("reconnecting", "TestNet", True, True, False, False), + ("different-network", "PreviousNet", False, False, True, True), ) - for state, adoption_ssid, adoption_result, expect_adopt, expect_start in cases: + for state, adoption_ssid, adoption_result, expect_adopt, expect_start, expect_clear_ipv6 in cases: with self.subTest(state=state): manager = build_wifi_manager() manager._dhcp_adoption_ssid = adoption_ssid @@ -915,6 +916,7 @@ def test_station_dhcp_adoption(self): assert manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) assert bool(manager._dhcp.adopt.call_count) == expect_adopt assert bool(manager._dhcp.start.call_count) == expect_start + assert bool(manager._dhcp.clear_ipv6_state.call_count) == expect_clear_ipv6 def test_mid_association_adoption_starts_fresh_timeout(self): self.manager._dhcp_adoption_ssid = "TestNet" diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 9a7e787df17d33..c77974f7d3e360 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -237,6 +237,13 @@ def station_reconfigured(ssid: str): if ctrl is not None: self._ctrl = ctrl + def _consume_dhcp_adoption(self, ssid: str) -> bool: + adoption_ssid = self._dhcp_adoption_ssid + self._dhcp_adoption_ssid = None + if adoption_ssid is not None and adoption_ssid != ssid: + self._dhcp.clear_ipv6_state() + return adoption_ssid == ssid + def _request(self, cmd: str) -> str: ctrl = self._ctrl if ctrl is None: @@ -296,8 +303,7 @@ def worker(): return if connection_status == ConnectStatus.CONNECTED and ssid is not None: - adopt_dhcp = self._dhcp_adoption_ssid == ssid - self._dhcp_adoption_ssid = None + adopt_dhcp = self._consume_dhcp_adoption(ssid) self._handle_connected(ssid, adopt_dhcp=adopt_dhcp, expected_epoch=epoch) else: if connection_status == ConnectStatus.CONNECTING and self._last_connecting_at == 0.0: @@ -645,8 +651,7 @@ def _handle_event(self, event: str): ssid = status.get("ssid") if ssid: - adopt_dhcp = self._dhcp_adoption_ssid == ssid - self._dhcp_adoption_ssid = None + adopt_dhcp = self._consume_dhcp_adoption(ssid) self._handle_connected(ssid, adopt_dhcp=adopt_dhcp, expected_epoch=epoch) elif "CTRL-EVENT-DISCONNECTED" in event: From d5e5fd47ab04a64d746a436da9de32abddcbe60e Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 20:45:03 +0300 Subject: [PATCH 14/71] wifi: reject oversized saved SSIDs --- openpilot/system/ui/lib/tests/test_network_store.py | 12 ++++++++++++ openpilot/system/ui/lib/wifi_network_store.py | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index de7434f2fbce8f..384b4ed5aa2dea 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -181,6 +181,18 @@ def test_skips_profiles_with_invalid_psks(self): assert store.get("Oversized") is None assert require_entry(store, "Raw")["psk"] == "a" * 64 + def test_enforces_ssid_byte_limit(self): + valid_ssid = "é" * 16 + oversized_ssid = "é" * 17 + write_profile(self.persistent, "valid.nmconnection", valid_ssid) + write_profile(self.persistent, "oversized.nmconnection", oversized_ssid) + + with self.patch_reads(): + store = self.make_store() + + assert require_entry(store, valid_ssid)["psk"] == "password123" + assert store.get(oversized_ssid) is None + def test_loads_autoconnect_priority(self): write_profile(self.persistent, "preferred.nmconnection", "Preferred", autoconnect_priority=42) diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index d20a35e1cc33be..d85e9a14a5ac1b 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -17,6 +17,7 @@ NM_CONNECTIONS_DIR = "/data/etc/NetworkManager/system-connections" RUNTIME_CONNECTIONS_DIR = "/run/NetworkManager/system-connections" NETPLAN_CONNECTIONS_DIR = "/data/etc/netplan" +IEEE80211_MAX_SSID_BYTES = 32 # Only key-mgmt values we can actually drive via wpa_supplicant. Anything else # (wpa-eap, sae, ieee8021x, ...) gets skipped on load. Coercing those to @@ -222,7 +223,7 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s return ssid = _decode_keyfile_ssid(cp.get("wifi", "ssid", fallback="")) mode = cp.get("wifi", "mode", fallback="infrastructure") - if not ssid or mode != "infrastructure": + if not ssid or len(ssid.encode("utf-8", errors="surrogateescape")) > IEEE80211_MAX_SSID_BYTES or mode != "infrastructure": return if not imported: persistent_ssids.add(ssid) From 20fa28f06af88ad65f60fb2455aadfeb88af2f2e Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 23:02:18 +0300 Subject: [PATCH 15/71] wifi: clean stale DHCP state on startup --- openpilot/system/ui/lib/dhcp_client.py | 11 +++-------- openpilot/system/ui/lib/tests/test_dhcp_client.py | 14 +++++++++++--- .../ui/lib/tests/test_handle_state_change.py | 11 +++++++++++ openpilot/system/ui/lib/wifi_manager.py | 5 +++++ 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/openpilot/system/ui/lib/dhcp_client.py b/openpilot/system/ui/lib/dhcp_client.py index c98f3daf202eeb..3a7daba4660ece 100644 --- a/openpilot/system/ui/lib/dhcp_client.py +++ b/openpilot/system/ui/lib/dhcp_client.py @@ -96,9 +96,6 @@ def adopt(self) -> bool: def start(self): self.stop() - # Replace any unadopted client so a fresh association gets a fresh lease. - subprocess.run(["sudo", "pkill", "-f", f"^udhcpc -i {self._iface}( |$)"], check=False) - self._flush_lease() self._spawn() self._start_client_thread() @@ -107,7 +104,6 @@ def stop(self): if self._client_thread is not None: self._client_thread.join(timeout=self.DISCOVER_TIMEOUT_SECONDS) self._client_thread = None - had_client = self._proc is not None or self._adopted if self._proc is not None: try: self._proc.terminate() @@ -120,7 +116,6 @@ def stop(self): pass self._proc = None self._adopted = False - if had_client: - # Kill orphaned udhcpc children before flushing their lease state. - subprocess.run(["sudo", "pkill", "-f", f"^udhcpc -i {self._iface}( |$)"], check=False) - self._flush_lease() + # Kill orphaned udhcpc children before flushing their lease state. + subprocess.run(["sudo", "pkill", "-f", f"^udhcpc -i {self._iface}( |$)"], check=False) + self._flush_lease() diff --git a/openpilot/system/ui/lib/tests/test_dhcp_client.py b/openpilot/system/ui/lib/tests/test_dhcp_client.py index 9662d97821a038..d0f07d6c39def2 100644 --- a/openpilot/system/ui/lib/tests/test_dhcp_client.py +++ b/openpilot/system/ui/lib/tests/test_dhcp_client.py @@ -31,14 +31,12 @@ def test_adopt_existing_udhcpc_without_restarting_it(self): def test_start_detaches_udhcpc_from_ui_session(self): client = DhcpClient() with ( - patch.object(client, "stop") as stop, patch.object(dhcp_client_module.subprocess, "run") as run, patch.object(dhcp_client_module.subprocess, "Popen") as popen, patch.object(dhcp_client_module.threading, "Thread") as thread, ): client.start() - stop.assert_called_once() assert [call.args[0] for call in run.call_args_list] == [ ["sudo", "pkill", "-f", "^udhcpc -i wlan0( |$)"], ["sudo", "ip", "-4", "route", "flush", "dev", "wlan0"], @@ -82,7 +80,6 @@ def test_start_flushes_stale_lease_before_spawning_client(self): client = DhcpClient() events = [] with ( - patch.object(client, "stop"), patch.object(dhcp_client_module.subprocess, "run"), patch.object(client, "_flush_address", side_effect=lambda: events.append("flush")), patch.object(client, "_spawn", side_effect=lambda: events.append("spawn") or True), @@ -152,6 +149,17 @@ def test_stop_cleans_only_wlan_dhcp_routes_and_address(self): ["sudo", "ip", "-4", "addr", "flush", "dev", "wlan0"], ] + def test_stop_cleans_surviving_client_without_process_handle(self): + client = DhcpClient() + with patch.object(dhcp_client_module.subprocess, "run") as run: + client.stop() + + assert [call.args[0] for call in run.call_args_list] == [ + ["sudo", "pkill", "-f", "^udhcpc -i wlan0( |$)"], + ["sudo", "ip", "-4", "route", "flush", "dev", "wlan0"], + ["sudo", "ip", "-4", "addr", "flush", "dev", "wlan0"], + ] + def test_clear_ipv6_state_cleans_global_addresses_and_routes(self): client = DhcpClient() with patch.object(dhcp_client_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run: diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index fde3b56cbbdf83..ac1caf0dfd2359 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -937,6 +937,17 @@ def test_mid_association_adoption_starts_fresh_timeout(self): self.manager._ctrl.request.assert_not_called() assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + def test_disconnected_startup_cleans_station_state(self): + self.manager._dhcp_adoption_ssid = "TestNet" + self.manager._ctrl.request.return_value = "wpa_state=DISCONNECTED\nmode=station\n" + + self.manager._init_wifi_state() + + assert self.manager.wifi_state == WifiState() + assert self.manager._dhcp_adoption_ssid is None + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + def test_hotspot_adopts_with_dhcp_and_nat(self): self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nmode=AP\nssid=Hotspot\n" diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index c77974f7d3e360..ae723003ac0d3f 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -308,6 +308,11 @@ def worker(): else: if connection_status == ConnectStatus.CONNECTING and self._last_connecting_at == 0.0: self._last_connecting_at = time.monotonic() + elif connection_status == ConnectStatus.DISCONNECTED: + self._dhcp_adoption_ssid = None + self._clear_station_state() + if self._user_epoch != epoch: + return self._wifi_state = WifiState(ssid=ssid, status=connection_status) if block: From 5061a555e4a0bcf10193fa54d620f2f5581d1ac5 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 23:03:47 +0300 Subject: [PATCH 16/71] wifi: reject invalid interactive SSIDs --- .../system/ui/lib/tests/test_handle_state_change.py | 10 ++++++++++ openpilot/system/ui/lib/wifi_manager.py | 5 ++++- openpilot/system/ui/lib/wifi_network_store.py | 5 ++--- openpilot/system/ui/lib/wpa_ctrl.py | 8 ++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index ac1caf0dfd2359..e5a4ef715c3764 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -536,6 +536,16 @@ def test_connect_rejects_invalid_passphrases(self): assert manager._pending_connection is None need_auth.assert_called_once_with("TestNet") + def test_connect_rejects_oversized_hidden_ssid(self): + ssid = "é" * 17 + + with patch.object(wifi_manager_module.threading.Thread, "start") as start: + self.manager.connect_to_network(ssid, "password123", hidden=True) + + assert self.manager.wifi_state == WifiState() + assert self.manager._pending_connection is None + start.assert_not_called() + def test_network_not_found_clears_connecting_state_after_reconciliation(self): disconnected = MagicMock() self.manager.add_callbacks(disconnected=disconnected) diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index ae723003ac0d3f..24e69372a809e7 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -16,7 +16,7 @@ WPA_SUPPLICANT_CONF, WPA_AP_CONF, WPA_CTRL_INTERFACE, stop_wpa_supplicant, wpa_supplicant_running, - sanitize_for_conf, format_psk_value, format_ssid_value, is_valid_psk, + sanitize_for_conf, format_psk_value, format_ssid_value, is_valid_psk, is_valid_ssid, generate_wpa_conf, parse_event_network_id, parse_event_ssid, parse_scan_results, flags_to_security_type, parse_status, dbm_to_percent, decode_ssid, @@ -1056,6 +1056,9 @@ def connect_to_network(self, ssid: str, password: str, hidden: bool = False): if self._tethering_active: cloudlog.warning(f"Ignoring connect to {ssid!r} while tethering is active") return + if not is_valid_ssid(ssid): + cloudlog.warning(f"Ignoring connect to invalid SSID {ssid!r}") + return if password and not is_valid_psk(password): cloudlog.warning(f"Ignoring connect to {ssid!r} with invalid passphrase") self._enqueue_callbacks(self._need_auth, ssid) diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index d85e9a14a5ac1b..05abacd028e0a5 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -11,13 +11,12 @@ from openpilot.common.swaglog import cloudlog from openpilot.common.utils import sudo_read -from openpilot.system.ui.lib.wpa_ctrl import is_valid_psk +from openpilot.system.ui.lib.wpa_ctrl import is_valid_psk, is_valid_ssid NM_CONNECTIONS_DIR = "/data/etc/NetworkManager/system-connections" RUNTIME_CONNECTIONS_DIR = "/run/NetworkManager/system-connections" NETPLAN_CONNECTIONS_DIR = "/data/etc/netplan" -IEEE80211_MAX_SSID_BYTES = 32 # Only key-mgmt values we can actually drive via wpa_supplicant. Anything else # (wpa-eap, sae, ieee8021x, ...) gets skipped on load. Coercing those to @@ -223,7 +222,7 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s return ssid = _decode_keyfile_ssid(cp.get("wifi", "ssid", fallback="")) mode = cp.get("wifi", "mode", fallback="infrastructure") - if not ssid or len(ssid.encode("utf-8", errors="surrogateescape")) > IEEE80211_MAX_SSID_BYTES or mode != "infrastructure": + if not is_valid_ssid(ssid) or mode != "infrastructure": return if not imported: persistent_ssids.add(ssid) diff --git a/openpilot/system/ui/lib/wpa_ctrl.py b/openpilot/system/ui/lib/wpa_ctrl.py index 6742ca8fbf9d8d..5abeeca4cac143 100644 --- a/openpilot/system/ui/lib/wpa_ctrl.py +++ b/openpilot/system/ui/lib/wpa_ctrl.py @@ -16,6 +16,7 @@ RECV_BUF_SIZE = 32768 +IEEE80211_MAX_SSID_BYTES = 32 WPA_SUPPLICANT_CONF = "/tmp/wpa_supplicant.conf" WPA_AP_CONF = "/tmp/wpa_supplicant_ap.conf" @@ -381,6 +382,13 @@ def format_ssid_value(ssid: str) -> str: return ssid.encode("utf-8", errors="surrogateescape").hex() +def is_valid_ssid(ssid: str) -> bool: + try: + return 0 < len(ssid.encode("utf-8", errors="surrogateescape")) <= IEEE80211_MAX_SSID_BYTES + except UnicodeEncodeError: + return False + + def _is_raw_psk(psk: str) -> bool: """True if psk is a pre-hashed 64-hex WPA PSK. Quoted 64-char values fail as too-long passphrases, so raw PSKs must be passed unquoted.""" From daf65e2bdcb4dcf8156a7347b99fbec8ba59fdaa Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 23:05:30 +0300 Subject: [PATCH 17/71] wifi: restore every saved profile on activation --- .../ui/lib/tests/test_handle_state_change.py | 22 ++++++++++++++++- openpilot/system/ui/lib/wifi_manager.py | 24 +++++++++++-------- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index e5a4ef715c3764..580360781d65a9 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -280,6 +280,26 @@ def test_activate_enables_every_profile_sharing_ssid(self): call("REASSOCIATE"), ] + def test_activate_restores_every_saved_profile(self): + profiles = ( + {"psk": "first-password", "hidden": False, "priority": 1, "bssid": "00:11:22:33:44:55"}, + {"psk": "second-password", "hidden": True, "priority": 2, "bssid": "66:77:88:99:aa:bb"}, + ) + self.manager._store.get_profiles.return_value = [("Pinned", profile) for profile in profiles] + + with ( + patch.object(self.manager, "_list_network_ids", return_value=[]), + patch.object(self.manager, "_add_and_select_network", side_effect=["1", "2"]) as add_and_select_network, + patch.object(self.manager, "_select_network_ids") as select_network_ids, + ): + self.manager.activate_connection("Pinned", block=True) + + assert add_and_select_network.call_args_list == [ + call("Pinned", "first-password", False, 1, bssid="00:11:22:33:44:55"), + call("Pinned", "second-password", True, 2, bssid="66:77:88:99:aa:bb"), + ] + select_network_ids.assert_called_once_with(["1", "2"]) + def test_metered_worker_updates_requested_network_only(self): self.manager._wifi_state = WifiState("FirstNet", ConnectStatus.CONNECTED) self.manager._current_network_metered = MeteredType.NO @@ -316,7 +336,7 @@ def test_activate_restores_saved_profile_constraints(self): for ssid, profile, priority, bssid in cases: with self.subTest(ssid=ssid): manager = build_wifi_manager() - manager._store.get.return_value = profile + manager._store.get_profiles.return_value = [(ssid, profile)] with ( patch.object(manager, "_list_network_ids", return_value=[]), diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 24e69372a809e7..baddca81752563 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -1216,16 +1216,20 @@ def reset_to_disconnected(): if ids: self._select_network_ids(ids) else: - # Network not in wpa_supplicant's runtime list, so add it from the store. - entry = self._require_store().get(ssid) - if entry: - self._add_and_select_network( - ssid, - entry.get("psk", ""), - entry.get("hidden", False), - entry.get("priority", 0), - bssid=entry.get("bssid") or None, - ) + profiles = [entry for profile_ssid, entry in self._require_store().get_profiles() if profile_ssid == ssid] + if profiles: + ids = [ + self._add_and_select_network( + ssid, + entry.get("psk", ""), + entry.get("hidden", False), + entry.get("priority", 0), + bssid=entry.get("bssid") or None, + ) + for entry in profiles + ] + if len(ids) > 1: + self._select_network_ids(ids) else: cloudlog.warning(f"Network {ssid} not found for activation") reset_to_disconnected() From cb9e520df986f219af108eb7768f69302947e7c8 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 23:07:36 +0300 Subject: [PATCH 18/71] hardwared: decode keyfile SSIDs for metering --- openpilot/common/hardware/comma/hardware.py | 8 +-- .../hardware/comma/tests/test_hardware.py | 16 ++++++ openpilot/common/nm_keyfile.py | 41 ++++++++++++++ openpilot/system/ui/lib/wifi_network_store.py | 54 ++----------------- 4 files changed, 66 insertions(+), 53 deletions(-) create mode 100644 openpilot/common/nm_keyfile.py diff --git a/openpilot/common/hardware/comma/hardware.py b/openpilot/common/hardware/comma/hardware.py index 32711a474435dd..2e260c9fdfc5e7 100644 --- a/openpilot/common/hardware/comma/hardware.py +++ b/openpilot/common/hardware/comma/hardware.py @@ -8,6 +8,7 @@ from pathlib import Path from openpilot.cereal import log +from openpilot.common.nm_keyfile import decode_nm_keyfile_ssid from openpilot.common.utils import sudo_read, sudo_write from openpilot.common.gpio import gpio_set, gpio_init, get_irqs_for_action from openpilot.common.esim.base import LPABase @@ -213,9 +214,8 @@ def get_network_metered(self, network_type) -> bool: if network_type == NetworkType.wifi: ssid = wpa_supplicant_cmd("STATUS").get("ssid", "") if ssid: - # wpa_supplicant escapes non-printable bytes as \xNN; NM keyfile stores ASCII SSIDs as a literal and others as a byte;byte; list + # wpa_supplicant escapes non-printable bytes as \xNN. ssid_bytes = ssid.encode().decode('unicode_escape').encode('latin-1') - ssid_keyfile_list = ';'.join(str(b) for b in ssid_bytes) + ';' nm_dirs = ("/run/NetworkManager/system-connections", "/data/etc/NetworkManager/system-connections") for fpath in (p for d in nm_dirs for p in Path(d).glob("*.nmconnection")): @@ -226,8 +226,8 @@ def get_network_metered(self, network_type) -> bool: try: cp.read_string(raw) wifi_section = "wifi" if cp.has_section("wifi") else "802-11-wireless" - keyfile_ssid = cp.get(wifi_section, "ssid", fallback="") - if keyfile_ssid != ssid and keyfile_ssid != ssid_keyfile_list: + keyfile_ssid = decode_nm_keyfile_ssid(cp.get(wifi_section, "ssid", fallback="")) + if keyfile_ssid.encode("utf-8", errors="surrogateescape") != ssid_bytes: continue metered = cp.getint("connection", "metered", fallback=0) except (configparser.Error, ValueError): diff --git a/openpilot/common/hardware/comma/tests/test_hardware.py b/openpilot/common/hardware/comma/tests/test_hardware.py index 6ab916bc2c7864..dda62ae9aa756f 100644 --- a/openpilot/common/hardware/comma/tests/test_hardware.py +++ b/openpilot/common/hardware/comma/tests/test_hardware.py @@ -22,3 +22,19 @@ def test_canonical_wifi_profile_metered(self): patch.object(hardware_module, "sudo_read", return_value=profile), ): assert HardwareComma().get_network_metered(NetworkType.wifi) + + def test_escaped_wifi_profile_metered(self): + profile = """\ +[connection] +metered=1 + +[wifi] +ssid=\\sGuest\\s +""" + + with ( + patch.object(hardware_module, "wpa_supplicant_cmd", return_value={"ssid": " Guest "}), + patch.object(Path, "glob", return_value=[Path("profile.nmconnection")]), + patch.object(hardware_module, "sudo_read", return_value=profile), + ): + assert HardwareComma().get_network_metered(NetworkType.wifi) diff --git a/openpilot/common/nm_keyfile.py b/openpilot/common/nm_keyfile.py new file mode 100644 index 00000000000000..3210e2b9dc079f --- /dev/null +++ b/openpilot/common/nm_keyfile.py @@ -0,0 +1,41 @@ +NM_KEYFILE_ESCAPES = { + "\\": "\\", + "n": "\n", + "r": "\r", + "s": " ", + "t": "\t", +} + + +def decode_nm_keyfile_string(value: str) -> str: + decoded = [] + i = 0 + while i < len(value): + if value[i] == "\\" and i + 1 < len(value): + escaped = NM_KEYFILE_ESCAPES.get(value[i + 1]) + if escaped is not None: + decoded.append(escaped) + i += 2 + continue + decoded.append(value[i]) + i += 1 + return "".join(decoded) + + +def decode_nm_keyfile_ssid(ssid: str) -> str: + ssid = decode_nm_keyfile_string(ssid) + if r"\;" in ssid: + return ssid.replace(r"\;", ";") + if not ssid.endswith(";"): + return ssid + + try: + ssid_bytes = bytes(int(p) for p in ssid[:-1].split(";")) + except ValueError: + return ssid + + if not ssid_bytes: + return ssid + if all(b == 0 for b in ssid_bytes): + return "" + return ssid_bytes.decode("utf-8", errors="surrogateescape") diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index 05abacd028e0a5..e9e3249b9a91a8 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -10,6 +10,7 @@ from enum import IntEnum from openpilot.common.swaglog import cloudlog +from openpilot.common.nm_keyfile import decode_nm_keyfile_ssid, decode_nm_keyfile_string from openpilot.common.utils import sudo_read from openpilot.system.ui.lib.wpa_ctrl import is_valid_psk, is_valid_ssid @@ -35,15 +36,6 @@ # NetworkManager-backed openpilot profiles use this DNS priority. The direct # stack does not consume it, but retaining it keeps the keyfile rollback-safe. _OPENPILOT_DNS_PRIORITY = "600" -_KEYFILE_ESCAPES = { - "\\": "\\", - "n": "\n", - "r": "\r", - "s": " ", - "t": "\t", -} - - class MeteredType(IntEnum): UNKNOWN = 0 YES = 1 @@ -65,22 +57,6 @@ def _parse_uuid(value: str) -> str | None: return None -def _decode_keyfile_string(value: str) -> str: - """Decode GLib keyfile string escapes.""" - decoded = [] - i = 0 - while i < len(value): - if value[i] == "\\" and i + 1 < len(value): - escaped = _KEYFILE_ESCAPES.get(value[i + 1]) - if escaped is not None: - decoded.append(escaped) - i += 2 - continue - decoded.append(value[i]) - i += 1 - return "".join(decoded) - - def _encode_keyfile_string(value: str) -> str: """Encode GLib keyfile string escapes, including boundary spaces.""" leading_spaces = len(value) - len(value.lstrip(" ")) @@ -108,26 +84,6 @@ def _encode_keyfile_ssid(ssid: str) -> str: return ";".join(str(b) for b in ssid.encode("utf-8", errors="surrogateescape")) + ";" -def _decode_keyfile_ssid(ssid: str) -> str: - """Decode NM keyfile byte-list SSIDs and escaped literal semicolons.""" - ssid = _decode_keyfile_string(ssid) - if r"\;" in ssid: - return ssid.replace(r"\;", ";") - if not ssid.endswith(";"): - return ssid - - try: - ssid_bytes = bytes(int(p) for p in ssid[:-1].split(";")) - except ValueError: - return ssid - - if not ssid_bytes: - return ssid - if all(b == 0 for b in ssid_bytes): - return "" - return ssid_bytes.decode("utf-8", errors="surrogateescape") - - def _normalize_keyfile_sections(cp: configparser.ConfigParser): for alias, canonical in ( ("wifi", "802-11-wireless"), @@ -220,7 +176,7 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s _normalize_keyfile_sections(cp) if not cp.has_section("wifi"): return - ssid = _decode_keyfile_ssid(cp.get("wifi", "ssid", fallback="")) + ssid = decode_nm_keyfile_ssid(cp.get("wifi", "ssid", fallback="")) mode = cp.get("wifi", "mode", fallback="infrastructure") if not is_valid_ssid(ssid) or mode != "infrastructure": return @@ -278,7 +234,7 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s if unsupported_security_options or auth_alg not in ("", "open") or psk_flags != 0: cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported security constraints") return - psk = _decode_keyfile_string(cp.get("wifi-security", "psk", fallback="")) + psk = decode_nm_keyfile_string(cp.get("wifi-security", "psk", fallback="")) # NM agent-managed secrets (psk-flags=1) live outside the keyfile. We can't # drive them via wpa_supplicant, and loading with psk="" would render as # key_mgmt=NONE, silently demoting a secure profile to open and inviting spoofs. @@ -466,7 +422,7 @@ def get_tethering_password(self, ssid: str) -> str | None: for cp, _, _, _ in self._tethering_profiles(ssid): security_section = _keyfile_section(cp, "wifi-security", "802-11-wireless-security") assert security_section is not None - password = _decode_keyfile_string(cp.get(security_section, "psk", fallback="")) + password = decode_nm_keyfile_string(cp.get(security_section, "psk", fallback="")) if password: return password return None @@ -495,7 +451,7 @@ def _tethering_profiles(self, ssid: str) -> list[tuple[configparser.ConfigParser security_section = _keyfile_section(cp, "wifi-security", "802-11-wireless-security") if wifi_section is None or security_section is None: continue - profile_ssid = _decode_keyfile_ssid(cp.get(wifi_section, "ssid", fallback="")) + profile_ssid = decode_nm_keyfile_ssid(cp.get(wifi_section, "ssid", fallback="")) if cp.get(wifi_section, "mode", fallback="infrastructure") != "ap" or profile_ssid != ssid: continue if cp.get(security_section, "key-mgmt", fallback="").lower() != "wpa-psk": From d1d99116331e2b476f7584e4a475e920dda39fc6 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 23:09:58 +0300 Subject: [PATCH 19/71] wifi: restore keyfiles after aborted updates --- .../system/ui/lib/tests/test_network_store.py | 3 ++- openpilot/system/ui/lib/wifi_network_store.py | 27 ++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index 384b4ed5aa2dea..f778ee71e65845 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -754,6 +754,7 @@ def test_runtime_cleanup_failure_preserves_existing_persistent_profile(self): persistent_path = Path(write_profile( self.persistent, f"{profile_uuid('shared-uuid')}-Duplicate.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="original-password", )) + original = persistent_path.read_text() runtime_path = Path(write_profile(self.runtime, "runtime.nmconnection", "Duplicate", file_uuid="shared-uuid")) def run(command, **kwargs): @@ -767,7 +768,7 @@ def run(command, **kwargs): with self.assertRaises(OSError): store.save_network("Duplicate", psk="replacement-password") - assert persistent_path.exists() + assert persistent_path.read_text() == original def test_runtime_cleanup_failure_preserves_noncanonical_persistent_profile(self): persistent_path = Path(write_profile( diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index e9e3249b9a91a8..927ead8b7a99cd 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -353,13 +353,27 @@ def _render_nmconnection(self, ssid: str, entry: dict) -> tuple[str, dict]: cp["ipv4"] = ipv4 cp["ipv6"] = entry.get("_ipv6", {"method": "auto"}) - self._install_keyfile(cp, canonical_path) + backup_path = None + if canonical_existed: + backup_path = f"{canonical_path}.openpilot-update-{uuid.uuid4().hex}" + result = subprocess.run([ + "sudo", "install", "-o", "root", "-g", "root", "-m", "600", canonical_path, backup_path, + ], check=False) + if result.returncode != 0: + raise OSError(f"failed to back up {canonical_path}") def cleanup_canonical_after_failure() -> bool: - if canonical_existed: - return True + if backup_path is not None: + return subprocess.run(["sudo", "mv", "-f", backup_path, canonical_path], check=False).returncode == 0 return subprocess.run(["sudo", "rm", "-f", canonical_path], check=False).returncode == 0 + try: + self._install_keyfile(cp, canonical_path) + except Exception as e: + if not cleanup_canonical_after_failure(): + raise OSError(f"failed to install and roll back {canonical_path}") from e + raise + runtime_filename = entry.get("_runtime_filename") if self._runtime_directory is not None and runtime_filename: runtime_path = os.path.join(self._runtime_directory, runtime_filename) @@ -399,6 +413,13 @@ def cleanup_canonical_after_failure() -> bool: cloudlog.exception("NetworkStore: failed to mirror keyfile to noncanonical path") entry["_filename"] = stored_fname + if backup_path is not None: + result = subprocess.run(["sudo", "rm", "-f", backup_path], check=False) + if result.returncode != 0: + if not cleanup_canonical_after_failure(): + raise OSError(f"failed to clean up {backup_path} and roll back {canonical_path}") + raise OSError(f"failed to clean up {backup_path}") + return file_uuid, entry def get_all(self) -> dict[str, dict]: From 3b471f707244941b20e0b11a0a3f53acfa8609b6 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 23:56:39 +0300 Subject: [PATCH 20/71] wifi: preserve newer connections during reconciliation --- .../ui/lib/tests/test_handle_state_change.py | 29 +++++++++++++++++++ openpilot/system/ui/lib/wifi_manager.py | 14 ++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 580360781d65a9..083af2596d837a 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -633,6 +633,35 @@ def test_reconcile_keeps_saved_runtime_network_after_transient_failure(self): assert call("ENABLE_NETWORK all") in self.manager._ctrl.request.call_args_list assert self.manager.wifi_state == WifiState() + def test_reconcile_rechecks_epoch_before_cancelling_stale_connection(self): + self.manager._set_connecting("PreviousNet") + self.manager._set_pending_connection("PreviousNet", "password123", False) + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + self.manager._ctrl.request.return_value = "wpa_state=DISCONNECTED\n" + contains_started = threading.Event() + release_contains = threading.Event() + + def contains(_ssid): + contains_started.set() + assert release_contains.wait(1) + return False + + self.manager._store.contains.side_effect = contains + worker = threading.Thread(target=self.manager._reconcile_connecting_state) + worker.start() + assert contains_started.wait(1) + + with patch.object(wifi_manager_module.threading.Thread, "start"): + self.manager.connect_to_network("NextNet", "next-password") + release_contains.set() + worker.join(1) + + assert not worker.is_alive() + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + assert self.manager._pending_connection is not None + assert self.manager._pending_connection.ssid == "NextNet" + self.manager._dhcp.stop.assert_not_called() + def test_reconcile_times_out_stalled_handshake(self): for wpa_state in ("AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): with self.subTest(wpa_state=wpa_state): diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index baddca81752563..9beff9b1a0dc1e 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -933,15 +933,17 @@ def _reconcile_connecting_state(self): self._last_scanning_recheck = time.monotonic() elif wpa_state in ("DISCONNECTED", "INACTIVE", "SCANNING", "AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): - ssid = current_state.ssid - pending = self._pending_connection + with self._state_lock: + if self._user_epoch != epoch: + return + ssid = current_state.ssid + pending = self._pending_connection temporary_ssid = ssid if ( pending is not None and ssid is not None and pending.ssid == ssid and not self._require_store().contains(ssid) ) else None - self._clear_pending_connection(ssid) # Drop the unsaved runtime network so ENABLE_NETWORK all doesn't re-arm # the failed credential for another retry. try: @@ -950,7 +952,11 @@ def _reconcile_connecting_state(self): self._request("ENABLE_NETWORK all") except Exception: cloudlog.exception("Failed to re-enable saved networks after stale CONNECTING") - self._set_connecting(None) + with self._state_lock: + if self._user_epoch != epoch: + return + self._clear_pending_connection(ssid) + self._set_connecting(None) self._dhcp.stop() self._ipv4_address = "" self._current_network_metered = MeteredType.UNKNOWN From 31cf5218b2e30edbd46a659f457dc32fe03c6aa5 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sun, 2 Aug 2026 23:57:19 +0300 Subject: [PATCH 21/71] wifi: accept mixed-mode networks with PSK --- openpilot/system/ui/lib/tests/test_wpa_ctrl.py | 1 + openpilot/system/ui/lib/wpa_ctrl.py | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_wpa_ctrl.py b/openpilot/system/ui/lib/tests/test_wpa_ctrl.py index 9eb5d5a07b2c12..63a891044b2afe 100644 --- a/openpilot/system/ui/lib/tests/test_wpa_ctrl.py +++ b/openpilot/system/ui/lib/tests/test_wpa_ctrl.py @@ -80,6 +80,7 @@ def test_security_types(self): ("[WPA2-PSK-CCMP][SAE]", SecurityType.WPA), ("[RSN-PSK-CCMP][SAE-CCMP]", SecurityType.WPA), ("[WPA2-PSK+SAE-CCMP][ESS]", SecurityType.WPA), + ("[WPA2-PSK+EAP-CCMP][ESS]", SecurityType.WPA), ("[WPA2-PSK-SHA256+SAE-CCMP][ESS]", SecurityType.UNSUPPORTED), ("[WPA2-PSK-SHA256-CCMP][ESS]", SecurityType.UNSUPPORTED), ("[WPA-PSK-SHA256-TKIP][ESS]", SecurityType.UNSUPPORTED), diff --git a/openpilot/system/ui/lib/wpa_ctrl.py b/openpilot/system/ui/lib/wpa_ctrl.py index 5abeeca4cac143..aee259d65a6a51 100644 --- a/openpilot/system/ui/lib/wpa_ctrl.py +++ b/openpilot/system/ui/lib/wpa_ctrl.py @@ -281,9 +281,7 @@ def flags_to_security_type(flags: str) -> SecurityType: flags_upper = flags.upper() flag_groups = re.findall(r"\[([^\]]+)\]", flags_upper) - # Enterprise / 802.1X / WEP → unsupported - if "EAP" in flags_upper or "802.1X" in flags_upper: - return SecurityType.UNSUPPORTED + # WEP → unsupported if "WEP" in flags_upper: return SecurityType.UNSUPPORTED @@ -291,6 +289,9 @@ def flags_to_security_type(flags: str) -> SecurityType: # and connects via WPA-PSK. Pure WPA3-Personal (SAE-only) falls through below. if any(re.search(r"(?:^|\+)(?:(?:WPA2|RSN|WPA)-)?PSK(?!-SHA256)(?:[-+]|$)", group) for group in flag_groups): return SecurityType.WPA + # Enterprise / 802.1X without a usable PSK suite → unsupported + if "EAP" in flags_upper or "802.1X" in flags_upper: + return SecurityType.UNSUPPORTED # SAE-only: would need key_mgmt=SAE, which the current AGNOS kernel + wpa_supplicant # build doesn't support. Mark unsupported so the UI doesn't prompt for a password # only to fail the handshake. Becomes connectable on vamOS + mainline kernel. From 19b3480e668259eddeea85ef3844d088a2c0281f Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Mon, 3 Aug 2026 00:00:15 +0300 Subject: [PATCH 22/71] wifi: roll back multi-profile updates atomically --- .../system/ui/lib/tests/test_network_store.py | 20 +++ openpilot/system/ui/lib/wifi_network_store.py | 124 +++++++++++++----- 2 files changed, 113 insertions(+), 31 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index f778ee71e65845..4e183a44e762b2 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -770,6 +770,26 @@ def run(command, **kwargs): assert persistent_path.read_text() == original + def test_multi_profile_password_update_rolls_back_earlier_profiles(self): + first_path = Path(write_profile(self.persistent, "a.nmconnection", "Duplicate", file_uuid="first-uuid", psk="first-password")) + second_path = Path(write_profile(self.persistent, "b.nmconnection", "Duplicate", file_uuid="second-uuid", psk="second-password")) + originals = {path: path.read_text() for path in (first_path, second_path)} + second_canonical = Path(self.persistent, f"{profile_uuid('second-uuid')}-Duplicate.nmconnection") + + def run(command, **kwargs): + if command[:2] == ["sudo", "install"] and command[-1] == str(second_canonical): + raise OSError("write failed") + return self.run_file_command(command, **kwargs) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run): + store = self.make_store() + + with self.assertRaises(OSError): + store.save_network("Duplicate", psk="replacement-password") + + assert {path: path.read_text() for path in (first_path, second_path)} == originals + assert {entry["psk"] for ssid, entry in store.get_profiles() if ssid == "Duplicate"} == {"first-password", "second-password"} + def test_runtime_cleanup_failure_preserves_noncanonical_persistent_profile(self): persistent_path = Path(write_profile( self.persistent, "saved.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="original-password", diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index 927ead8b7a99cd..cd71afd879294b 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -7,6 +7,8 @@ import tempfile import threading import uuid +from collections.abc import Iterator +from contextlib import contextmanager from enum import IntEnum from openpilot.common.swaglog import cloudlog @@ -304,6 +306,64 @@ def _install_keyfile(self, cp: configparser.ConfigParser, path: str): except FileNotFoundError: pass + @contextmanager + def _profile_update(self, ssid: str, profiles: list[dict]) -> Iterator[None]: + if len(profiles) < 2: + yield + return + + paths: set[str] = set() + for profile in profiles: + file_uuid = profile.get("uuid") + if file_uuid: + paths.add(os.path.join(self._directory, _canonical_filename(file_uuid, ssid))) + filename = profile.get("_filename") + if filename: + paths.add(os.path.join(self._directory, filename)) + runtime_filename = profile.get("_runtime_filename") + if self._runtime_directory is not None and runtime_filename: + paths.add(os.path.join(self._runtime_directory, runtime_filename)) + netplan_filename = profile.get("_netplan_filename") + if self._netplan_directory is not None and netplan_filename: + paths.add(os.path.join(self._netplan_directory, netplan_filename)) + + original_paths = {path for path in paths if os.path.exists(path)} + token = uuid.uuid4().hex + backups: dict[str, str] = {} + try: + for path in sorted(original_paths): + backup_path = f"{path}.openpilot-update-{token}" + result = subprocess.run([ + "sudo", "install", "-o", "root", "-g", "root", "-m", "600", path, backup_path, + ], check=False) + if result.returncode != 0: + raise OSError(f"failed to back up {path}") + backups[path] = backup_path + except Exception as e: + cleanup_failed = False + for backup_path in backups.values(): + cleanup_failed |= subprocess.run(["sudo", "rm", "-f", backup_path], check=False).returncode != 0 + if cleanup_failed: + raise OSError(f"failed to clean up profile backups for {ssid}") from e + raise + + try: + yield + except Exception as e: + rollback_failed = False + for path in sorted(paths - original_paths): + rollback_failed |= subprocess.run(["sudo", "rm", "-f", path], check=False).returncode != 0 + for path, backup_path in backups.items(): + rollback_failed |= subprocess.run(["sudo", "mv", "-f", backup_path, path], check=False).returncode != 0 + if rollback_failed: + raise OSError(f"failed to roll back profile update for {ssid}") from e + raise + else: + for backup_path in backups.values(): + result = subprocess.run(["sudo", "rm", "-f", backup_path], check=False) + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to clean up profile backup {backup_path} (rc={result.returncode})") + def _render_nmconnection(self, ssid: str, entry: dict) -> tuple[str, dict]: file_uuid = entry.get("uuid") if not file_uuid: @@ -548,28 +608,29 @@ def save_network(self, ssid: str, psk: str | None = None, metered: int | None = elif "hidden" not in existing: existing["hidden"] = False - file_uuid, updated = self._render_nmconnection(ssid, existing) - updated["uuid"] = file_uuid - if current is None: - profiles.append(updated) - else: - updated_profiles = [] - replaced_primary = False - for profile in profiles: - if profile is current: + with self._profile_update(ssid, profiles): + file_uuid, updated = self._render_nmconnection(ssid, existing) + updated["uuid"] = file_uuid + if current is None: + profiles.append(updated) + else: + updated_profiles = [] + replaced_primary = False + for profile in profiles: + if profile is current: + updated_profiles.append(updated) + replaced_primary = True + elif psk is not None and not profile.get("bssid"): + duplicate = dict(profile) + duplicate["psk"] = psk + duplicate_uuid, duplicate = self._render_nmconnection(ssid, duplicate) + duplicate["uuid"] = duplicate_uuid + updated_profiles.append(duplicate) + else: + updated_profiles.append(profile) + if not replaced_primary: updated_profiles.append(updated) - replaced_primary = True - elif psk is not None and not profile.get("bssid"): - duplicate = dict(profile) - duplicate["psk"] = psk - duplicate_uuid, duplicate = self._render_nmconnection(ssid, duplicate) - duplicate["uuid"] = duplicate_uuid - updated_profiles.append(duplicate) - else: - updated_profiles.append(profile) - if not replaced_primary: - updated_profiles.append(updated) - profiles = updated_profiles + profiles = updated_profiles with self._lock: self._profiles[ssid] = profiles self._networks[ssid] = updated @@ -639,16 +700,17 @@ def set_metered(self, ssid: str, metered: int): if not profiles: return primary = self._networks.get(ssid) - updated_profiles = [] - updated_primary = None - for current in profiles: - updated = dict(current) - updated["metered"] = metered - file_uuid, updated = self._render_nmconnection(ssid, updated) - updated["uuid"] = file_uuid - updated_profiles.append(updated) - if current is primary: - updated_primary = updated + with self._profile_update(ssid, profiles): + updated_profiles = [] + updated_primary = None + for current in profiles: + updated = dict(current) + updated["metered"] = metered + file_uuid, updated = self._render_nmconnection(ssid, updated) + updated["uuid"] = file_uuid + updated_profiles.append(updated) + if current is primary: + updated_primary = updated with self._lock: self._profiles[ssid] = updated_profiles self._networks[ssid] = updated_primary or updated_profiles[0] From 4d9a3f7a7abc7eae625c7b29e98032fc26b7c50c Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Mon, 3 Aug 2026 00:01:12 +0300 Subject: [PATCH 23/71] wifi: preserve shared netplan files --- .../system/ui/lib/tests/test_network_store.py | 23 +++++++++++++++++++ openpilot/system/ui/lib/wifi_network_store.py | 4 ++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index 4e183a44e762b2..52cb5d611dbc17 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -592,6 +592,29 @@ def test_forget_finds_renamed_netplan_source_by_uuid(self): assert not netplan_path.exists() + def test_forget_preserves_shared_renamed_netplan_source(self): + runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) + netplan_path = Path(self.netplan, "provisioned-wifi.yaml") + netplan_path.write_text(f"""\ +network: + version: 2 + wifis: + first: + networkmanager: + uuid: {profile_uuid('runtime-uuid')} + second: + networkmanager: + uuid: {profile_uuid('other-uuid')} +""") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert store.remove("Runtime") + + assert not runtime_path.exists() + assert netplan_path.exists() + assert not store.contains("Runtime") + def test_forget_ignores_unrelated_netplan_source(self): runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) netplan_path = Path(self.netplan, "provisioned-wifi.yaml") diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index cd71afd879294b..2a2f0fb7485bc5 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -148,7 +148,7 @@ def _find_netplan_filename(self, file_uuid: str) -> str | None: filenames = sorted(os.listdir(self._netplan_directory)) except OSError: return None - pattern = re.compile(rf"^\s*uuid\s*:\s*['\"]?{re.escape(file_uuid)}['\"]?\s*(?:#.*)?$", re.MULTILINE) + pattern = re.compile(r"^\s*uuid\s*:\s*['\"]?([^'\"\s#]+)['\"]?\s*(?:#.*)?$", re.MULTILINE) yaml_filenames = [fname for fname in filenames if fname.endswith(".yaml")] read_failed = False for fname in yaml_filenames: @@ -159,7 +159,7 @@ def _find_netplan_filename(self, file_uuid: str) -> str | None: continue if not raw: read_failed = True - elif pattern.search(raw): + elif {_parse_uuid(value) for value in pattern.findall(raw)} == {file_uuid}: return fname return expected if read_failed else None From 0b43b0993d992c078f0ae31fc4caac0e56458c3e Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Mon, 3 Aug 2026 00:52:37 +0300 Subject: [PATCH 24/71] wifi: clear IPv6 after reconnect timeout --- openpilot/system/ui/lib/tests/test_handle_state_change.py | 1 + openpilot/system/ui/lib/wifi_manager.py | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 083af2596d837a..c5dc3ccd0400ce 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -211,6 +211,7 @@ def test_disconnected_event_cleans_station_after_timeout(self): assert self.manager.ipv4_address == "" assert self.manager.current_network_metered == MeteredType.UNKNOWN self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() def test_disconnected_event_does_not_override_user_connection(self): self.manager._set_connecting("NextNet") diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 9beff9b1a0dc1e..631d4de18b89d8 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -957,9 +957,7 @@ def _reconcile_connecting_state(self): return self._clear_pending_connection(ssid) self._set_connecting(None) - self._dhcp.stop() - self._ipv4_address = "" - self._current_network_metered = MeteredType.UNKNOWN + self._clear_station_state() self._enqueue_callbacks(self._disconnected) def _update_networks(self, block: bool = True): From bf8aca2444f32c80446236dcce31a1b3b0877c95 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Mon, 3 Aug 2026 00:53:49 +0300 Subject: [PATCH 25/71] wifi: report completed persistent forgets --- .../ui/lib/tests/test_handle_state_change.py | 22 ++++++++++++++++--- openpilot/system/ui/lib/wifi_manager.py | 7 +++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index c5dc3ccd0400ce..410686b86985e4 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -803,7 +803,7 @@ def select_network(*_): assert call("DISCONNECT") not in self.manager._ctrl.request.call_args_list assert call("REASSOCIATE") not in self.manager._ctrl.request.call_args_list - def test_forget_withholds_callback_when_runtime_removal_fails(self): + def test_forget_reports_persistent_success_when_runtime_removal_fails(self): forgotten = MagicMock() forget_failed = MagicMock() self.manager.add_callbacks(forgotten=forgotten, forget_failed=forget_failed) @@ -822,8 +822,24 @@ def request(command): self.manager.forget_connection("SavedNet", block=True) self.manager.process_callbacks() - forgotten.assert_not_called() - forget_failed.assert_called_once_with("SavedNet") + forgotten.assert_called_once_with("SavedNet") + forget_failed.assert_not_called() + + def test_forget_removes_runtime_when_config_generation_fails(self): + forgotten = MagicMock() + self.manager.add_callbacks(forgotten=forgotten) + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = True + + with ( + patch.object(wifi_manager_module, "generate_wpa_conf", side_effect=OSError("read-only")), + patch.object(self.manager, "_remove_wpa_network") as remove_wpa_network, + ): + self.manager.forget_connection("SavedNet", block=True) + + self.manager.process_callbacks() + remove_wpa_network.assert_called_once_with("SavedNet") + forgotten.assert_called_once_with("SavedNet") def test_forget_allows_fallback_connection_after_disconnect_event(self): self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 631d4de18b89d8..060527c35b738a 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -1146,6 +1146,9 @@ def worker(): with self._connect_lock: try: generate_wpa_conf(store) + except Exception: + cloudlog.exception(f"Failed to regenerate configuration after forgetting {ssid}") + try: if self._ctrl: with self._state_lock: was_connected = self._wifi_state.ssid == ssid and self._wifi_state.status == ConnectStatus.CONNECTED @@ -1163,9 +1166,7 @@ def worker(): if was_connected: self._request("REASSOCIATE") except Exception: - cloudlog.exception(f"Failed to reconfigure after forgetting {ssid}") - self._enqueue_callbacks(self._forget_failed, ssid) - return + cloudlog.exception(f"Failed to remove runtime connection after forgetting {ssid}") self._enqueue_callbacks(self._forgotten, ssid) From f3480910c9c34206c76bdc688de86bc1f3d23045 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Mon, 3 Aug 2026 00:57:53 +0300 Subject: [PATCH 26/71] wifi: resolve metering from active profile --- .../ui/lib/tests/test_handle_state_change.py | 31 ++++++++++++++----- .../system/ui/lib/tests/test_network_store.py | 9 ++++++ .../system/ui/lib/tests/test_wpa_ctrl.py | 5 +++ openpilot/system/ui/lib/wifi_manager.py | 9 ++++-- openpilot/system/ui/lib/wifi_network_store.py | 7 +++-- openpilot/system/ui/lib/wpa_ctrl.py | 2 ++ 6 files changed, 52 insertions(+), 11 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 410686b86985e4..22e2682eea9080 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -283,8 +283,8 @@ def test_activate_enables_every_profile_sharing_ssid(self): def test_activate_restores_every_saved_profile(self): profiles = ( - {"psk": "first-password", "hidden": False, "priority": 1, "bssid": "00:11:22:33:44:55"}, - {"psk": "second-password", "hidden": True, "priority": 2, "bssid": "66:77:88:99:aa:bb"}, + {"psk": "first-password", "hidden": False, "priority": 1, "bssid": "00:11:22:33:44:55", "uuid": "first-uuid"}, + {"psk": "second-password", "hidden": True, "priority": 2, "bssid": "66:77:88:99:aa:bb", "uuid": "second-uuid"}, ) self.manager._store.get_profiles.return_value = [("Pinned", profile) for profile in profiles] @@ -296,8 +296,8 @@ def test_activate_restores_every_saved_profile(self): self.manager.activate_connection("Pinned", block=True) assert add_and_select_network.call_args_list == [ - call("Pinned", "first-password", False, 1, bssid="00:11:22:33:44:55"), - call("Pinned", "second-password", True, 2, bssid="66:77:88:99:aa:bb"), + call("Pinned", "first-password", False, 1, bssid="00:11:22:33:44:55", profile_uuid="first-uuid"), + call("Pinned", "second-password", True, 2, bssid="66:77:88:99:aa:bb", profile_uuid="second-uuid"), ] select_network_ids.assert_called_once_with(["1", "2"]) @@ -329,10 +329,20 @@ def test_metered_worker_reports_persistence_failure(self): exception.assert_called_once_with("Failed to update metered state for TestNet") assert self.manager.current_network_metered == MeteredType.NO + def test_active_profile_sets_metered_state(self): + self.manager._wifi_state = WifiState("Duplicate", ConnectStatus.CONNECTED) + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=Duplicate\nid_str=second-uuid\nip_address=192.168.1.20\n" + self.manager._store.get_metered.return_value = MeteredType.NO + + WifiManager._update_active_connection_info(self.manager) + + self.manager._store.get_metered.assert_called_once_with("Duplicate", "second-uuid") + assert self.manager.current_network_metered == MeteredType.NO + def test_activate_restores_saved_profile_constraints(self): cases = ( - ("Preferred", {"psk": "password123", "hidden": False, "priority": 42}, 42, None), - ("Pinned", {"psk": "password123", "hidden": False, "bssid": "00:11:22:33:44:55"}, 0, "00:11:22:33:44:55"), + ("Preferred", {"psk": "password123", "hidden": False, "priority": 42, "uuid": "preferred-uuid"}, 42, None), + ("Pinned", {"psk": "password123", "hidden": False, "bssid": "00:11:22:33:44:55", "uuid": "pinned-uuid"}, 0, "00:11:22:33:44:55"), ) for ssid, profile, priority, bssid in cases: with self.subTest(ssid=ssid): @@ -345,7 +355,7 @@ def test_activate_restores_saved_profile_constraints(self): ): manager.activate_connection(ssid, block=True) - add_and_select_network.assert_called_once_with(ssid, "password123", False, priority, bssid=bssid) + add_and_select_network.assert_called_once_with(ssid, "password123", False, priority, bssid=bssid, profile_uuid=profile["uuid"]) def test_connect_defers_dhcp_cleanup_to_worker(self): self.manager._wifi_state = WifiState("CurrentNet", ConnectStatus.CONNECTED) @@ -403,6 +413,13 @@ def test_runtime_network_encodes_control_characters_in_ssid(self): ssid_hex = b"Line\nBreak\r".hex() assert call(f"SET_NETWORK 0 ssid {ssid_hex}") in self.manager._ctrl.request.call_args_list + def test_runtime_network_sets_saved_profile_identifier(self): + self.manager._ctrl.request.side_effect = ["0", "OK", "OK", "OK", "OK", "OK"] + + self.manager._add_and_select_network("TestNet", profile_uuid="profile-uuid") + + assert call('SET_NETWORK 0 id_str "profile-uuid"') in self.manager._ctrl.request.call_args_list + def test_scan_only_reselects_when_disconnected(self): cases = ( (WifiState("TestNet", ConnectStatus.CONNECTED), "SCAN TYPE=ONLY"), diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index 52cb5d611dbc17..e506f1a9ee148c 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -447,6 +447,15 @@ def test_metered_updates_every_profile_with_the_same_ssid(self): raw = Path(self.persistent, f"{file_uuid}-Duplicate.nmconnection").read_text() assert "metered = 1" in raw + def test_gets_metering_from_selected_profile(self): + write_profile(self.persistent, "first.nmconnection", "Duplicate", file_uuid="first-uuid", extra_connection="metered=1") + write_profile(self.persistent, "second.nmconnection", "Duplicate", file_uuid="second-uuid", extra_connection="metered=2") + + with self.patch_reads(): + store = self.make_store() + + assert store.get_metered("Duplicate", profile_uuid("second-uuid")) == store_module.MeteredType.NO + def test_replacement_psk_preserves_other_profile_credentials(self): write_profile( self.persistent, diff --git a/openpilot/system/ui/lib/tests/test_wpa_ctrl.py b/openpilot/system/ui/lib/tests/test_wpa_ctrl.py index 63a891044b2afe..f0dcbc69ad1e84 100644 --- a/openpilot/system/ui/lib/tests/test_wpa_ctrl.py +++ b/openpilot/system/ui/lib/tests/test_wpa_ctrl.py @@ -203,6 +203,11 @@ def generate(self, ssid, profile): def test_emits_saved_network_priority(self): assert " priority=42\n" in self.generate("Preferred", {"psk": "password123", "hidden": False, "priority": 42}) + def test_emits_saved_profile_identifier(self): + assert ' id_str="11111111-1111-1111-1111-111111111111"\n' in self.generate( + "Preferred", {"psk": "password123", "uuid": "11111111-1111-1111-1111-111111111111"}, + ) + def test_grants_control_access_to_netdev_group(self): assert "ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev\n" in self.generate("Test", {"psk": "password123"}) diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 060527c35b738a..20d2e25eb5a50f 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -1026,12 +1026,14 @@ def worker(): def _update_active_connection_info(self): ipv4_address = "" metered = MeteredType.UNKNOWN + profile_uuid = None if self._wifi_state.status == ConnectStatus.CONNECTED: if self._ctrl: try: status = parse_status(self._request("STATUS")) ipv4_address = status.get("ip_address", "") + profile_uuid = status.get("id_str") except Exception: pass @@ -1050,7 +1052,7 @@ def _update_active_connection_info(self): ssid = self._wifi_state.ssid if ssid and self._store is not None: - metered = self._store.get_metered(ssid) + metered = self._store.get_metered(ssid, profile_uuid) self._ipv4_address = ipv4_address self._current_network_metered = metered @@ -1230,6 +1232,7 @@ def reset_to_disconnected(): entry.get("hidden", False), entry.get("priority", 0), bssid=entry.get("bssid") or None, + profile_uuid=entry.get("uuid"), ) for entry in profiles ] @@ -1256,7 +1259,7 @@ def _select_network_ids(self, net_ids: list[str]): raise RuntimeError(f"{command} failed: {resp}") def _add_and_select_network(self, ssid: str, psk: str = "", hidden: bool = False, - priority: int = 0, bssid: str | None = None) -> str: + priority: int = 0, bssid: str | None = None, profile_uuid: str | None = None) -> str: """Add a network and select it. Every SET_NETWORK is checked so a bad PSK/key_mgmt surfaces an immediate error instead of a delayed WRONG_KEY; orphans get REMOVE_NETWORK'd.""" net_id = self._request("ADD_NETWORK").strip() @@ -1273,6 +1276,8 @@ def _add_and_select_network(self, ssid: str, psk: str = "", hidden: bool = False self._wpa_set_network(net_id, "scan_ssid", "1") if bssid: self._wpa_set_network(net_id, "bssid", bssid) + if profile_uuid: + self._wpa_set_network(net_id, "id_str", f'"{sanitize_for_conf(profile_uuid)}"') self._wpa_set_network(net_id, "priority", str(priority)) resp = self._request(f"SELECT_NETWORK {net_id}").strip() if not resp.startswith("OK"): diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index 2a2f0fb7485bc5..e4becf0abc5537 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -715,9 +715,12 @@ def set_metered(self, ssid: str, metered: int): self._profiles[ssid] = updated_profiles self._networks[ssid] = updated_primary or updated_profiles[0] - def get_metered(self, ssid: str) -> MeteredType: + def get_metered(self, ssid: str, profile_uuid: str | None = None) -> MeteredType: with self._lock: - entry = self._networks.get(ssid) + if profile_uuid is None: + entry = self._networks.get(ssid) + else: + entry = next((profile for profile in self._profiles.get(ssid, []) if profile.get("uuid") == profile_uuid), None) if entry: m = entry.get("metered", 0) if m == MeteredType.YES: diff --git a/openpilot/system/ui/lib/wpa_ctrl.py b/openpilot/system/ui/lib/wpa_ctrl.py index aee259d65a6a51..3e937b9780f51b 100644 --- a/openpilot/system/ui/lib/wpa_ctrl.py +++ b/openpilot/system/ui/lib/wpa_ctrl.py @@ -438,6 +438,8 @@ def generate_wpa_conf(store, path: str = WPA_SUPPLICANT_CONF): lines.append(" scan_ssid=1") if bssid: lines.append(f" bssid={bssid}") + if profile_uuid := entry.get("uuid"): + lines.append(f' id_str="{sanitize_for_conf(profile_uuid)}"') lines.append(f" priority={priority}") lines.append("}") lines.append("") From d6c52f02bbb307371c52fb796e5254ddff51e3bf Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Mon, 3 Aug 2026 01:00:09 +0300 Subject: [PATCH 27/71] wifi: preserve station on cancelled tethering start --- .../ui/lib/tests/test_handle_state_change.py | 14 +++++++++++++- openpilot/system/ui/lib/wifi_manager.py | 8 ++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 22e2682eea9080..c7232c491e0c3b 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -1487,6 +1487,7 @@ def test_tethering_transitions_do_not_overlap(self): stop_entered = threading.Event() def start_tethering(): + manager._tethering_started = True start_entered.set() assert release_start.wait(1) @@ -1506,7 +1507,12 @@ def start_tethering(): def test_latest_tethering_request_wins(self): manager = build_wifi_manager() + manager._wifi_state = WifiState("Station", ConnectStatus.CONNECTED) + manager._ipv4_address = "192.168.1.20" + station_ctrl = manager._ctrl transitions = [] + disconnected = MagicMock() + manager.add_callbacks(disconnected=disconnected) def start_tethering(): transitions.append(True) @@ -1528,12 +1534,18 @@ def stop_tethering(): workers[1]() workers[0]() - assert transitions == [False] + manager.process_callbacks() + assert transitions == [] assert not manager.is_tethering_active() + assert manager.wifi_state == WifiState("Station", ConnectStatus.CONNECTED) + assert manager.ipv4_address == "192.168.1.20" + assert manager._ctrl is station_ctrl + disconnected.assert_called_once() def test_failed_tethering_stop_notifies_disconnected(self): manager = build_wifi_manager() manager._tethering_active = True + manager._tethering_started = True disconnected = MagicMock() manager.add_callbacks(disconnected=disconnected) diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 20d2e25eb5a50f..e898907af43685 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -157,6 +157,7 @@ def __init__(self): self._tethering_lock = threading.RLock() self._tethering_epoch = 0 self._tethering_transition_pending = False + self._tethering_started = False self._tethering_password_epoch = 0 # Coalesced so an undrained queue (user on another tab) can't grow unboundedly. self._networks_updated_pending = False @@ -572,6 +573,7 @@ def _adopt_ap_state(self, ssid: str | None) -> bool: return False with self._callback_lock: self._tethering_active = True + self._tethering_started = True self._wifi_state = WifiState(ssid=ssid or self._tethering_ssid, status=ConnectStatus.CONNECTED) self._ipv4_address = TETHERING_IP_ADDRESS self._callback_queue.extend(self._activated) @@ -1398,6 +1400,10 @@ def transition(): self._wifi_state = WifiState() self._enqueue_callbacks(self._disconnected) else: + if not self._tethering_started: + self._tethering_active = False + self._enqueue_callbacks(self._disconnected) + return try: self._stop_tethering() except Exception: @@ -1436,6 +1442,7 @@ def worker(): def _start_tethering(self): self._tethering_active = True + self._tethering_started = True self._set_connecting(self._tethering_ssid, requested=False) psk = self._tethering_psk @@ -1556,6 +1563,7 @@ def _stop_tethering(self): self._ensure_wpa_supplicant() self._tethering_active = False + self._tethering_started = False self._wifi_state = WifiState(ssid=None, status=ConnectStatus.DISCONNECTED) self._ipv4_address = "" self._enqueue_callbacks(self._disconnected) From 3a4b0a1a0df81caf8f8ac9dbc23c1720a818ad19 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Mon, 10 Aug 2026 11:24:01 +0000 Subject: [PATCH 28/71] wifi: consolidate backend tests --- .../system/ui/lib/tests/test_dhcp_client.py | 58 +++++--------- .../ui/lib/tests/test_handle_state_change.py | 77 +++++++------------ .../system/ui/lib/tests/test_network_store.py | 21 +---- .../ui/lib/tests/test_wifi_manager_bringup.py | 11 +-- .../system/ui/lib/tests/test_wpa_ctrl.py | 16 ---- 5 files changed, 50 insertions(+), 133 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_dhcp_client.py b/openpilot/system/ui/lib/tests/test_dhcp_client.py index d0f07d6c39def2..cc571d687bef8e 100644 --- a/openpilot/system/ui/lib/tests/test_dhcp_client.py +++ b/openpilot/system/ui/lib/tests/test_dhcp_client.py @@ -28,11 +28,12 @@ def test_adopt_existing_udhcpc_without_restarting_it(self): thread.assert_called_once_with(target=client._monitor_client, daemon=True) thread.return_value.start.assert_called_once() - def test_start_detaches_udhcpc_from_ui_session(self): + def test_start_flushes_stale_lease_and_detaches_udhcpc_from_ui_session(self): client = DhcpClient() + events = [] with ( - patch.object(dhcp_client_module.subprocess, "run") as run, - patch.object(dhcp_client_module.subprocess, "Popen") as popen, + patch.object(dhcp_client_module.subprocess, "run", side_effect=lambda command, **_: events.append(command)) as run, + patch.object(dhcp_client_module.subprocess, "Popen", side_effect=lambda *_, **__: events.append("spawn") or MagicMock()) as popen, patch.object(dhcp_client_module.threading, "Thread") as thread, ): client.start() @@ -42,6 +43,7 @@ def test_start_detaches_udhcpc_from_ui_session(self): ["sudo", "ip", "-4", "route", "flush", "dev", "wlan0"], ["sudo", "ip", "-4", "addr", "flush", "dev", "wlan0"], ] + assert events == [*([call.args[0] for call in run.call_args_list]), "spawn"] popen.assert_called_once_with( ["sudo", "udhcpc", "-i", "wlan0", "-f", "-t", "5", "-T", "3", "-s", dhcp_client_module.DHCP_SCRIPT], stdout=subprocess.DEVNULL, @@ -76,19 +78,6 @@ def test_missing_default_script_is_reported_before_launch(self): error.assert_called_once_with(f"udhcpc default script is not executable: {dhcp_client_module.DHCP_DEFAULT_SCRIPT}") popen.assert_not_called() - def test_start_flushes_stale_lease_before_spawning_client(self): - client = DhcpClient() - events = [] - with ( - patch.object(dhcp_client_module.subprocess, "run"), - patch.object(client, "_flush_address", side_effect=lambda: events.append("flush")), - patch.object(client, "_spawn", side_effect=lambda: events.append("spawn") or True), - patch.object(client, "_start_client_thread"), - ): - client.start() - - assert events == ["flush", "spawn"] - def test_exited_client_is_restarted(self): client = DhcpClient() client._proc = MagicMock() @@ -136,29 +125,20 @@ def test_dhcp_script_applies_metric_after_default_script(self): "ip -4 route del default via 192.168.1.1 dev wlan0 metric 0", ] - def test_stop_cleans_only_wlan_dhcp_routes_and_address(self): - client = DhcpClient() - client._proc = MagicMock() - with patch.object(dhcp_client_module.subprocess, "run") as run: - client.stop() - - assert client._proc is None - assert [call.args[0] for call in run.call_args_list] == [ - ["sudo", "pkill", "-f", "^udhcpc -i wlan0( |$)"], - ["sudo", "ip", "-4", "route", "flush", "dev", "wlan0"], - ["sudo", "ip", "-4", "addr", "flush", "dev", "wlan0"], - ] - - def test_stop_cleans_surviving_client_without_process_handle(self): - client = DhcpClient() - with patch.object(dhcp_client_module.subprocess, "run") as run: - client.stop() - - assert [call.args[0] for call in run.call_args_list] == [ - ["sudo", "pkill", "-f", "^udhcpc -i wlan0( |$)"], - ["sudo", "ip", "-4", "route", "flush", "dev", "wlan0"], - ["sudo", "ip", "-4", "addr", "flush", "dev", "wlan0"], - ] + def test_stop_cleans_wlan_dhcp_state_with_or_without_process_handle(self): + for proc in (MagicMock(), None): + with self.subTest(has_process_handle=proc is not None): + client = DhcpClient() + client._proc = proc + with patch.object(dhcp_client_module.subprocess, "run") as run: + client.stop() + + assert client._proc is None + assert [call.args[0] for call in run.call_args_list] == [ + ["sudo", "pkill", "-f", "^udhcpc -i wlan0( |$)"], + ["sudo", "ip", "-4", "route", "flush", "dev", "wlan0"], + ["sudo", "ip", "-4", "addr", "flush", "dev", "wlan0"], + ] def test_clear_ipv6_state_cleans_global_addresses_and_routes(self): client = DhcpClient() diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index c7232c491e0c3b..8fe1f8cb2dbac6 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -357,33 +357,26 @@ def test_activate_restores_saved_profile_constraints(self): add_and_select_network.assert_called_once_with(ssid, "password123", False, priority, bssid=bssid, profile_uuid=profile["uuid"]) - def test_connect_defers_dhcp_cleanup_to_worker(self): - self.manager._wifi_state = WifiState("CurrentNet", ConnectStatus.CONNECTED) - with ( - patch.object(wifi_manager_module.threading, "Thread") as thread, - patch.object(self.manager, "_list_network_ids", return_value=[]), - patch.object(self.manager, "_add_and_select_network"), - ): - self.manager.connect_to_network("NextNet", "password123") - self.manager._dhcp.stop.assert_not_called() - thread.call_args.kwargs["target"]() - - self.manager._dhcp.stop.assert_called_once() - self.manager._dhcp.clear_ipv6_state.assert_called_once() - - def test_activate_defers_dhcp_cleanup_to_worker(self): - self.manager._wifi_state = WifiState("CurrentNet", ConnectStatus.CONNECTED) - with ( - patch.object(wifi_manager_module.threading, "Thread") as thread, - patch.object(self.manager, "_list_network_ids", return_value=["1"]), - patch.object(self.manager, "_select_network_ids"), - ): - self.manager.activate_connection("NextNet") - self.manager._dhcp.stop.assert_not_called() - thread.call_args.kwargs["target"]() + def test_connection_changes_defer_dhcp_cleanup_to_worker(self): + for action in ("connect", "activate"): + with self.subTest(action=action): + manager = build_wifi_manager() + manager._wifi_state = WifiState("CurrentNet", ConnectStatus.CONNECTED) + with ( + patch.object(wifi_manager_module.threading, "Thread") as thread, + patch.object(manager, "_list_network_ids", return_value=["1"] if action == "activate" else []), + patch.object(manager, "_add_and_select_network"), + patch.object(manager, "_select_network_ids"), + ): + if action == "connect": + manager.connect_to_network("NextNet", "password123") + else: + manager.activate_connection("NextNet") + manager._dhcp.stop.assert_not_called() + thread.call_args.kwargs["target"]() - self.manager._dhcp.stop.assert_called_once() - self.manager._dhcp.clear_ipv6_state.assert_called_once() + manager._dhcp.stop.assert_called_once() + manager._dhcp.clear_ipv6_state.assert_called_once() def test_latest_connect_worker_owns_deferred_dhcp_cleanup(self): self.manager._wifi_state = WifiState("CurrentNet", ConnectStatus.CONNECTED) @@ -1585,31 +1578,13 @@ def test_latest_password_request_wins(self): password_file.write.assert_called_once_with("second-password") manager._store.set_tethering_password.assert_called_once_with("Hotspot", "second-password") - def test_startup_falls_back_to_existing_hotspot_password(self): - manager = build_wifi_manager() - manager._tethering_ssid = "weedle" - store = manager._store - assert store is not None - store.get_tethering_password.return_value = "custom-password" - manager._scan_thread = MagicMock() - manager._state_thread = MagicMock() - - with ( - patch.object(wifi_manager_module, "NetworkStore", return_value=store), - patch("builtins.open", side_effect=FileNotFoundError), - patch.object(wifi_manager_module, "generate_wpa_conf"), - patch.object(manager, "_ensure_wpa_supplicant"), - patch.object(manager, "_update_networks"), - patch.object(manager, "_init_wifi_state"), - patch.object(wifi_manager_module.threading, "Thread") as thread, - ): - manager._initialize() - thread.call_args.kwargs["target"]() - - assert manager.tethering_password == "custom-password" - - def test_startup_falls_back_when_password_file_is_unreadable(self): - for error in (PermissionError("denied"), UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid")): + def test_startup_falls_back_when_password_file_is_unavailable(self): + errors = ( + FileNotFoundError(), + PermissionError("denied"), + UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid"), + ) + for error in errors: with self.subTest(error=type(error).__name__): manager = build_wifi_manager() manager._tethering_ssid = "weedle" diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index e506f1a9ee148c..0fb08404e22c1a 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -920,7 +920,7 @@ def test_skips_profile_with_unsupported_addressing(self): assert "address1=192.168.50.10/24,192.168.50.1" in Path(path).read_text() def test_saved_profile_uses_nm_keyfile_compatible_name_and_mode(self): - with patch.object(store_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run: + with patch.object(store_module.subprocess, "run", side_effect=self.run_file_command) as run: store = self.make_store() store.save_network("Cafe/Wifi", psk="password123") @@ -929,14 +929,8 @@ def test_saved_profile_uses_nm_keyfile_compatible_name_and_mode(self): if item.args[0][:2] == ["sudo", "install"] and "-d" not in item.args[0]) assert install[-1].endswith("-Cafe_Wifi.nmconnection") assert install[install.index("-m") + 1] == "600" - - def test_new_profile_writes_rollback_dns_priority(self): - with patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): - store = self.make_store() - store.save_network("Rollback", psk="password123") - - raw = next(Path(self.persistent).glob("*.nmconnection")).read_text() - assert "dns-priority = 600" in raw + raw = next(Path(self.persistent).glob("*.nmconnection")).read_text() + assert "dns-priority = 600" in raw def test_round_trips_boundary_whitespace_with_keyfile_escaping(self): with patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): @@ -967,12 +961,3 @@ def test_round_trips_decimal_list_like_literal_ssid(self): reloaded = self.make_store() assert require_entry(reloaded, "65;66;67;")["psk"] == "password123" - - def test_get_returns_copy(self): - store = self.make_store() - store._networks["Test"] = {"psk": "password123"} - - entry = require_entry(store, "Test") - entry["psk"] = "changed" - - assert require_entry(store, "Test")["psk"] == "password123" diff --git a/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py b/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py index 2b2aa2b3969a1d..014c621dd49d47 100644 --- a/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py +++ b/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py @@ -55,15 +55,6 @@ def tethering_side_effects(manager: WifiManager, mode: str = "AP"): class TestTetheringFirewall(TestCase): - def test_start_owns_tethering_active_state(self): - manager = build_tethering_manager() - manager._tethering_active = False - - with tethering_side_effects(manager): - manager._start_tethering() - - assert manager.is_tethering_active() - def test_selects_xtables_backend(self): for discovered, expected in (("/usr/sbin/iptables-legacy", "iptables-legacy"), (None, "iptables")): with self.subTest(discovered=discovered), patch.object(wifi_manager_module.shutil, "which", return_value=discovered): @@ -71,6 +62,7 @@ def test_selects_xtables_backend(self): def test_installs_uplink_independent_masquerade(self): manager = build_tethering_manager() + manager._tethering_active = False with ( patch.object(wifi_manager_module.shutil, "which", return_value="/usr/sbin/iptables-legacy"), tethering_side_effects(manager) as (run, ctrl, _, _), @@ -84,6 +76,7 @@ def test_installs_uplink_independent_masquerade(self): assert "!" in nat_add and "-d" in nat_add assert "-o" not in nat_add assert TETHERING_NAT_COMMENT in nat_add + assert manager.is_tethering_active() assert manager._ctrl is ctrl assert manager._wifi_state == WifiState("weedle-test", ConnectStatus.CONNECTED) diff --git a/openpilot/system/ui/lib/tests/test_wpa_ctrl.py b/openpilot/system/ui/lib/tests/test_wpa_ctrl.py index f0dcbc69ad1e84..e42a2cf584ddc0 100644 --- a/openpilot/system/ui/lib/tests/test_wpa_ctrl.py +++ b/openpilot/system/ui/lib/tests/test_wpa_ctrl.py @@ -9,7 +9,6 @@ from openpilot.system.ui.lib import wpa_ctrl as wpa_ctrl_module from openpilot.system.ui.lib.wpa_ctrl import ( - RECV_BUF_SIZE, SecurityType, WpaCtrl, decode_ssid, @@ -137,21 +136,6 @@ def test_ssid_values(self): assert len(results) == 1 assert results[0].ssid == expected - def test_large_scan_fits_in_recv_buffer(self): - lines = [self.HEADER.strip()] - for i in range(200): - bssid = f"00:11:22:33:{i // 256:02x}:{i % 256:02x}" - ssid = f"Network_{i:03d}_with_a_longer_name_padding" - lines.append(f"{bssid}\t2437\t{-30 - (i % 70)}\t[WPA2-PSK-CCMP][ESS]\t{ssid}") - raw = "\n".join(lines) + "\n" - - assert len(raw.encode()) < RECV_BUF_SIZE - - results = parse_scan_results(raw) - assert len(results) == 200 - assert results[0].ssid == "Network_000_with_a_longer_name_padding" - assert results[199].ssid == "Network_199_with_a_longer_name_padding" - class TestDecodeSsid(TestCase): def test_values(self): cases = ( From 17edd0cc9dd3b6ba3d50ba86f1f393238414fc44 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Mon, 10 Aug 2026 11:51:36 +0000 Subject: [PATCH 29/71] wifi: restore imported profiles on update failure --- .../system/ui/lib/tests/test_network_store.py | 25 +++++++++++++------ openpilot/system/ui/lib/wifi_network_store.py | 2 +- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index 0fb08404e22c1a..2530fdf1f0319e 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -841,13 +841,17 @@ def run(command, **kwargs): assert persistent_path.exists() - def test_edit_runtime_profile_rolls_back_when_netplan_remove_fails(self): - write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") + def test_edit_runtime_profile_restores_sources_when_netplan_remove_fails(self): + runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) + original_runtime = runtime_path.read_text() netplan_path = Path(self.netplan, f"90-NM-{profile_uuid('runtime-uuid')}.yaml") netplan_path.write_text("network:\n version: 2\n") + original_netplan = netplan_path.read_text() - def run(command, **_): - return MagicMock(returncode=1 if command[-1] == str(netplan_path) else 0) + def run(command, **kwargs): + if command[:3] == ["sudo", "rm", "-f"] and command[-1] == str(netplan_path): + return MagicMock(returncode=1) + return self.run_file_command(command, **kwargs) with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run) as process: store = self.make_store() @@ -856,10 +860,15 @@ def run(command, **_): with self.assertRaises(OSError): store.save_network("Runtime", psk="replacement") - commands = [item.args[0] for item in process.call_args_list] - assert ["sudo", "rm", "-f", keyfile_path] in commands - assert require_entry(store, "Runtime")["psk"] == "password123" - assert require_entry(store, "Runtime")["_netplan_filename"] == f"90-NM-{profile_uuid('runtime-uuid')}.yaml" + commands = [item.args[0] for item in process.call_args_list] + assert ["sudo", "rm", "-f", keyfile_path] in commands + assert runtime_path.read_text() == original_runtime + assert netplan_path.read_text() == original_netplan + assert not Path(keyfile_path).exists() + assert not list(Path(self.root).rglob("*.openpilot-update-*")) + assert require_entry(store, "Runtime")["psk"] == "password123" + assert require_entry(store, "Runtime")["_runtime_filename"] == "netplan.nmconnection" + assert require_entry(store, "Runtime")["_netplan_filename"] == f"90-NM-{profile_uuid('runtime-uuid')}.yaml" def test_accepts_only_networkmanager_dns_priority(self): rollback_path = write_profile(self.persistent, "rollback.nmconnection", "Rollback") diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index e4becf0abc5537..d19fb4343dab3d 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -308,7 +308,7 @@ def _install_keyfile(self, cp: configparser.ConfigParser, path: str): @contextmanager def _profile_update(self, ssid: str, profiles: list[dict]) -> Iterator[None]: - if len(profiles) < 2: + if not profiles: yield return From ce820983fa43b3109960c91ba0017c0fd64b0476 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Mon, 10 Aug 2026 11:52:32 +0000 Subject: [PATCH 30/71] wifi: clear IPv6 after wrong-key recovery --- openpilot/system/ui/lib/tests/test_handle_state_change.py | 4 +++- openpilot/system/ui/lib/wifi_manager.py | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 8fe1f8cb2dbac6..5665f6459aed8e 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -453,12 +453,13 @@ def test_scan_accepts_psk_bss_among_unsupported_variants(self): assert self.manager.networks[0].ssid == "Mixed" assert self.manager.networks[0].security_type == SecurityType.WPA - def test_wrong_key_removes_runtime_credentials_and_stops_dhcp(self): + def test_wrong_key_removes_runtime_credentials_and_clears_station_state(self): need_auth = MagicMock() self.manager.add_callbacks(need_auth=need_auth) self.manager._set_connecting("TestNet") self.manager._set_pending_connection("TestNet", "wrongpass", False) self.manager._set_pending_network_id("0", self.manager._user_epoch) + self.manager._dhcp_adoption_ssid = "TestNet" self.manager._last_wrong_key_dispatch[("OldNet", None)] = 0.0 self.manager._ctrl.request.return_value = "OK" @@ -474,6 +475,7 @@ def test_wrong_key_removes_runtime_credentials_and_stops_dhcp(self): assert call("REMOVE_NETWORK 0") in self.manager._ctrl.request.call_args_list assert ("OldNet", None) not in self.manager._last_wrong_key_dispatch self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() need_auth.assert_called_once_with("TestNet") def test_wrong_key_ignores_same_ssid_event_for_other_profile(self): diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index e898907af43685..1718f3dbc712ca 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -752,9 +752,7 @@ def _handle_event(self, event: str): self._set_connecting(None) # CTRL-EVENT-DISCONNECTED is ignored while CONNECTING, so tear down # DHCP/IP/metered here ourselves in case it arrived before WRONG_KEY. - self._dhcp.stop() - self._ipv4_address = "" - self._current_network_metered = MeteredType.UNKNOWN + self._clear_station_state() self._enqueue_callbacks(self._disconnected) elif "CTRL-EVENT-NETWORK-NOT-FOUND" in event: From 28b62b190b69b55442ce5c707d0355a934079f27 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Mon, 10 Aug 2026 12:28:11 +0000 Subject: [PATCH 31/71] wifi: trim implementation comments --- openpilot/common/hardware/comma/hardware.py | 2 +- .../mici/layouts/settings/network/wifi_ui.py | 2 +- openpilot/system/ui/lib/dhcp_client.py | 7 +- openpilot/system/ui/lib/udhcpc.script | 2 +- openpilot/system/ui/lib/wifi_manager.py | 164 ++++++------------ openpilot/system/ui/lib/wifi_network_store.py | 46 ++--- openpilot/system/ui/lib/wpa_ctrl.py | 49 ++---- openpilot/system/ui/widgets/network.py | 3 +- 8 files changed, 87 insertions(+), 188 deletions(-) diff --git a/openpilot/common/hardware/comma/hardware.py b/openpilot/common/hardware/comma/hardware.py index 2e260c9fdfc5e7..a58797321ff4a2 100644 --- a/openpilot/common/hardware/comma/hardware.py +++ b/openpilot/common/hardware/comma/hardware.py @@ -214,7 +214,7 @@ def get_network_metered(self, network_type) -> bool: if network_type == NetworkType.wifi: ssid = wpa_supplicant_cmd("STATUS").get("ssid", "") if ssid: - # wpa_supplicant escapes non-printable bytes as \xNN. + # wpa_supplicant escapes non-printable bytes as \xNN ssid_bytes = ssid.encode().decode('unicode_escape').encode('latin-1') nm_dirs = ("/run/NetworkManager/system-connections", "/data/etc/NetworkManager/system-connections") diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py index 377060711d4381..77052769654fde 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py @@ -222,7 +222,7 @@ def _update_state(self): elif self._is_connected: self.set_value("tethering" if self._network.is_tethering else "connected") elif self._network_missing: - # after connecting/connected since wpa_supplicant will still attempt to connect/stay connected for a while + # wpa_supplicant may keep an out-of-range connection alive briefly self.set_value("not in range") else: self.set_value("unsupported") diff --git a/openpilot/system/ui/lib/dhcp_client.py b/openpilot/system/ui/lib/dhcp_client.py index 3a7daba4660ece..148825bc76f2f3 100644 --- a/openpilot/system/ui/lib/dhcp_client.py +++ b/openpilot/system/ui/lib/dhcp_client.py @@ -1,4 +1,3 @@ -"""udhcpc lifecycle for a single interface.""" import os import re import subprocess @@ -13,7 +12,7 @@ class DhcpClient: """Manage udhcpc for DHCP on wlan0.""" - # Matches udhcpc's -T retry timeout below. + # Match udhcpc's -T retry timeout DISCOVER_TIMEOUT_SECONDS = 3 DISCOVER_ATTEMPTS = 5 @@ -47,7 +46,7 @@ def clear_ipv6_state(self): delete_default_route = ["sudo", "ip", "-6", "route", "del", "default", "dev", self._iface] for command in ( ["sudo", "ip", "-6", "addr", "flush", "dev", self._iface, "scope", "global"], - # Router-advertised default routes require an explicit delete before the remaining routes can be flushed. + # Delete router-advertised defaults before flushing routes delete_default_route, ["sudo", "ip", "-6", "route", "flush", "dev", self._iface], ): @@ -116,6 +115,6 @@ def stop(self): pass self._proc = None self._adopted = False - # Kill orphaned udhcpc children before flushing their lease state. + # Kill orphaned udhcpc children before flushing lease state subprocess.run(["sudo", "pkill", "-f", f"^udhcpc -i {self._iface}( |$)"], check=False) self._flush_lease() diff --git a/openpilot/system/ui/lib/udhcpc.script b/openpilot/system/ui/lib/udhcpc.script index c2e5407781c0b5..ec710eb80de5d4 100755 --- a/openpilot/system/ui/lib/udhcpc.script +++ b/openpilot/system/ui/lib/udhcpc.script @@ -1,7 +1,7 @@ #!/bin/sh default_script=${UDHCPC_DEFAULT_SCRIPT:-/etc/udhcpc/default.script} -# NetworkManager's default Wi-Fi route metric is 600; AGNOS LTE uses 1000. +# Match NetworkManager's Wi-Fi route metric; AGNOS LTE uses 1000 wifi_route_metric=600 "$default_script" "$1" status=$? diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 1718f3dbc712ca..4b1930f2fc2681 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -39,7 +39,7 @@ SCAN_PERIOD_SECONDS = 5 CONNECTING_STALE_TIMEOUT_SECONDS = 5 NETWORK_NOT_FOUND_EVENTS_REQUIRED = 2 -# Suppress WRONG_KEY events from prior attempts that can clobber fresh credentials on a fast retry. +# Ignore stale WRONG_KEY events after a fast retry WRONG_KEY_DEBOUNCE_SECONDS = 2.0 @@ -78,13 +78,12 @@ class PendingConnection: def _iptables_executable() -> str: - # AGNOS 18.7 exposes NAT through xtables while /usr/sbin/iptables selects the unsupported nft frontend. + # AGNOS 18.7 requires iptables-legacy for NAT return "iptables-legacy" if shutil.which("iptables-legacy") is not None else "iptables" def _tethering_firewall_rules(op: str) -> list[list[str]]: - # Source-subnet MASQUERADE (no `-o `) so the session survives uplink changes. - # Mirrors NM's nm-firewall-utils.c:_share_iptables_set_masquerade_sync. + # Match NetworkManager's source-subnet MASQUERADE so NAT survives uplink changes command = ["sudo", _iptables_executable()] tagged = ["-m", "comment", "--comment", TETHERING_NAT_COMMENT] return [ @@ -150,7 +149,6 @@ def __init__(self): self._callback_queue: list[Callable] = [] self._callback_lock = threading.Lock() self._state_lock = threading.RLock() - # Serializes supplicant network mutations and connected-state transitions. self._connect_lock = threading.Lock() self._station_lock = threading.Lock() self._station_cleanup_pending = False @@ -159,7 +157,6 @@ def __init__(self): self._tethering_transition_pending = False self._tethering_started = False self._tethering_password_epoch = 0 - # Coalesced so an undrained queue (user on another tab) can't grow unboundedly. self._networks_updated_pending = False self._tethering_ssid = "weedle" @@ -202,8 +199,7 @@ def worker(): with self._tethering_lock: self._ensure_wpa_supplicant() - # Populate networks before wifi state so the connected SSID's strength is - # known on first render; otherwise it flashes the disconnected icon. + # Load signal strength before rendering the connected network self._update_networks(block=True) self._init_wifi_state() @@ -252,7 +248,7 @@ def _request(self, cmd: str) -> str: try: return ctrl.request(cmd) except OSError: - # Monitor recv doesn't raise on daemon SIGKILL; the epoch bump kicks it to respawn. + # Restart the monitor because recv may survive daemon death try: ctrl.close() except Exception: @@ -278,23 +274,19 @@ def worker(): ssid = status.get("ssid") if status.get("mode") == "AP": - # Hotspot adoption after UI restart. STATUS reports COMPLETED in AP mode too, - # so the STA path below would flush wlan0 and kill the live hotspot. + # Adopt a surviving hotspot before station cleanup if self._user_epoch != epoch: return if self._adopt_ap_state(ssid): return - # dnsmasq is gone, so the surviving AP daemon is half-broken. Stay - # DISCONNECTED rather than letting the COMPLETED branch below treat this - # as a station connect (which would start STA DHCP on wlan0 and clobber - # the hotspot's address). + # Avoid treating an incomplete AP as a station connection self._wifi_state = WifiState(ssid=None, status=ConnectStatus.DISCONNECTED) return if wpa_state == "COMPLETED": connection_status = ConnectStatus.CONNECTED elif wpa_state in ("SCANNING", "AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): - # Adopt mid-connect state; otherwise a WRONG_KEY event would bypass its current_ssid check. + # Preserve mid-connect state for WRONG_KEY validation connection_status = ConnectStatus.CONNECTING else: connection_status = ConnectStatus.DISCONNECTED @@ -436,8 +428,7 @@ def _persist_pending_connection(self, ssid: str | None): if ssid != pending.ssid or pending.epoch != self._user_epoch: return - # On filesystem error, keep credentials for later retry and swallow so - # _handle_connected can still fire DHCP/activated callbacks. + # Retain credentials after transient persistence failures try: store = self._require_store() store.save_network(ssid, psk=pending.password, hidden=pending.hidden) @@ -459,7 +450,7 @@ def _enqueue_callbacks(self, cbs: list[Callable], *args): self._callback_queue.append(lambda _cb=cb: _cb(*args)) def _mark_networks_updated(self): - # Coalesces across scans so the queue stays O(1) when the UI isn't draining. + # Coalesce scan callbacks to keep the undrained queue bounded with self._callback_lock: self._networks_updated_pending = True @@ -474,7 +465,6 @@ def process_callbacks(self): for cb in to_run: cb() if networks_cbs: - # Fire with the latest snapshot, not one captured when we were flagged. snapshot = self.networks for cb in networks_cbs: cb(snapshot) @@ -486,21 +476,15 @@ def set_active(self, active: bool): self._update_networks(block=False) def _monitor_state(self): - # If pgrep keeps finding our daemon but try_attach_ctrl keeps returning None, - # the process is alive with a dead/missing ctrl socket. After this many - # consecutive attach failures, force a full respawn instead of looping forever. + # Respawn an owned daemon whose control socket remains unreachable ATTACH_FAILURES_BEFORE_RESPAWN = 3 attach_failures = 0 while not self._exit: if self._ctrl is None: - # _start_tethering closes _ctrl and pkills the STA daemon before the AP daemon - # is up. Spawning STA in that gap races AP bringup and can keep the hotspot - # off wlan0. Wait for tethering to finish; _start_tethering will rebind _ctrl. + # Avoid spawning STA while tethering is taking over wlan0 if self._tethering_active: self._exit_event.wait(1) continue - # No owned daemon? Spawn one so wifi doesn't stay dead after a failed - # initial bringup or a crash. Otherwise just attach. daemon_alive = wpa_supplicant_running(WPA_SUPPLICANT_CONF) or wpa_supplicant_running(WPA_AP_CONF) stale_daemon = daemon_alive and attach_failures >= ATTACH_FAILURES_BEFORE_RESPAWN if daemon_alive and not stale_daemon: @@ -529,8 +513,7 @@ def _monitor_state(self): self._handle_event(event) except Exception: cloudlog.exception("wpa_supplicant monitor error, reconnecting...") - # Drop the ctrl handle so the next iteration re-attaches (or respawns - # if the daemon actually died); otherwise we'd wedge on a dead socket. + # Reattach after control-socket failure if self._ctrl is not None: try: self._ctrl.close() @@ -608,9 +591,7 @@ def _handle_connected(self, ssid: str, adopt_dhcp: bool = False, expected_epoch: self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTED) if already_connected: - # If a prior persist hit a transient FS error, _pending_connection is still - # populated for this SSID. Without the retry here, repeat CONNECTED events - # short-circuit and the network is forgotten after restart. + # Retry persistence after transient filesystem failures with self._state_lock: pending = self._pending_connection if pending is not None and pending.ssid == ssid: @@ -619,8 +600,7 @@ def _handle_connected(self, ssid: str, adopt_dhcp: bool = False, expected_epoch: self._persist_pending_connection(ssid) if not self._connected_transition_is_current(ssid, transition_epoch): return - # Re-enable saved networks so wpa_supplicant can auto-roam: SELECT_NETWORK disables - # every other network as a side effect. + # SELECT_NETWORK disables other profiles; re-enable them for roaming if self._ctrl is not None: try: self._request("ENABLE_NETWORK all") @@ -637,7 +617,6 @@ def _handle_connected(self, ssid: str, adopt_dhcp: bool = False, expected_epoch: self._poll_for_ip() def _handle_event(self, event: str): - """Dispatch wpa_supplicant event to state machine.""" if "CTRL-EVENT-SCAN-RESULTS" in event: self._update_networks(block=False) @@ -667,7 +646,6 @@ def _handle_event(self, event: str): epoch = self._user_epoch - # Don't clear state if we're connecting to something (user action in progress) if self._wifi_state.status == ConnectStatus.CONNECTING: return @@ -695,7 +673,7 @@ def _handle_event(self, event: str): if event_ssid is not None: with self._connect_lock: current_ssid = self._wifi_state.ssid - # Auto-connect may leave us in CONNECTING with ssid=None; the event's SSID is authoritative. + # The event SSID is authoritative for auto-connect connecting_unknown = ( self._wifi_state.status == ConnectStatus.CONNECTING and current_ssid is None @@ -710,8 +688,7 @@ def _handle_event(self, event: str): or event_network_id != pending.network_id): return - # Per-profile debounce suppresses duplicate events without masking a - # legitimate WRONG_KEY from another profile sharing the same SSID. + # Debounce WRONG_KEY per profile, not per SSID dispatch_key = (event_ssid, event_network_id) now = time.monotonic() self._last_wrong_key_dispatch = { @@ -723,9 +700,7 @@ def _handle_event(self, event: str): return self._last_wrong_key_dispatch[dispatch_key] = now - # Drop only the failed profile. If another profile for this SSID is - # available, keep the current attempt alive and try it before asking - # the user for replacement credentials. + # Try remaining profiles before requesting new credentials if self._ctrl is not None: try: if event_network_id is not None: @@ -750,17 +725,14 @@ def _handle_event(self, event: str): self._clear_pending_connection(event_ssid) self._enqueue_callbacks(self._need_auth, event_ssid) self._set_connecting(None) - # CTRL-EVENT-DISCONNECTED is ignored while CONNECTING, so tear down - # DHCP/IP/metered here ourselves in case it arrived before WRONG_KEY. + # DISCONNECTED may arrive while CONNECTING and skip cleanup self._clear_station_state() self._enqueue_callbacks(self._disconnected) elif "CTRL-EVENT-NETWORK-NOT-FOUND" in event: if self._wifi_state.status != ConnectStatus.CONNECTING: return - # The event has no network ID or SSID. A delayed event from the previous - # profile can arrive after a fresh SELECT_NETWORK, so let the existing - # stale-connection reconciliation confirm that this attempt also failed. + # Reconciliation disambiguates delayed NETWORK-NOT-FOUND events if time.monotonic() - self._last_connecting_at >= CONNECTING_STALE_TIMEOUT_SECONDS: self._network_not_found_events += 1 if self._network_not_found_events >= NETWORK_NOT_FOUND_EVENTS_REQUIRED: @@ -835,7 +807,6 @@ def _reconcile_connecting_state(self): if self._ctrl is None: return - # Detect missed CONNECTED event (e.g. monitor was reconnecting after tethering stop) if current_state.status == ConnectStatus.DISCONNECTED: now = time.monotonic() if now - self._last_connected_recheck < SCAN_PERIOD_SECONDS: @@ -846,24 +817,20 @@ def _reconcile_connecting_state(self): status = parse_status(self._request("STATUS")) except Exception: return - # A user tap during the blocking STATUS bumped the epoch; their CONNECTING - # state is fresh, so don't synthesize a connected from the stale STATUS. + # Ignore STATUS results superseded by newer user action if self._user_epoch != epoch: return - # wpa_supplicant reports COMPLETED in AP mode too; STA path would flush the hotspot. Re-adopt - # so a missed startup adoption (e.g. transient STATUS failure) doesn't strand us in DISCONNECTED - # while still attached to the AP daemon, which would route station actions to the AP socket. + # Re-adopt AP mode before station reconciliation if status.get("mode") == "AP": if self._adopt_ap_state(status.get("ssid")): return - # dnsmasq is missing, so the AP is incomplete. Stay DISCONNECTED so the user can recover via tethering toggle. + # Keep an incomplete AP disconnected so tethering can recover return if status.get("wpa_state") == "COMPLETED" and status.get("ssid"): self._handle_connected(status["ssid"], expected_epoch=epoch) return - # Detect missed DISCONNECTED if the monitor dropped an event. Gated at - # SCAN_PERIOD_SECONDS to avoid STATUS spam. + # Rate-limit recovery from missed DISCONNECTED events if current_state.status == ConnectStatus.CONNECTED: now = time.monotonic() if now - self._last_connected_recheck < SCAN_PERIOD_SECONDS: @@ -874,8 +841,7 @@ def _reconcile_connecting_state(self): status = parse_status(self._request("STATUS")) except Exception: return - # User started another connect while we were blocked in STATUS; their current - # CONNECTING state must not be clobbered by stale STATUS results below. + # Ignore STATUS results superseded by newer user action if self._user_epoch != epoch: return wpa_state = status.get("wpa_state", "") @@ -884,14 +850,11 @@ def _reconcile_connecting_state(self): self._handle_connected(status_ssid, expected_epoch=epoch) return if wpa_state == "COMPLETED" and status_ssid: - # Roamed while the monitor was down; adopt the current network instead of - # synthesizing a disconnect that would flush the live lease. + # Preserve the lease when adopting a roam missed by the monitor self._dhcp.clear_ipv6_state() self._handle_connected(status_ssid, expected_epoch=epoch) return - # Normal roam/rekey transits through these states briefly; treating them as - # disconnect would flush the live udhcpc lease for nothing. Wait for the - # next sample to see the terminal state. + # Preserve the lease during transient roam and rekey states if wpa_state in ("SCANNING", "AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): return @@ -903,15 +866,13 @@ def _reconcile_connecting_state(self): self._enqueue_callbacks(self._disconnected) return - # Reconcile even if ssid is None. STATUS below tells us definitively. if current_state.status != ConnectStatus.CONNECTING: return now = time.monotonic() if now - self._last_connecting_at < CONNECTING_STALE_TIMEOUT_SECONDS: return - # Snapshot the user epoch so a STATUS reply for a stale connect attempt can't - # clobber a fresh user-initiated one that started while we were blocked below. + # Snapshot the epoch before the blocking STATUS request epoch = self._user_epoch if self._network_not_found_epoch != epoch and now - self._last_scanning_recheck < CONNECTING_STALE_TIMEOUT_SECONDS: return @@ -929,7 +890,7 @@ def _reconcile_connecting_state(self): if wpa_state == "COMPLETED" and status_ssid: self._handle_connected(status_ssid, expected_epoch=epoch) elif wpa_state == "SCANNING" and self._network_not_found_epoch != epoch: - # Hidden-SSID joins can legitimately stay in SCANNING past the stale window; defer, don't fail. + # Hidden SSIDs may remain SCANNING beyond the stale timeout self._last_scanning_recheck = time.monotonic() elif wpa_state in ("DISCONNECTED", "INACTIVE", "SCANNING", "AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): @@ -944,8 +905,7 @@ def _reconcile_connecting_state(self): and pending.ssid == ssid and not self._require_store().contains(ssid) ) else None - # Drop the unsaved runtime network so ENABLE_NETWORK all doesn't re-arm - # the failed credential for another retry. + # Remove failed unsaved credentials before re-enabling profiles try: if temporary_ssid is not None: self._remove_wpa_network(temporary_ssid) @@ -996,10 +956,7 @@ def worker(): strength = 100 if is_tethering else dbm_to_percent(strongest.signal) networks.append(Network(ssid=ssid, strength=strength, security_type=security, is_tethering=is_tethering)) - # SCAN_RESULTS command failure already early-returns above, so reaching - # here means the scan succeeded; an empty result is a real "no APs in - # range" signal (drove away, area with no wifi, etc.) and the UI must - # see vanished SSIDs disappear instead of holding a stale snapshot. + # A successful empty scan clears stale networks self._networks = networks self._update_active_connection_info() self._mark_networks_updated() @@ -1010,7 +967,6 @@ def worker(): threading.Thread(target=worker, daemon=True).start() def _poll_for_ip(self): - """Poll for IP address after DHCP starts, then update connection info.""" epoch = self._user_epoch def worker(): @@ -1058,7 +1014,7 @@ def _update_active_connection_info(self): self._current_network_metered = metered def connect_to_network(self, ssid: str, password: str, hidden: bool = False): - # Backend guard: non-UI entry points (hidden-network dialog, automation) can still reach here. + # Guard non-UI callers while tethering if self._tethering_active: cloudlog.warning(f"Ignoring connect to {ssid!r} while tethering is active") return @@ -1081,18 +1037,16 @@ def worker(): return if self._ctrl is None: cloudlog.warning("No wpa_supplicant connection") - # If a fresher attempt landed during the supplicant-restart window, don't - # let this stale worker emit a false disconnect for it. + # Ignore failures superseded by a newer connection attempt if self._user_epoch != epoch: return self._clear_pending_connection(ssid) - # _init_wifi_state is a no-op while _ctrl is None, so reset CONNECTING inline. + # Reset inline because _init_wifi_state ignores a missing control socket self._set_connecting(None) self._enqueue_callbacks(self._disconnected) return - # Recheck inside the serialization lock so a stale worker cannot remove - # the runtime network a fresher worker just added. + # Serialize the epoch check with runtime-network replacement if self._user_epoch != epoch: return try: @@ -1109,10 +1063,7 @@ def worker(): self._request("ENABLE_NETWORK all") except Exception: cloudlog.exception("Failed to re-enable saved networks after connect failure") - # Setup failed before SELECT_NETWORK could land; STATUS won't tell us - # anything useful and _init_wifi_state would silently set DISCONNECTED - # without notifying the UI. Reset CONNECTING and fire disconnected - # ourselves so the UI unsticks. + # Notify the UI when setup fails before SELECT_NETWORK self._clear_pending_connection(ssid) self._set_connecting(None) self._enqueue_callbacks(self._disconnected) @@ -1136,9 +1087,7 @@ def worker(): existed = store.contains(ssid) removed = store.remove(ssid) if existed and not removed: - # rm failed, so the on-disk file survives and _load will restore the entry - # at next start. Don't tear down the runtime/regenerate config or fire - # `forgotten`, or the UI will lie about state until the file gets restored. + # Keep runtime state when persistent removal fails cloudlog.warning(f"forget_connection: failed to remove {ssid} from disk; leaving runtime intact") self._enqueue_callbacks(self._forget_failed, ssid) return @@ -1162,9 +1111,7 @@ def worker(): self._remove_wpa_network(ssid) if not preserve_selection: self._request("ENABLE_NETWORK all") - # Reassociate only when the forgotten profile was the live link, so the - # device falls back to the next saved network. Otherwise REASSOCIATE - # would briefly drop an unrelated active connection. + # Reassociate only when forgetting the active profile if was_connected: self._request("REASSOCIATE") except Exception: @@ -1193,11 +1140,10 @@ def worker(): return if self._ctrl is None: cloudlog.warning(f"No wpa_supplicant connection for activate {ssid}") - # Skip the reset if a fresher attempt has already moved on, otherwise - # this stale worker would emit a false disconnect for the current attempt. + # Ignore failures superseded by a newer connection attempt if self._user_epoch != epoch: return - # _init_wifi_state is a no-op while _ctrl is None, so reset CONNECTING inline. + # Reset inline because _init_wifi_state ignores a missing control socket self._set_connecting(None) self._enqueue_callbacks(self._disconnected) return @@ -1209,13 +1155,11 @@ def reset_to_disconnected(): self._request("ENABLE_NETWORK all") except Exception: cloudlog.exception("Failed to re-enable saved networks after activation failure") - # Mirror the _ctrl is None recovery: _init_wifi_state silently sets DISCONNECTED - # without firing the callback, which leaves the UI wedged at CONNECTING. + # Notify the UI when control-socket recovery fails self._set_connecting(None) self._enqueue_callbacks(self._disconnected) - # Recheck inside the serialization lock so a stale worker cannot mutate - # networks added by a fresher one. + # Serialize the epoch check with saved-network activation if self._user_epoch != epoch: return try: @@ -1251,7 +1195,6 @@ def reset_to_disconnected(): threading.Thread(target=worker, daemon=True).start() def _select_network_ids(self, net_ids: list[str]): - """Make only the supplied runtime profiles eligible, then reassociate.""" commands = ["DISABLE_NETWORK all", *(f"ENABLE_NETWORK {net_id}" for net_id in net_ids), "REASSOCIATE"] for command in commands: resp = self._request(command).strip() @@ -1325,11 +1268,11 @@ def is_connection_saved(self, ssid: str) -> bool: return self._store.contains(ssid) if self._store is not None else False def set_tethering_password(self, password: str): - # WPA PSKs are 8-63 UTF-8 bytes or exactly 64 hexadecimal characters. + # WPA PSKs use 8-63 UTF-8 bytes or 64 hexadecimal characters pw_bytes = len(password.encode("utf-8")) if not is_valid_psk(password): cloudlog.warning(f"set_tethering_password: rejecting invalid password (bytes={pw_bytes})") - # Notify the UI so it re-enables the tethering controls. + # Re-enable tethering controls after rejected input self._enqueue_callbacks(self._activated if self._tethering_active else self._disconnected) return self._tethering_password_epoch += 1 @@ -1350,7 +1293,7 @@ def transition(): self._tethering_psk = password if self._tethering_active: try: - # Keep the hotspot active while the password restart is in progress. + # Keep the hotspot active during the password restart self._stop_tethering() self._start_tethering() except Exception: @@ -1373,7 +1316,7 @@ def set_ipv4_forward(self, enabled: bool): self._ipv4_forward = enabled def set_tethering_active(self, active: bool): - # Enabling is visible immediately; disabling completes after station mode is restored. + # Report enable immediately and disable after station recovery self._tethering_epoch += 1 epoch = self._tethering_epoch self._tethering_transition_pending = True @@ -1390,7 +1333,6 @@ def transition(): except Exception: cloudlog.exception("Failed to start tethering, rolling back") try: - # Safe on a partial bringup. self._stop_tethering() except Exception: cloudlog.exception("Tethering rollback also failed") @@ -1406,7 +1348,7 @@ def transition(): self._stop_tethering() except Exception: cloudlog.exception("Failed to stop tethering") - # Force-clear so the UI isn't stuck reporting tethering active. + # Clear UI state even if teardown fails self._tethering_active = False self._wifi_state = WifiState() self._ipv4_address = "" @@ -1449,7 +1391,7 @@ def _start_tethering(self): self._ctrl.close() self._ctrl = None - # Target only openpilot-owned daemons, including surviving AP instances. + # Target only openpilot-owned daemons, including surviving AP instances self._monitor_epoch += 1 stop_wpa_supplicant(WPA_SUPPLICANT_CONF) stop_wpa_supplicant(WPA_AP_CONF) @@ -1468,7 +1410,6 @@ def _start_tethering(self): subprocess.run(["sudo", "wpa_supplicant", "-B", "-i", "wlan0", "-c", WPA_AP_CONF, "-D", "nl80211"], check=False) time.sleep(1) - # Treat interface configuration failures as incomplete AP bringup. subprocess.run(["sudo", "ip", "addr", "flush", "dev", "wlan0"], check=False) subprocess.run(["sudo", "ip", "addr", "add", f"{TETHERING_IP_ADDRESS}/24", "dev", "wlan0"], check=True) subprocess.run(["sudo", "ip", "link", "set", "wlan0", "up"], check=True) @@ -1483,22 +1424,20 @@ def _start_tethering(self): "--no-daemon", "--log-queries", ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) - # Fail bringup if clients cannot obtain leases. time.sleep(0.2) if self._dnsmasq_proc.poll() is not None: rc = self._dnsmasq_proc.returncode self._dnsmasq_proc = None raise RuntimeError(f"dnsmasq exited during tethering bringup (rc={rc})") - # Flush tagged copies so repeated starts remain idempotent. + # Flush tagged copies so repeated starts remain idempotent _delete_tethering_firewall_rules() - # Firewall and forwarding failures must roll back the hotspot. for rule in _tethering_firewall_rules("-A"): subprocess.run(rule, check=True) if self._ipv4_forward: subprocess.run(["sudo", "sysctl", "net.ipv4.ip_forward=1"], check=True) - # Verify that the owned daemon has control of wlan0 in AP mode. + # Verify that our daemon owns wlan0 in AP mode if not wpa_supplicant_running(WPA_AP_CONF): raise RuntimeError("AP wpa_supplicant did not start with our config; another daemon likely still owns wlan0") try: @@ -1550,7 +1489,6 @@ def _stop_tethering(self): self._ctrl.close() self._ctrl = None - # Stop AP wpa_supplicant (only the one running our AP config). self._monitor_epoch += 1 stop_wpa_supplicant(WPA_AP_CONF) time.sleep(0.5) @@ -1582,4 +1520,4 @@ def stop(self): self._state_thread.join() if ctrl is not None: ctrl.close() - # Network daemons outlive the UI and are adopted by the next controller. + # Network daemons outlive the UI and are adopted by the next controller diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index d19fb4343dab3d..c0eefae6e3fc51 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -1,5 +1,3 @@ -"""Persistent storage for saved WiFi networks, backed by .nmconnection files.""" - import configparser import os import re @@ -21,10 +19,7 @@ RUNTIME_CONNECTIONS_DIR = "/run/NetworkManager/system-connections" NETPLAN_CONNECTIONS_DIR = "/data/etc/netplan" -# Only key-mgmt values we can actually drive via wpa_supplicant. Anything else -# (wpa-eap, sae, ieee8021x, ...) gets skipped on load. Coercing those to -# psk="" would render as key_mgmt=NONE in wpa_supplicant.conf, silently turning -# a secure profile into an open one for the same SSID and inviting open spoofing. +# Never reinterpret unsupported secured profiles as open _SUPPORTED_KEY_MGMT = {"wpa-psk", "none"} _SUPPORTED_CONNECTION_OPTIONS = { "id", "uuid", "type", "autoconnect", "autoconnect-priority", "autoconnect-retries", "timestamp", "metered", "interface-name", @@ -35,8 +30,7 @@ _SUPPORTED_IPV6_METHODS = {"auto", "ignore"} _SUPPORTED_IPV4_OPTIONS = {"method", "dns-priority"} _SUPPORTED_IPV6_OPTIONS = {"method", "addr-gen-mode"} -# NetworkManager-backed openpilot profiles use this DNS priority. The direct -# stack does not consume it, but retaining it keeps the keyfile rollback-safe. +# Preserve NetworkManager's DNS priority for rollback compatibility _OPENPILOT_DNS_PRIORITY = "600" class MeteredType(IntEnum): UNKNOWN = 0 @@ -104,12 +98,9 @@ def _keyfile_section(cp: configparser.ConfigParser, alias: str, canonical: str) class NetworkStore: - """Persistent storage for saved WiFi networks using .nmconnection files.""" - def __init__(self, directory: str = NM_CONNECTIONS_DIR, runtime_directory: str | None = None, netplan_directory: str | None = None): self._directory = directory - # Netplan exposes active keyfiles in /run without a persistent copy. Custom - # test stores remain isolated unless an explicit runtime directory is supplied. + # Import Netplan's runtime-only keyfiles on production stores self._runtime_directory = RUNTIME_CONNECTIONS_DIR if directory == NM_CONNECTIONS_DIR else None if runtime_directory is not None: self._runtime_directory = runtime_directory @@ -206,8 +197,7 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s or unsupported_connection_options): cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported connection constraints") return - # Persistent /data profiles are authoritative over netplan's runtime - # copies, including unsupported or disabled persistent profiles. + # Persistent profiles take precedence over Netplan runtime copies if imported and ssid in persistent_ssids: primary = self._networks.get(ssid) if primary is not None and file_uuid == primary.get("uuid") and primary.get("_runtime_filename") is None: @@ -215,17 +205,14 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s primary["_netplan_filename"] = self._find_netplan_filename(file_uuid) return - # An open profile has no [wifi-security] section. A secure profile with a - # key-mgmt we can't reproduce (wpa-eap, sae, ...) must be skipped entirely. + # Skip secured profiles that cannot be reproduced safely psk = "" if cp.has_section("wifi-security"): key_mgmt = cp.get("wifi-security", "key-mgmt", fallback="").lower() if key_mgmt not in _SUPPORTED_KEY_MGMT: cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported key-mgmt={key_mgmt!r}") return - # NM stores WEP as `key-mgmt=none` plus `wep-key*`/`wep-key-type`/`auth-alg=shared`. - # Loading those as open (psk="") would let generate_wpa_conf demote the secured - # SSID to key_mgmt=NONE, enabling auto-association to an open spoof of the same SSID. + # key-mgmt=none can still represent WEP; never import it as open wep_keys = ("wep-key0", "wep-key1", "wep-key2", "wep-key3", "wep-key-type", "auth-alg") if key_mgmt == "none" and any(cp.has_option("wifi-security", k) for k in wep_keys): cloudlog.warning(f"NetworkStore: skipping {ssid!r} (WEP profile, unsupported)") @@ -237,15 +224,12 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported security constraints") return psk = decode_nm_keyfile_string(cp.get("wifi-security", "psk", fallback="")) - # NM agent-managed secrets (psk-flags=1) live outside the keyfile. We can't - # drive them via wpa_supplicant, and loading with psk="" would render as - # key_mgmt=NONE, silently demoting a secure profile to open and inviting spoofs. + # Agent-managed secrets are unavailable to wpa_supplicant if key_mgmt == "wpa-psk" and not is_valid_psk(psk): cloudlog.warning(f"NetworkStore: skipping {ssid!r} (wpa-psk with invalid inline secret)") return - # connection.autoconnect=false is user/provisioning intent. Do not load it - # only to have ENABLE_NETWORK all silently re-arm the auto-join. + # Respect disabled autoconnect profiles if not cp.getboolean("connection", "autoconnect", fallback=True): cloudlog.warning(f"NetworkStore: skipping {ssid!r} (connection.autoconnect=false)") return @@ -267,7 +251,7 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported addressing configuration") return - # getint/getboolean can raise ValueError on malformed values; skip the bad profile. + # Skip profiles with malformed integer or boolean values entry = { "psk": psk, "metered": cp.getint("connection", "metered", fallback=0), @@ -278,7 +262,7 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s "_connection": connection, "_ipv4": ipv4, "_ipv6": ipv6, - # Remember the on-disk filename so save/remove stay consistent with noncanonical files. + # Track the source filename for noncanonical profiles "_filename": None if imported else fname, "_runtime_filename": fname if imported else None, "_netplan_filename": self._find_netplan_filename(file_uuid) if imported else None, @@ -458,13 +442,11 @@ def cleanup_canonical_after_failure() -> bool: raise OSError(f"failed to remove {netplan_path}") entry["_netplan_filename"] = None - # Keep one canonical filename even when the tracked profile uses another name. + # Keep one canonical filename for noncanonical profiles if stored_fname and stored_fname != canonical_fname: stored_path = os.path.join(self._directory, stored_fname) result = subprocess.run(["sudo", "rm", "-f", stored_path], check=False) - # If cleanup fails (FS read-only, etc.) both files survive. Make both files - # hold the same content so they remain one UUID-equivalent profile. Pin - # _filename to the stored name so each update retries the cleanup. + # Mirror failed noncanonical cleanup so both copies remain equivalent if result.returncode != 0: cloudlog.warning(f"NetworkStore: cleanup of noncanonical {stored_fname} failed; mirroring content to keep both files in sync") try: @@ -643,7 +625,7 @@ def remove(self, ssid: str) -> bool: return False profiles = list(self._profiles.get(ssid, [entry])) - # Remove every representation so a duplicate cannot restore the network. + # Remove every representation so duplicates cannot restore the network paths: set[str] = set() netplan_paths: set[str] = set() for profile in profiles: @@ -684,7 +666,7 @@ def remove(self, ssid: str) -> bool: else: for p in existing_paths: result = subprocess.run(["sudo", "rm", "-f", p], check=False) - # Keep the in-memory entry when disk removal fails. + # Keep the in-memory profile when disk removal fails if result.returncode != 0: cloudlog.warning(f"NetworkStore: failed to remove {p} (rc={result.returncode})") return False diff --git a/openpilot/system/ui/lib/wpa_ctrl.py b/openpilot/system/ui/lib/wpa_ctrl.py index 3e937b9780f51b..cfbba149708b32 100644 --- a/openpilot/system/ui/lib/wpa_ctrl.py +++ b/openpilot/system/ui/lib/wpa_ctrl.py @@ -1,4 +1,3 @@ -"""wpa_supplicant control socket client and parsing helpers.""" import os import re import shutil @@ -131,8 +130,7 @@ def request(self, cmd: str) -> str: return sock.recv(RECV_BUF_SIZE).decode("utf-8", "replace") def close(self): - # Serialize against request() so close() waits for in-flight send/recv - # instead of ripping the fd out from under a concurrent caller. + # Let in-flight requests finish before closing the socket with self._request_lock: super().close() @@ -239,8 +237,7 @@ def decode_ssid(encoded: str) -> str: val = val * 8 + (ord(encoded[i]) - ord("0")) i += 1 out.append(val & 0xff) - # else: unknown escape. The backslash is consumed and the char falls - # through to the next iteration and is appended as a literal. + # Unknown escapes consume only the backslash if not out or all(b == 0 for b in out): return "" @@ -250,9 +247,7 @@ def decode_ssid(encoded: str) -> str: def parse_scan_results(raw: str) -> list[ScanResult]: """Parse wpa_supplicant SCAN_RESULTS output (tab-separated, first line is header).""" results = [] - # Don't .strip() the whole payload: SSIDs may legally end with spaces and - # wpa_supplicant leaves printable spaces unescaped, so a global strip would - # clip the last line's trailing-space SSID. + # Preserve legal trailing spaces in the final SSID lines = raw.splitlines() if len(lines) < 2: return results @@ -285,21 +280,16 @@ def flags_to_security_type(flags: str) -> SecurityType: if "WEP" in flags_upper: return SecurityType.UNSUPPORTED - # WPA2/WPA3 transitional networks advertise both PSK and SAE; PSK matches first - # and connects via WPA-PSK. Pure WPA3-Personal (SAE-only) falls through below. + # Transitional PSK+SAE networks remain usable through WPA-PSK if any(re.search(r"(?:^|\+)(?:(?:WPA2|RSN|WPA)-)?PSK(?!-SHA256)(?:[-+]|$)", group) for group in flag_groups): return SecurityType.WPA # Enterprise / 802.1X without a usable PSK suite → unsupported if "EAP" in flags_upper or "802.1X" in flags_upper: return SecurityType.UNSUPPORTED - # SAE-only: would need key_mgmt=SAE, which the current AGNOS kernel + wpa_supplicant - # build doesn't support. Mark unsupported so the UI doesn't prompt for a password - # only to fail the handshake. Becomes connectable on vamOS + mainline kernel. + # SAE-only is unsupported by the current AGNOS stack if "SAE" in flags_upper: return SecurityType.UNSUPPORTED - # These key-management modes are secured but do not use WPA-PSK. Treating - # them as open would configure key_mgmt=NONE and either fail or downgrade a - # transition network. + # Secured non-PSK modes must not fall through as open if any(mode in flags_upper for mode in ("OWE", "DPP", "OSEN", "FILS")): # codespell:ignore fils return SecurityType.UNSUPPORTED @@ -477,17 +467,13 @@ def ensure_wpa_supplicant(should_exit: Callable[[], bool], station_reconfigured: on_abandoned_ap: Callable[[], None] | None = None) -> WpaCtrl | None: """Attach to a wpa_supplicant we own, or spawn one. Never attach to NM's daemon. Returns the attached WpaCtrl, or None if ownership cannot be acquired.""" - # Wait for wlan0 on cold boot; _unmanage_wlan0 below silently fails if it's missing. - # If shutdown is requested while wlan0 is still absent, bail so stop() can't - # end up triggering _unmanage_wlan0 / pkill / ip flush after teardown. + # Wait for wlan0 without allowing teardown to mutate it afterward while not os.path.exists("/sys/class/net/wlan0"): if should_exit(): return None time.sleep(0.5) - # AP adoption: a hotspot owned by another UI process is still up, and STA cleanup would tear it down. - # Retry on transient ctrl unavailability (UI just restarted, AP socket briefly unbound) - # rather than falling through to STA cleanup, which would kill dnsmasq and flush wlan0. + # Retry attaching to an adopted AP before tearing it down if wpa_supplicant_running(WPA_AP_CONF): for _ in range(3): if should_exit(): @@ -496,10 +482,7 @@ def ensure_wpa_supplicant(should_exit: Callable[[], bool], station_reconfigured: if ctrl is not None: return ctrl time.sleep(0.5) - # AP process is alive but its ctrl socket is unreachable (deleted/wedged). The - # hotspot is unmanageable from our side, so kill it and fall through to STA - # spawn. Otherwise we'd loop forever returning None and the user cannot recover - # via tethering toggle since `_start_tethering` only kills STA-config daemons. + # Replace an owned AP whose control socket remains unreachable cloudlog.warning("AP daemon present but ctrl attach failed; killing it so STA spawn can recover") stop_wpa_supplicant(WPA_AP_CONF) if on_abandoned_ap is not None: @@ -508,7 +491,7 @@ def ensure_wpa_supplicant(should_exit: Callable[[], bool], station_reconfigured: except Exception: cloudlog.exception("Failed to clean up abandoned AP services") - # Our own STA daemon is still alive, so attach without disturbing NM. + # Reuse our station daemon without disturbing NetworkManager if wpa_supplicant_running(WPA_SUPPLICANT_CONF): if should_exit(): return None @@ -527,7 +510,7 @@ def ensure_wpa_supplicant(should_exit: Callable[[], bool], station_reconfigured: cloudlog.exception("Failed to reconcile running station configuration") ctrl.close() - # Honor cancellation before mutating NM / killing daemons / flushing IPs. + # Stop before mutating network state if should_exit(): return None @@ -535,8 +518,7 @@ def ensure_wpa_supplicant(should_exit: Callable[[], bool], station_reconfigured: cloudlog.warning("NetworkManager handoff failed; deferring station bringup") return None - # NM teardown is async (~800ms): wait for NM's ctrl socket to disappear before - # attaching or spawning, otherwise we bind to a socket NM is about to delete. + # Wait for NetworkManager's asynchronously removed control socket for _ in range(30): if should_exit(): return None @@ -544,10 +526,10 @@ def ensure_wpa_supplicant(should_exit: Callable[[], bool], station_reconfigured: break time.sleep(0.1) else: - # Socket still held by NM; the post-spawn pgrep gate below is the fallback. + # The post-spawn ownership check handles a lingering NetworkManager socket cloudlog.warning("/var/run/wpa_supplicant/wlan0 still present after NM unmanage; spawn will refuse to attach to foreign daemon") - # Target only OUR config so a system-managed daemon on another config survives. + # Stop only daemons using our config stop_wpa_supplicant(WPA_SUPPLICANT_CONF) stop_tethering_dnsmasq() subprocess.run(["sudo", "ip", "addr", "flush", "dev", "wlan0"], check=False) @@ -555,8 +537,7 @@ def ensure_wpa_supplicant(should_exit: Callable[[], bool], station_reconfigured: subprocess.run(["sudo", "wpa_supplicant", "-B", "-i", "wlan0", "-c", WPA_SUPPLICANT_CONF, "-D", "nl80211"], check=False) - # Gate on pgrep matching OUR config so we refuse to attach to NM's daemon if its - # teardown didn't finish above. + # Never attach to a daemon not using our config for _ in range(30): if should_exit(): return None diff --git a/openpilot/system/ui/widgets/network.py b/openpilot/system/ui/widgets/network.py index 1d6430974f4d42..34f712fe66be60 100644 --- a/openpilot/system/ui/widgets/network.py +++ b/openpilot/system/ui/widgets/network.py @@ -243,8 +243,7 @@ def update_password(result: DialogResult): password = self._keyboard.text self._wifi_manager.set_tethering_password(password) - # Only debounce while tethering is actually bouncing. When tethering - # is off, set_tethering_password doesn't emit activated/disconnected. + # Debounce only while an active hotspot restarts if self._wifi_manager.is_tethering_active(): self._tethering_password_action.set_enabled(False) From 322737a422afb0c779775bc057104909527e4278 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Tue, 18 Aug 2026 18:23:43 +0000 Subject: [PATCH 32/71] wifi: make profile persistence transactional --- .../system/ui/lib/tests/test_network_store.py | 107 ++++++++++++- openpilot/system/ui/lib/wifi_network_store.py | 151 +++++++++++++----- 2 files changed, 212 insertions(+), 46 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index 2530fdf1f0319e..21d1e0e7463467 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -9,7 +9,7 @@ from openpilot.system.ui.lib import wifi_network_store as store_module from openpilot.system.ui.lib.wifi_network_store import NetworkStore -from openpilot.system.ui.lib.wpa_ctrl import generate_wpa_conf +from openpilot.system.ui.lib.wpa_ctrl import SecurityType, generate_wpa_conf def profile_uuid(name: str) -> str: @@ -87,6 +87,37 @@ def run_file_command(self, command, **_): Path(command[-1]).unlink(missing_ok=True) return MagicMock(returncode=0) + def test_startup_deletes_stale_update_backup_when_original_exists(self): + original = Path(write_profile( + self.persistent, "saved.nmconnection", "Saved", file_uuid="saved", psk="new-password", + )) + remnant = Path(write_profile( + self.persistent, + f"{original.name}.openpilot-update-{'a' * 32}", + "Saved", + file_uuid="saved", + psk="old-password", + )) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + + assert original.exists() + assert not remnant.exists() + assert require_entry(store, "Saved")["psk"] == "new-password" + + def test_startup_restores_interrupted_forget_stage(self): + original = Path(write_profile(self.persistent, "saved.nmconnection", "Saved", file_uuid="saved")) + remnant = Path(f"{original}.openpilot-forget-{'b' * 32}") + original.replace(remnant) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + + assert original.exists() + assert not remnant.exists() + assert store.contains("Saved") + def test_loads_persistent_and_open_profiles(self): write_profile(self.persistent, "secure.nmconnection", "Secure") write_profile(self.persistent, "open.nmconnection", "Open", psk=None) @@ -96,6 +127,22 @@ def test_loads_persistent_and_open_profiles(self): assert require_entry(store, "Secure")["psk"] == "password123" assert require_entry(store, "Open")["psk"] == "" + def test_explicit_open_security_ignores_stale_psk(self): + write_profile( + self.persistent, + "open.nmconnection", + "Open", + psk="stale-password", + key_mgmt="none", + ) + + with self.patch_reads(): + store = self.make_store() + + entry = require_entry(store, "Open") + assert entry["security"] == SecurityType.OPEN + assert entry["psk"] == "" + def test_loads_canonical_networkmanager_sections(self): path = Path(write_profile(self.persistent, "canonical.nmconnection", "Canonical")) @@ -319,6 +366,29 @@ def test_updates_persistent_tethering_password(self): assert "psk = new-password" in path.read_text() + def test_tethering_update_restores_existing_target_when_runtime_cleanup_fails(self): + shared_uuid = "shared-hotspot-uuid" + persistent_path = Path(write_profile( + self.persistent, "hotspot.nmconnection", "weedle", file_uuid=shared_uuid, psk="old-password", mode="ap", + )) + runtime_path = Path(write_profile( + self.runtime, "runtime-hotspot.nmconnection", "weedle", file_uuid=shared_uuid, psk="old-password", mode="ap", + )) + original = persistent_path.read_text() + + def run(command, **kwargs): + if command[:3] == ["sudo", "mv", "-f"] and command[-2] == str(runtime_path): + return MagicMock(returncode=1) + return self.run_file_command(command, **kwargs) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run): + store = self.make_store() + with self.assertRaises(OSError): + store.set_tethering_password("weedle", "new-password") + + assert persistent_path.read_text() == original + assert runtime_path.exists() + def test_persists_runtime_tethering_profile_for_rollback(self): runtime_path = Path(write_profile( self.runtime, "netplan-hotspot.nmconnection", "weedle", file_uuid="hotspot-uuid", psk="old-password", mode="ap", @@ -350,7 +420,7 @@ def test_runtime_profiles_remain_live_sources_despite_stale_marker(self): def test_persistent_profile_wins_runtime_duplicate(self): write_profile(self.persistent, "persistent.nmconnection", "Duplicate", psk="persistent") - write_profile(self.runtime, "runtime.nmconnection", "Duplicate", psk="runtime") + write_profile(self.runtime, "runtime.nmconnection", "Duplicate", psk="runtime-password") with self.patch_reads(): store = self.make_store() @@ -359,7 +429,7 @@ def test_persistent_profile_wins_runtime_duplicate(self): def test_edit_persistent_profile_removes_shadowed_runtime_copy(self): write_profile(self.persistent, "persistent.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="persistent") - runtime_path = Path(write_profile(self.runtime, "runtime.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="runtime")) + runtime_path = Path(write_profile(self.runtime, "runtime.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="runtime-password")) netplan_path = Path(self.netplan, f"90-NM-{profile_uuid('shared-uuid')}.yaml") netplan_path.write_text("network:\n version: 2\n") @@ -533,23 +603,46 @@ def blocking_render(*args, **kwargs): assert not writer.is_alive() assert not reader.is_alive() - def test_unsupported_persistent_profile_blocks_runtime_duplicate(self): + def test_unsupported_persistent_profile_does_not_block_valid_runtime_profile(self): write_profile(self.persistent, "persistent.nmconnection", "Enterprise", psk=None, key_mgmt="wpa-eap", extra_security="identity=user") write_profile(self.runtime, "runtime.nmconnection", "Enterprise") with self.patch_reads(): store = self.make_store() - assert store.get("Enterprise") is None + assert require_entry(store, "Enterprise")["psk"] == "password123" - def test_persistent_profile_with_unsupported_wifi_options_blocks_runtime_duplicate(self): + def test_invalid_persistent_wifi_options_do_not_block_valid_runtime_profile(self): write_profile(self.persistent, "persistent.nmconnection", "Randomized", extra_wifi="cloned-mac-address=stable") write_profile(self.runtime, "runtime.nmconnection", "Randomized") with self.patch_reads(): store = self.make_store() - assert store.get("Randomized") is None + assert require_entry(store, "Randomized")["psk"] == "password123" + + def test_runtime_shadow_matches_every_persistent_profile_uuid(self): + write_profile(self.persistent, "first.nmconnection", "Duplicate", file_uuid="first-uuid", psk="first-password") + write_profile(self.persistent, "second.nmconnection", "Duplicate", file_uuid="second-uuid", psk="second-password") + write_profile(self.runtime, "runtime.nmconnection", "Duplicate", file_uuid="second-uuid", psk="runtime-password") + + with self.patch_reads(): + store = self.make_store() + + profiles = [entry for ssid, entry in store.get_profiles() if ssid == "Duplicate"] + assert len(profiles) == 2 + second = next(entry for entry in profiles if entry["uuid"] == profile_uuid("second-uuid")) + assert second["psk"] == "second-password" + assert second["_runtime_filename"] == "runtime.nmconnection" + + def test_new_profile_uses_preallocated_uuid(self): + selected_uuid = profile_uuid("selected") + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + store.save_network("Selected", psk="password123", profile_uuid=selected_uuid) + + assert require_entry(store, "Selected")["uuid"] == selected_uuid + assert Path(self.persistent, f"{selected_uuid}-Selected.nmconnection").exists() def test_forget_runtime_profile_removes_netplan_source(self): write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index c0eefae6e3fc51..88c003a55a5c50 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -12,7 +12,7 @@ from openpilot.common.swaglog import cloudlog from openpilot.common.nm_keyfile import decode_nm_keyfile_ssid, decode_nm_keyfile_string from openpilot.common.utils import sudo_read -from openpilot.system.ui.lib.wpa_ctrl import is_valid_psk, is_valid_ssid +from openpilot.system.ui.lib.wpa_ctrl import SecurityType, is_valid_psk, is_valid_ssid NM_CONNECTIONS_DIR = "/data/etc/NetworkManager/system-connections" @@ -32,6 +32,9 @@ _SUPPORTED_IPV6_OPTIONS = {"method", "addr-gen-mode"} # Preserve NetworkManager's DNS priority for rollback compatibility _OPENPILOT_DNS_PRIORITY = "600" +_TRANSACTION_REMNANT_RE = re.compile(r"^(?P.+)\.openpilot-(?:update|forget)-[0-9a-f]{32}$") + + class MeteredType(IntEnum): UNKNOWN = 0 YES = 1 @@ -111,8 +114,35 @@ def __init__(self, directory: str = NM_CONNECTIONS_DIR, runtime_directory: str | self._mutation_lock = threading.Lock() self._networks: dict[str, dict] = {} self._profiles: dict[str, list[dict]] = {} + self._recover_transaction_remnants() self._load() + def _recover_transaction_remnants(self): + directories = dict.fromkeys(( + self._directory, + self._runtime_directory, + self._netplan_directory, + )) + for directory in directories: + if directory is None: + continue + try: + filenames = sorted(os.listdir(directory)) + except OSError: + continue + for filename in filenames: + match = _TRANSACTION_REMNANT_RE.fullmatch(filename) + if match is None: + continue + remnant_path = os.path.join(directory, filename) + original_path = os.path.join(directory, match.group("original")) + command = ["sudo", "rm", "-f", remnant_path] if os.path.exists(original_path) else [ + "sudo", "mv", "-f", remnant_path, original_path, + ] + result = subprocess.run(command, check=False) + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to recover transaction remnant {remnant_path} (rc={result.returncode})") + def _load(self): self._networks = {} self._profiles = {} @@ -120,14 +150,14 @@ def _load(self): if self._runtime_directory is not None: sources.append((self._runtime_directory, True)) - persistent_ssids: set[str] = set() + persistent_uuids: dict[str, set[str]] = {} for directory, imported in sources: try: filenames = sorted(os.listdir(directory)) except OSError: continue for fname in filenames: - self._load_keyfile(directory, fname, imported, persistent_ssids) + self._load_keyfile(directory, fname, imported, persistent_uuids) def _find_netplan_filename(self, file_uuid: str) -> str | None: if self._netplan_directory is None or not file_uuid: @@ -154,7 +184,7 @@ def _find_netplan_filename(self, file_uuid: str) -> str | None: return fname return expected if read_failed else None - def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_ssids: set[str]): + def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_uuids: dict[str, set[str]]): if not fname.endswith(".nmconnection"): return fpath = os.path.join(directory, fname) @@ -173,8 +203,6 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s mode = cp.get("wifi", "mode", fallback="infrastructure") if not is_valid_ssid(ssid) or mode != "infrastructure": return - if not imported: - persistent_ssids.add(ssid) if {key for key, value in cp.items("wifi") if value} - _SUPPORTED_WIFI_OPTIONS: cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported Wi-Fi options") return @@ -197,22 +225,15 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s or unsupported_connection_options): cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported connection constraints") return - # Persistent profiles take precedence over Netplan runtime copies - if imported and ssid in persistent_ssids: - primary = self._networks.get(ssid) - if primary is not None and file_uuid == primary.get("uuid") and primary.get("_runtime_filename") is None: - primary["_runtime_filename"] = fname - primary["_netplan_filename"] = self._find_netplan_filename(file_uuid) - return - - # Skip secured profiles that cannot be reproduced safely + # Skip secured profiles that cannot be reproduced safely. + security = SecurityType.OPEN psk = "" if cp.has_section("wifi-security"): key_mgmt = cp.get("wifi-security", "key-mgmt", fallback="").lower() if key_mgmt not in _SUPPORTED_KEY_MGMT: cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported key-mgmt={key_mgmt!r}") return - # key-mgmt=none can still represent WEP; never import it as open + # key-mgmt=none can still represent WEP; never import it as open. wep_keys = ("wep-key0", "wep-key1", "wep-key2", "wep-key3", "wep-key-type", "auth-alg") if key_mgmt == "none" and any(cp.has_option("wifi-security", k) for k in wep_keys): cloudlog.warning(f"NetworkStore: skipping {ssid!r} (WEP profile, unsupported)") @@ -223,11 +244,13 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s if unsupported_security_options or auth_alg not in ("", "open") or psk_flags != 0: cloudlog.warning(f"NetworkStore: skipping {ssid!r} with unsupported security constraints") return - psk = decode_nm_keyfile_string(cp.get("wifi-security", "psk", fallback="")) - # Agent-managed secrets are unavailable to wpa_supplicant - if key_mgmt == "wpa-psk" and not is_valid_psk(psk): - cloudlog.warning(f"NetworkStore: skipping {ssid!r} (wpa-psk with invalid inline secret)") - return + if key_mgmt == "wpa-psk": + security = SecurityType.WPA + psk = decode_nm_keyfile_string(cp.get("wifi-security", "psk", fallback="")) + # Agent-managed secrets are unavailable to wpa_supplicant. + if not is_valid_psk(psk): + cloudlog.warning(f"NetworkStore: skipping {ssid!r} (wpa-psk with invalid inline secret)") + return # Respect disabled autoconnect profiles if not cp.getboolean("connection", "autoconnect", fallback=True): @@ -254,6 +277,7 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s # Skip profiles with malformed integer or boolean values entry = { "psk": psk, + "security": security, "metered": cp.getint("connection", "metered", fallback=0), "priority": cp.getint("connection", "autoconnect-priority", fallback=0), "hidden": cp.getboolean("wifi", "hidden", fallback=False), @@ -268,9 +292,17 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_s "_netplan_filename": self._find_netplan_filename(file_uuid) if imported else None, } profiles = self._profiles.setdefault(ssid, []) - if file_uuid and any(profile.get("uuid") == file_uuid for profile in profiles): + if imported and file_uuid in persistent_uuids.get(ssid, set()): + persistent = next((profile for profile in profiles if profile.get("uuid") == file_uuid), None) + if persistent is not None and persistent.get("_runtime_filename") is None: + persistent["_runtime_filename"] = fname + persistent["_netplan_filename"] = self._find_netplan_filename(file_uuid) + return + if any(profile.get("uuid") == file_uuid for profile in profiles): return profiles.append(entry) + if not imported: + persistent_uuids.setdefault(ssid, set()).add(file_uuid) self._networks.setdefault(ssid, entry) except (configparser.Error, ValueError): return @@ -386,7 +418,8 @@ def _render_nmconnection(self, ssid: str, entry: dict) -> tuple[str, dict]: cp["wifi"] = wifi psk = entry.get("psk", "") - if psk: + security = entry.get("security", SecurityType.WPA if psk else SecurityType.OPEN) + if security == SecurityType.WPA: cp["wifi-security"] = { "key-mgmt": "wpa-psk", "psk": _encode_keyfile_string(psk), @@ -554,33 +587,72 @@ def set_tethering_password(self, ssid: str, password: str) -> bool: if netplan_path is not None and not os.path.exists(netplan_path): return False - self._install_keyfile(cp, target_path) - - def cleanup_target_after_failure() -> bool: - if target_existed: - return True - return subprocess.run(["sudo", "rm", "-f", target_path], check=False).returncode == 0 - - for source_path in (runtime_path, netplan_path): - if source_path is None: - continue - result = subprocess.run(["sudo", "rm", "-f", source_path], check=False) + token = uuid.uuid4().hex + target_backup = f"{target_path}.openpilot-update-{token}" if target_existed else None + if target_backup is not None: + result = subprocess.run([ + "sudo", "install", "-o", "root", "-g", "root", "-m", "600", target_path, target_backup, + ], check=False) if result.returncode != 0: - if not cleanup_target_after_failure(): - raise OSError(f"failed to remove {source_path} and roll back {target_path}") - raise OSError(f"failed to remove {source_path}") + raise OSError(f"failed to back up {target_path}") + + staged_sources: list[tuple[str, str]] = [] + try: + self._install_keyfile(cp, target_path) + for source_path in (runtime_path, netplan_path): + if source_path is None: + continue + staged_path = f"{source_path}.openpilot-update-{token}" + result = subprocess.run(["sudo", "mv", "-f", source_path, staged_path], check=False) + if result.returncode != 0: + raise OSError(f"failed to stage {source_path}") + staged_sources.append((source_path, staged_path)) + except Exception as e: + rollback_failed = False + for source_path, staged_path in reversed(staged_sources): + rollback_failed |= subprocess.run(["sudo", "mv", "-f", staged_path, source_path], check=False).returncode != 0 + if target_backup is not None: + rollback_failed |= subprocess.run(["sudo", "mv", "-f", target_backup, target_path], check=False).returncode != 0 + else: + rollback_failed |= subprocess.run(["sudo", "rm", "-f", target_path], check=False).returncode != 0 + if rollback_failed: + raise OSError(f"failed to roll back tethering password update for {ssid}") from e + raise + + for _, staged_path in staged_sources: + if subprocess.run(["sudo", "rm", "-f", staged_path], check=False).returncode != 0: + cloudlog.warning(f"NetworkStore: failed to clean up staged tethering source {staged_path}") + if target_backup is not None and subprocess.run(["sudo", "rm", "-f", target_backup], check=False).returncode != 0: + cloudlog.warning(f"NetworkStore: failed to clean up tethering backup {target_backup}") return True - def save_network(self, ssid: str, psk: str | None = None, metered: int | None = None, hidden: bool | None = None): + def save_network(self, ssid: str, psk: str | None = None, metered: int | None = None, hidden: bool | None = None, + security: SecurityType | None = None, profile_uuid: str | None = None): with self._mutation_lock: with self._lock: current = self._networks.get(ssid) profiles = list(self._profiles.get(ssid, [])) existing = dict(current or {}) + if profile_uuid is not None: + canonical_uuid = _parse_uuid(profile_uuid) + if canonical_uuid is None: + raise ValueError(f"invalid profile UUID: {profile_uuid!r}") + if current is not None and current.get("uuid") != canonical_uuid: + raise ValueError(f"profile UUID changed for {ssid!r}") + existing["uuid"] = canonical_uuid if psk is not None: existing["psk"] = psk - elif "psk" not in existing: + existing["security"] = security if security is not None else (SecurityType.WPA if psk else SecurityType.OPEN) + else: + existing.setdefault("psk", "") + if security is not None: + existing["security"] = security + else: + existing.setdefault("security", SecurityType.WPA if existing["psk"] else SecurityType.OPEN) + if existing["security"] == SecurityType.OPEN: existing["psk"] = "" + elif not is_valid_psk(existing["psk"]): + raise ValueError(f"invalid WPA PSK for {ssid!r}") if metered is not None: existing["metered"] = metered elif "metered" not in existing: @@ -605,6 +677,7 @@ def save_network(self, ssid: str, psk: str | None = None, metered: int | None = elif psk is not None and not profile.get("bssid"): duplicate = dict(profile) duplicate["psk"] = psk + duplicate["security"] = SecurityType.WPA if psk else SecurityType.OPEN duplicate_uuid, duplicate = self._render_nmconnection(ssid, duplicate) duplicate["uuid"] = duplicate_uuid updated_profiles.append(duplicate) From 844e6f729f95492806d413fba608f9e931f76b35 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Tue, 18 Aug 2026 18:24:39 +0000 Subject: [PATCH 33/71] wifi: make daemon and route ownership explicit --- openpilot/common/hardware/comma/hardware.py | 27 ++-- .../hardware/comma/tests/test_hardware.py | 40 ++++- openpilot/common/wifi.py | 67 +++++++++ openpilot/system/ui/lib/dhcp_client.py | 46 +++++- .../system/ui/lib/tests/test_dhcp_client.py | 93 +++++++++++- .../system/ui/lib/tests/test_wpa_ctrl.py | 46 ++++-- openpilot/system/ui/lib/udhcpc.script | 21 ++- openpilot/system/ui/lib/wpa_ctrl.py | 142 +++++++----------- 8 files changed, 351 insertions(+), 131 deletions(-) create mode 100644 openpilot/common/wifi.py diff --git a/openpilot/common/hardware/comma/hardware.py b/openpilot/common/hardware/comma/hardware.py index a58797321ff4a2..b9bf6097b63956 100644 --- a/openpilot/common/hardware/comma/hardware.py +++ b/openpilot/common/hardware/comma/hardware.py @@ -9,6 +9,7 @@ from openpilot.cereal import log from openpilot.common.nm_keyfile import decode_nm_keyfile_ssid +from openpilot.common.wifi import WPA_CTRL_PATH, decode_wpa_ssid from openpilot.common.utils import sudo_read, sudo_write from openpilot.common.gpio import gpio_set, gpio_init, get_irqs_for_action from openpilot.common.esim.base import LPABase @@ -42,7 +43,7 @@ def wpa_supplicant_cmd(cmd: str, timeout: float = 0.2) -> dict[str, str]: with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock: sock.settimeout(timeout) sock.bind(f"\0openpilot-wpa-{os.getpid()}-{time.monotonic_ns()}") - sock.connect("/run/wpa_supplicant/wlan0") + sock.connect(WPA_CTRL_PATH) sock.send(cmd.encode()) while True: @@ -212,23 +213,25 @@ def get_network_metered(self, network_type) -> bool: return Params().get_bool("GsmMetered") try: if network_type == NetworkType.wifi: - ssid = wpa_supplicant_cmd("STATUS").get("ssid", "") - if ssid: - # wpa_supplicant escapes non-printable bytes as \xNN - ssid_bytes = ssid.encode().decode('unicode_escape').encode('latin-1') - - nm_dirs = ("/run/NetworkManager/system-connections", "/data/etc/NetworkManager/system-connections") - for fpath in (p for d in nm_dirs for p in Path(d).glob("*.nmconnection")): + status = wpa_supplicant_cmd("STATUS") + profile_uuid = status.get("id_str", "").strip('"') + ssid = decode_wpa_ssid(status.get("ssid", "")) + if profile_uuid or ssid: + nm_dirs = ("/data/etc/NetworkManager/system-connections", "/run/NetworkManager/system-connections") + for fpath in (path for directory in nm_dirs for path in Path(directory).glob("*.nmconnection")): raw = sudo_read(str(fpath)) if not raw: continue cp = configparser.ConfigParser(interpolation=None) try: cp.read_string(raw) - wifi_section = "wifi" if cp.has_section("wifi") else "802-11-wireless" - keyfile_ssid = decode_nm_keyfile_ssid(cp.get(wifi_section, "ssid", fallback="")) - if keyfile_ssid.encode("utf-8", errors="surrogateescape") != ssid_bytes: - continue + if profile_uuid: + if cp.get("connection", "uuid", fallback="") != profile_uuid: + continue + else: + wifi_section = "wifi" if cp.has_section("wifi") else "802-11-wireless" + if decode_nm_keyfile_ssid(cp.get(wifi_section, "ssid", fallback="")) != ssid: + continue metered = cp.getint("connection", "metered", fallback=0) except (configparser.Error, ValueError): continue diff --git a/openpilot/common/hardware/comma/tests/test_hardware.py b/openpilot/common/hardware/comma/tests/test_hardware.py index dda62ae9aa756f..fda89888c90a98 100644 --- a/openpilot/common/hardware/comma/tests/test_hardware.py +++ b/openpilot/common/hardware/comma/tests/test_hardware.py @@ -1,6 +1,6 @@ from pathlib import Path from unittest import TestCase -from unittest.mock import patch +from unittest.mock import MagicMock, patch from openpilot.common.hardware.comma import hardware as hardware_module from openpilot.common.hardware.comma.hardware import HardwareComma, NetworkType @@ -38,3 +38,41 @@ def test_escaped_wifi_profile_metered(self): patch.object(hardware_module, "sudo_read", return_value=profile), ): assert HardwareComma().get_network_metered(NetworkType.wifi) + def test_selected_profile_uuid_controls_metering(self): + first_uuid = "11111111-1111-1111-1111-111111111111" + second_uuid = "22222222-2222-2222-2222-222222222222" + profiles = { + "first.nmconnection": f"""\\ +[connection] +uuid={first_uuid} +metered=1 + +[wifi] +ssid=Duplicate +""", + "second.nmconnection": f"""\\ +[connection] +uuid={second_uuid} +metered=2 + +[wifi] +ssid=Duplicate +""", + } + + with ( + patch.object(hardware_module, "wpa_supplicant_cmd", return_value={"ssid": "Duplicate", "id_str": second_uuid}), + patch.object(Path, "glob", return_value=[Path(name) for name in profiles]), + patch.object(hardware_module, "sudo_read", side_effect=lambda path: profiles[path]), + ): + assert not HardwareComma().get_network_metered(NetworkType.wifi) + + def test_hardware_uses_owned_control_socket(self): + sock = MagicMock() + sock.__enter__.return_value = sock + sock.recv.return_value = b"FAIL\\n" + + with patch.object(hardware_module.socket, "socket", return_value=sock): + assert hardware_module.wpa_supplicant_cmd("STATUS") == {} + + sock.connect.assert_called_once_with("/run/openpilot-wpa/wlan0") diff --git a/openpilot/common/wifi.py b/openpilot/common/wifi.py new file mode 100644 index 00000000000000..ebf219a2c0be56 --- /dev/null +++ b/openpilot/common/wifi.py @@ -0,0 +1,67 @@ +import os + + +WPA_CTRL_DIR = "/run/openpilot-wpa" +WPA_CTRL_PATH = os.path.join(WPA_CTRL_DIR, "wlan0") +WPA_PID_FILE = os.path.join(WPA_CTRL_DIR, "wpa_supplicant.pid") + +_HEX = "0123456789abcdefABCDEF" + + +def decode_wpa_ssid(encoded: str) -> str: + """Decode a wpa_supplicant printf_encode'd SSID without losing byte identity.""" + out = bytearray() + i = 0 + while i < len(encoded): + char = encoded[i] + if char != "\\": + out.append(ord(char) & 0xff) + i += 1 + continue + + i += 1 + if i >= len(encoded): + break + + escaped = encoded[i] + if escaped == "\\": + out.append(ord("\\")) + i += 1 + elif escaped == '"': + out.append(ord('"')) + i += 1 + elif escaped == "n": + out.append(ord("\n")) + i += 1 + elif escaped == "r": + out.append(ord("\r")) + i += 1 + elif escaped == "t": + out.append(ord("\t")) + i += 1 + elif escaped == "e": + out.append(0x1b) + i += 1 + elif escaped == "x": + i += 1 + if i + 1 < len(encoded) and encoded[i] in _HEX and encoded[i + 1] in _HEX: + out.append(int(encoded[i:i + 2], 16)) + i += 2 + elif i < len(encoded) and encoded[i] in _HEX: + out.append(int(encoded[i], 16)) + i += 1 + elif "0" <= escaped <= "7": + value = ord(escaped) - ord("0") + i += 1 + if i < len(encoded) and "0" <= encoded[i] <= "7": + value = value * 8 + ord(encoded[i]) - ord("0") + i += 1 + if i < len(encoded) and "0" <= encoded[i] <= "7": + value = value * 8 + ord(encoded[i]) - ord("0") + i += 1 + out.append(value & 0xff) + # Unknown escapes consume only the backslash. + + if not out or all(byte == 0 for byte in out): + return "" + return out.decode("utf-8", errors="surrogateescape") diff --git a/openpilot/system/ui/lib/dhcp_client.py b/openpilot/system/ui/lib/dhcp_client.py index 148825bc76f2f3..456ccd3859cb0b 100644 --- a/openpilot/system/ui/lib/dhcp_client.py +++ b/openpilot/system/ui/lib/dhcp_client.py @@ -1,5 +1,5 @@ import os -import re +from pathlib import Path import subprocess import threading @@ -7,6 +7,7 @@ DHCP_SCRIPT = os.path.join(os.path.dirname(__file__), "udhcpc.script") DHCP_DEFAULT_SCRIPT = "/etc/udhcpc/default.script" +DHCP_RUNTIME_DIR = "/run/openpilot-wifi" class DhcpClient: @@ -18,6 +19,7 @@ class DhcpClient: def __init__(self, iface: str = "wlan0"): self._iface = iface + self._pid_file = os.path.join(DHCP_RUNTIME_DIR, f"udhcpc-{iface}.pid") self._proc: subprocess.Popen | None = None self._adopted = False self._client_thread: threading.Thread | None = None @@ -28,12 +30,39 @@ def _start_client_thread(self): self._client_thread = threading.Thread(target=self._monitor_client, daemon=True) self._client_thread.start() + def _owned_pid(self) -> int | None: + try: + pid = int(Path(self._pid_file).read_text().strip()) + if pid <= 1: + return None + args = [os.fsdecode(arg) for arg in Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0") if arg] + except (OSError, ValueError): + return None + + def flag_value(flag: str) -> str | None: + try: + return args[args.index(flag) + 1] + except (ValueError, IndexError): + return None + + if ( + not args + or os.path.basename(args[0]) != "udhcpc" + or flag_value("-i") != self._iface + or flag_value("-p") != self._pid_file + or flag_value("-s") != DHCP_SCRIPT + ): + return None + return pid + def _client_running(self) -> bool: if self._proc is not None and self._proc.poll() is None: return True - script = re.escape(DHCP_SCRIPT) - result = subprocess.run(["pgrep", "-f", f"^udhcpc -i {self._iface}( |$).* -s {script}( |$)"], capture_output=True, check=False) - return result.returncode == 0 + return self._owned_pid() is not None + + def _prepare_runtime(self): + subprocess.run(["sudo", "install", "-d", "-o", "root", "-g", "root", "-m", "755", DHCP_RUNTIME_DIR], check=True) + subprocess.run(["sudo", "rm", "-f", self._pid_file], check=False) def _flush_address(self): subprocess.run(["sudo", "ip", "-4", "addr", "flush", "dev", self._iface], capture_output=True, check=False) @@ -67,10 +96,11 @@ def _spawn(self) -> bool: cloudlog.error(f"udhcpc default script is not executable: {DHCP_DEFAULT_SCRIPT}") return False try: + self._prepare_runtime() self._proc = subprocess.Popen( ["sudo", "udhcpc", "-i", self._iface, "-f", "-t", str(self.DISCOVER_ATTEMPTS), "-T", str(self.DISCOVER_TIMEOUT_SECONDS), - "-s", DHCP_SCRIPT], + "-p", self._pid_file, "-s", DHCP_SCRIPT], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) @@ -103,6 +133,7 @@ def stop(self): if self._client_thread is not None: self._client_thread.join(timeout=self.DISCOVER_TIMEOUT_SECONDS) self._client_thread = None + owned_pid = self._owned_pid() if self._proc is not None: try: self._proc.terminate() @@ -114,7 +145,8 @@ def stop(self): except Exception: pass self._proc = None + if owned_pid is not None: + subprocess.run(["sudo", "kill", str(owned_pid)], check=False) + subprocess.run(["sudo", "rm", "-f", self._pid_file], check=False) self._adopted = False - # Kill orphaned udhcpc children before flushing lease state - subprocess.run(["sudo", "pkill", "-f", f"^udhcpc -i {self._iface}( |$)"], check=False) self._flush_lease() diff --git a/openpilot/system/ui/lib/tests/test_dhcp_client.py b/openpilot/system/ui/lib/tests/test_dhcp_client.py index cc571d687bef8e..7e4ebc34eb307a 100644 --- a/openpilot/system/ui/lib/tests/test_dhcp_client.py +++ b/openpilot/system/ui/lib/tests/test_dhcp_client.py @@ -16,18 +16,25 @@ def setUp(self): def test_adopt_existing_udhcpc_without_restarting_it(self): client = DhcpClient() with ( - patch.object(dhcp_client_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run, + patch.object(client, "_owned_pid", return_value=123), patch.object(dhcp_client_module.subprocess, "Popen") as popen, patch.object(dhcp_client_module.threading, "Thread") as thread, ): assert client.adopt() - script = dhcp_client_module.re.escape(dhcp_client_module.DHCP_SCRIPT) - run.assert_called_once_with(["pgrep", "-f", f"^udhcpc -i wlan0( |$).* -s {script}( |$)"], capture_output=True, check=False) popen.assert_not_called() thread.assert_called_once_with(target=client._monitor_client, daemon=True) thread.return_value.start.assert_called_once() + def test_pidfile_rejects_foreign_udhcpc_command(self): + client = DhcpClient() + command = b"udhcpc\0-i\0wlan1\0-p\0/run/foreign.pid\0-s\0/tmp/script\0" + with ( + patch.object(Path, "read_text", return_value="123"), + patch.object(Path, "read_bytes", return_value=command), + ): + assert client._owned_pid() is None + def test_start_flushes_stale_lease_and_detaches_udhcpc_from_ui_session(self): client = DhcpClient() events = [] @@ -39,13 +46,18 @@ def test_start_flushes_stale_lease_and_detaches_udhcpc_from_ui_session(self): client.start() assert [call.args[0] for call in run.call_args_list] == [ - ["sudo", "pkill", "-f", "^udhcpc -i wlan0( |$)"], + ["sudo", "rm", "-f", client._pid_file], ["sudo", "ip", "-4", "route", "flush", "dev", "wlan0"], ["sudo", "ip", "-4", "addr", "flush", "dev", "wlan0"], + ["sudo", "install", "-d", "-o", "root", "-g", "root", "-m", "755", dhcp_client_module.DHCP_RUNTIME_DIR], + ["sudo", "rm", "-f", client._pid_file], ] assert events == [*([call.args[0] for call in run.call_args_list]), "spawn"] popen.assert_called_once_with( - ["sudo", "udhcpc", "-i", "wlan0", "-f", "-t", "5", "-T", "3", "-s", dhcp_client_module.DHCP_SCRIPT], + [ + "sudo", "udhcpc", "-i", "wlan0", "-f", "-t", "5", "-T", "3", + "-p", client._pid_file, "-s", dhcp_client_module.DHCP_SCRIPT, + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, @@ -106,7 +118,11 @@ def test_dhcp_script_applies_metric_after_default_script(self): default_script.write_text('#!/bin/sh\nprintf "default %s\\n" "$1" >> "$TRACE"\n') default_script.chmod(0o755) ip = root / "ip" - ip.write_text('#!/bin/sh\nprintf "ip %s\\n" "$*" >> "$TRACE"\n') + ip.write_text("""#!/bin/sh +printf "ip %s\\n" "$*" >> "$TRACE" +[ "$*" = "-4 route show default dev wlan0" ] && printf "default via 192.168.1.1 dev wlan0 metric 600\\n" +exit 0 +""") ip.chmod(0o755) env = { **os.environ, @@ -121,9 +137,55 @@ def test_dhcp_script_applies_metric_after_default_script(self): assert trace.read_text().splitlines() == [ "default renew", + "ip -4 route flush default dev wlan0", "ip -4 route replace default via 192.168.1.1 dev wlan0 metric 600", - "ip -4 route del default via 192.168.1.1 dev wlan0 metric 0", + "ip -4 route show default dev wlan0", ] + def test_dhcp_script_propagates_route_install_failure(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + default_script = root / "default.script" + default_script.write_text("#!/bin/sh\nexit 0\n") + default_script.chmod(0o755) + ip = root / "ip" + ip.write_text("#!/bin/sh\nexit 1\n") + ip.chmod(0o755) + env = { + **os.environ, + "PATH": f"{root}:{os.environ['PATH']}", + "UDHCPC_DEFAULT_SCRIPT": str(default_script), + "interface": "wlan0", + "router": "192.168.1.1", + } + + result = subprocess.run([dhcp_client_module.DHCP_SCRIPT, "bound"], env=env, check=False) + assert result.returncode != 0 + + def test_dhcp_script_rejects_noncanonical_default_routes(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + default_script = root / "default.script" + default_script.write_text("#!/bin/sh\nexit 0\n") + default_script.chmod(0o755) + ip = root / "ip" + ip.write_text("""\ +#!/bin/sh +if [ "$*" = "-4 route show default dev wlan0" ]; then + printf "default via 192.168.1.1 dev wlan0 metric 600\\ndefault via 192.168.1.2 dev wlan0 metric 0\\n" +fi +""") + ip.chmod(0o755) + env = { + **os.environ, + "PATH": f"{root}:{os.environ['PATH']}", + "UDHCPC_DEFAULT_SCRIPT": str(default_script), + "interface": "wlan0", + "router": "192.168.1.1", + } + + result = subprocess.run([dhcp_client_module.DHCP_SCRIPT, "renew"], env=env, check=False) + assert result.returncode != 0 + def test_stop_cleans_wlan_dhcp_state_with_or_without_process_handle(self): for proc in (MagicMock(), None): @@ -135,11 +197,26 @@ def test_stop_cleans_wlan_dhcp_state_with_or_without_process_handle(self): assert client._proc is None assert [call.args[0] for call in run.call_args_list] == [ - ["sudo", "pkill", "-f", "^udhcpc -i wlan0( |$)"], + ["sudo", "rm", "-f", client._pid_file], ["sudo", "ip", "-4", "route", "flush", "dev", "wlan0"], ["sudo", "ip", "-4", "addr", "flush", "dev", "wlan0"], ] + def test_stop_kills_only_pidfile_owned_adopted_client(self): + client = DhcpClient() + with ( + patch.object(client, "_owned_pid", return_value=123), + patch.object(dhcp_client_module.subprocess, "run") as run, + ): + client.stop() + + assert [call.args[0] for call in run.call_args_list] == [ + ["sudo", "kill", "123"], + ["sudo", "rm", "-f", client._pid_file], + ["sudo", "ip", "-4", "route", "flush", "dev", "wlan0"], + ["sudo", "ip", "-4", "addr", "flush", "dev", "wlan0"], + ] + def test_clear_ipv6_state_cleans_global_addresses_and_routes(self): client = DhcpClient() with patch.object(dhcp_client_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run: diff --git a/openpilot/system/ui/lib/tests/test_wpa_ctrl.py b/openpilot/system/ui/lib/tests/test_wpa_ctrl.py index e42a2cf584ddc0..70adaeb34643ea 100644 --- a/openpilot/system/ui/lib/tests/test_wpa_ctrl.py +++ b/openpilot/system/ui/lib/tests/test_wpa_ctrl.py @@ -193,7 +193,7 @@ def test_emits_saved_profile_identifier(self): ) def test_grants_control_access_to_netdev_group(self): - assert "ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev\n" in self.generate("Test", {"psk": "password123"}) + assert "ctrl_interface=DIR=/run/openpilot-wpa GROUP=netdev\n" in self.generate("Test", {"psk": "password123"}) def test_emits_saved_bssid_restriction(self): assert " bssid=00:11:22:33:44:55\n" in self.generate( @@ -202,6 +202,16 @@ def test_emits_saved_bssid_restriction(self): def test_encodes_control_characters_in_ssid_losslessly(self): assert f" ssid={b'Line\nBreak\r'.hex()}\n" in self.generate("Line\nBreak\r", {"psk": "password123"}) + def test_explicit_open_security_does_not_render_stale_psk(self): + config = self.generate("Open", {"security": SecurityType.OPEN, "psk": "stale-password"}) + assert " key_mgmt=NONE\n" in config + assert " psk=" not in config + + def test_rejects_control_characters_in_passphrases(self): + for password in ("password\n", "password\r", "password\x00", "pass\tword"): + with self.subTest(password=password): + assert not wpa_ctrl_module.is_valid_psk(password) + class _RacySock: @@ -322,17 +332,31 @@ def test_unmanage_failure_is_nonfatal(self): class TestTetheringDnsmasqOwnership(TestCase): - def test_process_patterns_do_not_match_sudo_parent(self): - assert wpa_ctrl_module.TETHERING_DNSMASQ_PATTERN.startswith("^dnsmasq ") + def test_pidfile_proves_exact_supplicant_ownership(self): + pid = 123 + command = "\0".join(( + "/usr/sbin/wpa_supplicant", "-B", "-i", "wlan0", "-c", + wpa_ctrl_module.WPA_SUPPLICANT_CONF, "-P", wpa_ctrl_module.WPA_PID_FILE, "", + )).encode() - with patch.object(wpa_ctrl_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run: + with ( + patch.object(Path, "read_text", return_value=str(pid)), + patch.object(Path, "read_bytes", return_value=command), + ): assert wpa_ctrl_module.wpa_supplicant_running(wpa_ctrl_module.WPA_SUPPLICANT_CONF) - running_pattern = run.call_args.args[0][2] + assert not wpa_ctrl_module.wpa_supplicant_running(wpa_ctrl_module.WPA_AP_CONF) + + def test_stop_targets_only_pidfile_owned_supplicant(self): + with ( + patch.object(wpa_ctrl_module, "_owned_wpa_pid", return_value=123), + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): wpa_ctrl_module.stop_wpa_supplicant(wpa_ctrl_module.WPA_SUPPLICANT_CONF) - kill_pattern = run.call_args.args[0][-1] - assert running_pattern == kill_pattern - assert running_pattern.startswith("^wpa_supplicant ") + assert [item.args[0] for item in run.call_args_list] == [ + ["sudo", "kill", "123"], + ["sudo", "rm", "-f", wpa_ctrl_module.WPA_PID_FILE, wpa_ctrl_module.WPA_CTRL_PATH], + ] def test_stop_targets_only_openpilot_tethering(self): with patch.object(wpa_ctrl_module.subprocess, "run") as run: @@ -406,7 +430,7 @@ def running(conf): kill.assert_called_once_with(wpa_ctrl_module.WPA_SUPPLICANT_CONF) assert [ "sudo", "wpa_supplicant", "-B", "-i", "wlan0", - "-c", wpa_ctrl_module.WPA_SUPPLICANT_CONF, "-D", "nl80211", + "-c", wpa_ctrl_module.WPA_SUPPLICANT_CONF, "-P", wpa_ctrl_module.WPA_PID_FILE, "-D", "nl80211", ] in [item.args[0] for item in run.call_args_list] def test_attaches_existing_hotspot_before_station_cleanup(self): @@ -502,7 +526,7 @@ def running(conf): kill.assert_called_once_with(wpa_ctrl_module.WPA_SUPPLICANT_CONF) assert [ "sudo", "wpa_supplicant", "-B", "-i", "wlan0", - "-c", wpa_ctrl_module.WPA_SUPPLICANT_CONF, "-D", "nl80211", + "-c", wpa_ctrl_module.WPA_SUPPLICANT_CONF, "-P", wpa_ctrl_module.WPA_PID_FILE, "-D", "nl80211", ] in [item.args[0] for item in run.call_args_list] def test_failed_networkmanager_handoff_does_not_mutate_interface(self): @@ -557,4 +581,4 @@ def test_exit_before_interface_mutation(self): assert result is None unmanage.assert_not_called() kill.assert_not_called() - assert all(item.args[0][0] == "pgrep" for item in run.call_args_list) + run.assert_not_called() diff --git a/openpilot/system/ui/lib/udhcpc.script b/openpilot/system/ui/lib/udhcpc.script index ec710eb80de5d4..c168fa3e8b367c 100755 --- a/openpilot/system/ui/lib/udhcpc.script +++ b/openpilot/system/ui/lib/udhcpc.script @@ -13,9 +13,22 @@ fi case "$1" in bound|renew) router=${router%% *} - if [ -n "$router" ]; then - ip -4 route replace default via "$router" dev "$interface" metric "$wifi_route_metric" && - ip -4 route del default via "$router" dev "$interface" metric 0 2>/dev/null || true - fi + [ -n "$router" ] || exit 1 + ip -4 route flush default dev "$interface" || exit $? + ip -4 route replace default via "$router" dev "$interface" metric "$wifi_route_metric" || exit $? + defaults=$(ip -4 route show default dev "$interface") || exit $? + printf '%s\n' "$defaults" | awk -v iface="$interface" -v router="$router" -v target="$wifi_route_metric" ' + NF { + count++ + dev = via = metric = "" + for (i = 1; i <= NF; i++) { + if ($i == "dev" && i < NF) dev = $(i + 1) + if ($i == "via" && i < NF) via = $(i + 1) + if ($i == "metric" && i < NF) metric = $(i + 1) + } + if ($1 != "default" || dev != iface || via != router || metric != target) exit 1 + } + END { if (count != 1) exit 1 } + ' || exit $? ;; esac diff --git a/openpilot/system/ui/lib/wpa_ctrl.py b/openpilot/system/ui/lib/wpa_ctrl.py index cfbba149708b32..4f3e117ea3d1a8 100644 --- a/openpilot/system/ui/lib/wpa_ctrl.py +++ b/openpilot/system/ui/lib/wpa_ctrl.py @@ -6,12 +6,15 @@ import subprocess import threading import time +import unicodedata from collections.abc import Callable from dataclasses import dataclass from enum import IntEnum +from pathlib import Path from openpilot.common.swaglog import cloudlog from openpilot.common.utils import atomic_write +from openpilot.common.wifi import WPA_CTRL_DIR, WPA_CTRL_PATH, WPA_PID_FILE, decode_wpa_ssid RECV_BUF_SIZE = 32768 @@ -19,7 +22,7 @@ WPA_SUPPLICANT_CONF = "/tmp/wpa_supplicant.conf" WPA_AP_CONF = "/tmp/wpa_supplicant_ap.conf" -WPA_CTRL_INTERFACE = "ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev" +WPA_CTRL_INTERFACE = f"ctrl_interface=DIR={WPA_CTRL_DIR} GROUP=netdev" TETHERING_DNSMASQ_PATTERN = r"^dnsmasq .*--dhcp-range=192\.168\.43\.2" @@ -44,7 +47,7 @@ class _WpaCtrlBase: _counter = 0 _counter_lock = threading.Lock() - def __init__(self, ctrl_path: str = "/var/run/wpa_supplicant/wlan0"): + def __init__(self, ctrl_path: str = WPA_CTRL_PATH): self._ctrl_path = ctrl_path self._sock: socket.socket | None = None self._local_path: str = "" @@ -114,7 +117,7 @@ def __del__(self): class WpaCtrl(_WpaCtrlBase): """Synchronous wpa_supplicant control socket command client.""" - def __init__(self, ctrl_path: str = "/var/run/wpa_supplicant/wlan0"): + def __init__(self, ctrl_path: str = WPA_CTRL_PATH): super().__init__(ctrl_path) self._request_lock = threading.Lock() @@ -178,70 +181,8 @@ def close(self): super().close() -_HEX = "0123456789abcdefABCDEF" - - -def decode_ssid(encoded: str) -> str: - """Decode a wpa_supplicant printf_encode'd SSID (hostap common.c:526). - Escapes: \\\\, \\", \\e/n/r/t, \\xNN/\\xN, octal \\0..\\777. - Bytes are reinterpreted as UTF-8; all-null SSIDs (hidden APs) normalize to "".""" - out = bytearray() - i = 0 - n = len(encoded) - while i < n: - c = encoded[i] - if c != "\\": - out.append(ord(c) & 0xff) - i += 1 - continue - - i += 1 # consume backslash - if i >= n: - break # trailing backslash: dropped - - nxt = encoded[i] - if nxt == "\\": - out.append(ord("\\")) - i += 1 - elif nxt == '"': - out.append(ord('"')) - i += 1 - elif nxt == "n": - out.append(ord("\n")) - i += 1 - elif nxt == "r": - out.append(ord("\r")) - i += 1 - elif nxt == "t": - out.append(ord("\t")) - i += 1 - elif nxt == "e": - out.append(0x1b) - i += 1 - elif nxt == "x": - i += 1 # consume 'x' - if i + 1 < n and encoded[i] in _HEX and encoded[i + 1] in _HEX: - out.append(int(encoded[i:i + 2], 16)) - i += 2 - elif i < n and encoded[i] in _HEX: - out.append(int(encoded[i], 16)) - i += 1 - # else: malformed \x, so drop the escape and continue parsing at i - elif "0" <= nxt <= "7": - val = ord(nxt) - ord("0") - i += 1 - if i < n and "0" <= encoded[i] <= "7": - val = val * 8 + (ord(encoded[i]) - ord("0")) - i += 1 - if i < n and "0" <= encoded[i] <= "7": - val = val * 8 + (ord(encoded[i]) - ord("0")) - i += 1 - out.append(val & 0xff) - # Unknown escapes consume only the backslash - - if not out or all(b == 0 for b in out): - return "" - return out.decode("utf-8", errors="surrogateescape") +# Keep the public name while sharing one decoder with hardwared. +decode_ssid = decode_wpa_ssid def parse_scan_results(raw: str) -> list[ScanResult]: @@ -341,17 +282,47 @@ def parse_event_network_id(event: str) -> str | None: return match.group(1) if match is not None else None +def _owned_wpa_pid(conf: str) -> int | None: + try: + pid = int(Path(WPA_PID_FILE).read_text().strip()) + if pid <= 1: + return None + args = [os.fsdecode(arg) for arg in Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0") if arg] + except (OSError, ValueError): + return None + + def flag_value(flag: str) -> str | None: + try: + return args[args.index(flag) + 1] + except (ValueError, IndexError): + return None + + if ( + not args + or os.path.basename(args[0]) != "wpa_supplicant" + or flag_value("-i") != "wlan0" + or flag_value("-c") != conf + or flag_value("-P") != WPA_PID_FILE + ): + return None + return pid + + def wpa_supplicant_running(conf: str) -> bool: - """True if a wpa_supplicant running the given config exists. Narrow pgrep so - a system-managed daemon on another config isn't conflated with ours.""" - pattern = rf"^wpa_supplicant .* -c {re.escape(conf)}( |$)" - return subprocess.run(["pgrep", "-f", pattern], capture_output=True).returncode == 0 + return _owned_wpa_pid(conf) is not None + + +def prepare_wpa_runtime() -> None: + subprocess.run(["sudo", "install", "-d", "-o", "root", "-g", "netdev", "-m", "775", WPA_CTRL_DIR], check=True) + subprocess.run(["sudo", "rm", "-f", WPA_PID_FILE, WPA_CTRL_PATH], check=False) def stop_wpa_supplicant(conf: str) -> None: - """Kill only wpa_supplicant processes running our config; a system-managed daemon survives.""" - pattern = rf"^wpa_supplicant .* -c {re.escape(conf)}( |$)" - subprocess.run(["sudo", "pkill", "-f", pattern], check=False) + pid = _owned_wpa_pid(conf) + if pid is None: + return + subprocess.run(["sudo", "kill", str(pid)], check=False) + subprocess.run(["sudo", "rm", "-f", WPA_PID_FILE, WPA_CTRL_PATH], check=False) def tethering_dnsmasq_running() -> bool: @@ -388,6 +359,8 @@ def _is_raw_psk(psk: str) -> bool: def is_valid_psk(psk: str) -> bool: try: + if any(unicodedata.category(char) == "Cc" for char in psk): + return False return 8 <= len(psk.encode("utf-8")) <= 63 or _is_raw_psk(psk) except UnicodeEncodeError: return False @@ -411,6 +384,7 @@ def generate_wpa_conf(store, path: str = WPA_SUPPLICANT_CONF): for ssid, entry in store.get_profiles(): psk = entry.get("psk", "") + security = entry.get("security", SecurityType.WPA if psk else SecurityType.OPEN) hidden = entry.get("hidden", False) priority = entry.get("priority", 0) bssid = entry.get("bssid", "") @@ -419,7 +393,7 @@ def generate_wpa_conf(store, path: str = WPA_SUPPLICANT_CONF): continue lines.append("network={") lines.append(f" ssid={ssid_value}") - if psk: + if security == SecurityType.WPA: lines.append(f' psk={format_psk_value(psk)}') lines.append(" key_mgmt=WPA-PSK") else: @@ -518,24 +492,16 @@ def ensure_wpa_supplicant(should_exit: Callable[[], bool], station_reconfigured: cloudlog.warning("NetworkManager handoff failed; deferring station bringup") return None - # Wait for NetworkManager's asynchronously removed control socket - for _ in range(30): - if should_exit(): - return None - if not os.path.exists("/var/run/wpa_supplicant/wlan0"): - break - time.sleep(0.1) - else: - # The post-spawn ownership check handles a lingering NetworkManager socket - cloudlog.warning("/var/run/wpa_supplicant/wlan0 still present after NM unmanage; spawn will refuse to attach to foreign daemon") - - # Stop only daemons using our config stop_wpa_supplicant(WPA_SUPPLICANT_CONF) + prepare_wpa_runtime() stop_tethering_dnsmasq() subprocess.run(["sudo", "ip", "addr", "flush", "dev", "wlan0"], check=False) time.sleep(0.5) - subprocess.run(["sudo", "wpa_supplicant", "-B", "-i", "wlan0", "-c", WPA_SUPPLICANT_CONF, "-D", "nl80211"], check=False) + subprocess.run([ + "sudo", "wpa_supplicant", "-B", "-i", "wlan0", + "-c", WPA_SUPPLICANT_CONF, "-P", WPA_PID_FILE, "-D", "nl80211", + ], check=False) # Never attach to a daemon not using our config for _ in range(30): From c27670dc9c80d197258d7b5fe37ee0123f9c34bc Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Tue, 18 Aug 2026 18:25:35 +0000 Subject: [PATCH 34/71] wifi: make network transitions transactional --- .../ui/lib/tests/test_handle_state_change.py | 381 ++++++++++---- .../ui/lib/tests/test_wifi_manager_bringup.py | 73 ++- openpilot/system/ui/lib/wifi_manager.py | 498 +++++++++++++----- openpilot/system/ui/widgets/network.py | 6 +- 4 files changed, 702 insertions(+), 256 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 5665f6459aed8e..a085226925599b 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -34,6 +34,7 @@ def build_wifi_manager() -> WifiManager: manager._exit = True manager._ctrl = MagicMock() manager._ipv4_forward = True + manager._apply_ipv4_forward = MagicMock() manager._tethering_ssid = "Hotspot" manager._tethering_psk = "hotspot-password" manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 @@ -41,6 +42,11 @@ def build_wifi_manager() -> WifiManager: manager._poll_for_ip = MagicMock() manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=TestNet\n" return manager +def complete_station_connection(manager: WifiManager, ssid: str): + manager._ipv4_address = "192.168.1.20" + manager._complete_station_connection(ssid, manager._user_epoch) + + class TestConnectionState(TestCase): @@ -51,22 +57,51 @@ def test_connected_persists_after_auth_and_is_idempotent(self): activated = MagicMock() self.manager.add_callbacks(activated=activated) self.manager._set_connecting("TestNet") - self.manager._set_pending_connection("TestNet", "password123", False) + self.manager._set_pending_connection("TestNet", "password123", False, SecurityType.WPA) + profile_uuid = self.manager._pending_connection.profile_uuid with patch.object(wifi_manager_module, "generate_wpa_conf"): self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") self.manager._handle_connected("TestNet") self.manager.process_callbacks() assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) - self.manager._store.save_network.assert_called_once_with("TestNet", psk="password123", hidden=False) + self.manager._store.save_network.assert_called_once_with( + "TestNet", psk="password123", hidden=False, security=SecurityType.WPA, profile_uuid=profile_uuid, + ) self.manager._dhcp.start.assert_called_once() activated.assert_called_once() assert call("ENABLE_NETWORK all") in self.manager._ctrl.request.call_args_list + def test_connected_waits_for_ip_before_activation(self): + activated = MagicMock() + self.manager.add_callbacks(activated=activated) + self.manager._set_connecting("TestNet") + self.manager._set_pending_connection("TestNet", "password123", False, SecurityType.WPA) + epoch = self.manager._user_epoch + + with patch.object(wifi_manager_module, "generate_wpa_conf"): + self.manager._handle_connected("TestNet", expected_epoch=epoch) + + self.manager.process_callbacks() + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + assert self.manager.connected_ssid is None + self.manager._dhcp.start.assert_called_once() + activated.assert_not_called() + + self.manager._ipv4_address = "192.168.1.20" + self.manager._complete_station_connection("TestNet", epoch) + self.manager.process_callbacks() + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + assert self.manager.connected_ssid == "TestNet" + activated.assert_called_once() + + def test_connected_transitions_are_serialized(self): self.manager._set_connecting("TestNet") - self.manager._set_pending_connection("TestNet", "password123", False) + self.manager._set_pending_connection("TestNet", "password123", False, SecurityType.WPA) first_persist_started = threading.Event() concurrent_persist_started = threading.Event() release_persist = threading.Event() @@ -103,7 +138,7 @@ def persist(_ssid): def test_connect_tap_does_not_wait_for_connected_transition(self): self.manager._set_connecting("CurrentNet") - self.manager._set_pending_connection("CurrentNet", "current-password", False) + self.manager._set_pending_connection("CurrentNet", "current-password", False, SecurityType.WPA) current_epoch = self.manager._user_epoch persist_started = threading.Event() release_persist = threading.Event() @@ -137,14 +172,14 @@ def connect(): assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) assert self.manager._pending_connection is not None assert self.manager._pending_connection.ssid == "NextNet" - self.manager._dhcp.start.assert_not_called() + self.manager._dhcp.start.assert_called_once() def test_pending_persistence_is_retried_without_restarting_dhcp(self): for retry in ("connected", "reconcile"): with self.subTest(retry=retry): manager = build_wifi_manager() manager._set_connecting("TestNet") - manager._set_pending_connection("TestNet", "password123", False) + manager._set_pending_connection("TestNet", "password123", False, SecurityType.WPA) manager._store.save_network.side_effect = [OSError("read-only"), None] with patch.object(wifi_manager_module, "generate_wpa_conf"): @@ -152,6 +187,7 @@ def test_pending_persistence_is_retried_without_restarting_dhcp(self): if retry == "connected": manager._handle_connected("TestNet") else: + manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 manager._reconcile_connecting_state() assert manager._store.save_network.call_count == 2 @@ -179,6 +215,7 @@ def test_reconnect_after_disconnected_event_adopts_dhcp(self): self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=TestNet\n" self.manager._handle_event("CTRL-EVENT-CONNECTED") + complete_station_connection(self.manager, "TestNet") assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) assert self.manager.ipv4_address == "192.168.1.20" self.manager._dhcp.adopt.assert_called_once() @@ -192,6 +229,7 @@ def test_disconnected_event_allows_fallback_to_different_saved_network(self): self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=NextNet\n" self.manager._handle_event("CTRL-EVENT-CONNECTED") + complete_station_connection(self.manager, "NextNet") assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTED) self.manager._dhcp.adopt.assert_not_called() self.manager._dhcp.clear_ipv6_state.assert_called_once() @@ -230,7 +268,7 @@ def test_connected_event_rejects_unconfirmed_or_previous_network(self): with self.subTest(status=status): manager = build_wifi_manager() manager._set_connecting("NextNet") - manager._set_pending_connection("NextNet", password, False) + manager._set_pending_connection("NextNet", password, False, SecurityType.WPA) manager._ctrl.request.return_value = status manager._handle_event("CTRL-EVENT-CONNECTED") @@ -255,7 +293,7 @@ def __enter__(self): def __exit__(self, *_): lock.release() - self.manager.__dict__["_connect_lock"] = SignalingLock() + self.manager.__dict__["_station_lock"] = SignalingLock() worker = threading.Thread(target=self.manager._handle_event, args=("CTRL-EVENT-CONNECTED",)) worker.start() assert waiting_for_lock.wait(1) @@ -283,8 +321,8 @@ def test_activate_enables_every_profile_sharing_ssid(self): def test_activate_restores_every_saved_profile(self): profiles = ( - {"psk": "first-password", "hidden": False, "priority": 1, "bssid": "00:11:22:33:44:55", "uuid": "first-uuid"}, - {"psk": "second-password", "hidden": True, "priority": 2, "bssid": "66:77:88:99:aa:bb", "uuid": "second-uuid"}, + {"psk": "first-password", "hidden": False, "priority": 1, "bssid": "00:11:22:33:44:55", "uuid": "first-uuid", "security": SecurityType.WPA}, + {"psk": "second-password", "hidden": True, "priority": 2, "bssid": "66:77:88:99:aa:bb", "uuid": "second-uuid", "security": SecurityType.WPA}, ) self.manager._store.get_profiles.return_value = [("Pinned", profile) for profile in profiles] @@ -296,8 +334,8 @@ def test_activate_restores_every_saved_profile(self): self.manager.activate_connection("Pinned", block=True) assert add_and_select_network.call_args_list == [ - call("Pinned", "first-password", False, 1, bssid="00:11:22:33:44:55", profile_uuid="first-uuid"), - call("Pinned", "second-password", True, 2, bssid="66:77:88:99:aa:bb", profile_uuid="second-uuid"), + call("Pinned", "first-password", False, 1, bssid="00:11:22:33:44:55", profile_uuid="first-uuid", security=SecurityType.WPA), + call("Pinned", "second-password", True, 2, bssid="66:77:88:99:aa:bb", profile_uuid="second-uuid", security=SecurityType.WPA), ] select_network_ids.assert_called_once_with(["1", "2"]) @@ -341,8 +379,13 @@ def test_active_profile_sets_metered_state(self): def test_activate_restores_saved_profile_constraints(self): cases = ( - ("Preferred", {"psk": "password123", "hidden": False, "priority": 42, "uuid": "preferred-uuid"}, 42, None), - ("Pinned", {"psk": "password123", "hidden": False, "bssid": "00:11:22:33:44:55", "uuid": "pinned-uuid"}, 0, "00:11:22:33:44:55"), + ("Preferred", {"psk": "password123", "hidden": False, "priority": 42, "uuid": "preferred-uuid", "security": SecurityType.WPA}, 42, None), + ( + "Pinned", + {"psk": "password123", "hidden": False, "bssid": "00:11:22:33:44:55", "uuid": "pinned-uuid", "security": SecurityType.WPA}, + 0, + "00:11:22:33:44:55", + ), ) for ssid, profile, priority, bssid in cases: with self.subTest(ssid=ssid): @@ -355,7 +398,9 @@ def test_activate_restores_saved_profile_constraints(self): ): manager.activate_connection(ssid, block=True) - add_and_select_network.assert_called_once_with(ssid, "password123", False, priority, bssid=bssid, profile_uuid=profile["uuid"]) + add_and_select_network.assert_called_once_with( + ssid, "password123", False, priority, bssid=bssid, profile_uuid=profile["uuid"], security=SecurityType.WPA, + ) def test_connection_changes_defer_dhcp_cleanup_to_worker(self): for action in ("connect", "activate"): @@ -386,7 +431,7 @@ def test_latest_connect_worker_owns_deferred_dhcp_cleanup(self): with ( patch.object(wifi_manager_module.threading, "Thread") as thread, patch.object(self.manager, "_list_network_ids", return_value=[]), - patch.object(self.manager, "_add_and_select_network", side_effect=lambda *_: events.append("select") or "1"), + patch.object(self.manager, "_add_and_select_network", side_effect=lambda *_, **__: events.append("select") or "1"), ): self.manager.connect_to_network("FirstNet", "password123") first_worker = thread.call_args.kwargs["target"] @@ -398,6 +443,42 @@ def test_latest_connect_worker_owns_deferred_dhcp_cleanup(self): assert events == ["cleanup", "select"] + def test_superseded_after_select_removes_exact_runtime_network(self): + first_selected = threading.Event() + release_first = threading.Event() + second_committed = threading.Event() + real_set_pending_network_id = self.manager._set_pending_network_id + + def add_network(ssid, *_, **__): + if ssid == "FirstNet": + first_selected.set() + assert release_first.wait(1) + return "1" + return "2" + + def set_pending_network_id(net_id, epoch): + real_set_pending_network_id(net_id, epoch) + if net_id == "2": + second_committed.set() + + self.manager._ctrl.request.return_value = "OK" + with ( + patch.object(self.manager, "_list_network_ids", return_value=[]), + patch.object(self.manager, "_add_and_select_network", side_effect=add_network), + patch.object(self.manager, "_set_pending_network_id", side_effect=set_pending_network_id), + patch.object(wifi_manager_module, "generate_wpa_conf"), + ): + self.manager.connect_to_network("FirstNet", "first-password") + assert first_selected.wait(1) + self.manager.connect_to_network("SecondNet", "second-password") + release_first.set() + assert second_committed.wait(1) + + assert call("REMOVE_NETWORK 1") in self.manager._ctrl.request.call_args_list + assert self.manager._pending_connection is not None + assert self.manager._pending_connection.ssid == "SecondNet" + assert self.manager._pending_connection.network_id == "2" + def test_runtime_network_encodes_control_characters_in_ssid(self): self.manager._ctrl.request.side_effect = ["0", "OK", "OK", "OK", "OK"] @@ -413,6 +494,17 @@ def test_runtime_network_sets_saved_profile_identifier(self): assert call('SET_NETWORK 0 id_str "profile-uuid"') in self.manager._ctrl.request.call_args_list + def test_runtime_open_network_ignores_stale_psk(self): + self.manager._ctrl.request.side_effect = ["0", "OK", "OK", "OK", "OK"] + + self.manager._add_and_select_network( + "TestNet", psk="stale-password", security=SecurityType.OPEN, + ) + + requests = self.manager._ctrl.request.call_args_list + assert call("SET_NETWORK 0 key_mgmt NONE") in requests + assert not any(" psk " in item.args[0] for item in requests) + def test_scan_only_reselects_when_disconnected(self): cases = ( (WifiState("TestNet", ConnectStatus.CONNECTED), "SCAN TYPE=ONLY"), @@ -457,7 +549,7 @@ def test_wrong_key_removes_runtime_credentials_and_clears_station_state(self): need_auth = MagicMock() self.manager.add_callbacks(need_auth=need_auth) self.manager._set_connecting("TestNet") - self.manager._set_pending_connection("TestNet", "wrongpass", False) + self.manager._set_pending_connection("TestNet", "wrongpass", False, SecurityType.WPA) self.manager._set_pending_network_id("0", self.manager._user_epoch) self.manager._dhcp_adoption_ssid = "TestNet" self.manager._last_wrong_key_dispatch[("OldNet", None)] = 0.0 @@ -482,7 +574,7 @@ def test_wrong_key_ignores_same_ssid_event_for_other_profile(self): need_auth = MagicMock() self.manager.add_callbacks(need_auth=need_auth) self.manager._set_connecting("TestNet") - self.manager._set_pending_connection("TestNet", "correct-password", False) + self.manager._set_pending_connection("TestNet", "correct-password", False, SecurityType.WPA) self.manager._set_pending_network_id("1", self.manager._user_epoch) self.manager._handle_event('CTRL-EVENT-SSID-TEMP-DISABLED id=0 ssid="TestNet" reason=WRONG_KEY') @@ -583,8 +675,9 @@ def test_network_not_found_clears_connecting_state_after_reconciliation(self): disconnected = MagicMock() self.manager.add_callbacks(disconnected=disconnected) self.manager._set_connecting("MissingNet") - self.manager._set_pending_connection("MissingNet", "password123", True) - self.manager._ctrl.request.return_value = "wpa_state=SCANNING\n" + self.manager._set_pending_connection("MissingNet", "password123", True, SecurityType.WPA) + self.manager._set_pending_network_id("7", self.manager._user_epoch) + self.manager._ctrl.request.side_effect = lambda command: "wpa_state=SCANNING\n" if command == "STATUS" else "OK" with patch.object(self.manager, "_remove_wpa_network") as remove_wpa_network: self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 @@ -599,14 +692,16 @@ def test_network_not_found_clears_connecting_state_after_reconciliation(self): self.manager.process_callbacks() assert self.manager.wifi_state == WifiState() assert self.manager._pending_connection is None - remove_wpa_network.assert_called_once_with("MissingNet") - assert call("ENABLE_NETWORK all") in self.manager._ctrl.request.call_args_list + remove_wpa_network.assert_not_called() + assert call("REMOVE_NETWORK 7") in self.manager._ctrl.request.call_args_list + assert call("RECONFIGURE") in self.manager._ctrl.request.call_args_list + assert call("ENABLE_NETWORK all") not in self.manager._ctrl.request.call_args_list self.manager._dhcp.stop.assert_called_once() disconnected.assert_called_once() def test_network_not_found_ends_scanning_after_reconciliation_deferred(self): self.manager._set_connecting("MissingNet") - self.manager._set_pending_connection("MissingNet", "password123", True) + self.manager._set_pending_connection("MissingNet", "password123", True, SecurityType.WPA) self.manager._ctrl.request.return_value = "wpa_state=SCANNING\n" self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 @@ -622,7 +717,7 @@ def test_delayed_network_not_found_does_not_bind_to_fresh_attempt(self): self.manager._set_connecting("PreviousNet") self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 self.manager._set_connecting("HiddenNet") - self.manager._set_pending_connection("HiddenNet", "password123", True) + self.manager._set_pending_connection("HiddenNet", "password123", True, SecurityType.WPA) self.manager._ctrl.request.return_value = "wpa_state=SCANNING\n" self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 @@ -637,36 +732,36 @@ def test_reconcile_keeps_saved_runtime_network_after_transient_failure(self): self.manager._store.contains.return_value = True self.manager._set_connecting("SavedNet") self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 - self.manager._ctrl.request.return_value = "wpa_state=DISCONNECTED\n" + self.manager._ctrl.request.side_effect = lambda command: "wpa_state=DISCONNECTED\n" if command == "STATUS" else "OK" with patch.object(self.manager, "_remove_wpa_network") as remove_wpa_network: self.manager._reconcile_connecting_state() remove_wpa_network.assert_not_called() - assert call("ENABLE_NETWORK all") in self.manager._ctrl.request.call_args_list + assert call("RECONFIGURE") in self.manager._ctrl.request.call_args_list + assert call("ENABLE_NETWORK all") not in self.manager._ctrl.request.call_args_list assert self.manager.wifi_state == WifiState() def test_reconcile_rechecks_epoch_before_cancelling_stale_connection(self): self.manager._set_connecting("PreviousNet") - self.manager._set_pending_connection("PreviousNet", "password123", False) + self.manager._set_pending_connection("PreviousNet", "password123", False, SecurityType.WPA) self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 self.manager._ctrl.request.return_value = "wpa_state=DISCONNECTED\n" - contains_started = threading.Event() - release_contains = threading.Event() + restore_started = threading.Event() + release_restore = threading.Event() - def contains(_ssid): - contains_started.set() - assert release_contains.wait(1) - return False + def restore(*_): + restore_started.set() + assert release_restore.wait(1) - self.manager._store.contains.side_effect = contains + self.manager._restore_station_runtime = MagicMock(side_effect=restore) worker = threading.Thread(target=self.manager._reconcile_connecting_state) worker.start() - assert contains_started.wait(1) + assert restore_started.wait(1) with patch.object(wifi_manager_module.threading.Thread, "start"): self.manager.connect_to_network("NextNet", "next-password") - release_contains.set() + release_restore.set() worker.join(1) assert not worker.is_alive() @@ -681,15 +776,20 @@ def test_reconcile_times_out_stalled_handshake(self): manager = build_wifi_manager() manager._store.contains.return_value = True manager._set_connecting("StalledNet") - manager._set_pending_connection("StalledNet", "password123", False) + manager._set_pending_connection("StalledNet", "password123", False, SecurityType.WPA) + manager._set_pending_network_id("7", manager._user_epoch) manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 - manager._ctrl.request.return_value = f"wpa_state={wpa_state}\nssid=StalledNet\n" + def request(command, state=wpa_state): + return f"wpa_state={state}\nssid=StalledNet\n" if command == "STATUS" else "OK" + manager._ctrl.request.side_effect = request manager._reconcile_connecting_state() assert manager.wifi_state == WifiState() assert manager._pending_connection is None - assert call("ENABLE_NETWORK all") in manager._ctrl.request.call_args_list + assert call("REMOVE_NETWORK 7") in manager._ctrl.request.call_args_list + assert call("RECONFIGURE") in manager._ctrl.request.call_args_list + assert call("ENABLE_NETWORK all") not in manager._ctrl.request.call_args_list manager._dhcp.stop.assert_called_once() def test_reconcile_does_not_report_generic_disconnect_as_auth_failure(self): @@ -715,13 +815,15 @@ def test_reconcile_clears_ipv6_state_before_adopting_another_network(self): self.manager._reconcile_connecting_state() + complete_station_connection(self.manager, "TestNet") assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) self.manager._dhcp.clear_ipv6_state.assert_called_once() self.manager._dhcp.start.assert_called_once() def test_stale_network_not_found_does_not_clear_fresh_connection(self): self.manager._set_connecting("TestNet") - self.manager._set_pending_connection("TestNet", "password123", False) + self.manager._set_pending_connection("TestNet", "password123", False, SecurityType.WPA) + profile_uuid = self.manager._pending_connection.profile_uuid with ( patch.object(self.manager, "_remove_wpa_network") as remove_wpa_network, @@ -730,8 +832,11 @@ def test_stale_network_not_found_does_not_clear_fresh_connection(self): self.manager._handle_event("CTRL-EVENT-NETWORK-NOT-FOUND") self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) - self.manager._store.save_network.assert_called_once_with("TestNet", psk="password123", hidden=False) + self.manager._store.save_network.assert_called_once_with( + "TestNet", psk="password123", hidden=False, security=SecurityType.WPA, profile_uuid=profile_uuid, + ) remove_wpa_network.assert_not_called() def test_forget_cancels_in_flight_connection(self): @@ -746,7 +851,7 @@ def remove_network(ssid): if connect_added.is_set(): forget_removed.set() - def add_network(ssid, *_): + def add_network(ssid, *_, **__): connect_started.set() assert release_connect.wait(1) runtime_networks.add(ssid) @@ -786,7 +891,7 @@ def remove_saved_network(ssid): assert release_forget.wait(1) return True - def select_network(*_): + def select_network(*_, **__): new_network_selected.set() return "1" @@ -805,10 +910,11 @@ def select_network(*_): assert forget_removing.wait(1) self.manager.connect_to_network("NextNet", "password123") - assert new_network_selected.wait(1) + assert not new_network_selected.wait(0.1) release_forget.set() assert forget_finished.wait(1) + assert new_network_selected.wait(1) assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) assert call("ENABLE_NETWORK all") not in self.manager._ctrl.request.call_args_list @@ -874,6 +980,7 @@ def request(command): self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=NextNet\n" self.manager._handle_event("CTRL-EVENT-CONNECTED") + complete_station_connection(self.manager, "NextNet") assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTED) def test_forget_failure_releases_caller_without_reporting_success(self): @@ -901,9 +1008,9 @@ def __init__(self, *args, **kwargs): def start(self): self.thread.start() - def fail_after_fresh_selection(*_): + def fail_after_fresh_selection(*_, **__): self.manager._set_connecting("NextNet") - self.manager._set_pending_connection("NextNet", "new-password", False) + self.manager._set_pending_connection("NextNet", "new-password", False, SecurityType.WPA) raise OSError("stale request failed") with ( @@ -929,7 +1036,7 @@ def fail_after_fresh_selection(*_): assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) - def test_failed_connect_reenables_saved_networks(self): + def test_failed_connect_restores_previous_selection_exactly(self): class ImmediateThread: def __init__(self, target, **_): self._target = target @@ -950,15 +1057,92 @@ def request(command): with patch.object(wifi_manager_module.threading, "Thread", ImmediateThread): self.manager.connect_to_network("TestNet", "password123") - assert call("ENABLE_NETWORK all") in self.manager._ctrl.request.call_args_list + requests = self.manager._ctrl.request.call_args_list + assert call("REMOVE_NETWORK 2") in requests + assert call("ENABLE_NETWORK 1") in requests + assert call("REASSOCIATE") in requests + assert call("ENABLE_NETWORK all") not in requests assert self.manager.wifi_state == WifiState() - def test_failed_activate_reenables_saved_networks(self): + def test_failed_replacement_removes_selected_network_before_restoring_profiles(self): + class ImmediateThread: + def __init__(self, target, **_): + self._target = target + + def start(self): + self._target() + + def request(command): + if command == "LIST_NETWORKS": + return "network id / ssid / bssid / flags\n1\tTestNet\tany\t\n2\tTestNet\tany\t\n" + if command == "ADD_NETWORK": + return "3" + if command == "REMOVE_NETWORK 1": + return "FAIL" + return "OK" + + self.manager._ctrl.request.side_effect = request + with patch.object(wifi_manager_module.threading, "Thread", ImmediateThread): + self.manager.connect_to_network("TestNet", "password123") + + requests = self.manager._ctrl.request.call_args_list + assert call("REMOVE_NETWORK 3") in requests + assert call("DISABLE_NETWORK all") in requests + assert call("ENABLE_NETWORK 1") in requests + assert call("ENABLE_NETWORK 2") in requests + assert call("REASSOCIATE") in requests + assert call("ENABLE_NETWORK all") not in requests + + def test_failed_second_old_id_removal_reconfigures_durable_profiles(self): + class ImmediateThread: + def __init__(self, target, **_): + self._target = target + + def start(self): + self._target() + + def request(command): + if command == "LIST_NETWORKS": + return "network id / ssid / bssid / flags\n1\tTestNet\tany\t\n2\tTestNet\tany\t\n" + if command == "ADD_NETWORK": + return "3" + if command == "REMOVE_NETWORK 2": + return "FAIL" + return "OK" + + self.manager._ctrl.request.side_effect = request + with patch.object(wifi_manager_module.threading, "Thread", ImmediateThread): + self.manager.connect_to_network("TestNet", "password123") + + requests = self.manager._ctrl.request.call_args_list + assert call("REMOVE_NETWORK 1") in requests + assert call("REMOVE_NETWORK 3") in requests + assert call("RECONFIGURE") in requests + assert call("ENABLE_NETWORK all") not in requests + + def test_saved_replacement_timeout_removes_exact_pending_network(self): + self.manager._store.contains.return_value = True + self.manager._set_connecting("TestNet") + self.manager._set_pending_connection("TestNet", "new-password", False, SecurityType.WPA) + epoch = self.manager._user_epoch + self.manager._set_pending_network_id("7", epoch) + self.manager._ctrl.request.side_effect = lambda command: "wpa_state=DISCONNECTED\n" if command == "STATUS" else "OK" + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + + self.manager._reconcile_connecting_state() + + assert call("REMOVE_NETWORK 7") in self.manager._ctrl.request.call_args_list + assert call("RECONFIGURE") in self.manager._ctrl.request.call_args_list + assert call("ENABLE_NETWORK all") not in self.manager._ctrl.request.call_args_list + + + def test_failed_activate_reconfigures_saved_networks(self): self.manager._ctrl.request.side_effect = lambda command: "FAIL" if command == "REASSOCIATE" else "OK" with patch.object(self.manager, "_list_network_ids", return_value=["1"]): self.manager.activate_connection("TestNet", block=True) - assert call("ENABLE_NETWORK all") in self.manager._ctrl.request.call_args_list + assert call("RECONFIGURE") in self.manager._ctrl.request.call_args_list + assert call("ENABLE_NETWORK all") not in self.manager._ctrl.request.call_args_list assert self.manager.wifi_state == WifiState() def test_request_error_invalidates_control_socket(self): @@ -1001,6 +1185,7 @@ def test_station_dhcp_adoption(self): if state in ("reconnecting", "different-network"): manager._handle_event("CTRL-EVENT-CONNECTED") + complete_station_connection(manager, "TestNet") assert manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) assert bool(manager._dhcp.adopt.call_count) == expect_adopt assert bool(manager._dhcp.start.call_count) == expect_start @@ -1098,6 +1283,7 @@ def test_reconcile_adopts_missed_connection(self): self.manager._reconcile_connecting_state() + complete_station_connection(self.manager, "TestNet") assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) self.manager._dhcp.start.assert_called_once() @@ -1236,11 +1422,11 @@ def ensure(_should_exit, _station_reconfigured, on_abandoned_ap): patch.object(wifi_manager_module, "ensure_wpa_supplicant", side_effect=ensure), patch.object(wifi_manager_module, "stop_tethering_dnsmasq") as stop_dnsmasq, patch.object(wifi_manager_module, "_delete_tethering_firewall_rules") as delete_firewall, - patch.object(wifi_manager_module.subprocess, "run") as run, + patch.object(wifi_manager_module.subprocess, "run"), ): manager._ensure_wpa_supplicant() - run.assert_called_once_with(["sudo", "sysctl", "net.ipv4.ip_forward=0"], check=True) + manager._apply_ipv4_forward.assert_called_once_with(False) stop_dnsmasq.assert_called_once() delete_firewall.assert_called_once() assert manager._ctrl is ctrl @@ -1450,6 +1636,7 @@ def ensure_station(): assert release_station.wait(1) with ( + patch.object(wifi_manager_module, "NetworkStore", return_value=manager._store), patch("builtins.open", side_effect=FileNotFoundError), patch.object(wifi_manager_module, "generate_wpa_conf"), patch.object(manager, "_ensure_wpa_supplicant", side_effect=ensure_station), @@ -1561,12 +1748,10 @@ def test_failed_tethering_stop_notifies_disconnected(self): class TestTetheringPassword(TestCase): def test_latest_password_request_wins(self): manager = build_wifi_manager() - password_file = MagicMock() - password_write = MagicMock() - password_write.__enter__.return_value = password_file + manager._store.set_tethering_password.return_value = True with ( - patch.object(wifi_manager_module, "atomic_write", return_value=password_write), + patch.object(wifi_manager_module, "atomic_write") as legacy_write, patch.object(wifi_manager_module.threading, "Thread") as thread, ): manager.set_tethering_password("first-password") @@ -1577,38 +1762,55 @@ def test_latest_password_request_wins(self): workers[0]() assert manager.tethering_password == "second-password" - password_file.write.assert_called_once_with("second-password") + legacy_write.assert_not_called() manager._store.set_tethering_password.assert_called_once_with("Hotspot", "second-password") - def test_startup_falls_back_when_password_file_is_unavailable(self): - errors = ( - FileNotFoundError(), - PermissionError("denied"), - UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid"), - ) - for error in errors: - with self.subTest(error=type(error).__name__): - manager = build_wifi_manager() - manager._tethering_ssid = "weedle" - store = manager._store - assert store is not None - store.get_tethering_password.return_value = "custom-password" - manager._scan_thread = MagicMock() - manager._state_thread = MagicMock() + def test_startup_prefers_keyfile_without_reading_legacy_password(self): + manager = build_wifi_manager() + manager._tethering_ssid = "weedle" + store = manager._store + assert store is not None + store.get_tethering_password.return_value = "custom-password" + manager._scan_thread = MagicMock() + manager._state_thread = MagicMock() - with ( - patch.object(wifi_manager_module, "NetworkStore", return_value=store), - patch("builtins.open", side_effect=error), - patch.object(wifi_manager_module, "generate_wpa_conf"), - patch.object(manager, "_ensure_wpa_supplicant"), - patch.object(manager, "_update_networks"), - patch.object(manager, "_init_wifi_state"), - patch.object(wifi_manager_module.threading, "Thread") as thread, - ): - manager._initialize() - thread.call_args.kwargs["target"]() + with ( + patch.object(wifi_manager_module, "NetworkStore", return_value=store), + patch("builtins.open") as legacy_open, + patch.object(manager, "_ensure_wpa_supplicant"), + patch.object(manager, "_update_networks"), + patch.object(manager, "_init_wifi_state"), + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager._initialize() + thread.call_args.kwargs["target"]() + + assert manager.tethering_password == "custom-password" + legacy_open.assert_not_called() + + def test_startup_migrates_legacy_password_into_keyfile(self): + manager = build_wifi_manager() + manager._tethering_ssid = "weedle" + store = manager._store + assert store is not None + store.get_tethering_password.return_value = None + store.set_tethering_password.return_value = True + manager._scan_thread = MagicMock() + manager._state_thread = MagicMock() - assert manager.tethering_password == "custom-password" + with ( + patch.object(wifi_manager_module, "NetworkStore", return_value=store), + patch("builtins.open", mock_open(read_data="legacy-password\n")), + patch.object(manager, "_ensure_wpa_supplicant"), + patch.object(manager, "_update_networks"), + patch.object(manager, "_init_wifi_state"), + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager._initialize() + thread.call_args.kwargs["target"]() + + assert manager.tethering_password == "legacy-password" + store.set_tethering_password.assert_called_once_with("weedle", "legacy-password") def test_persist_failure_reenables_active_tethering_controls(self): manager = build_wifi_manager() @@ -1619,10 +1821,8 @@ def test_persist_failure_reenables_active_tethering_controls(self): manager.process_callbacks() activated.reset_mock() - with ( - patch.object(wifi_manager_module, "atomic_write", side_effect=OSError("read-only")), - patch.object(wifi_manager_module.threading, "Thread") as thread, - ): + manager._store.set_tethering_password.return_value = False + with patch.object(wifi_manager_module.threading, "Thread") as thread: manager.set_tethering_password("replacement-password") thread.call_args.kwargs["target"]() @@ -1636,12 +1836,9 @@ def test_teardown_failure_reenables_active_tethering_controls(self): manager._wifi_state = WifiState(manager._tethering_ssid, ConnectStatus.CONNECTED) disconnected = MagicMock() manager.add_callbacks(disconnected=disconnected) - password_file = MagicMock() - password_write = MagicMock() - password_write.__enter__.return_value = password_file + manager._store.set_tethering_password.return_value = True with ( - patch.object(wifi_manager_module, "atomic_write", return_value=password_write), patch.object(manager, "_stop_tethering", side_effect=OSError("cleanup failed")) as stop_tethering, patch.object(manager, "_start_tethering") as start_tethering, patch.object(wifi_manager_module.threading, "Thread") as thread, diff --git a/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py b/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py index 014c621dd49d47..c74e02eb95365f 100644 --- a/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py +++ b/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py @@ -6,6 +6,9 @@ from openpilot.system.ui.lib import wifi_manager as wifi_manager_module from openpilot.system.ui.lib.wifi_manager import ( ConnectStatus, + TETHERING_FORWARD_CHAIN, + TETHERING_INPUT_CHAIN, + TETHERING_NAT_CHAIN, TETHERING_NAT_COMMENT, TETHERING_SUBNET, WifiManager, @@ -50,8 +53,9 @@ def tethering_side_effects(manager: WifiManager, mode: str = "AP"): patch.object(wifi_manager_module, "WpaCtrl", return_value=ctrl), patch.object(wifi_manager_module.subprocess, "Popen", return_value=dnsmasq), patch.object(wifi_manager_module.subprocess, "run", return_value=MagicMock(returncode=1)) as run, + patch.object(manager, "_apply_ipv4_forward") as apply_ipv4_forward, ): - yield run, ctrl, ap_file, atomic_write + yield run, ctrl, ap_file, atomic_write, apply_ipv4_forward class TestTetheringFirewall(TestCase): @@ -65,10 +69,11 @@ def test_installs_uplink_independent_masquerade(self): manager._tethering_active = False with ( patch.object(wifi_manager_module.shutil, "which", return_value="/usr/sbin/iptables-legacy"), - tethering_side_effects(manager) as (run, ctrl, _, _), + tethering_side_effects(manager) as (run, ctrl, _, _, apply_ipv4_forward), ): manager._start_tethering() + apply_ipv4_forward.assert_called_once_with(True) commands = [item.args[0] for item in run.call_args_list] nat_add = next(command for command in commands if "-A" in command and "MASQUERADE" in command) assert nat_add[:2] == ["sudo", "iptables-legacy"] @@ -76,6 +81,7 @@ def test_installs_uplink_independent_masquerade(self): assert "!" in nat_add and "-d" in nat_add assert "-o" not in nat_add assert TETHERING_NAT_COMMENT in nat_add + assert TETHERING_NAT_CHAIN in nat_add assert manager.is_tethering_active() assert manager._ctrl is ctrl assert manager._wifi_state == WifiState("weedle-test", ConnectStatus.CONNECTED) @@ -84,22 +90,22 @@ def test_installs_client_input_and_forward_rules(self): manager = build_tethering_manager() with ( patch.object(wifi_manager_module.shutil, "which", return_value="/usr/sbin/iptables-legacy"), - tethering_side_effects(manager) as (run, _, _, _), + tethering_side_effects(manager) as (run, _, _, _, _), ): manager._start_tethering() commands = [item.args[0] for item in run.call_args_list] added = [command for command in commands if "-A" in command] - assert any("INPUT" in command and "udp" in command and "67" in command and "ACCEPT" in command for command in added) - assert any("INPUT" in command and "udp" in command and "53" in command and "ACCEPT" in command for command in added) - assert any("INPUT" in command and "tcp" in command and "53" in command and "ACCEPT" in command for command in added) - assert any("FORWARD" in command and "-i" in command and TETHERING_SUBNET in command and "ACCEPT" in command for command in added) - assert any("FORWARD" in command and "-o" in command and "ESTABLISHED,RELATED" in command and "ACCEPT" in command for command in added) + assert any(TETHERING_INPUT_CHAIN in command and "udp" in command and "67" in command and "ACCEPT" in command for command in added) + assert any(TETHERING_INPUT_CHAIN in command and "udp" in command and "53" in command and "ACCEPT" in command for command in added) + assert any(TETHERING_INPUT_CHAIN in command and "tcp" in command and "53" in command and "ACCEPT" in command for command in added) + assert any(TETHERING_FORWARD_CHAIN in command and "-i" in command and TETHERING_SUBNET in command and "ACCEPT" in command for command in added) + assert any(TETHERING_FORWARD_CHAIN in command and "-o" in command and "ESTABLISHED,RELATED" in command and "ACCEPT" in command for command in added) assert all(TETHERING_NAT_COMMENT in command for command in added) def test_start_preserves_untagged_masquerade_rules(self): manager = build_tethering_manager() - with tethering_side_effects(manager) as (run, _, _, _): + with tethering_side_effects(manager) as (run, _, _, _, _): manager._start_tethering() commands = [item.args[0] for item in run.call_args_list] @@ -115,7 +121,7 @@ def fail_nat_add(command, **_): with ( patch.object(wifi_manager_module.shutil, "which", return_value="/usr/sbin/iptables-legacy"), - tethering_side_effects(manager) as (run, _, _, _), + tethering_side_effects(manager) as (run, _, _, _, _), ): run.side_effect = fail_nat_add with self.assertRaises(subprocess.CalledProcessError): @@ -126,7 +132,7 @@ def fail_nat_add(command, **_): def test_non_ap_daemon_aborts_bringup(self): manager = build_tethering_manager() - with tethering_side_effects(manager, mode="station") as (_, ctrl, _, _): + with tethering_side_effects(manager, mode="station") as (_, ctrl, _, _, _): with self.assertRaisesRegex(RuntimeError, "did not take over wlan0"): manager._start_tethering() @@ -135,23 +141,48 @@ def test_non_ap_daemon_aborts_bringup(self): def test_ap_config_uses_wpa2_with_ccmp(self): manager = build_tethering_manager() - with tethering_side_effects(manager) as (_, _, ap_file, _): + with tethering_side_effects(manager) as (_, _, ap_file, _, _): manager._start_tethering() config = ap_file().write.call_args.args[0] - assert "ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev\n" in config + assert "ctrl_interface=DIR=/run/openpilot-wpa GROUP=netdev\n" in config assert " proto=RSN\n" in config assert " pairwise=CCMP\n" in config assert " group=CCMP\n" in config def test_ap_config_is_written_atomically(self): manager = build_tethering_manager() - with tethering_side_effects(manager) as (_, _, ap_file, atomic_write): + with tethering_side_effects(manager) as (_, _, ap_file, atomic_write, _): manager._start_tethering() atomic_write.assert_called_once_with(wifi_manager_module.WPA_AP_CONF, overwrite=True) ap_file().write.assert_called_once() + def test_applies_ipv4_forwarding_changes_while_active(self): + manager = build_tethering_manager() + with patch.object(manager, "_apply_ipv4_forward") as apply_ipv4_forward: + manager.set_ipv4_forward(False) + + assert not manager._ipv4_forward + apply_ipv4_forward.assert_called_once_with(False) + + def test_ipv4_forwarding_write_has_kernel_postcondition(self): + manager = build_tethering_manager() + with ( + patch.object(wifi_manager_module.subprocess, "run") as run, + patch.object(wifi_manager_module.Path, "read_text", return_value="0\n"), + ): + manager._apply_ipv4_forward(False) + + run.assert_called_once_with(["sudo", "sysctl", "net.ipv4.ip_forward=0"], check=True) + + with self.assertRaisesRegex(RuntimeError, "actual='1'"): + with ( + patch.object(wifi_manager_module.subprocess, "run"), + patch.object(wifi_manager_module.Path, "read_text", return_value="1\n"), + ): + manager._apply_ipv4_forward(False) + def test_stop_removes_nat_and_restores_station(self): manager = build_tethering_manager() manager._ctrl = MagicMock() @@ -164,14 +195,20 @@ def test_stop_removes_nat_and_restores_station(self): patch.object(wifi_manager_module, "generate_wpa_conf"), patch.object(wifi_manager_module.time, "sleep"), patch.object(wifi_manager_module.subprocess, "run", return_value=MagicMock(returncode=1)) as run, + patch.object(manager, "_apply_ipv4_forward") as apply_ipv4_forward, ): manager._stop_tethering() - expected_rules = wifi_manager_module._tethering_firewall_rules("-D") + expected_cleanup = wifi_manager_module._tethering_firewall_jumps("-D") + for command, chain in wifi_manager_module._tethering_firewall_chains(): + expected_cleanup.extend(( + [*command, "-F", chain], + [*command, "-X", chain], + )) commands = [item.args[0] for item in run.call_args_list] - for rule in expected_rules: - assert rule in commands - assert ["sudo", "sysctl", "net.ipv4.ip_forward=0"] in commands + for command in expected_cleanup: + assert command in commands + apply_ipv4_forward.assert_called_once_with(False) ensure_wpa_supplicant.assert_called_once() assert not manager._tethering_active assert manager._wifi_state == WifiState() diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 4b1930f2fc2681..fdba47ab1832d7 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -3,9 +3,11 @@ import subprocess import threading import time +import uuid from collections.abc import Callable from dataclasses import dataclass from enum import IntEnum +from pathlib import Path from typing import TYPE_CHECKING from openpilot.common.swaglog import cloudlog @@ -14,13 +16,13 @@ from openpilot.system.ui.lib.wifi_network_store import MeteredType, NetworkStore from openpilot.system.ui.lib.wpa_ctrl import (WpaCtrl, WpaCtrlMonitor, SecurityType, WPA_SUPPLICANT_CONF, WPA_AP_CONF, - WPA_CTRL_INTERFACE, + WPA_CTRL_INTERFACE, WPA_PID_FILE, stop_wpa_supplicant, wpa_supplicant_running, sanitize_for_conf, format_psk_value, format_ssid_value, is_valid_psk, is_valid_ssid, generate_wpa_conf, parse_event_network_id, parse_event_ssid, parse_scan_results, flags_to_security_type, parse_status, dbm_to_percent, decode_ssid, - ensure_wpa_supplicant, try_attach_ctrl, + ensure_wpa_supplicant, prepare_wpa_runtime, try_attach_ctrl, stop_tethering_dnsmasq, tethering_dnsmasq_running) if TYPE_CHECKING: @@ -34,6 +36,9 @@ TETHERING_IP_ADDRESS = "192.168.43.1" TETHERING_SUBNET = "192.168.43.0/24" TETHERING_NAT_COMMENT = "openpilot-tethering" +TETHERING_NAT_CHAIN = "OPENPILOT_TETHERING_NAT" +TETHERING_INPUT_CHAIN = "OPENPILOT_TETHERING_INPUT" +TETHERING_FORWARD_CHAIN = "OPENPILOT_TETHERING_FORWARD" DEFAULT_TETHERING_PASSWORD = "swagswagcomma" TETHERING_PASSWORD_FILE = "/data/tethering_password" SCAN_PERIOD_SECONDS = 5 @@ -68,12 +73,32 @@ class WifiState: status: ConnectStatus = ConnectStatus.DISCONNECTED +class StationOperationKind(IntEnum): + CONNECT = 0 + ACTIVATE = 1 + ASSOCIATED = 2 + FORGET = 3 + AUTH_FAILURE = 4 + TIMEOUT = 5 + + +@dataclass(frozen=True) +class StationOperation: + epoch: int + kind: StationOperationKind + ssid: str | None + profile_uuid: str | None = None + runtime_network_id: str | None = None + + @dataclass(frozen=True) class PendingConnection: ssid: str password: str hidden: bool + security: SecurityType epoch: int + profile_uuid: str network_id: str | None = None @@ -87,33 +112,71 @@ def _tethering_firewall_rules(op: str) -> list[list[str]]: command = ["sudo", _iptables_executable()] tagged = ["-m", "comment", "--comment", TETHERING_NAT_COMMENT] return [ - [*command, "-t", "nat", op, "POSTROUTING", + [*command, "-t", "nat", op, TETHERING_NAT_CHAIN, "-s", TETHERING_SUBNET, "!", "-d", TETHERING_SUBNET, *tagged, "-j", "MASQUERADE"], - [*command, op, "INPUT", "-i", "wlan0", "-p", "udp", "--dport", "67", *tagged, "-j", "ACCEPT"], - [*command, op, "INPUT", "-i", "wlan0", "-p", "udp", "--dport", "53", *tagged, "-j", "ACCEPT"], - [*command, op, "INPUT", "-i", "wlan0", "-p", "tcp", "--dport", "53", *tagged, "-j", "ACCEPT"], - [*command, op, "FORWARD", "-i", "wlan0", "-s", TETHERING_SUBNET, *tagged, "-j", "ACCEPT"], - [*command, op, "FORWARD", "-o", "wlan0", "-d", TETHERING_SUBNET, + [*command, op, TETHERING_INPUT_CHAIN, "-i", "wlan0", "-p", "udp", "--dport", "67", *tagged, "-j", "ACCEPT"], + [*command, op, TETHERING_INPUT_CHAIN, "-i", "wlan0", "-p", "udp", "--dport", "53", *tagged, "-j", "ACCEPT"], + [*command, op, TETHERING_INPUT_CHAIN, "-i", "wlan0", "-p", "tcp", "--dport", "53", *tagged, "-j", "ACCEPT"], + [*command, op, TETHERING_FORWARD_CHAIN, "-i", "wlan0", "-s", TETHERING_SUBNET, *tagged, "-j", "ACCEPT"], + [*command, op, TETHERING_FORWARD_CHAIN, "-o", "wlan0", "-d", TETHERING_SUBNET, "-m", "conntrack", "--ctstate", "ESTABLISHED,RELATED", *tagged, "-j", "ACCEPT"], ] +def _tethering_firewall_chains() -> list[tuple[list[str], str]]: + command = ["sudo", _iptables_executable()] + return [ + ([*command, "-t", "nat"], TETHERING_NAT_CHAIN), + (command, TETHERING_INPUT_CHAIN), + (command, TETHERING_FORWARD_CHAIN), + ] + + +def _tethering_firewall_jumps(op: str) -> list[list[str]]: + command = ["sudo", _iptables_executable()] + tagged = ["-m", "comment", "--comment", TETHERING_NAT_COMMENT] + return [ + [*command, "-t", "nat", op, "POSTROUTING", *tagged, "-j", TETHERING_NAT_CHAIN], + [*command, op, "INPUT", *tagged, "-j", TETHERING_INPUT_CHAIN], + [*command, op, "FORWARD", *tagged, "-j", TETHERING_FORWARD_CHAIN], + ] + + +def _delete_firewall_jumps(): + for jump in _tethering_firewall_jumps("-D"): + while subprocess.run(jump, capture_output=True, check=False).returncode == 0: + pass + + +def _install_tethering_firewall_rules(): + for command, chain in _tethering_firewall_chains(): + subprocess.run([*command, "-N", chain], capture_output=True, check=False) + subprocess.run([*command, "-F", chain], check=True) + for rule in _tethering_firewall_rules("-A"): + subprocess.run(rule, check=True) + + # Exactly one jump from each shared built-in chain into our owned chains. + _delete_firewall_jumps() + for jump in _tethering_firewall_jumps("-A"): + subprocess.run(jump, check=True) + + def _tethering_firewall_ready() -> bool: try: + checks = [*_tethering_firewall_rules("-C"), *_tethering_firewall_jumps("-C")] return all(subprocess.run(rule, capture_output=True, check=False).returncode == 0 - for rule in _tethering_firewall_rules("-C")) + for rule in checks) except OSError: cloudlog.exception("Failed to verify tethering firewall rules") return False def _delete_tethering_firewall_rules(): - for rule in _tethering_firewall_rules("-D"): - for _ in range(4): - result = subprocess.run(rule, capture_output=True, check=False) - if result.returncode != 0: - break + _delete_firewall_jumps() + for command, chain in _tethering_firewall_chains(): + subprocess.run([*command, "-F", chain], capture_output=True, check=False) + subprocess.run([*command, "-X", chain], capture_output=True, check=False) class WifiManager: @@ -130,12 +193,15 @@ def __init__(self): self._wifi_state: WifiState = WifiState() self._user_epoch: int = 0 self._ipv4_address: str = "" + self._associated_ssid: str | None = None + self._associated_epoch: int | None = None self._dhcp_adoption_ssid: str | None = None self._current_network_metered: MeteredType = MeteredType.UNKNOWN self._ipv4_forward = False self._tethering_active = False self._tethering_psk = DEFAULT_TETHERING_PASSWORD self._dnsmasq_proc: subprocess.Popen | None = None + self._station_operation: StationOperation | None = None self._pending_connection: PendingConnection | None = None self._requested_ssid: str | None = None self._network_not_found_epoch: int | None = None @@ -149,8 +215,9 @@ def __init__(self): self._callback_queue: list[Callable] = [] self._callback_lock = threading.Lock() self._state_lock = threading.RLock() - self._connect_lock = threading.Lock() - self._station_lock = threading.Lock() + # All wpa_supplicant and DHCP station mutations share one serialized owner. + self._station_lock = threading.RLock() + self._connect_lock = self._station_lock self._station_cleanup_pending = False self._tethering_lock = threading.RLock() self._tethering_epoch = 0 @@ -184,17 +251,27 @@ def worker(): try: store = NetworkStore() self._store = store - # WPA passphrases may legally include leading or trailing spaces, so only - # trim the file terminator. - try: - with open(TETHERING_PASSWORD_FILE) as f: - raw = f.read() - self._tethering_psk = raw[:-1] if raw.endswith("\n") else raw - except FileNotFoundError: - self._tethering_psk = store.get_tethering_password(self._tethering_ssid) or DEFAULT_TETHERING_PASSWORD - except (OSError, UnicodeError): - cloudlog.exception("Failed to read tethering password") - self._tethering_psk = store.get_tethering_password(self._tethering_ssid) or DEFAULT_TETHERING_PASSWORD + persisted_password = store.get_tethering_password(self._tethering_ssid) + if persisted_password is not None and is_valid_psk(persisted_password): + self._tethering_psk = persisted_password + else: + if persisted_password is not None: + cloudlog.warning("Ignoring invalid tethering password in NetworkManager profile") + # The standalone file is migration input only; the keyfile remains the + # durable source after a successful import. + try: + with open(TETHERING_PASSWORD_FILE) as f: + raw = f.read() + legacy_password = raw[:-1] if raw.endswith("\n") else raw + if is_valid_psk(legacy_password) and store.set_tethering_password(self._tethering_ssid, legacy_password): + self._tethering_psk = legacy_password + else: + self._tethering_psk = DEFAULT_TETHERING_PASSWORD + except FileNotFoundError: + self._tethering_psk = DEFAULT_TETHERING_PASSWORD + except (OSError, UnicodeError): + cloudlog.exception("Failed to migrate legacy tethering password") + self._tethering_psk = DEFAULT_TETHERING_PASSWORD with self._tethering_lock: self._ensure_wpa_supplicant() @@ -366,7 +443,9 @@ def connected_ssid(self) -> str | None: def tethering_password(self) -> str: return self._tethering_psk - def _set_connecting(self, ssid: str | None, requested: bool = True): + def _set_connecting(self, ssid: str | None, requested: bool = True, + kind: StationOperationKind = StationOperationKind.CONNECT, + operation_ssid: str | None = None): with self._state_lock: self._dhcp_adoption_ssid = None self._user_epoch += 1 @@ -375,6 +454,9 @@ def _set_connecting(self, ssid: str | None, requested: bool = True): self._network_not_found_events = 0 self._last_connecting_at = time.monotonic() if ssid is not None else 0.0 self._last_scanning_recheck = 0.0 + self._associated_ssid = None + self._associated_epoch = None + self._station_operation = StationOperation(self._user_epoch, kind, operation_ssid if operation_ssid is not None else ssid) self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.DISCONNECTED if ssid is None else ConnectStatus.CONNECTING) def _clear_station_state(self): @@ -382,6 +464,9 @@ def _clear_station_state(self): self._dhcp.clear_ipv6_state() self._ipv4_address = "" self._current_network_metered = MeteredType.UNKNOWN + with self._state_lock: + self._associated_ssid = None + self._associated_epoch = None def _prepare_connection(self, epoch: int) -> bool: with self._station_lock: @@ -395,9 +480,25 @@ def _prepare_connection(self, epoch: int) -> bool: with self._state_lock: return self._user_epoch == epoch - def _set_pending_connection(self, ssid: str, password: str, hidden: bool): + def _set_pending_connection(self, ssid: str, password: str, hidden: bool, security: SecurityType): + profile_uuid = None + if self._store is not None: + entry = self._store.get(ssid) + if isinstance(entry, dict): + profile_uuid = entry.get("uuid") + try: + profile_uuid = str(uuid.UUID(profile_uuid)) if profile_uuid is not None else str(uuid.uuid4()) + except (AttributeError, TypeError, ValueError): + profile_uuid = str(uuid.uuid4()) + with self._state_lock: - self._pending_connection = PendingConnection(ssid=ssid, password=password, hidden=hidden, epoch=self._user_epoch) + epoch = self._user_epoch + self._pending_connection = PendingConnection( + ssid=ssid, password=password, hidden=hidden, security=security, epoch=epoch, profile_uuid=profile_uuid, + ) + self._station_operation = StationOperation( + epoch, StationOperationKind.CONNECT, ssid, profile_uuid=profile_uuid, + ) def _set_pending_network_id(self, net_id: str, epoch: int): with self._state_lock: @@ -408,17 +509,51 @@ def _set_pending_network_id(self, net_id: str, epoch: int): ssid=pending.ssid, password=pending.password, hidden=pending.hidden, + security=pending.security, epoch=pending.epoch, + profile_uuid=pending.profile_uuid, network_id=net_id, ) + self._station_operation = StationOperation( + epoch, StationOperationKind.CONNECT, pending.ssid, pending.profile_uuid, net_id, + ) - def _clear_pending_connection(self, ssid: str | None = None): + def _clear_pending_connection(self, ssid: str | None = None, epoch: int | None = None): with self._state_lock: - if self._pending_connection is None: + pending = self._pending_connection + if pending is None: + return + if epoch is not None and pending.epoch != epoch: return - if ssid is None or self._pending_connection.ssid == ssid: + if ssid is None or pending.ssid == ssid: self._pending_connection = None + def _restore_station_runtime(self, selected_id: str | None, previous_ids: list[str], removed_ids: list[str]): + exact = True + if selected_id is not None: + try: + self._remove_wpa_network_id(selected_id) + except Exception: + exact = False + cloudlog.exception(f"Failed to remove selected runtime network {selected_id}") + + if exact and not removed_ids and previous_ids: + try: + self._select_network_ids(previous_ids) + return + except Exception: + cloudlog.exception("Failed to restore previous runtime network selection") + + try: + store = self._require_store() + generate_wpa_conf(store) + if self._ctrl is not None: + response = self._request("RECONFIGURE").strip() + if not response.startswith("OK"): + raise RuntimeError(f"RECONFIGURE failed: {response}") + except Exception: + cloudlog.exception("Failed to restore runtime networks from durable profiles") + def _persist_pending_connection(self, ssid: str | None): with self._state_lock: pending = self._pending_connection @@ -431,7 +566,13 @@ def _persist_pending_connection(self, ssid: str | None): # Retain credentials after transient persistence failures try: store = self._require_store() - store.save_network(ssid, psk=pending.password, hidden=pending.hidden) + store.save_network( + ssid, + psk=pending.password, + hidden=pending.hidden, + security=pending.security, + profile_uuid=pending.profile_uuid, + ) generate_wpa_conf(store) except Exception: cloudlog.exception("Failed to persist pending connection for %s", ssid) @@ -442,7 +583,25 @@ def _persist_pending_connection(self, ssid: str | None): def _connected_transition_is_current(self, ssid: str, epoch: int) -> bool: with self._state_lock: - return self._user_epoch == epoch and self._wifi_state == WifiState(ssid, ConnectStatus.CONNECTED) + return ( + self._user_epoch == epoch + and self._associated_ssid == ssid + and self._associated_epoch == epoch + ) + + def _complete_station_connection(self, ssid: str, epoch: int): + with self._station_lock, self._state_lock: + if not self._ipv4_address or not self._connected_transition_is_current(ssid, epoch): + return + if self._wifi_state == WifiState(ssid, ConnectStatus.CONNECTED): + return + self._requested_ssid = None + self._last_connecting_at = 0.0 + self._last_scanning_recheck = 0.0 + self._network_not_found_epoch = None + self._network_not_found_events = 0 + self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTED) + self._enqueue_callbacks(self._activated) def _enqueue_callbacks(self, cbs: list[Callable], *args): with self._callback_lock: @@ -537,6 +696,13 @@ def _adopt_ap_state(self, ssid: str | None) -> bool: cloudlog.warning("AP services are incomplete; refusing adoption and tearing down orphan AP") self._stop_tethering() return False + try: + self._apply_ipv4_forward(self._ipv4_forward) + except Exception: + cloudlog.exception("Failed to enforce IPv4 forwarding policy while adopting AP") + self._stop_tethering() + return False + if not self._ap_config_matches_password(): cloudlog.warning("Persisted tethering password differs from the running AP; rebuilding hotspot") self._tethering_active = True @@ -572,49 +738,52 @@ def _ap_config_matches_password(self) -> bool: return False def _handle_connected(self, ssid: str, adopt_dhcp: bool = False, expected_epoch: int | None = None): - """Transition to CONNECTED. Idempotent on (ssid, CONNECTED) so the monitor and - reconcile paths can both call in without each one killing the previous udhcpc.""" - with self._connect_lock: + """Handle L2 association. CONNECTED and activation remain IP-ready states.""" + with self._station_lock: with self._state_lock: if expected_epoch is not None and self._user_epoch != expected_epoch: return if self._requested_ssid is not None and self._requested_ssid != ssid: return - already_connected = self._wifi_state == WifiState(ssid, ConnectStatus.CONNECTED) transition_epoch = self._user_epoch - if not already_connected: - self._requested_ssid = None - self._last_connecting_at = 0.0 - self._last_scanning_recheck = 0.0 - self._network_not_found_epoch = None - self._network_not_found_events = 0 - self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTED) + already_associated = self._connected_transition_is_current(ssid, transition_epoch) + already_connected = self._wifi_state == WifiState(ssid, ConnectStatus.CONNECTED) + if not already_associated: + self._associated_ssid = ssid + self._associated_epoch = transition_epoch + pending = self._pending_connection + self._station_operation = StationOperation( + transition_epoch, + StationOperationKind.ASSOCIATED, + ssid, + profile_uuid=pending.profile_uuid if pending is not None and pending.ssid == ssid else None, + runtime_network_id=pending.network_id if pending is not None and pending.ssid == ssid else None, + ) + self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTING) if already_connected: - # Retry persistence after transient filesystem failures - with self._state_lock: - pending = self._pending_connection + # Retry persistence after transient filesystem failures. + pending = self._pending_connection if pending is not None and pending.ssid == ssid: self._persist_pending_connection(ssid) return + + if not already_associated: + if not adopt_dhcp or not self._dhcp.adopt(): + self._ipv4_address = "" + self._dhcp.start() + if not self._connected_transition_is_current(ssid, transition_epoch): + return + self._persist_pending_connection(ssid) - if not self._connected_transition_is_current(ssid, transition_epoch): - return - # SELECT_NETWORK disables other profiles; re-enable them for roaming - if self._ctrl is not None: + if self._ctrl is not None and self._connected_transition_is_current(ssid, transition_epoch): try: + # SELECT_NETWORK disables other profiles; re-enable them for roaming. self._request("ENABLE_NETWORK all") except Exception: cloudlog.exception("Failed to re-enable saved networks for auto-roam") - if not self._connected_transition_is_current(ssid, transition_epoch): - return - if not adopt_dhcp or not self._dhcp.adopt(): - self._ipv4_address = "" - self._dhcp.start() - if not self._connected_transition_is_current(ssid, transition_epoch): - return - self._enqueue_callbacks(self._activated) - self._poll_for_ip() + if self._connected_transition_is_current(ssid, transition_epoch): + self._poll_for_ip(ssid, transition_epoch) def _handle_event(self, event: str): if "CTRL-EVENT-SCAN-RESULTS" in event: @@ -719,12 +888,12 @@ def _handle_event(self, event: str): self._network_not_found_epoch = None self._network_not_found_events = 0 return - self._request("ENABLE_NETWORK all") + self._restore_station_runtime(None, [], ["wrong-key"]) except Exception: cloudlog.exception("Failed to re-enable saved networks after WRONG_KEY") self._clear_pending_connection(event_ssid) self._enqueue_callbacks(self._need_auth, event_ssid) - self._set_connecting(None) + self._set_connecting(None, kind=StationOperationKind.AUTH_FAILURE, operation_ssid=event_ssid) # DISCONNECTED may arrive while CONNECTING and skip cleanup self._clear_station_state() self._enqueue_callbacks(self._disconnected) @@ -767,7 +936,8 @@ def _request_scan(self): if self._ctrl is None: return try: - self._request("SCAN TYPE=ONLY" if self._wifi_state.status == ConnectStatus.CONNECTED else "SCAN") + associated = self._associated_ssid is not None + self._request("SCAN TYPE=ONLY" if associated or self._wifi_state.status == ConnectStatus.CONNECTED else "SCAN") except Exception: cloudlog.exception("Failed to request scan") @@ -782,6 +952,7 @@ def _reconcile_tethering_state(self): return try: + self._apply_ipv4_forward(self._ipv4_forward) status = parse_status(self._request("STATUS")) if (status.get("mode") == "AP" and status.get("wpa_state") == "COMPLETED" and tethering_dnsmasq_running() and _tethering_firewall_ready()): @@ -894,31 +1065,31 @@ def _reconcile_connecting_state(self): self._last_scanning_recheck = time.monotonic() elif wpa_state in ("DISCONNECTED", "INACTIVE", "SCANNING", "AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): - with self._state_lock: - if self._user_epoch != epoch: - return - ssid = current_state.ssid - pending = self._pending_connection - temporary_ssid = ssid if ( - pending is not None - and ssid is not None - and pending.ssid == ssid - and not self._require_store().contains(ssid) - ) else None - # Remove failed unsaved credentials before re-enabling profiles - try: - if temporary_ssid is not None: - self._remove_wpa_network(temporary_ssid) - self._request("ENABLE_NETWORK all") - except Exception: - cloudlog.exception("Failed to re-enable saved networks after stale CONNECTING") - with self._state_lock: - if self._user_epoch != epoch: - return - self._clear_pending_connection(ssid) - self._set_connecting(None) - self._clear_station_state() - self._enqueue_callbacks(self._disconnected) + with self._connect_lock: + with self._state_lock: + if self._user_epoch != epoch: + return + ssid = current_state.ssid + pending = self._pending_connection + pending_network_id = pending.network_id if ( + pending is not None + and pending.epoch == epoch + and pending.ssid == ssid + ) else None + + self._restore_station_runtime( + pending_network_id, + [], + [pending_network_id] if pending_network_id is not None else [], + ) + + with self._state_lock: + if self._user_epoch != epoch or self._pending_connection is not pending: + return + self._clear_pending_connection(ssid, epoch=epoch) + self._set_connecting(None, kind=StationOperationKind.TIMEOUT, operation_ssid=ssid) + self._clear_station_state() + self._enqueue_callbacks(self._disconnected) def _update_networks(self, block: bool = True): def worker(): @@ -966,15 +1137,17 @@ def worker(): else: threading.Thread(target=worker, daemon=True).start() - def _poll_for_ip(self): - epoch = self._user_epoch + def _poll_for_ip(self, ssid: str | None = None, epoch: int | None = None): + ssid = self._associated_ssid if ssid is None else ssid + epoch = self._user_epoch if epoch is None else epoch def worker(): for _ in range(50): # 10 seconds max - if self._wifi_state.status != ConnectStatus.CONNECTED or self._user_epoch != epoch: + if ssid is None or not self._connected_transition_is_current(ssid, epoch): return self._update_active_connection_info() if self._ipv4_address: + self._complete_station_connection(ssid, epoch) return time.sleep(0.2) threading.Thread(target=worker, daemon=True).start() @@ -984,7 +1157,11 @@ def _update_active_connection_info(self): metered = MeteredType.UNKNOWN profile_uuid = None - if self._wifi_state.status == ConnectStatus.CONNECTED: + with self._state_lock: + station_ssid = self._associated_ssid + station_active = station_ssid is not None or self._wifi_state.status == ConnectStatus.CONNECTED + + if station_active: if self._ctrl: try: status = parse_status(self._request("STATUS")) @@ -1006,14 +1183,15 @@ def _update_active_connection_info(self): except Exception: pass - ssid = self._wifi_state.ssid + ssid = station_ssid or self._wifi_state.ssid if ssid and self._store is not None: metered = self._store.get_metered(ssid, profile_uuid) self._ipv4_address = ipv4_address self._current_network_metered = metered - def connect_to_network(self, ssid: str, password: str, hidden: bool = False): + def connect_to_network(self, ssid: str, password: str, hidden: bool = False, + security: SecurityType | None = None): # Guard non-UI callers while tethering if self._tethering_active: cloudlog.warning(f"Ignoring connect to {ssid!r} while tethering is active") @@ -1021,14 +1199,21 @@ def connect_to_network(self, ssid: str, password: str, hidden: bool = False): if not is_valid_ssid(ssid): cloudlog.warning(f"Ignoring connect to invalid SSID {ssid!r}") return - if password and not is_valid_psk(password): + security = security if security is not None else (SecurityType.WPA if password else SecurityType.OPEN) + if security not in (SecurityType.OPEN, SecurityType.WPA): + cloudlog.warning(f"Ignoring connect to {ssid!r} with unsupported security") + return + if security == SecurityType.WPA and not is_valid_psk(password): cloudlog.warning(f"Ignoring connect to {ssid!r} with invalid passphrase") self._enqueue_callbacks(self._need_auth, ssid) return + if security == SecurityType.OPEN and password: + cloudlog.warning(f"Ignoring open-network connect to {ssid!r} with a passphrase") + return with self._state_lock: self._station_cleanup_pending |= self._wifi_state.status == ConnectStatus.CONNECTED or self._dhcp_adoption_ssid is not None self._set_connecting(ssid) - self._set_pending_connection(ssid, password, hidden) + self._set_pending_connection(ssid, password, hidden, security) epoch = self._user_epoch def worker(): @@ -1042,39 +1227,55 @@ def worker(): return self._clear_pending_connection(ssid) # Reset inline because _init_wifi_state ignores a missing control socket - self._set_connecting(None) + self._set_connecting(None, operation_ssid=ssid) self._enqueue_callbacks(self._disconnected) return # Serialize the epoch check with runtime-network replacement if self._user_epoch != epoch: return + existing_ids: list[str] = [] + removed_ids: list[str] = [] + net_id = None try: existing_ids = self._list_network_ids(ssid) - net_id = self._add_and_select_network(ssid, password, hidden) + with self._state_lock: + pending = self._pending_connection + profile_uuid = pending.profile_uuid if pending is not None and pending.epoch == epoch else None + if profile_uuid is None: + return + net_id = self._add_and_select_network( + ssid, password, hidden, profile_uuid=profile_uuid, security=security, + ) + self._set_pending_network_id(net_id, epoch) + if self._user_epoch != epoch: + self._restore_station_runtime(net_id, existing_ids, removed_ids) + return for existing_id in existing_ids: self._remove_wpa_network_id(existing_id) - self._set_pending_network_id(net_id, epoch) + removed_ids.append(existing_id) + if self._user_epoch != epoch: + self._restore_station_runtime(net_id, existing_ids, removed_ids) + return except Exception: cloudlog.exception(f"Failed to connect to {ssid}") + if net_id is not None or removed_ids: + self._restore_station_runtime(net_id, existing_ids, removed_ids) if self._user_epoch != epoch: return - try: - self._request("ENABLE_NETWORK all") - except Exception: - cloudlog.exception("Failed to re-enable saved networks after connect failure") - # Notify the UI when setup fails before SELECT_NETWORK - self._clear_pending_connection(ssid) - self._set_connecting(None) + self._clear_pending_connection(ssid, epoch=epoch) + self._set_connecting(None, operation_ssid=ssid) self._enqueue_callbacks(self._disconnected) threading.Thread(target=worker, daemon=True).start() def forget_connection(self, ssid: str, block: bool = False): if self._wifi_state.status == ConnectStatus.CONNECTING and self._wifi_state.ssid == ssid: - self._set_connecting(None) + self._set_connecting(None, kind=StationOperationKind.FORGET, operation_ssid=ssid) - def worker(): + def transition(): + with self._state_lock: + self._station_operation = StationOperation(self._user_epoch, StationOperationKind.FORGET, ssid) self._clear_pending_connection(ssid) try: @@ -1105,7 +1306,7 @@ def worker(): was_connected = self._wifi_state.ssid == ssid and self._wifi_state.status == ConnectStatus.CONNECTED preserve_selection = self._wifi_state.status == ConnectStatus.CONNECTING and self._wifi_state.ssid != ssid if was_connected: - self._set_connecting(None) + self._set_connecting(None, kind=StationOperationKind.FORGET, operation_ssid=ssid) self._clear_station_state() self._request("DISCONNECT") self._remove_wpa_network(ssid) @@ -1119,6 +1320,10 @@ def worker(): self._enqueue_callbacks(self._forgotten, ssid) + def worker(): + with self._connect_lock: + transition() + if block: worker() else: @@ -1130,7 +1335,7 @@ def activate_connection(self, ssid: str, block: bool = False): return with self._state_lock: self._station_cleanup_pending |= self._wifi_state.status == ConnectStatus.CONNECTED or self._dhcp_adoption_ssid is not None - self._set_connecting(ssid) + self._set_connecting(ssid, kind=StationOperationKind.ACTIVATE) self._clear_pending_connection() epoch = self._user_epoch @@ -1144,19 +1349,16 @@ def worker(): if self._user_epoch != epoch: return # Reset inline because _init_wifi_state ignores a missing control socket - self._set_connecting(None) + self._set_connecting(None, kind=StationOperationKind.ACTIVATE, operation_ssid=ssid) self._enqueue_callbacks(self._disconnected) return def reset_to_disconnected(): if self._user_epoch != epoch: return - try: - self._request("ENABLE_NETWORK all") - except Exception: - cloudlog.exception("Failed to re-enable saved networks after activation failure") + self._restore_station_runtime(None, [], ["activation"]) # Notify the UI when control-socket recovery fails - self._set_connecting(None) + self._set_connecting(None, kind=StationOperationKind.ACTIVATE, operation_ssid=ssid) self._enqueue_callbacks(self._disconnected) # Serialize the epoch check with saved-network activation @@ -1177,6 +1379,7 @@ def reset_to_disconnected(): entry.get("priority", 0), bssid=entry.get("bssid") or None, profile_uuid=entry.get("uuid"), + security=entry.get("security"), ) for entry in profiles ] @@ -1201,8 +1404,15 @@ def _select_network_ids(self, net_ids: list[str]): if not resp.startswith("OK"): raise RuntimeError(f"{command} failed: {resp}") - def _add_and_select_network(self, ssid: str, psk: str = "", hidden: bool = False, - priority: int = 0, bssid: str | None = None, profile_uuid: str | None = None) -> str: + def _add_and_select_network(self, ssid: str, psk: str = "", hidden: bool = False, priority: int = 0, + bssid: str | None = None, profile_uuid: str | None = None, + security: SecurityType | None = None) -> str: + security = security if security is not None else (SecurityType.WPA if psk else SecurityType.OPEN) + if security not in (SecurityType.OPEN, SecurityType.WPA): + raise ValueError(f"Unsupported security type: {security!r}") + if security == SecurityType.WPA and not is_valid_psk(psk): + raise ValueError("Invalid WPA passphrase") + """Add a network and select it. Every SET_NETWORK is checked so a bad PSK/key_mgmt surfaces an immediate error instead of a delayed WRONG_KEY; orphans get REMOVE_NETWORK'd.""" net_id = self._request("ADD_NETWORK").strip() @@ -1211,7 +1421,7 @@ def _add_and_select_network(self, ssid: str, psk: str = "", hidden: bool = False try: self._wpa_set_network(net_id, "ssid", format_ssid_value(ssid)) - if psk: + if security == SecurityType.WPA: self._wpa_set_network(net_id, "psk", format_psk_value(psk)) else: self._wpa_set_network(net_id, "key_mgmt", "NONE") @@ -1279,17 +1489,13 @@ def set_tethering_password(self, password: str): epoch = self._tethering_password_epoch def transition(): try: - with atomic_write(TETHERING_PASSWORD_FILE, overwrite=True) as f: - f.write(password) + store = self._require_store() + if not store.set_tethering_password(self._tethering_ssid, password): + raise OSError("no durable tethering profile") except Exception: cloudlog.exception("Failed to persist tethering password; runtime state unchanged") self._enqueue_callbacks(self._activated if self._tethering_active else self._disconnected) return - try: - if self._store is not None: - self._store.set_tethering_password(self._tethering_ssid, password) - except Exception: - cloudlog.exception("Failed to update NetworkManager tethering profile") self._tethering_psk = password if self._tethering_active: try: @@ -1312,8 +1518,18 @@ def worker(): transition() threading.Thread(target=worker, daemon=True).start() + def _apply_ipv4_forward(self, enabled: bool): + value = "1" if enabled else "0" + subprocess.run(["sudo", "sysctl", f"net.ipv4.ip_forward={value}"], check=True) + actual = Path("/proc/sys/net/ipv4/ip_forward").read_text().strip() + if actual != value: + raise RuntimeError(f"Failed to set net.ipv4.ip_forward={value} (actual={actual!r})") + def set_ipv4_forward(self, enabled: bool): - self._ipv4_forward = enabled + with self._tethering_lock: + self._ipv4_forward = enabled + if self._tethering_active: + self._apply_ipv4_forward(enabled) def set_tethering_active(self, active: bool): # Report enable immediately and disable after station recovery @@ -1326,10 +1542,6 @@ def transition(): if active: try: self._start_tethering() - if not self._ipv4_forward: - time.sleep(5) - cloudlog.warning("net.ipv4.ip_forward = 0") - subprocess.run(["sudo", "sysctl", "net.ipv4.ip_forward=0"], check=False) except Exception: cloudlog.exception("Failed to start tethering, rolling back") try: @@ -1396,7 +1608,9 @@ def _start_tethering(self): stop_wpa_supplicant(WPA_SUPPLICANT_CONF) stop_wpa_supplicant(WPA_AP_CONF) self._dhcp.stop() + prepare_wpa_runtime() time.sleep(0.5) + self._apply_ipv4_forward(self._ipv4_forward) safe_tether_ssid = sanitize_for_conf(self._tethering_ssid) lines = [WPA_CTRL_INTERFACE, "ap_scan=2", "", @@ -1407,7 +1621,10 @@ def _start_tethering(self): with atomic_write(WPA_AP_CONF, overwrite=True) as f: f.write(ap_conf) - subprocess.run(["sudo", "wpa_supplicant", "-B", "-i", "wlan0", "-c", WPA_AP_CONF, "-D", "nl80211"], check=False) + subprocess.run([ + "sudo", "wpa_supplicant", "-B", "-i", "wlan0", + "-c", WPA_AP_CONF, "-P", WPA_PID_FILE, "-D", "nl80211", + ], check=False) time.sleep(1) subprocess.run(["sudo", "ip", "addr", "flush", "dev", "wlan0"], check=False) @@ -1421,7 +1638,7 @@ def _start_tethering(self): "--bind-interfaces", "--dhcp-range=192.168.43.2,192.168.43.254,24h", "--dhcp-leasefile=/tmp/dnsmasq.leases", - "--no-daemon", "--log-queries", + "--no-daemon", ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) time.sleep(0.2) @@ -1430,12 +1647,7 @@ def _start_tethering(self): self._dnsmasq_proc = None raise RuntimeError(f"dnsmasq exited during tethering bringup (rc={rc})") - # Flush tagged copies so repeated starts remain idempotent - _delete_tethering_firewall_rules() - for rule in _tethering_firewall_rules("-A"): - subprocess.run(rule, check=True) - if self._ipv4_forward: - subprocess.run(["sudo", "sysctl", "net.ipv4.ip_forward=1"], check=True) + _install_tethering_firewall_rules() # Verify that our daemon owns wlan0 in AP mode if not wpa_supplicant_running(WPA_AP_CONF): @@ -1462,8 +1674,8 @@ def _start_tethering(self): def _clear_tethering_network_state(self): try: - subprocess.run(["sudo", "sysctl", "net.ipv4.ip_forward=0"], check=True) - except (OSError, subprocess.CalledProcessError): + self._apply_ipv4_forward(False) + except (OSError, RuntimeError, subprocess.CalledProcessError): cloudlog.exception("Failed to disable IPv4 forwarding during tethering teardown") try: diff --git a/openpilot/system/ui/widgets/network.py b/openpilot/system/ui/widgets/network.py index 34f712fe66be60..d9ef807222dfcf 100644 --- a/openpilot/system/ui/widgets/network.py +++ b/openpilot/system/ui/widgets/network.py @@ -221,10 +221,10 @@ def enter_password(result: DialogResult): password = self._keyboard.text if password == "": # connect without password - self._wifi_manager.connect_to_network(ssid, "", hidden=True) + self._wifi_manager.connect_to_network(ssid, "", hidden=True, security=SecurityType.OPEN) return - self._wifi_manager.connect_to_network(ssid, password, hidden=True) + self._wifi_manager.connect_to_network(ssid, password, hidden=True, security=SecurityType.WPA) self._keyboard.reset(min_text_size=0) self._keyboard.set_title(tr("Enter password"), tr("for \"{}\"").format(ssid)) @@ -442,7 +442,7 @@ def connect_to_network(self, network: Network, password=''): if self._wifi_manager.is_connection_saved(network.ssid) and not password: self._wifi_manager.activate_connection(network.ssid) else: - self._wifi_manager.connect_to_network(network.ssid, password) + self._wifi_manager.connect_to_network(network.ssid, password, security=network.security_type) def forget_network(self, network: Network): self.state = UIState.FORGETTING From 131dc3023120c3ab4cdfe6fac995797158644fcc Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Wed, 19 Aug 2026 19:50:29 +0000 Subject: [PATCH 35/71] wifi: serialize radio mode transitions --- .../ui/lib/tests/test_handle_state_change.py | 56 ++++- openpilot/system/ui/lib/wifi_manager.py | 197 ++++++++++-------- 2 files changed, 160 insertions(+), 93 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index a085226925599b..56d878811b70f2 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -174,6 +174,34 @@ def connect(): assert self.manager._pending_connection.ssid == "NextNet" self.manager._dhcp.start.assert_called_once() + def test_connect_cleans_dhcp_when_superseding_an_association(self): + self.manager._wifi_state = WifiState("CurrentNet", ConnectStatus.CONNECTING) + self.manager._associated_ssid = "CurrentNet" + self.manager._associated_epoch = self.manager._user_epoch + + with ( + patch.object(wifi_manager_module.threading, "Thread") as thread, + patch.object(self.manager, "_list_network_ids", return_value=[]), + patch.object(self.manager, "_add_and_select_network", return_value="1"), + ): + self.manager.connect_to_network("NextNet", "next-password") + thread.call_args.kwargs["target"]() + + self.manager._dhcp.stop.assert_called_once() + + def test_activate_cleans_dhcp_when_superseding_an_association(self): + self.manager._wifi_state = WifiState("CurrentNet", ConnectStatus.CONNECTING) + self.manager._associated_ssid = "CurrentNet" + self.manager._associated_epoch = self.manager._user_epoch + + with ( + patch.object(self.manager, "_list_network_ids", return_value=["1"]), + patch.object(self.manager, "_select_network_ids"), + ): + self.manager.activate_connection("NextNet", block=True) + + self.manager._dhcp.stop.assert_called_once() + def test_pending_persistence_is_retried_without_restarting_dhcp(self): for retry in ("connected", "reconcile"): with self.subTest(retry=retry): @@ -293,7 +321,7 @@ def __enter__(self): def __exit__(self, *_): lock.release() - self.manager.__dict__["_station_lock"] = SignalingLock() + self.manager.__dict__["_radio_lock"] = SignalingLock() worker = threading.Thread(target=self.manager._handle_event, args=("CTRL-EVENT-CONNECTED",)) worker.start() assert waiting_for_lock.wait(1) @@ -1570,6 +1598,32 @@ def test_callbacks_coalesce_network_updates(self): class TestTetheringTransitions(TestCase): + def test_station_transition_does_not_overlap_tethering_start(self): + manager = build_wifi_manager() + station_entered = threading.Event() + release_station = threading.Event() + tethering_entered = threading.Event() + + def add_station_network(*_args, **_kwargs): + station_entered.set() + assert release_station.wait(1) + return "1" + + with ( + patch.object(manager, "_list_network_ids", return_value=[]), + patch.object(manager, "_add_and_select_network", side_effect=add_station_network), + patch.object(manager, "_start_tethering", side_effect=lambda: tethering_entered.set()), + ): + manager.connect_to_network("Station", "station-password") + assert station_entered.wait(1) + + manager.set_tethering_active(True) + try: + assert not tethering_entered.wait(0.1) + finally: + release_station.set() + assert tethering_entered.wait(1) + def test_hotspot_adoption_does_not_overlap_tethering_transition(self): manager = build_wifi_manager() start_entered = threading.Event() diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index fdba47ab1832d7..cbc30cb45c7070 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -215,11 +215,9 @@ def __init__(self): self._callback_queue: list[Callable] = [] self._callback_lock = threading.Lock() self._state_lock = threading.RLock() - # All wpa_supplicant and DHCP station mutations share one serialized owner. - self._station_lock = threading.RLock() - self._connect_lock = self._station_lock + # Serialize wlan0, wpa_supplicant, and DHCP lifecycle changes across STA and AP. + self._radio_lock = threading.RLock() self._station_cleanup_pending = False - self._tethering_lock = threading.RLock() self._tethering_epoch = 0 self._tethering_transition_pending = False self._tethering_started = False @@ -273,7 +271,7 @@ def worker(): cloudlog.exception("Failed to migrate legacy tethering password") self._tethering_psk = DEFAULT_TETHERING_PASSWORD - with self._tethering_lock: + with self._radio_lock: self._ensure_wpa_supplicant() # Load signal strength before rendering the connected network @@ -296,43 +294,46 @@ def _require_store(self) -> NetworkStore: return self._store def _ensure_wpa_supplicant(self): - self._dhcp_adoption_ssid = None - if not wpa_supplicant_running(WPA_AP_CONF): - try: - generate_wpa_conf(self._require_store()) - except Exception: - cloudlog.exception("Failed to generate wpa_supplicant configuration") - return + with self._radio_lock: + self._dhcp_adoption_ssid = None + if not wpa_supplicant_running(WPA_AP_CONF): + try: + generate_wpa_conf(self._require_store()) + except Exception: + cloudlog.exception("Failed to generate wpa_supplicant configuration") + return - def station_reconfigured(ssid: str): - self._dhcp_adoption_ssid = ssid - ctrl = ensure_wpa_supplicant(lambda: self._exit, station_reconfigured, - on_abandoned_ap=self._clear_tethering_network_state) - if ctrl is not None: - self._ctrl = ctrl + def station_reconfigured(ssid: str): + self._dhcp_adoption_ssid = ssid + ctrl = ensure_wpa_supplicant(lambda: self._exit, station_reconfigured, + on_abandoned_ap=self._clear_tethering_network_state) + if ctrl is not None: + self._ctrl = ctrl def _consume_dhcp_adoption(self, ssid: str) -> bool: - adoption_ssid = self._dhcp_adoption_ssid - self._dhcp_adoption_ssid = None - if adoption_ssid is not None and adoption_ssid != ssid: - self._dhcp.clear_ipv6_state() - return adoption_ssid == ssid + with self._radio_lock: + adoption_ssid = self._dhcp_adoption_ssid + self._dhcp_adoption_ssid = None + if adoption_ssid is not None and adoption_ssid != ssid: + self._dhcp.clear_ipv6_state() + return adoption_ssid == ssid def _request(self, cmd: str) -> str: - ctrl = self._ctrl - if ctrl is None: - raise OSError("wpa_supplicant ctrl not attached") - try: - return ctrl.request(cmd) - except OSError: - # Restart the monitor because recv may survive daemon death + with self._radio_lock: + ctrl = self._ctrl + if ctrl is None: + raise OSError("wpa_supplicant ctrl not attached") try: - ctrl.close() - except Exception: - pass - self._ctrl = None - self._monitor_epoch += 1 - raise + return ctrl.request(cmd) + except OSError: + # Restart the monitor because recv may survive daemon death + try: + ctrl.close() + except Exception: + pass + self._ctrl = None + self._monitor_epoch += 1 + raise def _init_wifi_state(self, block: bool = True): def worker(): @@ -460,16 +461,17 @@ def _set_connecting(self, ssid: str | None, requested: bool = True, self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.DISCONNECTED if ssid is None else ConnectStatus.CONNECTING) def _clear_station_state(self): - self._dhcp.stop() - self._dhcp.clear_ipv6_state() - self._ipv4_address = "" - self._current_network_metered = MeteredType.UNKNOWN - with self._state_lock: - self._associated_ssid = None - self._associated_epoch = None + with self._radio_lock: + self._dhcp.stop() + self._dhcp.clear_ipv6_state() + self._ipv4_address = "" + self._current_network_metered = MeteredType.UNKNOWN + with self._state_lock: + self._associated_ssid = None + self._associated_epoch = None def _prepare_connection(self, epoch: int) -> bool: - with self._station_lock: + with self._radio_lock: with self._state_lock: if self._user_epoch != epoch: return False @@ -590,7 +592,7 @@ def _connected_transition_is_current(self, ssid: str, epoch: int) -> bool: ) def _complete_station_connection(self, ssid: str, epoch: int): - with self._station_lock, self._state_lock: + with self._radio_lock, self._state_lock: if not self._ipv4_address or not self._connected_transition_is_current(ssid, epoch): return if self._wifi_state == WifiState(ssid, ConnectStatus.CONNECTED): @@ -640,26 +642,24 @@ def _monitor_state(self): attach_failures = 0 while not self._exit: if self._ctrl is None: - # Avoid spawning STA while tethering is taking over wlan0 - if self._tethering_active: - self._exit_event.wait(1) + with self._radio_lock: + # Avoid spawning STA while tethering is taking over wlan0 + if self._ctrl is None and not self._tethering_active: + daemon_alive = wpa_supplicant_running(WPA_SUPPLICANT_CONF) or wpa_supplicant_running(WPA_AP_CONF) + stale_daemon = daemon_alive and attach_failures >= ATTACH_FAILURES_BEFORE_RESPAWN + if daemon_alive and not stale_daemon: + ctrl = try_attach_ctrl() + if ctrl is None: + attach_failures += 1 + else: + self._ctrl = ctrl + attach_failures = 0 + else: + self._ensure_wpa_supplicant() + attach_failures = 0 + if self._ctrl is None: + self._exit_event.wait(SCAN_PERIOD_SECONDS) continue - daemon_alive = wpa_supplicant_running(WPA_SUPPLICANT_CONF) or wpa_supplicant_running(WPA_AP_CONF) - stale_daemon = daemon_alive and attach_failures >= ATTACH_FAILURES_BEFORE_RESPAWN - if daemon_alive and not stale_daemon: - ctrl = try_attach_ctrl() - if ctrl is None: - attach_failures += 1 - self._exit_event.wait(SCAN_PERIOD_SECONDS) - continue - self._ctrl = ctrl - attach_failures = 0 - else: - self._ensure_wpa_supplicant() - attach_failures = 0 - if self._ctrl is None: - self._exit_event.wait(SCAN_PERIOD_SECONDS) - continue monitor = None try: epoch = self._monitor_epoch @@ -673,12 +673,13 @@ def _monitor_state(self): except Exception: cloudlog.exception("wpa_supplicant monitor error, reconnecting...") # Reattach after control-socket failure - if self._ctrl is not None: - try: - self._ctrl.close() - except Exception: - pass - self._ctrl = None + with self._radio_lock: + if self._ctrl is not None: + try: + self._ctrl.close() + except Exception: + pass + self._ctrl = None finally: if monitor is not None: try: @@ -691,7 +692,7 @@ def _monitor_state(self): def _adopt_ap_state(self, ssid: str | None) -> bool: """Adopt a hotspot only when its DHCP and NAT services are ready. On refusal, tear down its network services so the monitor can recover station mode.""" - with self._tethering_lock: + with self._radio_lock: if not (tethering_dnsmasq_running() and _tethering_firewall_ready()): cloudlog.warning("AP services are incomplete; refusing adoption and tearing down orphan AP") self._stop_tethering() @@ -739,7 +740,7 @@ def _ap_config_matches_password(self) -> bool: def _handle_connected(self, ssid: str, adopt_dhcp: bool = False, expected_epoch: int | None = None): """Handle L2 association. CONNECTED and activation remain IP-ready states.""" - with self._station_lock: + with self._radio_lock: with self._state_lock: if expected_epoch is not None and self._user_epoch != expected_epoch: return @@ -840,7 +841,7 @@ def _handle_event(self, event: str): event_ssid = parse_event_ssid(event) event_network_id = parse_event_network_id(event) if event_ssid is not None: - with self._connect_lock: + with self._radio_lock: current_ssid = self._wifi_state.ssid # The event SSID is authoritative for auto-connect connecting_unknown = ( @@ -947,7 +948,7 @@ def _reconcile_tethering_state(self): return self._last_connected_recheck = now - with self._tethering_lock: + with self._radio_lock: if self._tethering_transition_pending or not self._tethering_active: return @@ -1022,19 +1023,23 @@ def _reconcile_connecting_state(self): return if wpa_state == "COMPLETED" and status_ssid: # Preserve the lease when adopting a roam missed by the monitor - self._dhcp.clear_ipv6_state() - self._handle_connected(status_ssid, expected_epoch=epoch) + with self._radio_lock: + self._dhcp.clear_ipv6_state() + self._handle_connected(status_ssid, expected_epoch=epoch) return # Preserve the lease during transient roam and rekey states if wpa_state in ("SCANNING", "AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): return - self._wifi_state = WifiState(ssid=None, status=ConnectStatus.DISCONNECTED) - self._dhcp.stop() - self._dhcp.clear_ipv6_state() - self._ipv4_address = "" - self._current_network_metered = MeteredType.UNKNOWN - self._enqueue_callbacks(self._disconnected) + with self._radio_lock: + if self._user_epoch != epoch: + return + self._wifi_state = WifiState(ssid=None, status=ConnectStatus.DISCONNECTED) + self._dhcp.stop() + self._dhcp.clear_ipv6_state() + self._ipv4_address = "" + self._current_network_metered = MeteredType.UNKNOWN + self._enqueue_callbacks(self._disconnected) return if current_state.status != ConnectStatus.CONNECTING: @@ -1065,7 +1070,7 @@ def _reconcile_connecting_state(self): self._last_scanning_recheck = time.monotonic() elif wpa_state in ("DISCONNECTED", "INACTIVE", "SCANNING", "AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): - with self._connect_lock: + with self._radio_lock: with self._state_lock: if self._user_epoch != epoch: return @@ -1211,13 +1216,17 @@ def connect_to_network(self, ssid: str, password: str, hidden: bool = False, cloudlog.warning(f"Ignoring open-network connect to {ssid!r} with a passphrase") return with self._state_lock: - self._station_cleanup_pending |= self._wifi_state.status == ConnectStatus.CONNECTED or self._dhcp_adoption_ssid is not None + self._station_cleanup_pending |= ( + self._associated_ssid is not None + or self._wifi_state.status == ConnectStatus.CONNECTED + or self._dhcp_adoption_ssid is not None + ) self._set_connecting(ssid) self._set_pending_connection(ssid, password, hidden, security) epoch = self._user_epoch def worker(): - with self._connect_lock: + with self._radio_lock: if not self._prepare_connection(epoch): return if self._ctrl is None: @@ -1295,7 +1304,7 @@ def transition(): if not removed: cloudlog.warning(f"Trying to forget unknown connection: {ssid}") - with self._connect_lock: + with self._radio_lock: try: generate_wpa_conf(store) except Exception: @@ -1321,7 +1330,7 @@ def transition(): self._enqueue_callbacks(self._forgotten, ssid) def worker(): - with self._connect_lock: + with self._radio_lock: transition() if block: @@ -1334,13 +1343,17 @@ def activate_connection(self, ssid: str, block: bool = False): cloudlog.warning(f"Ignoring activate {ssid!r} while tethering is active") return with self._state_lock: - self._station_cleanup_pending |= self._wifi_state.status == ConnectStatus.CONNECTED or self._dhcp_adoption_ssid is not None + self._station_cleanup_pending |= ( + self._associated_ssid is not None + or self._wifi_state.status == ConnectStatus.CONNECTED + or self._dhcp_adoption_ssid is not None + ) self._set_connecting(ssid, kind=StationOperationKind.ACTIVATE) self._clear_pending_connection() epoch = self._user_epoch def worker(): - with self._connect_lock: + with self._radio_lock: if not self._prepare_connection(epoch): return if self._ctrl is None: @@ -1513,7 +1526,7 @@ def transition(): self._enqueue_callbacks(self._disconnected) def worker(): - with self._tethering_lock: + with self._radio_lock: if self._tethering_password_epoch == epoch: transition() threading.Thread(target=worker, daemon=True).start() @@ -1526,7 +1539,7 @@ def _apply_ipv4_forward(self, enabled: bool): raise RuntimeError(f"Failed to set net.ipv4.ip_forward={value} (actual={actual!r})") def set_ipv4_forward(self, enabled: bool): - with self._tethering_lock: + with self._radio_lock: self._ipv4_forward = enabled if self._tethering_active: self._apply_ipv4_forward(enabled) @@ -1567,7 +1580,7 @@ def transition(): self._enqueue_callbacks(self._disconnected) def worker(): - with self._tethering_lock: + with self._radio_lock: if self._tethering_epoch == epoch: try: transition() From aa32a0a41b479a481e8d92a173e01c1f76ed7786 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Wed, 19 Aug 2026 19:52:31 +0000 Subject: [PATCH 36/71] wifi: require route-ready station activation --- .../ui/lib/tests/test_handle_state_change.py | 66 +++++++++++++++++++ openpilot/system/ui/lib/wifi_manager.py | 47 +++++++++++-- 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 56d878811b70f2..17d05dded55808 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -40,6 +40,7 @@ def build_wifi_manager() -> WifiManager: manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 manager._update_active_connection_info = MagicMock() manager._poll_for_ip = MagicMock() + manager._wifi_default_route_ready = MagicMock(return_value=True) manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=TestNet\n" return manager def complete_station_connection(manager: WifiManager, ssid: str): @@ -98,6 +99,71 @@ def test_connected_waits_for_ip_before_activation(self): assert self.manager.connected_ssid == "TestNet" activated.assert_called_once() + def test_connected_waits_for_metric_600_default_route(self): + activated = MagicMock() + self.manager.add_callbacks(activated=activated) + self.manager._set_connecting("TestNet") + epoch = self.manager._user_epoch + self.manager._associated_ssid = "TestNet" + self.manager._associated_epoch = epoch + self.manager._ipv4_address = "192.168.1.20" + self.manager._wifi_default_route_ready.return_value = False + + self.manager._complete_station_connection("TestNet", epoch) + self.manager.process_callbacks() + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + activated.assert_not_called() + + self.manager._wifi_default_route_ready.return_value = True + self.manager._complete_station_connection("TestNet", epoch) + self.manager.process_callbacks() + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + activated.assert_called_once() + + def test_ip_poll_continues_until_default_route_is_ready(self): + self.manager._set_connecting("TestNet") + epoch = self.manager._user_epoch + self.manager._associated_ssid = "TestNet" + self.manager._associated_epoch = epoch + self.manager._ipv4_address = "192.168.1.20" + self.manager._wifi_default_route_ready.side_effect = [False, True] + + with ( + patch.object(wifi_manager_module.threading, "Thread") as thread, + patch.object(wifi_manager_module.time, "sleep"), + ): + WifiManager._poll_for_ip(self.manager, "TestNet", epoch) + thread.call_args.kwargs["target"]() + + assert self.manager._update_active_connection_info.call_count == 2 + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + + def test_wifi_default_route_ready_requires_one_metric_600_gateway(self): + cases = ( + ("default via 192.168.1.1 dev wlan0 metric 600\n", True), + ("", False), + ("default via 192.168.1.1 dev wlan0 metric 0\n", False), + ("default dev wlan0 metric 600\n", False), + ( + "default via 192.168.1.1 dev wlan0 metric 600\ndefault via 192.168.1.2 dev wlan0 metric 600\n", + False, + ), + ) + for output, expected in cases: + with self.subTest(output=output): + result = MagicMock(returncode=0, stdout=output) + with patch.object(wifi_manager_module.subprocess, "run", return_value=result) as run: + assert WifiManager._wifi_default_route_ready(self.manager) is expected + run.assert_called_once_with( + ["ip", "-4", "route", "show", "default", "dev", "wlan0"], + capture_output=True, + check=False, + text=True, + timeout=2, + ) + def test_connected_transitions_are_serialized(self): self.manager._set_connecting("TestNet") diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index cbc30cb45c7070..32d0bb3a12c61a 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -591,12 +591,49 @@ def _connected_transition_is_current(self, ssid: str, epoch: int) -> bool: and self._associated_epoch == epoch ) - def _complete_station_connection(self, ssid: str, epoch: int): + def _wifi_default_route_ready(self) -> bool: + try: + result = subprocess.run( + ["ip", "-4", "route", "show", "default", "dev", "wlan0"], + capture_output=True, + check=False, + text=True, + timeout=2, + ) + except (OSError, subprocess.TimeoutExpired): + return False + if result.returncode != 0: + return False + + routes = [line.split() for line in result.stdout.splitlines() if line.strip()] + if len(routes) != 1: + return False + + route = routes[0] + try: + via_index = route.index("via") + dev_index = route.index("dev") + metric_index = route.index("metric") + except ValueError: + return False + return ( + route[0] == "default" + and via_index + 1 < len(route) + and route[via_index + 1] not in ("dev", "metric") + and dev_index + 1 < len(route) + and route[dev_index + 1] == "wlan0" + and metric_index + 1 < len(route) + and route[metric_index + 1] == "600" + ) + + def _complete_station_connection(self, ssid: str, epoch: int) -> bool: with self._radio_lock, self._state_lock: if not self._ipv4_address or not self._connected_transition_is_current(ssid, epoch): - return + return False + if not self._wifi_default_route_ready(): + return False if self._wifi_state == WifiState(ssid, ConnectStatus.CONNECTED): - return + return True self._requested_ssid = None self._last_connecting_at = 0.0 self._last_scanning_recheck = 0.0 @@ -604,6 +641,7 @@ def _complete_station_connection(self, ssid: str, epoch: int): self._network_not_found_events = 0 self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTED) self._enqueue_callbacks(self._activated) + return True def _enqueue_callbacks(self, cbs: list[Callable], *args): with self._callback_lock: @@ -1151,8 +1189,7 @@ def worker(): if ssid is None or not self._connected_transition_is_current(ssid, epoch): return self._update_active_connection_info() - if self._ipv4_address: - self._complete_station_connection(ssid, epoch) + if self._ipv4_address and self._complete_station_connection(ssid, epoch): return time.sleep(0.2) threading.Thread(target=worker, daemon=True).start() From e5bac416e8be459212c7d4dfde23d0826c037c78 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Wed, 19 Aug 2026 19:55:08 +0000 Subject: [PATCH 37/71] wifi: preserve tethering state across UI recovery --- .../ui/lib/tests/test_handle_state_change.py | 42 +++++++++++++++++++ .../ui/lib/tests/test_wifi_manager_bringup.py | 8 ++++ openpilot/system/ui/lib/wifi_manager.py | 21 ++++++---- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 17d05dded55808..206056a39f9284 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -1344,6 +1344,23 @@ def test_hotspot_adoption_notifies_callback_registered_after_startup(self): activated.assert_called_once() + def test_hotspot_adoption_preserves_forwarding_until_policy_is_known(self): + self.manager._ipv4_forward = None + self.manager._apply_ipv4_forward.reset_mock() + + with ( + patch.object(wifi_manager_module, "tethering_dnsmasq_running", return_value=True), + patch.object(wifi_manager_module, "_tethering_firewall_ready", return_value=True), + patch("builtins.open", mock_open(read_data=' psk="hotspot-password"\n')), + ): + assert self.manager._adopt_ap_state("Hotspot") + + self.manager._apply_ipv4_forward.assert_not_called() + + self.manager.set_ipv4_forward(True) + + self.manager._apply_ipv4_forward.assert_called_once_with(True) + def test_hotspot_password_mismatch_rebuilds_ap(self): with ( patch.object(wifi_manager_module, "tethering_dnsmasq_running", return_value=True), @@ -1453,6 +1470,7 @@ def test_manager_starts_inactive_until_ui_is_shown(self): assert not manager._active assert manager._store is None + assert manager._ipv4_forward is None network_store.assert_not_called() def test_initialization_loads_network_store_in_worker(self): @@ -1973,3 +1991,27 @@ def test_teardown_failure_reenables_active_tethering_controls(self): assert stop_tethering.call_count == 2 start_tethering.assert_not_called() disconnected.assert_called_once() + + def test_restart_failure_keeps_committed_password(self): + manager = build_wifi_manager() + manager._tethering_active = True + manager._wifi_state = WifiState(manager._tethering_ssid, ConnectStatus.CONNECTED) + manager._store.set_tethering_password.return_value = True + + def stop_tethering(): + manager._tethering_active = False + manager._wifi_state = WifiState() + + with ( + patch.object(manager, "_stop_tethering", side_effect=stop_tethering) as stop_tethering, + patch.object(manager, "_start_tethering", side_effect=OSError("restart failed")) as start_tethering, + patch.object(wifi_manager_module.threading, "Thread") as thread, + ): + manager.set_tethering_password("replacement-password") + thread.call_args.kwargs["target"]() + + assert manager.tethering_password == "replacement-password" + manager._store.set_tethering_password.assert_called_once_with("Hotspot", "replacement-password") + start_tethering.assert_called_once() + assert stop_tethering.call_count == 2 + assert not manager.is_tethering_active() diff --git a/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py b/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py index c74e02eb95365f..459ad78ab75722 100644 --- a/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py +++ b/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py @@ -150,6 +150,14 @@ def test_ap_config_uses_wpa2_with_ccmp(self): assert " pairwise=CCMP\n" in config assert " group=CCMP\n" in config + def test_fresh_tethering_start_requires_forwarding_policy(self): + manager = build_tethering_manager() + manager._ipv4_forward = None + + with tethering_side_effects(manager): + with self.assertRaisesRegex(RuntimeError, "forwarding policy"): + manager._start_tethering() + def test_ap_config_is_written_atomically(self): manager = build_tethering_manager() with tethering_side_effects(manager) as (_, _, ap_file, atomic_write, _): diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 32d0bb3a12c61a..b8b3b40827dac1 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -197,7 +197,7 @@ def __init__(self): self._associated_epoch: int | None = None self._dhcp_adoption_ssid: str | None = None self._current_network_metered: MeteredType = MeteredType.UNKNOWN - self._ipv4_forward = False + self._ipv4_forward: bool | None = None self._tethering_active = False self._tethering_psk = DEFAULT_TETHERING_PASSWORD self._dnsmasq_proc: subprocess.Popen | None = None @@ -735,12 +735,13 @@ def _adopt_ap_state(self, ssid: str | None) -> bool: cloudlog.warning("AP services are incomplete; refusing adoption and tearing down orphan AP") self._stop_tethering() return False - try: - self._apply_ipv4_forward(self._ipv4_forward) - except Exception: - cloudlog.exception("Failed to enforce IPv4 forwarding policy while adopting AP") - self._stop_tethering() - return False + if self._ipv4_forward is not None: + try: + self._apply_ipv4_forward(self._ipv4_forward) + except Exception: + cloudlog.exception("Failed to enforce IPv4 forwarding policy while adopting AP") + self._stop_tethering() + return False if not self._ap_config_matches_password(): cloudlog.warning("Persisted tethering password differs from the running AP; rebuilding hotspot") @@ -991,7 +992,8 @@ def _reconcile_tethering_state(self): return try: - self._apply_ipv4_forward(self._ipv4_forward) + if self._ipv4_forward is not None: + self._apply_ipv4_forward(self._ipv4_forward) status = parse_status(self._request("STATUS")) if (status.get("mode") == "AP" and status.get("wpa_state") == "COMPLETED" and tethering_dnsmasq_running() and _tethering_firewall_ready()): @@ -1643,6 +1645,9 @@ def worker(): threading.Thread(target=worker, daemon=True).start() def _start_tethering(self): + if self._ipv4_forward is None: + raise RuntimeError("IPv4 forwarding policy is not initialized") + self._tethering_active = True self._tethering_started = True self._set_connecting(self._tethering_ssid, requested=False) From 29df36e4c4a266a08a5a9d4e950c0307547c857a Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Thu, 20 Aug 2026 08:11:07 +0000 Subject: [PATCH 38/71] wifi: commit forget transactions before cleanup --- .../system/ui/lib/tests/test_network_store.py | 46 +++++++++++++ openpilot/system/ui/lib/wifi_network_store.py | 64 ++++++++++++++++--- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index 21d1e0e7463467..8d147119f04f9a 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -118,6 +118,22 @@ def test_startup_restores_interrupted_forget_stage(self): assert not remnant.exists() assert store.contains("Saved") + def test_startup_deletes_committed_forget_stage(self): + original = Path(write_profile(self.persistent, "saved.nmconnection", "Saved", file_uuid="saved")) + token = "c" * 32 + remnant = Path(f"{original}.openpilot-forget-{token}") + marker = Path(self.persistent, f".openpilot-forget-committed-{token}") + original.replace(remnant) + marker.touch() + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + + assert not original.exists() + assert not remnant.exists() + assert not marker.exists() + assert not store.contains("Saved") + def test_loads_persistent_and_open_profiles(self): write_profile(self.persistent, "secure.nmconnection", "Secure") write_profile(self.persistent, "open.nmconnection", "Open", psk=None) @@ -829,6 +845,36 @@ def run(command, **kwargs): assert second_path.exists() assert store.contains("Duplicate") + def test_forget_cleanup_failure_does_not_resurrect_profile(self): + first_path = Path(write_profile(self.persistent, "a.nmconnection", "Duplicate", file_uuid="first-uuid")) + second_path = Path(write_profile(self.persistent, "b.nmconnection", "Duplicate", file_uuid="second-uuid")) + cleanup_failed = False + + def run(command, **kwargs): + nonlocal cleanup_failed + if command[:3] == ["sudo", "rm", "-f"] and ".openpilot-forget-" in command[-1] and not cleanup_failed: + cleanup_failed = True + return MagicMock(returncode=1) + return self.run_file_command(command, **kwargs) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run): + store = self.make_store() + assert store.remove("Duplicate") + + assert cleanup_failed + assert not first_path.exists() + assert not second_path.exists() + assert not store.contains("Duplicate") + assert list(Path(self.persistent).glob("*.openpilot-forget-*")) + assert list(Path(self.persistent).glob(".openpilot-forget-committed-*")) + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + recovered = self.make_store() + + assert not recovered.contains("Duplicate") + assert not list(Path(self.persistent).glob("*.openpilot-forget-*")) + assert not list(Path(self.persistent).glob(".openpilot-forget-committed-*")) + def test_edit_runtime_profile_installs_keyfile_before_removing_netplan(self): write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") netplan_path = Path(self.netplan, f"90-NM-{profile_uuid('runtime-uuid')}.yaml") diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index 88c003a55a5c50..713a07c5a8f671 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -32,7 +32,9 @@ _SUPPORTED_IPV6_OPTIONS = {"method", "addr-gen-mode"} # Preserve NetworkManager's DNS priority for rollback compatibility _OPENPILOT_DNS_PRIORITY = "600" -_TRANSACTION_REMNANT_RE = re.compile(r"^(?P.+)\.openpilot-(?:update|forget)-[0-9a-f]{32}$") +_UPDATE_REMNANT_RE = re.compile(r"^(?P.+)\.openpilot-update-[0-9a-f]{32}$") +_FORGET_REMNANT_RE = re.compile(r"^(?P.+)\.openpilot-forget-(?P[0-9a-f]{32})$") +_FORGET_COMMIT_MARKER_RE = re.compile(r"^\.openpilot-forget-committed-(?P[0-9a-f]{32})$") class MeteredType(IntEnum): @@ -118,30 +120,57 @@ def __init__(self, directory: str = NM_CONNECTIONS_DIR, runtime_directory: str | self._load() def _recover_transaction_remnants(self): - directories = dict.fromkeys(( + directories = list(dict.fromkeys(( self._directory, self._runtime_directory, self._netplan_directory, - )) + ))) + directory_filenames: dict[str, list[str]] = {} + recovery_complete = True for directory in directories: if directory is None: continue try: - filenames = sorted(os.listdir(directory)) + directory_filenames[directory] = sorted(os.listdir(directory)) except OSError: - continue + if directory == self._directory: + return + recovery_complete = False + + committed_markers = { + match.group("token"): os.path.join(self._directory, filename) + for filename in directory_filenames.get(self._directory, []) + if (match := _FORGET_COMMIT_MARKER_RE.fullmatch(filename)) is not None + } + committed_cleanup_failed: set[str] = set() + for directory, filenames in directory_filenames.items(): for filename in filenames: - match = _TRANSACTION_REMNANT_RE.fullmatch(filename) - if match is None: + update_match = _UPDATE_REMNANT_RE.fullmatch(filename) + forget_match = _FORGET_REMNANT_RE.fullmatch(filename) + if update_match is None and forget_match is None: continue remnant_path = os.path.join(directory, filename) + match = update_match or forget_match + assert match is not None original_path = os.path.join(directory, match.group("original")) - command = ["sudo", "rm", "-f", remnant_path] if os.path.exists(original_path) else [ + forget_committed = forget_match is not None and forget_match.group("token") in committed_markers + command = ["sudo", "rm", "-f", remnant_path] if forget_committed or os.path.exists(original_path) else [ "sudo", "mv", "-f", remnant_path, original_path, ] result = subprocess.run(command, check=False) if result.returncode != 0: cloudlog.warning(f"NetworkStore: failed to recover transaction remnant {remnant_path} (rc={result.returncode})") + if forget_committed: + assert forget_match is not None + committed_cleanup_failed.add(forget_match.group("token")) + + if recovery_complete: + for token, marker_path in committed_markers.items(): + if token in committed_cleanup_failed: + continue + result = subprocess.run(["sudo", "rm", "-f", marker_path], check=False) + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to clean up forget commit marker {marker_path} (rc={result.returncode})") def _load(self): self._networks = {} @@ -732,10 +761,29 @@ def remove(self, ssid: str) -> bool: cloudlog.warning(f"NetworkStore: failed to roll back {original_path} (rc={rollback.returncode})") return False staged_paths.append((p, staged_path)) + + commit_marker = os.path.join(self._directory, f".openpilot-forget-committed-{token}") + result = subprocess.run([ + "sudo", "install", "-o", "root", "-g", "root", "-m", "600", "/dev/null", commit_marker, + ], check=False) + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to commit removal of {ssid!r} (rc={result.returncode})") + for original_path, rollback_path in reversed(staged_paths): + rollback = subprocess.run(["sudo", "mv", "-f", rollback_path, original_path], check=False) + if rollback.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to roll back {original_path} (rc={rollback.returncode})") + return False + + cleanup_failed = False for _, staged_path in staged_paths: result = subprocess.run(["sudo", "rm", "-f", staged_path], check=False) if result.returncode != 0: + cleanup_failed = True cloudlog.warning(f"NetworkStore: failed to clean up staged profile {staged_path} (rc={result.returncode})") + if not cleanup_failed: + result = subprocess.run(["sudo", "rm", "-f", commit_marker], check=False) + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to clean up forget commit marker {commit_marker} (rc={result.returncode})") else: for p in existing_paths: result = subprocess.run(["sudo", "rm", "-f", p], check=False) From 973f1d25a95b9db9cc1491b76b0ac73d0192f5a9 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Thu, 20 Aug 2026 08:13:58 +0000 Subject: [PATCH 39/71] wifi: create missing tethering profile --- .../ui/lib/tests/test_handle_state_change.py | 1 + .../system/ui/lib/tests/test_network_store.py | 22 +++++++++ openpilot/system/ui/lib/wifi_manager.py | 6 +++ openpilot/system/ui/lib/wifi_network_store.py | 49 ++++++++++++++++++- 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 206056a39f9284..3be6d623074433 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -1496,6 +1496,7 @@ def test_initialization_loads_network_store_in_worker(self): network_store.assert_called_once() assert manager._store is store assert manager.tethering_password == "custom-password" + store.ensure_tethering_profile.assert_called_once_with("Hotspot", "custom-password") def test_initial_config_failure_recovers_without_restart(self): manager = build_wifi_manager() diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index 8d147119f04f9a..90ffe8d2423f03 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -1,3 +1,4 @@ +import configparser import os import shutil import tempfile @@ -373,6 +374,27 @@ def test_reads_existing_tethering_password_without_importing_profile(self): assert store.get_tethering_password("weedle") == "custom-password" assert store.get("weedle") is None + def test_creates_tethering_profile_in_empty_store(self): + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert store.ensure_tethering_profile("weedle", "fresh-password") + + paths = list(Path(self.persistent).glob("*.nmconnection")) + assert len(paths) == 1 + cp = configparser.ConfigParser(interpolation=None) + cp.read(paths[0]) + assert cp.get("connection", "id") == "Hotspot" + assert cp.get("connection", "interface-name") == "wlan0" + assert not cp.getboolean("connection", "autoconnect") + assert cp.get("wifi", "ssid") == store_module._encode_keyfile_ssid("weedle") + assert cp.get("wifi", "mode") == "ap" + assert cp.get("wifi-security", "key-mgmt") == "wpa-psk" + assert cp.get("wifi-security", "psk") == "fresh-password" + assert cp.get("ipv4", "method") == "shared" + assert cp.get("ipv4", "address1") == "192.168.43.1/24" + assert cp.get("ipv6", "method") == "ignore" + assert store.get_tethering_password("weedle") == "fresh-password" + def test_updates_persistent_tethering_password(self): path = Path(write_profile(self.persistent, "hotspot.nmconnection", "weedle", psk="old-password", mode="ap")) diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index b8b3b40827dac1..6ae672ed27fc79 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -271,6 +271,12 @@ def worker(): cloudlog.exception("Failed to migrate legacy tethering password") self._tethering_psk = DEFAULT_TETHERING_PASSWORD + try: + if not store.ensure_tethering_profile(self._tethering_ssid, self._tethering_psk): + cloudlog.warning("Failed to create durable tethering profile") + except Exception: + cloudlog.exception("Failed to create durable tethering profile") + with self._radio_lock: self._ensure_wpa_supplicant() diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index 713a07c5a8f671..82d1895b22ec63 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -589,11 +589,58 @@ def _tethering_profiles(self, ssid: str) -> list[tuple[configparser.ConfigParser continue return profiles + def _create_tethering_profile(self, ssid: str, password: str) -> bool: + if not is_valid_ssid(ssid) or not is_valid_psk(password): + return False + + file_uuid = str(uuid.uuid4()) + cp = configparser.ConfigParser(interpolation=None) + cp["connection"] = { + "id": "Hotspot", + "uuid": file_uuid, + "type": "wifi", + "interface-name": "wlan0", + "autoconnect": "false", + "autoconnect-retries": "0", + } + cp["wifi"] = { + "ssid": _encode_keyfile_ssid(ssid), + "mode": "ap", + "band": "bg", + } + cp["wifi-security"] = { + "key-mgmt": "wpa-psk", + "psk": _encode_keyfile_string(password), + "group": "ccmp;", + "pairwise": "ccmp;", + "proto": "rsn;", + } + cp["ipv4"] = { + "method": "shared", + "address1": "192.168.43.1/24", + "never-default": "true", + } + cp["ipv6"] = { + "method": "ignore", + "addr-gen-mode": "default", + } + cp["proxy"] = {} + + subprocess.run(["sudo", "install", "-d", "-o", "root", "-g", "root", "-m", "700", self._directory], check=True) + self._install_keyfile(cp, os.path.join(self._directory, _canonical_filename(file_uuid, ssid))) + return True + + def ensure_tethering_profile(self, ssid: str, password: str) -> bool: + with self._mutation_lock: + if self._tethering_profiles(ssid): + return True + return self._create_tethering_profile(ssid, password) + def set_tethering_password(self, ssid: str, password: str) -> bool: with self._mutation_lock: profiles = self._tethering_profiles(ssid) if not profiles: - return False + return self._create_tethering_profile(ssid, password) cp, source_directory, source_filename, file_uuid = profiles[0] security_section = _keyfile_section(cp, "wifi-security", "802-11-wireless-security") From c8bf140918faaf2b7ba283644497bc12094b8b13 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Thu, 20 Aug 2026 08:18:44 +0000 Subject: [PATCH 40/71] wifi: honor ignored IPv6 profiles --- openpilot/system/ui/lib/dhcp_client.py | 13 ++++++++ .../system/ui/lib/tests/test_dhcp_client.py | 22 ++++++++++++++ .../ui/lib/tests/test_handle_state_change.py | 25 ++++++++++++++++ .../system/ui/lib/tests/test_network_store.py | 1 + openpilot/system/ui/lib/wifi_manager.py | 30 ++++++++++++++----- openpilot/system/ui/lib/wifi_network_store.py | 8 +++++ 6 files changed, 91 insertions(+), 8 deletions(-) diff --git a/openpilot/system/ui/lib/dhcp_client.py b/openpilot/system/ui/lib/dhcp_client.py index 456ccd3859cb0b..92c2e5d5eec2bd 100644 --- a/openpilot/system/ui/lib/dhcp_client.py +++ b/openpilot/system/ui/lib/dhcp_client.py @@ -24,6 +24,7 @@ def __init__(self, iface: str = "wlan0"): self._adopted = False self._client_thread: threading.Thread | None = None self._client_stop = threading.Event() + self._ipv6_enabled: bool | None = None def _start_client_thread(self): self._client_stop.clear() @@ -91,6 +92,18 @@ def clear_ipv6_state(self): except OSError: cloudlog.exception(f"Failed to clear {self._iface} IPv6 state") + def set_ipv6_enabled(self, enabled: bool): + if self._ipv6_enabled == enabled: + return + disabled = "0" if enabled else "1" + subprocess.run(["sudo", "sysctl", f"net.ipv6.conf.{self._iface}.disable_ipv6={disabled}"], check=True) + actual = Path(f"/proc/sys/net/ipv6/conf/{self._iface}/disable_ipv6").read_text().strip() + if actual != disabled: + raise RuntimeError(f"Failed to set IPv6 enabled={enabled} on {self._iface} (actual={actual!r})") + self._ipv6_enabled = enabled + if not enabled: + self.clear_ipv6_state() + def _spawn(self) -> bool: if not os.access(DHCP_DEFAULT_SCRIPT, os.X_OK): cloudlog.error(f"udhcpc default script is not executable: {DHCP_DEFAULT_SCRIPT}") diff --git a/openpilot/system/ui/lib/tests/test_dhcp_client.py b/openpilot/system/ui/lib/tests/test_dhcp_client.py index 7e4ebc34eb307a..38aef3ec1b51da 100644 --- a/openpilot/system/ui/lib/tests/test_dhcp_client.py +++ b/openpilot/system/ui/lib/tests/test_dhcp_client.py @@ -242,3 +242,25 @@ def test_clear_ipv6_state_ignores_absent_default_route(self): client.clear_ipv6_state() warning.assert_not_called() + + def test_ipv6_policy_is_applied_once_per_value(self): + client = DhcpClient() + with ( + patch.object(dhcp_client_module.subprocess, "run") as run, + patch.object(Path, "read_text", return_value="1\n"), + patch.object(client, "clear_ipv6_state") as clear_ipv6_state, + ): + client.set_ipv6_enabled(False) + client.set_ipv6_enabled(False) + + run.assert_called_once_with(["sudo", "sysctl", "net.ipv6.conf.wlan0.disable_ipv6=1"], check=True) + clear_ipv6_state.assert_called_once() + + def test_ipv6_policy_verifies_kernel_state(self): + client = DhcpClient() + with ( + patch.object(dhcp_client_module.subprocess, "run"), + patch.object(Path, "read_text", return_value="1\n"), + ): + with self.assertRaisesRegex(RuntimeError, "actual='1'"): + client.set_ipv6_enabled(True) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 3be6d623074433..a7c47efd37fd55 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -1,5 +1,6 @@ import threading import time +import uuid from typing import cast from unittest import TestCase from unittest.mock import MagicMock, call, mock_open, patch @@ -99,6 +100,30 @@ def test_connected_waits_for_ip_before_activation(self): assert self.manager.connected_ssid == "TestNet" activated.assert_called_once() + def test_connected_applies_active_profile_ipv6_policy(self): + for method, enabled in (("auto", True), ("ignore", False)): + with self.subTest(method=method): + manager = build_wifi_manager() + manager._set_connecting("TestNet") + profile_uuid = str(uuid.uuid4()) + manager._store.get_ipv6_method.return_value = method + + manager._handle_connected("TestNet", profile_uuid=profile_uuid) + + manager._store.get_ipv6_method.assert_called_once_with("TestNet", profile_uuid) + manager._dhcp.set_ipv6_enabled.assert_called_once_with(enabled) + manager._dhcp.start.assert_called_once() + + def test_connected_retries_after_ipv6_policy_failure(self): + self.manager._set_connecting("TestNet") + self.manager._dhcp.set_ipv6_enabled.side_effect = OSError("sysctl failed") + + self.manager._handle_connected("TestNet") + + assert self.manager._associated_ssid is None + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + self.manager._dhcp.start.assert_not_called() + def test_connected_waits_for_metric_600_default_route(self): activated = MagicMock() self.manager.add_callbacks(activated=activated) diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index 90ffe8d2423f03..343533221f046b 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -208,6 +208,7 @@ def test_loads_current_networkmanager_profile_defaults(self): store = self.make_store() assert require_entry(store, "SavedNet")["psk"] == "password123" + assert store.get_ipv6_method("SavedNet", profile_uuid("SavedNet")) == "ignore" def test_skips_profiles_with_invalid_uuids(self): write_profile(self.persistent, "station.nmconnection", "Station", file_uuid="../../station", valid_uuid=False) diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 6ae672ed27fc79..91ae4702c07abc 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -381,7 +381,7 @@ def worker(): if connection_status == ConnectStatus.CONNECTED and ssid is not None: adopt_dhcp = self._consume_dhcp_adoption(ssid) - self._handle_connected(ssid, adopt_dhcp=adopt_dhcp, expected_epoch=epoch) + self._handle_connected(ssid, adopt_dhcp=adopt_dhcp, expected_epoch=epoch, profile_uuid=status.get("id_str")) else: if connection_status == ConnectStatus.CONNECTING and self._last_connecting_at == 0.0: self._last_connecting_at = time.monotonic() @@ -783,7 +783,8 @@ def _ap_config_matches_password(self) -> bool: cloudlog.exception("Failed to read running AP configuration") return False - def _handle_connected(self, ssid: str, adopt_dhcp: bool = False, expected_epoch: int | None = None): + def _handle_connected(self, ssid: str, adopt_dhcp: bool = False, expected_epoch: int | None = None, + profile_uuid: str | None = None): """Handle L2 association. CONNECTED and activation remain IP-ready states.""" with self._radio_lock: with self._state_lock: @@ -794,15 +795,17 @@ def _handle_connected(self, ssid: str, adopt_dhcp: bool = False, expected_epoch: transition_epoch = self._user_epoch already_associated = self._connected_transition_is_current(ssid, transition_epoch) already_connected = self._wifi_state == WifiState(ssid, ConnectStatus.CONNECTED) + previous_operation = self._station_operation if not already_associated: self._associated_ssid = ssid self._associated_epoch = transition_epoch pending = self._pending_connection + active_profile_uuid = profile_uuid or (pending.profile_uuid if pending is not None and pending.ssid == ssid else None) self._station_operation = StationOperation( transition_epoch, StationOperationKind.ASSOCIATED, ssid, - profile_uuid=pending.profile_uuid if pending is not None and pending.ssid == ssid else None, + profile_uuid=active_profile_uuid, runtime_network_id=pending.network_id if pending is not None and pending.ssid == ssid else None, ) self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTING) @@ -815,6 +818,17 @@ def _handle_connected(self, ssid: str, adopt_dhcp: bool = False, expected_epoch: return if not already_associated: + try: + ipv6_method = self._store.get_ipv6_method(ssid, active_profile_uuid) if self._store is not None else "auto" + self._dhcp.set_ipv6_enabled(ipv6_method != "ignore") + except Exception: + cloudlog.exception("Failed to apply IPv6 policy for %s", ssid) + with self._state_lock: + if self._connected_transition_is_current(ssid, transition_epoch): + self._associated_ssid = None + self._associated_epoch = None + self._station_operation = previous_operation + return if not adopt_dhcp or not self._dhcp.adopt(): self._ipv4_address = "" self._dhcp.start() @@ -853,7 +867,7 @@ def _handle_event(self, event: str): ssid = status.get("ssid") if ssid: adopt_dhcp = self._consume_dhcp_adoption(ssid) - self._handle_connected(ssid, adopt_dhcp=adopt_dhcp, expected_epoch=epoch) + self._handle_connected(ssid, adopt_dhcp=adopt_dhcp, expected_epoch=epoch, profile_uuid=status.get("id_str")) elif "CTRL-EVENT-DISCONNECTED" in event: if self._tethering_active: @@ -1045,7 +1059,7 @@ def _reconcile_connecting_state(self): # Keep an incomplete AP disconnected so tethering can recover return if status.get("wpa_state") == "COMPLETED" and status.get("ssid"): - self._handle_connected(status["ssid"], expected_epoch=epoch) + self._handle_connected(status["ssid"], expected_epoch=epoch, profile_uuid=status.get("id_str")) return # Rate-limit recovery from missed DISCONNECTED events @@ -1065,13 +1079,13 @@ def _reconcile_connecting_state(self): wpa_state = status.get("wpa_state", "") status_ssid = status.get("ssid") if wpa_state == "COMPLETED" and status_ssid is not None and status_ssid == current_state.ssid: - self._handle_connected(status_ssid, expected_epoch=epoch) + self._handle_connected(status_ssid, expected_epoch=epoch, profile_uuid=status.get("id_str")) return if wpa_state == "COMPLETED" and status_ssid: # Preserve the lease when adopting a roam missed by the monitor with self._radio_lock: self._dhcp.clear_ipv6_state() - self._handle_connected(status_ssid, expected_epoch=epoch) + self._handle_connected(status_ssid, expected_epoch=epoch, profile_uuid=status.get("id_str")) return # Preserve the lease during transient roam and rekey states if wpa_state in ("SCANNING", "AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", @@ -1110,7 +1124,7 @@ def _reconcile_connecting_state(self): status_ssid = status.get("ssid") if wpa_state == "COMPLETED" and status_ssid: - self._handle_connected(status_ssid, expected_epoch=epoch) + self._handle_connected(status_ssid, expected_epoch=epoch, profile_uuid=status.get("id_str")) elif wpa_state == "SCANNING" and self._network_not_found_epoch != epoch: # Hidden SSIDs may remain SCANNING beyond the stale timeout self._last_scanning_recheck = time.monotonic() diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index 82d1895b22ec63..d5ae2453f86fd1 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -879,6 +879,14 @@ def get_metered(self, ssid: str, profile_uuid: str | None = None) -> MeteredType return MeteredType.NO return MeteredType.UNKNOWN + def get_ipv6_method(self, ssid: str, profile_uuid: str | None = None) -> str: + with self._lock: + if profile_uuid is None: + entry = self._networks.get(ssid) + else: + entry = next((profile for profile in self._profiles.get(ssid, []) if profile.get("uuid") == profile_uuid), None) + return entry.get("_ipv6", {}).get("method", "auto").lower() if entry is not None else "auto" + def contains(self, ssid: str) -> bool: with self._lock: return ssid in self._networks From 626da7a28e029e02135182844aa13c46d742d89b Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Thu, 20 Aug 2026 08:19:37 +0000 Subject: [PATCH 41/71] wifi: avoid redundant forwarding writes --- .../system/ui/lib/tests/test_wifi_manager_bringup.py | 8 ++++++++ openpilot/system/ui/lib/wifi_manager.py | 2 ++ 2 files changed, 10 insertions(+) diff --git a/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py b/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py index 459ad78ab75722..9646668a8c27a5 100644 --- a/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py +++ b/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py @@ -174,6 +174,14 @@ def test_applies_ipv4_forwarding_changes_while_active(self): assert not manager._ipv4_forward apply_ipv4_forward.assert_called_once_with(False) + def test_skips_redundant_ipv4_forwarding_writes(self): + manager = build_tethering_manager() + with patch.object(manager, "_apply_ipv4_forward") as apply_ipv4_forward: + manager.set_ipv4_forward(False) + manager.set_ipv4_forward(False) + + apply_ipv4_forward.assert_called_once_with(False) + def test_ipv4_forwarding_write_has_kernel_postcondition(self): manager = build_tethering_manager() with ( diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 91ae4702c07abc..a19ed251f75165 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -1599,6 +1599,8 @@ def _apply_ipv4_forward(self, enabled: bool): def set_ipv4_forward(self, enabled: bool): with self._radio_lock: + if self._ipv4_forward == enabled: + return self._ipv4_forward = enabled if self._tethering_active: self._apply_ipv4_forward(enabled) From c7516bf63fed42bd1e6a211218dab3a5744ba3f4 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Thu, 20 Aug 2026 08:22:27 +0000 Subject: [PATCH 42/71] wifi: defer hidden mici auth prompts --- .../mici/layouts/settings/network/wifi_ui.py | 16 +++++- .../ui/lib/tests/test_standalone_wifi.py | 51 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py index 77052769654fde..eb462236598047 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py @@ -284,6 +284,8 @@ def __init__(self, wifi_manager: WifiManager): self._wifi_manager = wifi_manager self._networks: dict[str, Network] = {} + self._shown = False + self._pending_auth_ssid: str | None = None self._wifi_manager.add_callbacks( need_auth=self._on_need_auth, @@ -302,8 +304,10 @@ def show_event(self): super().show_event() self._networks = {n.ssid: n for n in self._wifi_manager.networks} self._update_buttons(re_sort=True) + self._shown = True def hide_event(self): + self._shown = False super().hide_event() def _on_network_updated(self, networks: list[Network]): @@ -352,7 +356,10 @@ def _connect_to_network(self, ssid: str): cloudlog.warning(f"Trying to connect to unknown network: {ssid}") return - if self._wifi_manager.is_connection_saved(network.ssid): + if self._pending_auth_ssid == network.ssid: + self._on_need_auth(network.ssid, False) + return + elif self._wifi_manager.is_connection_saved(network.ssid): self._wifi_manager.activate_connection(network.ssid) elif network.security_type == SecurityType.OPEN: self._wifi_manager.connect_to_network(network.ssid, "") @@ -369,11 +376,18 @@ def _on_need_auth(self, ssid, incorrect_password=True): btn.set_wrong_password() break + if not self._shown: + self._pending_auth_ssid = ssid + return + + self._pending_auth_ssid = None dlg = BigInputDialog("enter password...", "", minimum_length=8, confirm_callback=lambda _password: self._connect_with_password(ssid, _password)) gui_app.push_widget(dlg) def _on_forgotten(self, ssid): + if self._pending_auth_ssid == ssid: + self._pending_auth_ssid = None # For eager UI forget for btn in self._scroller.items: if isinstance(btn, WifiButton) and btn.network.ssid == ssid: diff --git a/openpilot/system/ui/lib/tests/test_standalone_wifi.py b/openpilot/system/ui/lib/tests/test_standalone_wifi.py index 98b73cafe07d67..0f27f9280c36e4 100644 --- a/openpilot/system/ui/lib/tests/test_standalone_wifi.py +++ b/openpilot/system/ui/lib/tests/test_standalone_wifi.py @@ -52,6 +52,7 @@ def __init__(self): button = WifiButton() wifi_ui = WifiUIMici.__new__(WifiUIMici) wifi_ui._scroller = MagicMock(items=[button]) + wifi_ui._pending_auth_ssid = None dialog = MagicMock() with ( @@ -79,6 +80,8 @@ def __init__(self): button = WifiButton() wifi_ui = WifiUIMici.__new__(WifiUIMici) wifi_ui._scroller = MagicMock(items=[button]) + wifi_ui._shown = True + wifi_ui._pending_auth_ssid = None dialog = MagicMock() with ( @@ -91,6 +94,54 @@ def __init__(self): button.set_wrong_password.assert_called_once() push_widget.assert_called_once_with(dialog) + def test_mici_hidden_wrong_password_defers_password_dialog(self): + try: + from openpilot.selfdrive.ui.mici.layouts.settings.network import wifi_ui as wifi_ui_module + from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici + except ImportError as e: + raise SkipTest("mici UI dependencies are unavailable") from e + + class WifiButton: + def __init__(self): + self.network = MagicMock(ssid="SavedNet") + self.set_wrong_password = MagicMock() + + button = WifiButton() + wifi_ui = WifiUIMici.__new__(WifiUIMici) + wifi_ui._scroller = MagicMock(items=[button]) + wifi_ui._shown = False + wifi_ui._pending_auth_ssid = None + + with ( + patch.object(wifi_ui_module, "WifiButton", WifiButton), + patch.object(wifi_ui_module.gui_app, "push_widget") as push_widget, + ): + wifi_ui._on_need_auth("SavedNet") + + button.set_wrong_password.assert_called_once() + assert wifi_ui._pending_auth_ssid == "SavedNet" + push_widget.assert_not_called() + + def test_mici_pending_auth_opens_on_network_tap(self): + try: + from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici + except ImportError as e: + raise SkipTest("mici UI dependencies are unavailable") from e + + wifi_ui = WifiUIMici.__new__(WifiUIMici) + wifi_ui._wifi_manager = MagicMock() + wifi_ui._wifi_manager.is_tethering_active.return_value = False + wifi_ui._wifi_manager.is_connection_saved.return_value = True + wifi_ui._networks = {"SavedNet": MagicMock(ssid="SavedNet")} + wifi_ui._pending_auth_ssid = "SavedNet" + wifi_ui._on_need_auth = MagicMock() + wifi_ui._move_network_to_front = MagicMock() + + wifi_ui._connect_to_network("SavedNet") + + wifi_ui._on_need_auth.assert_called_once_with("SavedNet", False) + wifi_ui._wifi_manager.activate_connection.assert_not_called() + def test_mici_wifi_page_leaves_manager_active_for_parent(self): try: from openpilot.selfdrive.ui.mici.layouts.settings.network import wifi_ui as wifi_ui_module From 25f09da08875dc9eb9af9c893abd6b39e56cc046 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Thu, 20 Aug 2026 09:58:31 +0000 Subject: [PATCH 43/71] wifi: retry failed forwarding policy writes --- .../system/ui/lib/tests/test_wifi_manager_bringup.py | 12 ++++++++++++ openpilot/system/ui/lib/wifi_manager.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py b/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py index 9646668a8c27a5..c066c46a4ccd11 100644 --- a/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py +++ b/openpilot/system/ui/lib/tests/test_wifi_manager_bringup.py @@ -182,6 +182,18 @@ def test_skips_redundant_ipv4_forwarding_writes(self): apply_ipv4_forward.assert_called_once_with(False) + def test_retries_ipv4_forwarding_after_failed_write(self): + manager = build_tethering_manager() + with patch.object(manager, "_apply_ipv4_forward", side_effect=(RuntimeError("sysctl failed"), None)) as apply_ipv4_forward: + with self.assertRaisesRegex(RuntimeError, "sysctl failed"): + manager.set_ipv4_forward(False) + + assert manager._ipv4_forward + manager.set_ipv4_forward(False) + + assert not manager._ipv4_forward + assert apply_ipv4_forward.call_count == 2 + def test_ipv4_forwarding_write_has_kernel_postcondition(self): manager = build_tethering_manager() with ( diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index a19ed251f75165..1d97076ef816c4 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -1601,9 +1601,9 @@ def set_ipv4_forward(self, enabled: bool): with self._radio_lock: if self._ipv4_forward == enabled: return - self._ipv4_forward = enabled if self._tethering_active: self._apply_ipv4_forward(enabled) + self._ipv4_forward = enabled def set_tethering_active(self, active: bool): # Report enable immediately and disable after station recovery From 0d9e391fa5138f1108a665e7ddbfd579200dff22 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Thu, 20 Aug 2026 09:59:03 +0000 Subject: [PATCH 44/71] wifi: test fresh tether profile persistence --- openpilot/system/ui/lib/tests/test_network_store.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index 343533221f046b..347cfb070deef4 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -375,10 +375,10 @@ def test_reads_existing_tethering_password_without_importing_profile(self): assert store.get_tethering_password("weedle") == "custom-password" assert store.get("weedle") is None - def test_creates_tethering_profile_in_empty_store(self): + def test_tethering_password_creates_profile_in_empty_store(self): with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): store = self.make_store() - assert store.ensure_tethering_profile("weedle", "fresh-password") + assert store.set_tethering_password("weedle", "fresh-password") paths = list(Path(self.persistent).glob("*.nmconnection")) assert len(paths) == 1 @@ -396,6 +396,11 @@ def test_creates_tethering_profile_in_empty_store(self): assert cp.get("ipv6", "method") == "ignore" assert store.get_tethering_password("weedle") == "fresh-password" + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + restarted = self.make_store() + + assert restarted.get_tethering_password("weedle") == "fresh-password" + def test_updates_persistent_tethering_password(self): path = Path(write_profile(self.persistent, "hotspot.nmconnection", "weedle", psk="old-password", mode="ap")) From 9121c18e090caa324bff551790bad711987219e1 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Thu, 20 Aug 2026 10:00:38 +0000 Subject: [PATCH 45/71] wifi: reuse mici wrong-password state --- .../mici/layouts/settings/network/wifi_ui.py | 12 ++++++------ .../ui/lib/tests/test_standalone_wifi.py | 18 +++++++++++------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py index eb462236598047..be6b6ab0758913 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py @@ -134,6 +134,10 @@ def set_wrong_password(self): self._wrong_password = True self.trigger_shake() + @property + def wrong_password(self) -> bool: + return self._wrong_password + @property def network(self) -> Network: return self._network @@ -285,7 +289,6 @@ def __init__(self, wifi_manager: WifiManager): self._wifi_manager = wifi_manager self._networks: dict[str, Network] = {} self._shown = False - self._pending_auth_ssid: str | None = None self._wifi_manager.add_callbacks( need_auth=self._on_need_auth, @@ -356,7 +359,8 @@ def _connect_to_network(self, ssid: str): cloudlog.warning(f"Trying to connect to unknown network: {ssid}") return - if self._pending_auth_ssid == network.ssid: + button = next((btn for btn in self._scroller.items if isinstance(btn, WifiButton) and btn.network.ssid == ssid), None) + if button is not None and button.wrong_password: self._on_need_auth(network.ssid, False) return elif self._wifi_manager.is_connection_saved(network.ssid): @@ -377,17 +381,13 @@ def _on_need_auth(self, ssid, incorrect_password=True): break if not self._shown: - self._pending_auth_ssid = ssid return - self._pending_auth_ssid = None dlg = BigInputDialog("enter password...", "", minimum_length=8, confirm_callback=lambda _password: self._connect_with_password(ssid, _password)) gui_app.push_widget(dlg) def _on_forgotten(self, ssid): - if self._pending_auth_ssid == ssid: - self._pending_auth_ssid = None # For eager UI forget for btn in self._scroller.items: if isinstance(btn, WifiButton) and btn.network.ssid == ssid: diff --git a/openpilot/system/ui/lib/tests/test_standalone_wifi.py b/openpilot/system/ui/lib/tests/test_standalone_wifi.py index 0f27f9280c36e4..f6ce457907e858 100644 --- a/openpilot/system/ui/lib/tests/test_standalone_wifi.py +++ b/openpilot/system/ui/lib/tests/test_standalone_wifi.py @@ -52,7 +52,6 @@ def __init__(self): button = WifiButton() wifi_ui = WifiUIMici.__new__(WifiUIMici) wifi_ui._scroller = MagicMock(items=[button]) - wifi_ui._pending_auth_ssid = None dialog = MagicMock() with ( @@ -81,7 +80,6 @@ def __init__(self): wifi_ui = WifiUIMici.__new__(WifiUIMici) wifi_ui._scroller = MagicMock(items=[button]) wifi_ui._shown = True - wifi_ui._pending_auth_ssid = None dialog = MagicMock() with ( @@ -110,7 +108,6 @@ def __init__(self): wifi_ui = WifiUIMici.__new__(WifiUIMici) wifi_ui._scroller = MagicMock(items=[button]) wifi_ui._shown = False - wifi_ui._pending_auth_ssid = None with ( patch.object(wifi_ui_module, "WifiButton", WifiButton), @@ -119,25 +116,32 @@ def __init__(self): wifi_ui._on_need_auth("SavedNet") button.set_wrong_password.assert_called_once() - assert wifi_ui._pending_auth_ssid == "SavedNet" push_widget.assert_not_called() - def test_mici_pending_auth_opens_on_network_tap(self): + def test_mici_wrong_password_opens_auth_on_network_tap(self): try: + from openpilot.selfdrive.ui.mici.layouts.settings.network import wifi_ui as wifi_ui_module from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici except ImportError as e: raise SkipTest("mici UI dependencies are unavailable") from e + class WifiButton: + def __init__(self): + self.network = MagicMock(ssid="SavedNet") + self.wrong_password = True + + button = WifiButton() wifi_ui = WifiUIMici.__new__(WifiUIMici) wifi_ui._wifi_manager = MagicMock() wifi_ui._wifi_manager.is_tethering_active.return_value = False wifi_ui._wifi_manager.is_connection_saved.return_value = True wifi_ui._networks = {"SavedNet": MagicMock(ssid="SavedNet")} - wifi_ui._pending_auth_ssid = "SavedNet" + wifi_ui._scroller = MagicMock(items=[button]) wifi_ui._on_need_auth = MagicMock() wifi_ui._move_network_to_front = MagicMock() - wifi_ui._connect_to_network("SavedNet") + with patch.object(wifi_ui_module, "WifiButton", WifiButton): + wifi_ui._connect_to_network("SavedNet") wifi_ui._on_need_auth.assert_called_once_with("SavedNet", False) wifi_ui._wifi_manager.activate_connection.assert_not_called() From 3c697797d8067af812337f79888ebbaf14346a16 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Fri, 21 Aug 2026 19:02:54 +0000 Subject: [PATCH 46/71] wifi: clear state after missed disconnect --- .../ui/lib/tests/test_handle_state_change.py | 19 +++++++++++++++++++ openpilot/system/ui/lib/wifi_manager.py | 8 +++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index a7c47efd37fd55..c0bacc5d57d9b0 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -370,6 +370,25 @@ def test_disconnected_event_cleans_station_after_timeout(self): self.manager._dhcp.stop.assert_called_once() self.manager._dhcp.clear_ipv6_state.assert_called_once() + def test_missed_disconnect_clears_association_before_reconnect(self): + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") + self.manager._dhcp.start.reset_mock() + + self.manager._last_connected_recheck = 0.0 + self.manager._ctrl.request.return_value = "wpa_state=DISCONNECTED\n" + self.manager._reconcile_connecting_state() + + self.assertIsNone(self.manager._associated_ssid) + self.assertIsNone(self.manager._associated_epoch) + + self.manager._last_connected_recheck = 0.0 + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=TestNet\n" + self.manager._reconcile_connecting_state() + + self.manager._dhcp.start.assert_called_once() + def test_disconnected_event_does_not_override_user_connection(self): self.manager._set_connecting("NextNet") diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 1d97076ef816c4..2e127a81c25f2d 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -1094,11 +1094,9 @@ def _reconcile_connecting_state(self): with self._radio_lock: if self._user_epoch != epoch: return - self._wifi_state = WifiState(ssid=None, status=ConnectStatus.DISCONNECTED) - self._dhcp.stop() - self._dhcp.clear_ipv6_state() - self._ipv4_address = "" - self._current_network_metered = MeteredType.UNKNOWN + self._wifi_state = WifiState() + self._dhcp_adoption_ssid = None + self._clear_station_state() self._enqueue_callbacks(self._disconnected) return From f5c4df1ccfe64212f77a65a8d9bdd57897d38091 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Fri, 21 Aug 2026 19:05:26 +0000 Subject: [PATCH 47/71] wifi: clear retained lease when forgetting --- .../ui/lib/tests/test_handle_state_change.py | 77 +++++++++++++++++++ openpilot/system/ui/lib/wifi_manager.py | 31 ++++++-- 2 files changed, 100 insertions(+), 8 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index c0bacc5d57d9b0..06df6c7742ec63 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -1121,6 +1121,83 @@ def request(command): complete_station_connection(self.manager, "NextNet") assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTED) + def test_forget_during_reconnect_clears_retained_station_state(self): + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = True + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + self.manager._dhcp.stop.reset_mock() + self.manager._dhcp.clear_ipv6_state.reset_mock() + + with ( + patch.object(wifi_manager_module, "generate_wpa_conf"), + patch.object(self.manager, "_remove_wpa_network"), + ): + self.manager.forget_connection("TestNet", block=True) + + assert self.manager.wifi_state == WifiState() + assert self.manager.ipv4_address == "" + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + + def test_forget_during_reconnect_cleans_before_fresh_connection(self): + real_thread = threading.Thread + worker_threads = [] + forget_removing = threading.Event() + release_forget = threading.Event() + + class CapturingThread: + def __init__(self, *args, **kwargs): + self.thread = real_thread(*args, **kwargs) + worker_threads.append(self.thread) + + def start(self): + self.thread.start() + + def remove_saved_network(ssid): + assert ssid == "TestNet" + forget_removing.set() + assert release_forget.wait(1) + return True + + def select_network(*_, **__): + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + return "2" + + self.manager._store.contains.return_value = True + self.manager._store.remove.side_effect = remove_saved_network + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + self.manager._dhcp.stop.reset_mock() + self.manager._dhcp.clear_ipv6_state.reset_mock() + self.manager._ctrl.request.reset_mock() + + with ( + patch.object(wifi_manager_module.threading, "Thread", CapturingThread), + patch.object(self.manager, "_list_network_ids", return_value=[]), + patch.object(self.manager, "_remove_wpa_network"), + patch.object(self.manager, "_add_and_select_network", side_effect=select_network), + patch.object(wifi_manager_module, "generate_wpa_conf"), + ): + self.manager.forget_connection("TestNet") + assert forget_removing.wait(1) + self.manager.connect_to_network("NextNet", "password123") + release_forget.set() + for worker_thread in worker_threads: + worker_thread.join(1) + assert not worker_thread.is_alive() + + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + assert call("DISCONNECT") not in self.manager._ctrl.request.call_args_list + assert call("REASSOCIATE") not in self.manager._ctrl.request.call_args_list + def test_forget_failure_releases_caller_without_reporting_success(self): forgotten = MagicMock() forget_failed = MagicMock() diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 2e127a81c25f2d..71a75834d7ea35 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -1336,12 +1336,25 @@ def worker(): threading.Thread(target=worker, daemon=True).start() def forget_connection(self, ssid: str, block: bool = False): - if self._wifi_state.status == ConnectStatus.CONNECTING and self._wifi_state.ssid == ssid: - self._set_connecting(None, kind=StationOperationKind.FORGET, operation_ssid=ssid) + with self._state_lock: + forget_active = ( + self._wifi_state.ssid == ssid and self._wifi_state.status in (ConnectStatus.CONNECTING, ConnectStatus.CONNECTED) + or self._associated_ssid == ssid + or self._dhcp_adoption_ssid == ssid + ) + if forget_active: + self._station_cleanup_pending |= ( + self._wifi_state == WifiState(ssid, ConnectStatus.CONNECTED) + or self._associated_ssid == ssid + or self._dhcp_adoption_ssid == ssid + ) + self._set_connecting(None, kind=StationOperationKind.FORGET, operation_ssid=ssid) + forget_epoch = self._user_epoch def transition(): with self._state_lock: - self._station_operation = StationOperation(self._user_epoch, StationOperationKind.FORGET, ssid) + if self._user_epoch == forget_epoch: + self._station_operation = StationOperation(forget_epoch, StationOperationKind.FORGET, ssid) self._clear_pending_connection(ssid) try: @@ -1366,20 +1379,22 @@ def transition(): generate_wpa_conf(store) except Exception: cloudlog.exception(f"Failed to regenerate configuration after forgetting {ssid}") + if forget_active: + owns_epoch = self._prepare_connection(forget_epoch) + else: + with self._state_lock: + owns_epoch = self._user_epoch == forget_epoch try: if self._ctrl: with self._state_lock: - was_connected = self._wifi_state.ssid == ssid and self._wifi_state.status == ConnectStatus.CONNECTED preserve_selection = self._wifi_state.status == ConnectStatus.CONNECTING and self._wifi_state.ssid != ssid - if was_connected: - self._set_connecting(None, kind=StationOperationKind.FORGET, operation_ssid=ssid) - self._clear_station_state() + if forget_active and owns_epoch: self._request("DISCONNECT") self._remove_wpa_network(ssid) if not preserve_selection: self._request("ENABLE_NETWORK all") # Reassociate only when forgetting the active profile - if was_connected: + if forget_active and owns_epoch: self._request("REASSOCIATE") except Exception: cloudlog.exception(f"Failed to remove runtime connection after forgetting {ssid}") From 9d3fcb7fcd037fdbc8aa2a144dc1a3732f6119cb Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Fri, 21 Aug 2026 19:06:41 +0000 Subject: [PATCH 48/71] wifi: make association reconciliation idempotent --- .../ui/lib/tests/test_handle_state_change.py | 11 ++++++ openpilot/system/ui/lib/wifi_manager.py | 39 +++++++++++-------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 06df6c7742ec63..75dded847e9631 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -313,6 +313,17 @@ def test_pending_persistence_is_retried_without_restarting_dhcp(self): assert manager._pending_connection is None manager._dhcp.start.assert_called_once() + def test_reconcile_completed_association_does_not_spawn_more_ip_pollers(self): + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + + for _ in range(3): + self.manager._reconcile_connecting_state() + + self.manager._dhcp.start.assert_called_once() + self.manager._poll_for_ip.assert_called_once() + def test_disconnected_event_defers_station_cleanup(self): self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) self.manager._ipv4_address = "192.168.1.20" diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 71a75834d7ea35..c7515a144736ae 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -817,23 +817,28 @@ def _handle_connected(self, ssid: str, adopt_dhcp: bool = False, expected_epoch: self._persist_pending_connection(ssid) return - if not already_associated: - try: - ipv6_method = self._store.get_ipv6_method(ssid, active_profile_uuid) if self._store is not None else "auto" - self._dhcp.set_ipv6_enabled(ipv6_method != "ignore") - except Exception: - cloudlog.exception("Failed to apply IPv6 policy for %s", ssid) - with self._state_lock: - if self._connected_transition_is_current(ssid, transition_epoch): - self._associated_ssid = None - self._associated_epoch = None - self._station_operation = previous_operation - return - if not adopt_dhcp or not self._dhcp.adopt(): - self._ipv4_address = "" - self._dhcp.start() - if not self._connected_transition_is_current(ssid, transition_epoch): - return + if already_associated: + self._persist_pending_connection(ssid) + self._update_active_connection_info() + self._complete_station_connection(ssid, transition_epoch) + return + + try: + ipv6_method = self._store.get_ipv6_method(ssid, active_profile_uuid) if self._store is not None else "auto" + self._dhcp.set_ipv6_enabled(ipv6_method != "ignore") + except Exception: + cloudlog.exception("Failed to apply IPv6 policy for %s", ssid) + with self._state_lock: + if self._connected_transition_is_current(ssid, transition_epoch): + self._associated_ssid = None + self._associated_epoch = None + self._station_operation = previous_operation + return + if not adopt_dhcp or not self._dhcp.adopt(): + self._ipv4_address = "" + self._dhcp.start() + if not self._connected_transition_is_current(ssid, transition_epoch): + return self._persist_pending_connection(ssid) if self._ctrl is not None and self._connected_transition_is_current(ssid, transition_epoch): From 73907749cdef5abb035e667f7026dc5333c1b4e7 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 06:24:03 +0000 Subject: [PATCH 49/71] wifi: invalidate stale connection timeouts --- .../ui/lib/tests/test_handle_state_change.py | 34 +++++++++++++++++++ openpilot/system/ui/lib/wifi_manager.py | 19 ++++++++--- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 75dded847e9631..2b60833e6f168f 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -919,6 +919,40 @@ def restore(*_): assert self.manager._pending_connection.ssid == "NextNet" self.manager._dhcp.stop.assert_not_called() + def test_reconcile_does_not_timeout_connection_associated_after_status(self): + self.manager._set_connecting("TestNet") + self.manager._set_pending_connection("TestNet", "password123", False, SecurityType.WPA) + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + epoch = self.manager._user_epoch + status_started = threading.Event() + release_status = threading.Event() + + def request(command): + if command == "STATUS": + status_started.set() + assert release_status.wait(1) + return "wpa_state=DISCONNECTED\n" + return "OK" + + self.manager._request = MagicMock(side_effect=request) + self.manager._restore_station_runtime = MagicMock() + worker = threading.Thread(target=self.manager._reconcile_connecting_state) + worker.start() + assert status_started.wait(1) + + with patch.object(wifi_manager_module, "generate_wpa_conf"): + self.manager._handle_connected("TestNet", expected_epoch=epoch) + release_status.set() + worker.join(1) + + assert not worker.is_alive() + assert self.manager._associated_ssid == "TestNet" + assert self.manager._associated_epoch == epoch + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + self.manager._dhcp.start.assert_called_once() + self.manager._dhcp.stop.assert_not_called() + self.manager._restore_station_runtime.assert_not_called() + def test_reconcile_times_out_stalled_handshake(self): for wpa_state in ("AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): with self.subTest(wpa_state=wpa_state): diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index c7515a144736ae..4b10279a16ae9d 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -1111,8 +1111,10 @@ def _reconcile_connecting_state(self): if now - self._last_connecting_at < CONNECTING_STALE_TIMEOUT_SECONDS: return - # Snapshot the epoch before the blocking STATUS request - epoch = self._user_epoch + # Snapshot the operation before the blocking STATUS request + with self._state_lock: + epoch = self._user_epoch + expected_operation = self._station_operation if self._network_not_found_epoch != epoch and now - self._last_scanning_recheck < CONNECTING_STALE_TIMEOUT_SECONDS: return try: @@ -1135,7 +1137,11 @@ def _reconcile_connecting_state(self): "ASSOCIATED", "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): with self._radio_lock: with self._state_lock: - if self._user_epoch != epoch: + if ( + self._user_epoch != epoch + or self._station_operation is not expected_operation + or self._associated_epoch == epoch + ): return ssid = current_state.ssid pending = self._pending_connection @@ -1152,7 +1158,12 @@ def _reconcile_connecting_state(self): ) with self._state_lock: - if self._user_epoch != epoch or self._pending_connection is not pending: + if ( + self._user_epoch != epoch + or self._station_operation is not expected_operation + or self._associated_epoch == epoch + or self._pending_connection is not pending + ): return self._clear_pending_connection(ssid, epoch=epoch) self._set_connecting(None, kind=StationOperationKind.TIMEOUT, operation_ssid=ssid) From 04ace8b3aa0e97036d5505c9decf809dd1be6e53 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 06:26:59 +0000 Subject: [PATCH 50/71] wifi: preserve state when forget fails --- .../ui/lib/tests/test_handle_state_change.py | 49 ++++++++++++++++++- openpilot/system/ui/lib/wifi_manager.py | 37 +++++++------- 2 files changed, 66 insertions(+), 20 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 2b60833e6f168f..bf633bd5829c32 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -1054,13 +1054,14 @@ def add_network(ssid, *_, **__): assert connect_started.wait(1) self.manager.forget_connection("TestNet") - assert self.manager.wifi_state == WifiState() + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) release_connect.set() assert connect_added.wait(1) assert forget_removed.wait(1) assert runtime_networks == set() + assert self.manager.wifi_state == WifiState() def test_forget_does_not_disconnect_fresh_connection(self): forget_removing = threading.Event() @@ -1256,6 +1257,52 @@ def test_forget_failure_releases_caller_without_reporting_success(self): forgotten.assert_not_called() forget_failed.assert_called_once_with("SavedNet") + def test_forget_failure_preserves_connected_station_state(self): + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") + epoch = self.manager._user_epoch + operation = self.manager._station_operation + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = False + self.manager._dhcp.stop.reset_mock() + self.manager._dhcp.clear_ipv6_state.reset_mock() + + self.manager.forget_connection("TestNet", block=True) + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + assert self.manager._user_epoch == epoch + assert self.manager._associated_ssid == "TestNet" + assert self.manager._associated_epoch == epoch + assert self.manager._station_operation is operation + assert not self.manager._station_cleanup_pending + self.manager._dhcp.stop.assert_not_called() + self.manager._dhcp.clear_ipv6_state.assert_not_called() + + def test_forget_failure_preserves_retained_reconnect_state(self): + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + epoch = self.manager._user_epoch + operation = self.manager._station_operation + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = False + self.manager._dhcp.stop.reset_mock() + self.manager._dhcp.clear_ipv6_state.reset_mock() + + self.manager.forget_connection("TestNet", block=True) + + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + assert self.manager._user_epoch == epoch + assert self.manager._associated_ssid == "TestNet" + assert self.manager._associated_epoch == epoch + assert self.manager._dhcp_adoption_ssid == "TestNet" + assert self.manager._station_operation is operation + assert not self.manager._station_cleanup_pending + self.manager._dhcp.stop.assert_not_called() + self.manager._dhcp.clear_ipv6_state.assert_not_called() + def test_failed_connect_worker_does_not_reset_fresh_selection(self): real_thread = threading.Thread worker_threads = [] diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 4b10279a16ae9d..5883d6896bd6d1 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -1358,21 +1358,14 @@ def forget_connection(self, ssid: str, block: bool = False): or self._associated_ssid == ssid or self._dhcp_adoption_ssid == ssid ) - if forget_active: - self._station_cleanup_pending |= ( - self._wifi_state == WifiState(ssid, ConnectStatus.CONNECTED) - or self._associated_ssid == ssid - or self._dhcp_adoption_ssid == ssid - ) - self._set_connecting(None, kind=StationOperationKind.FORGET, operation_ssid=ssid) + cleanup_required = ( + self._wifi_state == WifiState(ssid, ConnectStatus.CONNECTED) + or self._associated_ssid == ssid + or self._dhcp_adoption_ssid == ssid + ) forget_epoch = self._user_epoch def transition(): - with self._state_lock: - if self._user_epoch == forget_epoch: - self._station_operation = StationOperation(forget_epoch, StationOperationKind.FORGET, ssid) - self._clear_pending_connection(ssid) - try: store = self._require_store() except RuntimeError: @@ -1395,22 +1388,28 @@ def transition(): generate_wpa_conf(store) except Exception: cloudlog.exception(f"Failed to regenerate configuration after forgetting {ssid}") - if forget_active: - owns_epoch = self._prepare_connection(forget_epoch) - else: - with self._state_lock: - owns_epoch = self._user_epoch == forget_epoch + + self._clear_pending_connection(ssid, epoch=forget_epoch) + with self._state_lock: + owns_epoch = self._user_epoch == forget_epoch + cleanup_epoch = None + if forget_active and owns_epoch: + self._station_cleanup_pending |= cleanup_required + self._set_connecting(None, kind=StationOperationKind.FORGET, operation_ssid=ssid) + cleanup_epoch = self._user_epoch + owns_runtime = self._prepare_connection(cleanup_epoch) if cleanup_epoch is not None else owns_epoch + try: if self._ctrl: with self._state_lock: preserve_selection = self._wifi_state.status == ConnectStatus.CONNECTING and self._wifi_state.ssid != ssid - if forget_active and owns_epoch: + if forget_active and owns_runtime: self._request("DISCONNECT") self._remove_wpa_network(ssid) if not preserve_selection: self._request("ENABLE_NETWORK all") # Reassociate only when forgetting the active profile - if forget_active and owns_epoch: + if forget_active and owns_runtime: self._request("REASSOCIATE") except Exception: cloudlog.exception(f"Failed to remove runtime connection after forgetting {ssid}") From cb19dd057e32d8d1225b9c7a38d157c17b003290 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 06:28:29 +0000 Subject: [PATCH 51/71] wifi: keep exhausted profiles disabled --- openpilot/system/ui/lib/tests/test_handle_state_change.py | 3 +++ openpilot/system/ui/lib/wifi_manager.py | 3 +-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index bf633bd5829c32..fe686ee70f2f01 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -745,6 +745,7 @@ def test_wrong_key_exhausts_same_ssid_profiles_before_auth_failure(self): patch.object(self.manager, "_list_network_ids", side_effect=[["0", "1"], ["1"]]), patch.object(self.manager, "_remove_wpa_network_id") as remove_network, patch.object(self.manager, "_select_network_ids") as select_networks, + patch.object(self.manager, "_restore_station_runtime") as restore_runtime, patch.object(wifi_manager_module.time, "monotonic", return_value=100), ): self.manager._handle_event('CTRL-EVENT-SSID-TEMP-DISABLED id=0 ssid="TestNet" reason=WRONG_KEY') @@ -755,9 +756,11 @@ def test_wrong_key_exhausts_same_ssid_profiles_before_auth_failure(self): need_auth.assert_not_called() self.manager._handle_event('CTRL-EVENT-SSID-TEMP-DISABLED id=1 ssid="TestNet" reason=WRONG_KEY') + self.manager._handle_event('CTRL-EVENT-SSID-TEMP-DISABLED id=1 ssid="TestNet" reason=WRONG_KEY') self.manager.process_callbacks() assert remove_network.call_args_list == [call("0"), call("1")] + restore_runtime.assert_not_called() assert self.manager.wifi_state == WifiState() self.manager._dhcp.stop.assert_called_once() need_auth.assert_called_once_with("TestNet") diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 5883d6896bd6d1..9aa83ba31cfad5 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -953,9 +953,8 @@ def _handle_event(self, event: str): self._network_not_found_epoch = None self._network_not_found_events = 0 return - self._restore_station_runtime(None, [], ["wrong-key"]) except Exception: - cloudlog.exception("Failed to re-enable saved networks after WRONG_KEY") + cloudlog.exception("Failed to update saved networks after WRONG_KEY") self._clear_pending_connection(event_ssid) self._enqueue_callbacks(self._need_auth, event_ssid) self._set_connecting(None, kind=StationOperationKind.AUTH_FAILURE, operation_ssid=event_ssid) From 782efbffeb8a66c4ba7f2bf9c557094f70d3f763 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 06:30:13 +0000 Subject: [PATCH 52/71] wifi: guard active info publication --- .../ui/lib/tests/test_handle_state_change.py | 20 +++++++++++++++++++ openpilot/system/ui/lib/wifi_manager.py | 15 ++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index fe686ee70f2f01..f310054e9c7820 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -526,6 +526,26 @@ def test_active_profile_sets_metered_state(self): self.manager._store.get_metered.assert_called_once_with("Duplicate", "second-uuid") assert self.manager.current_network_metered == MeteredType.NO + def test_active_info_does_not_publish_after_station_teardown(self): + self.manager._set_connecting("TestNet") + epoch = self.manager._user_epoch + self.manager._associated_ssid = "TestNet" + self.manager._associated_epoch = epoch + self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) + self.manager._ctrl.request.return_value = ( + "wpa_state=COMPLETED\nssid=TestNet\nid_str=test-uuid\nip_address=192.168.1.20\n" + ) + + def get_metered(*_): + self.manager._clear_station_state() + return MeteredType.YES + + self.manager._store.get_metered.side_effect = get_metered + WifiManager._update_active_connection_info(self.manager) + + assert self.manager.ipv4_address == "" + assert self.manager.current_network_metered == MeteredType.UNKNOWN + def test_activate_restores_saved_profile_constraints(self): cases = ( ("Preferred", {"psk": "password123", "hidden": False, "priority": 42, "uuid": "preferred-uuid", "security": SecurityType.WPA}, 42, None), diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 9aa83ba31cfad5..0e106b1f606047 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -1235,7 +1235,10 @@ def _update_active_connection_info(self): profile_uuid = None with self._state_lock: + station_epoch = self._user_epoch station_ssid = self._associated_ssid + associated_epoch = self._associated_epoch + station_operation = self._station_operation station_active = station_ssid is not None or self._wifi_state.status == ConnectStatus.CONNECTED if station_active: @@ -1264,8 +1267,16 @@ def _update_active_connection_info(self): if ssid and self._store is not None: metered = self._store.get_metered(ssid, profile_uuid) - self._ipv4_address = ipv4_address - self._current_network_metered = metered + with self._state_lock: + if ( + self._user_epoch != station_epoch + or self._associated_ssid != station_ssid + or self._associated_epoch != associated_epoch + or self._station_operation is not station_operation + ): + return + self._ipv4_address = ipv4_address + self._current_network_metered = metered def connect_to_network(self, ssid: str, password: str, hidden: bool = False, security: SecurityType | None = None): From 3745070bea7fca15180a26b230ee23445071bd8d Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 06:48:16 +0000 Subject: [PATCH 53/71] wifi: track active profile identity --- .../ui/lib/tests/test_handle_state_change.py | 20 +++++++ openpilot/system/ui/lib/wifi_manager.py | 52 ++++++++++++++----- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index f310054e9c7820..c02aa6d6c813d8 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -124,6 +124,26 @@ def test_connected_retries_after_ipv6_policy_failure(self): assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) self.manager._dhcp.start.assert_not_called() + def test_same_ssid_profile_switch_reapplies_ipv6_policy(self): + first_uuid = str(uuid.uuid4()) + second_uuid = str(uuid.uuid4()) + self.manager._store.get_ipv6_method.side_effect = ["ignore", "auto"] + self.manager._set_connecting("TestNet") + + self.manager._handle_connected("TestNet", profile_uuid=first_uuid) + complete_station_connection(self.manager, "TestNet") + self.manager._handle_connected("TestNet", profile_uuid=second_uuid) + + assert self.manager._store.get_ipv6_method.call_args_list == [ + call("TestNet", first_uuid), + call("TestNet", second_uuid), + ] + assert self.manager._dhcp.set_ipv6_enabled.call_args_list == [call(False), call(True)] + assert self.manager._station_operation is not None + assert self.manager._station_operation.profile_uuid == second_uuid + self.manager._dhcp.start.assert_called_once() + self.manager._poll_for_ip.assert_called_once() + def test_connected_waits_for_metric_600_default_route(self): activated = MagicMock() self.manager.add_callbacks(activated=activated) diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 0e106b1f606047..f7d9a9785a0a60 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -796,11 +796,17 @@ def _handle_connected(self, ssid: str, adopt_dhcp: bool = False, expected_epoch: already_associated = self._connected_transition_is_current(ssid, transition_epoch) already_connected = self._wifi_state == WifiState(ssid, ConnectStatus.CONNECTED) previous_operation = self._station_operation + pending = self._pending_connection + active_profile_uuid = profile_uuid or (pending.profile_uuid if pending is not None and pending.ssid == ssid else None) + previous_profile_uuid = previous_operation.profile_uuid if previous_operation is not None else None + profile_changed = ( + already_associated + and active_profile_uuid is not None + and active_profile_uuid != previous_profile_uuid + ) if not already_associated: self._associated_ssid = ssid self._associated_epoch = transition_epoch - pending = self._pending_connection - active_profile_uuid = profile_uuid or (pending.profile_uuid if pending is not None and pending.ssid == ssid else None) self._station_operation = StationOperation( transition_epoch, StationOperationKind.ASSOCIATED, @@ -810,11 +816,42 @@ def _handle_connected(self, ssid: str, adopt_dhcp: bool = False, expected_epoch: ) self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTING) + if not already_associated or profile_changed: + try: + ipv6_method = self._store.get_ipv6_method(ssid, active_profile_uuid) if self._store is not None else "auto" + self._dhcp.set_ipv6_enabled(ipv6_method != "ignore") + except Exception: + cloudlog.exception("Failed to apply IPv6 policy for %s", ssid) + if not already_associated: + with self._state_lock: + if self._connected_transition_is_current(ssid, transition_epoch): + self._associated_ssid = None + self._associated_epoch = None + self._station_operation = previous_operation + return + + if profile_changed: + with self._state_lock: + if ( + not self._connected_transition_is_current(ssid, transition_epoch) + or self._station_operation is not previous_operation + ): + return + self._station_operation = StationOperation( + transition_epoch, + StationOperationKind.ASSOCIATED, + ssid, + profile_uuid=active_profile_uuid, + runtime_network_id=previous_operation.runtime_network_id if previous_operation is not None else None, + ) + if already_connected: # Retry persistence after transient filesystem failures. pending = self._pending_connection if pending is not None and pending.ssid == ssid: self._persist_pending_connection(ssid) + if profile_changed: + self._update_active_connection_info() return if already_associated: @@ -823,17 +860,6 @@ def _handle_connected(self, ssid: str, adopt_dhcp: bool = False, expected_epoch: self._complete_station_connection(ssid, transition_epoch) return - try: - ipv6_method = self._store.get_ipv6_method(ssid, active_profile_uuid) if self._store is not None else "auto" - self._dhcp.set_ipv6_enabled(ipv6_method != "ignore") - except Exception: - cloudlog.exception("Failed to apply IPv6 policy for %s", ssid) - with self._state_lock: - if self._connected_transition_is_current(ssid, transition_epoch): - self._associated_ssid = None - self._associated_epoch = None - self._station_operation = previous_operation - return if not adopt_dhcp or not self._dhcp.adopt(): self._ipv4_address = "" self._dhcp.start() From 3f82e48b4b48f4010e33d1807e22b5b74e47fcad Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 08:31:27 +0000 Subject: [PATCH 54/71] wifi: confirm terminal state before timeout --- .../ui/lib/tests/test_handle_state_change.py | 62 ++++++++++++++++--- openpilot/system/ui/lib/wifi_manager.py | 17 ++++- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index c02aa6d6c813d8..c88d573f849b95 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -964,38 +964,86 @@ def restore(*_): def test_reconcile_does_not_timeout_connection_associated_after_status(self): self.manager._set_connecting("TestNet") - self.manager._set_pending_connection("TestNet", "password123", False, SecurityType.WPA) + self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 epoch = self.manager._user_epoch + operation = self.manager._station_operation status_started = threading.Event() release_status = threading.Event() + status_requests = 0 def request(command): + nonlocal status_requests if command == "STATUS": - status_started.set() - assert release_status.wait(1) - return "wpa_state=DISCONNECTED\n" + status_requests += 1 + if status_requests == 1: + status_started.set() + assert release_status.wait(1) + return "wpa_state=DISCONNECTED\n" + return "wpa_state=COMPLETED\nssid=TestNet\n" return "OK" self.manager._request = MagicMock(side_effect=request) self.manager._restore_station_runtime = MagicMock() + self.manager._dhcp.stop.reset_mock() worker = threading.Thread(target=self.manager._reconcile_connecting_state) worker.start() assert status_started.wait(1) - with patch.object(wifi_manager_module, "generate_wpa_conf"): - self.manager._handle_connected("TestNet", expected_epoch=epoch) + self.manager._handle_connected("TestNet", expected_epoch=epoch) + assert self.manager._station_operation is operation release_status.set() worker.join(1) assert not worker.is_alive() + assert status_requests == 2 assert self.manager._associated_ssid == "TestNet" assert self.manager._associated_epoch == epoch - assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) self.manager._dhcp.start.assert_called_once() self.manager._dhcp.stop.assert_not_called() self.manager._restore_station_runtime.assert_not_called() + def test_reconcile_times_out_disconnected_established_connection(self): + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + self.manager._request = MagicMock(return_value="wpa_state=DISCONNECTED\n") + self.manager._restore_station_runtime = MagicMock() + self.manager._dhcp.stop.reset_mock() + self.manager._dhcp.clear_ipv6_state.reset_mock() + + self.manager._reconcile_connecting_state() + + assert self.manager.wifi_state == WifiState() + assert self.manager._associated_ssid is None + assert self.manager._associated_epoch is None + assert self.manager._dhcp_adoption_ssid is None + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + + def test_reconcile_times_out_disconnected_ip_pending_connection(self): + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + self.manager._handle_event("CTRL-EVENT-DISCONNECTED reason=3") + self.manager._last_connecting_at = time.monotonic() - CONNECTING_STALE_TIMEOUT_SECONDS - 1 + self.manager._request = MagicMock(return_value="wpa_state=DISCONNECTED\n") + self.manager._restore_station_runtime = MagicMock() + self.manager._dhcp.stop.reset_mock() + self.manager._dhcp.clear_ipv6_state.reset_mock() + + self.manager._reconcile_connecting_state() + + assert self.manager.wifi_state == WifiState() + assert self.manager._associated_ssid is None + assert self.manager._associated_epoch is None + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + def test_reconcile_times_out_stalled_handshake(self): for wpa_state in ("AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): with self.subTest(wpa_state=wpa_state): diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index f7d9a9785a0a60..fdae14d4b32037 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -1165,9 +1165,23 @@ def _reconcile_connecting_state(self): if ( self._user_epoch != epoch or self._station_operation is not expected_operation - or self._associated_epoch == epoch ): return + + try: + latest_status = parse_status(self._request("STATUS")) + except Exception: + cloudlog.exception("Failed to confirm terminal wifi state from STATUS") + return + if latest_status.get("wpa_state") == "COMPLETED" and latest_status.get("ssid"): + self._handle_connected( + latest_status["ssid"], expected_epoch=epoch, profile_uuid=latest_status.get("id_str"), + ) + return + + with self._state_lock: + if self._user_epoch != epoch or self._station_operation is not expected_operation: + return ssid = current_state.ssid pending = self._pending_connection pending_network_id = pending.network_id if ( @@ -1186,7 +1200,6 @@ def _reconcile_connecting_state(self): if ( self._user_epoch != epoch or self._station_operation is not expected_operation - or self._associated_epoch == epoch or self._pending_connection is not pending ): return From 428b6cd463780cd982d43a799b214ffaa4f70fdb Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 08:33:38 +0000 Subject: [PATCH 55/71] wifi: re-enable fallback after wrong key --- .../ui/lib/tests/test_handle_state_change.py | 36 ++++++++++++++++--- openpilot/system/ui/lib/wifi_manager.py | 3 ++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index c88d573f849b95..6754c498154d90 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -780,26 +780,52 @@ def test_wrong_key_exhausts_same_ssid_profiles_before_auth_failure(self): need_auth = MagicMock() self.manager.add_callbacks(need_auth=need_auth) self.manager._set_connecting("TestNet") + runtime_networks = {"0": "TestNet", "1": "TestNet", "2": "FallbackNet"} + enabled_networks = {"0"} + + def list_network_ids(ssid): + return [net_id for net_id, network_ssid in runtime_networks.items() if network_ssid == ssid] + + def remove_network(net_id): + runtime_networks.pop(net_id) + enabled_networks.discard(net_id) + + def select_networks(net_ids): + enabled_networks.clear() + enabled_networks.update(net_ids) + + def request(command): + if command == "ENABLE_NETWORK all": + enabled_networks.update(runtime_networks) + return "OK" + if command == "RECONFIGURE": + raise AssertionError("wrong-key recovery must not restore durable credentials") + return "OK" with ( - patch.object(self.manager, "_list_network_ids", side_effect=[["0", "1"], ["1"]]), - patch.object(self.manager, "_remove_wpa_network_id") as remove_network, - patch.object(self.manager, "_select_network_ids") as select_networks, + patch.object(self.manager, "_list_network_ids", side_effect=list_network_ids), + patch.object(self.manager, "_remove_wpa_network_id", side_effect=remove_network) as remove_network_mock, + patch.object(self.manager, "_select_network_ids", side_effect=select_networks) as select_networks_mock, patch.object(self.manager, "_restore_station_runtime") as restore_runtime, patch.object(wifi_manager_module.time, "monotonic", return_value=100), ): + self.manager._request = MagicMock(side_effect=request) self.manager._handle_event('CTRL-EVENT-SSID-TEMP-DISABLED id=0 ssid="TestNet" reason=WRONG_KEY') self.manager.process_callbacks() assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) - select_networks.assert_called_once_with(["1"]) + select_networks_mock.assert_called_once_with(["1"]) + assert enabled_networks == {"1"} need_auth.assert_not_called() self.manager._handle_event('CTRL-EVENT-SSID-TEMP-DISABLED id=1 ssid="TestNet" reason=WRONG_KEY') self.manager._handle_event('CTRL-EVENT-SSID-TEMP-DISABLED id=1 ssid="TestNet" reason=WRONG_KEY') self.manager.process_callbacks() - assert remove_network.call_args_list == [call("0"), call("1")] + assert remove_network_mock.call_args_list == [call("0"), call("1")] + assert runtime_networks == {"2": "FallbackNet"} + assert enabled_networks == {"2"} + assert call("ENABLE_NETWORK all") in self.manager._request.call_args_list restore_runtime.assert_not_called() assert self.manager.wifi_state == WifiState() self.manager._dhcp.stop.assert_called_once() diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index fdae14d4b32037..9935d6f51b6461 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -979,6 +979,9 @@ def _handle_event(self, event: str): self._network_not_found_epoch = None self._network_not_found_events = 0 return + response = self._request("ENABLE_NETWORK all").strip() + if not response.startswith("OK"): + raise RuntimeError(f"ENABLE_NETWORK all failed: {response}") except Exception: cloudlog.exception("Failed to update saved networks after WRONG_KEY") self._clear_pending_connection(event_ssid) From 01b49d1972ff9191b4a4a3b066c2295831be1b30 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 09:38:05 +0000 Subject: [PATCH 56/71] wifi: make profile updates crash consistent --- .../system/ui/lib/tests/test_network_store.py | 99 +++++++- openpilot/system/ui/lib/wifi_network_store.py | 213 ++++++++---------- 2 files changed, 190 insertions(+), 122 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index 347cfb070deef4..6b5cca4276b14a 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -88,7 +88,7 @@ def run_file_command(self, command, **_): Path(command[-1]).unlink(missing_ok=True) return MagicMock(returncode=0) - def test_startup_deletes_stale_update_backup_when_original_exists(self): + def test_startup_restores_uncommitted_update_when_original_exists(self): original = Path(write_profile( self.persistent, "saved.nmconnection", "Saved", file_uuid="saved", psk="new-password", )) @@ -105,7 +105,92 @@ def test_startup_deletes_stale_update_backup_when_original_exists(self): assert original.exists() assert not remnant.exists() + assert require_entry(store, "Saved")["psk"] == "old-password" + + def test_startup_rolls_back_partial_multi_profile_update(self): + token = "a" * 32 + first = Path(write_profile(self.persistent, "first.nmconnection", "Duplicate", file_uuid="first", psk="first-old")) + second = Path(write_profile(self.persistent, "second.nmconnection", "Duplicate", file_uuid="second", psk="second-old")) + for path in (first, second): + shutil.copyfile(path, f"{path}.openpilot-update-{token}") + write_profile(self.persistent, first.name, "Duplicate", file_uuid="first", psk="first-new") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + + assert {entry["psk"] for ssid, entry in store.get_profiles() if ssid == "Duplicate"} == {"first-old", "second-old"} + assert not list(Path(self.persistent).glob("*.openpilot-update-*")) + + def test_startup_removes_created_path_from_uncommitted_update(self): + token = "a" * 32 + stored = Path(write_profile( + self.persistent, "saved.nmconnection", "Saved", file_uuid="saved", psk="old-password", + )) + backup = Path(f"{stored}.openpilot-update-{token}") + shutil.copyfile(stored, backup) + canonical = Path(write_profile( + self.persistent, f"{profile_uuid('saved')}-Saved.nmconnection", "Saved", file_uuid="saved", psk="new-password", + )) + created_marker = Path(f"{canonical}.openpilot-update-created-{token}") + created_marker.touch() + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + + assert stored.exists() + assert not backup.exists() + assert not canonical.exists() + assert not created_marker.exists() + assert require_entry(store, "Saved")["psk"] == "old-password" + + def test_startup_keeps_committed_update(self): + token = "a" * 32 + original = Path(write_profile( + self.persistent, "saved.nmconnection", "Saved", file_uuid="saved", psk="new-password", + )) + backup = Path(write_profile( + self.persistent, f"{original.name}.openpilot-update-{token}", "Saved", file_uuid="saved", psk="old-password", + )) + created = Path(write_profile( + self.persistent, f"{profile_uuid('created')}-Created.nmconnection", "Created", file_uuid="created", psk="created-password", + )) + created_marker = Path(f"{created}.openpilot-update-created-{token}") + created_marker.touch() + marker = Path(self.persistent, f".openpilot-update-committed-{token}") + marker.touch() + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + + assert original.exists() + assert not backup.exists() + assert created.exists() + assert not created_marker.exists() + assert not marker.exists() assert require_entry(store, "Saved")["psk"] == "new-password" + assert require_entry(store, "Created")["psk"] == "created-password" + + def test_startup_rolls_back_interrupted_tethering_migration(self): + token = "a" * 32 + runtime = Path(write_profile( + self.runtime, "tether.nmconnection", "weedle", file_uuid="tether", psk="old-password", + )) + runtime_backup = Path(f"{runtime}.openpilot-update-{token}") + runtime.replace(runtime_backup) + persistent = Path(write_profile( + self.persistent, f"{profile_uuid('tether')}-weedle.nmconnection", "weedle", file_uuid="tether", psk="new-password", + )) + created_marker = Path(f"{persistent}.openpilot-update-created-{token}") + created_marker.touch() + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + + assert runtime.exists() + assert not runtime_backup.exists() + assert not persistent.exists() + assert not created_marker.exists() + assert require_entry(store, "weedle")["psk"] == "old-password" def test_startup_restores_interrupted_forget_stage(self): original = Path(write_profile(self.persistent, "saved.nmconnection", "Saved", file_uuid="saved")) @@ -421,7 +506,7 @@ def test_tethering_update_restores_existing_target_when_runtime_cleanup_fails(se original = persistent_path.read_text() def run(command, **kwargs): - if command[:3] == ["sudo", "mv", "-f"] and command[-2] == str(runtime_path): + if command[:3] == ["sudo", "rm", "-f"] and command[-1] == str(runtime_path): return MagicMock(returncode=1) return self.run_file_command(command, **kwargs) @@ -503,8 +588,9 @@ def test_edit_persistent_profile_preserves_runtime_profile_with_different_uuid(s assert runtime_path.exists() assert netplan_path.exists() - def test_failed_noncanonical_cleanup_keeps_profile_copies_equivalent(self): + def test_failed_noncanonical_cleanup_rolls_back_profile_update(self): stored_path = Path(write_profile(self.persistent, "stored.nmconnection", "Stored", file_uuid="stored-uuid")) + original = stored_path.read_text() def run(command, **kwargs): if command[:3] == ["sudo", "rm", "-f"] and command[-1] == str(stored_path): @@ -513,10 +599,13 @@ def run(command, **kwargs): with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=run): store = self.make_store() - store.set_metered("Stored", 1) + with self.assertRaises(OSError): + store.set_metered("Stored", 1) canonical_path = Path(self.persistent, f"{profile_uuid('stored-uuid')}-Stored.nmconnection") - assert canonical_path.read_text() == stored_path.read_text() + assert not canonical_path.exists() + assert stored_path.read_text() == original + assert require_entry(store, "Stored")["metered"] == 0 assert require_entry(store, "Stored")["_filename"] == "stored.nmconnection" def test_emits_multiple_profiles_with_the_same_ssid(self): diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index d5ae2453f86fd1..e6896a96355219 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -32,7 +32,9 @@ _SUPPORTED_IPV6_OPTIONS = {"method", "addr-gen-mode"} # Preserve NetworkManager's DNS priority for rollback compatibility _OPENPILOT_DNS_PRIORITY = "600" -_UPDATE_REMNANT_RE = re.compile(r"^(?P.+)\.openpilot-update-[0-9a-f]{32}$") +_UPDATE_REMNANT_RE = re.compile(r"^(?P.+)\.openpilot-update-(?P[0-9a-f]{32})$") +_UPDATE_CREATED_RE = re.compile(r"^(?P.+)\.openpilot-update-created-(?P[0-9a-f]{32})$") +_UPDATE_COMMIT_MARKER_RE = re.compile(r"^\.openpilot-update-committed-(?P[0-9a-f]{32})$") _FORGET_REMNANT_RE = re.compile(r"^(?P.+)\.openpilot-forget-(?P[0-9a-f]{32})$") _FORGET_COMMIT_MARKER_RE = re.compile(r"^\.openpilot-forget-committed-(?P[0-9a-f]{32})$") @@ -137,35 +139,56 @@ def _recover_transaction_remnants(self): return recovery_complete = False - committed_markers = { + update_committed_markers = { + match.group("token"): os.path.join(self._directory, filename) + for filename in directory_filenames.get(self._directory, []) + if (match := _UPDATE_COMMIT_MARKER_RE.fullmatch(filename)) is not None + } + forget_committed_markers = { match.group("token"): os.path.join(self._directory, filename) for filename in directory_filenames.get(self._directory, []) if (match := _FORGET_COMMIT_MARKER_RE.fullmatch(filename)) is not None } + update_cleanup_failed: set[str] = set() committed_cleanup_failed: set[str] = set() for directory, filenames in directory_filenames.items(): for filename in filenames: update_match = _UPDATE_REMNANT_RE.fullmatch(filename) + update_created_match = _UPDATE_CREATED_RE.fullmatch(filename) forget_match = _FORGET_REMNANT_RE.fullmatch(filename) - if update_match is None and forget_match is None: + if update_match is None and update_created_match is None and forget_match is None: continue remnant_path = os.path.join(directory, filename) - match = update_match or forget_match + match = update_match or update_created_match or forget_match assert match is not None original_path = os.path.join(directory, match.group("original")) - forget_committed = forget_match is not None and forget_match.group("token") in committed_markers - command = ["sudo", "rm", "-f", remnant_path] if forget_committed or os.path.exists(original_path) else [ - "sudo", "mv", "-f", remnant_path, original_path, - ] - result = subprocess.run(command, check=False) + token = match.group("token") + update_committed = (update_match is not None or update_created_match is not None) and token in update_committed_markers + forget_committed = forget_match is not None and token in forget_committed_markers + if update_created_match is not None and not update_committed: + result = subprocess.run(["sudo", "rm", "-f", original_path], check=False) + if result.returncode == 0: + result = subprocess.run(["sudo", "rm", "-f", remnant_path], check=False) + else: + command = ["sudo", "rm", "-f", remnant_path] if update_committed or forget_committed else [ + "sudo", "mv", "-f", remnant_path, original_path, + ] + result = subprocess.run(command, check=False) if result.returncode != 0: cloudlog.warning(f"NetworkStore: failed to recover transaction remnant {remnant_path} (rc={result.returncode})") + if update_committed: + update_cleanup_failed.add(token) if forget_committed: - assert forget_match is not None - committed_cleanup_failed.add(forget_match.group("token")) + committed_cleanup_failed.add(token) if recovery_complete: - for token, marker_path in committed_markers.items(): + for token, marker_path in update_committed_markers.items(): + if token in update_cleanup_failed: + continue + result = subprocess.run(["sudo", "rm", "-f", marker_path], check=False) + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to clean up update commit marker {marker_path} (rc={result.returncode})") + for token, marker_path in forget_committed_markers.items(): if token in committed_cleanup_failed: continue result = subprocess.run(["sudo", "rm", "-f", marker_path], check=False) @@ -352,29 +375,17 @@ def _install_keyfile(self, cp: configparser.ConfigParser, path: str): pass @contextmanager - def _profile_update(self, ssid: str, profiles: list[dict]) -> Iterator[None]: - if not profiles: + def _update_transaction(self, paths: set[str], description: str) -> Iterator[None]: + if not paths: yield return - paths: set[str] = set() - for profile in profiles: - file_uuid = profile.get("uuid") - if file_uuid: - paths.add(os.path.join(self._directory, _canonical_filename(file_uuid, ssid))) - filename = profile.get("_filename") - if filename: - paths.add(os.path.join(self._directory, filename)) - runtime_filename = profile.get("_runtime_filename") - if self._runtime_directory is not None and runtime_filename: - paths.add(os.path.join(self._runtime_directory, runtime_filename)) - netplan_filename = profile.get("_netplan_filename") - if self._netplan_directory is not None and netplan_filename: - paths.add(os.path.join(self._netplan_directory, netplan_filename)) - original_paths = {path for path in paths if os.path.exists(path)} + created_paths = paths - original_paths token = uuid.uuid4().hex backups: dict[str, str] = {} + created_markers = {path: f"{path}.openpilot-update-created-{token}" for path in created_paths} + commit_marker = os.path.join(self._directory, f".openpilot-update-committed-{token}") try: for path in sorted(original_paths): backup_path = f"{path}.openpilot-update-{token}" @@ -384,30 +395,67 @@ def _profile_update(self, ssid: str, profiles: list[dict]) -> Iterator[None]: if result.returncode != 0: raise OSError(f"failed to back up {path}") backups[path] = backup_path - except Exception as e: - cleanup_failed = False - for backup_path in backups.values(): - cleanup_failed |= subprocess.run(["sudo", "rm", "-f", backup_path], check=False).returncode != 0 - if cleanup_failed: - raise OSError(f"failed to clean up profile backups for {ssid}") from e - raise - - try: + for marker_path in created_markers.values(): + result = subprocess.run([ + "sudo", "install", "-o", "root", "-g", "root", "-m", "600", "/dev/null", marker_path, + ], check=False) + if result.returncode != 0: + raise OSError(f"failed to mark created path for {description}") yield + result = subprocess.run([ + "sudo", "install", "-o", "root", "-g", "root", "-m", "600", "/dev/null", commit_marker, + ], check=False) + if result.returncode != 0: + raise OSError(f"failed to commit update for {description}") except Exception as e: rollback_failed = False - for path in sorted(paths - original_paths): - rollback_failed |= subprocess.run(["sudo", "rm", "-f", path], check=False).returncode != 0 + rollback_failed |= subprocess.run(["sudo", "rm", "-f", commit_marker], check=False).returncode != 0 + for path, marker_path in created_markers.items(): + removed = subprocess.run(["sudo", "rm", "-f", path], check=False).returncode == 0 + rollback_failed |= not removed + if removed: + rollback_failed |= subprocess.run(["sudo", "rm", "-f", marker_path], check=False).returncode != 0 for path, backup_path in backups.items(): rollback_failed |= subprocess.run(["sudo", "mv", "-f", backup_path, path], check=False).returncode != 0 if rollback_failed: - raise OSError(f"failed to roll back profile update for {ssid}") from e + raise OSError(f"failed to roll back update for {description}") from e raise else: + cleanup_failed = False for backup_path in backups.values(): result = subprocess.run(["sudo", "rm", "-f", backup_path], check=False) if result.returncode != 0: - cloudlog.warning(f"NetworkStore: failed to clean up profile backup {backup_path} (rc={result.returncode})") + cleanup_failed = True + cloudlog.warning(f"NetworkStore: failed to clean up update backup {backup_path} (rc={result.returncode})") + for marker_path in created_markers.values(): + result = subprocess.run(["sudo", "rm", "-f", marker_path], check=False) + if result.returncode != 0: + cleanup_failed = True + cloudlog.warning(f"NetworkStore: failed to clean up created-path marker {marker_path} (rc={result.returncode})") + if not cleanup_failed: + result = subprocess.run(["sudo", "rm", "-f", commit_marker], check=False) + if result.returncode != 0: + cloudlog.warning(f"NetworkStore: failed to clean up update commit marker {commit_marker} (rc={result.returncode})") + + @contextmanager + def _profile_update(self, ssid: str, profiles: list[dict]) -> Iterator[None]: + paths: set[str] = set() + for profile in profiles: + file_uuid = profile.get("uuid") + if file_uuid: + paths.add(os.path.join(self._directory, _canonical_filename(file_uuid, ssid))) + filename = profile.get("_filename") + if filename: + paths.add(os.path.join(self._directory, filename)) + runtime_filename = profile.get("_runtime_filename") + if self._runtime_directory is not None and runtime_filename: + paths.add(os.path.join(self._runtime_directory, runtime_filename)) + netplan_filename = profile.get("_netplan_filename") + if self._netplan_directory is not None and netplan_filename: + paths.add(os.path.join(self._netplan_directory, netplan_filename)) + + with self._update_transaction(paths, repr(ssid)): + yield def _render_nmconnection(self, ssid: str, entry: dict) -> tuple[str, dict]: file_uuid = entry.get("uuid") @@ -422,7 +470,6 @@ def _render_nmconnection(self, ssid: str, entry: dict) -> tuple[str, dict]: canonical_fname = _canonical_filename(file_uuid, ssid) canonical_path = os.path.join(self._directory, canonical_fname) - canonical_existed = os.path.exists(canonical_path) stored_fname = entry.get("_filename") entry["_filename"] = canonical_fname @@ -459,34 +506,13 @@ def _render_nmconnection(self, ssid: str, entry: dict) -> tuple[str, dict]: cp["ipv4"] = ipv4 cp["ipv6"] = entry.get("_ipv6", {"method": "auto"}) - backup_path = None - if canonical_existed: - backup_path = f"{canonical_path}.openpilot-update-{uuid.uuid4().hex}" - result = subprocess.run([ - "sudo", "install", "-o", "root", "-g", "root", "-m", "600", canonical_path, backup_path, - ], check=False) - if result.returncode != 0: - raise OSError(f"failed to back up {canonical_path}") - - def cleanup_canonical_after_failure() -> bool: - if backup_path is not None: - return subprocess.run(["sudo", "mv", "-f", backup_path, canonical_path], check=False).returncode == 0 - return subprocess.run(["sudo", "rm", "-f", canonical_path], check=False).returncode == 0 - - try: - self._install_keyfile(cp, canonical_path) - except Exception as e: - if not cleanup_canonical_after_failure(): - raise OSError(f"failed to install and roll back {canonical_path}") from e - raise + self._install_keyfile(cp, canonical_path) runtime_filename = entry.get("_runtime_filename") if self._runtime_directory is not None and runtime_filename: runtime_path = os.path.join(self._runtime_directory, runtime_filename) result = subprocess.run(["sudo", "rm", "-f", runtime_path], check=False) if result.returncode != 0: - if not cleanup_canonical_after_failure(): - raise OSError(f"failed to remove {runtime_path} and roll back {canonical_path}") raise OSError(f"failed to remove {runtime_path}") entry["_runtime_filename"] = None @@ -494,13 +520,9 @@ def cleanup_canonical_after_failure() -> bool: if self._netplan_directory is not None and netplan_filename: netplan_path = os.path.join(self._netplan_directory, netplan_filename) if not os.path.exists(netplan_path): - if not cleanup_canonical_after_failure(): - raise OSError(f"failed to find {netplan_path} and roll back {canonical_path}") raise OSError(f"failed to find {netplan_path}") result = subprocess.run(["sudo", "rm", "-f", netplan_path], check=False) if result.returncode != 0: - if not cleanup_canonical_after_failure(): - raise OSError(f"failed to remove {netplan_path} and roll back {canonical_path}") raise OSError(f"failed to remove {netplan_path}") entry["_netplan_filename"] = None @@ -508,21 +530,8 @@ def cleanup_canonical_after_failure() -> bool: if stored_fname and stored_fname != canonical_fname: stored_path = os.path.join(self._directory, stored_fname) result = subprocess.run(["sudo", "rm", "-f", stored_path], check=False) - # Mirror failed noncanonical cleanup so both copies remain equivalent if result.returncode != 0: - cloudlog.warning(f"NetworkStore: cleanup of noncanonical {stored_fname} failed; mirroring content to keep both files in sync") - try: - subprocess.run(["sudo", "install", "-o", "root", "-g", "root", "-m", "600", os.path.join(self._directory, canonical_fname), stored_path], check=True) - except Exception: - cloudlog.exception("NetworkStore: failed to mirror keyfile to noncanonical path") - entry["_filename"] = stored_fname - - if backup_path is not None: - result = subprocess.run(["sudo", "rm", "-f", backup_path], check=False) - if result.returncode != 0: - if not cleanup_canonical_after_failure(): - raise OSError(f"failed to clean up {backup_path} and roll back {canonical_path}") - raise OSError(f"failed to clean up {backup_path}") + raise OSError(f"failed to remove noncanonical profile {stored_path}") return file_uuid, entry @@ -653,8 +662,6 @@ def set_tethering_password(self, ssid: str, password: str) -> bool: if not file_uuid: return False target_path = os.path.join(self._directory, _canonical_filename(file_uuid, ssid)) - target_existed = os.path.exists(target_path) - runtime_profile = next((profile for profile in profiles if profile[1] == self._runtime_directory and profile[3] == file_uuid), None) runtime_path = os.path.join(self._runtime_directory, runtime_profile[2]) if self._runtime_directory is not None and runtime_profile is not None else None @@ -663,43 +670,15 @@ def set_tethering_password(self, ssid: str, password: str) -> bool: if netplan_path is not None and not os.path.exists(netplan_path): return False - token = uuid.uuid4().hex - target_backup = f"{target_path}.openpilot-update-{token}" if target_existed else None - if target_backup is not None: - result = subprocess.run([ - "sudo", "install", "-o", "root", "-g", "root", "-m", "600", target_path, target_backup, - ], check=False) - if result.returncode != 0: - raise OSError(f"failed to back up {target_path}") - - staged_sources: list[tuple[str, str]] = [] - try: + paths = {path for path in (target_path, runtime_path, netplan_path) if path is not None} + with self._update_transaction(paths, f"tethering profile {ssid!r}"): self._install_keyfile(cp, target_path) for source_path in (runtime_path, netplan_path): if source_path is None: continue - staged_path = f"{source_path}.openpilot-update-{token}" - result = subprocess.run(["sudo", "mv", "-f", source_path, staged_path], check=False) + result = subprocess.run(["sudo", "rm", "-f", source_path], check=False) if result.returncode != 0: - raise OSError(f"failed to stage {source_path}") - staged_sources.append((source_path, staged_path)) - except Exception as e: - rollback_failed = False - for source_path, staged_path in reversed(staged_sources): - rollback_failed |= subprocess.run(["sudo", "mv", "-f", staged_path, source_path], check=False).returncode != 0 - if target_backup is not None: - rollback_failed |= subprocess.run(["sudo", "mv", "-f", target_backup, target_path], check=False).returncode != 0 - else: - rollback_failed |= subprocess.run(["sudo", "rm", "-f", target_path], check=False).returncode != 0 - if rollback_failed: - raise OSError(f"failed to roll back tethering password update for {ssid}") from e - raise - - for _, staged_path in staged_sources: - if subprocess.run(["sudo", "rm", "-f", staged_path], check=False).returncode != 0: - cloudlog.warning(f"NetworkStore: failed to clean up staged tethering source {staged_path}") - if target_backup is not None and subprocess.run(["sudo", "rm", "-f", target_backup], check=False).returncode != 0: - cloudlog.warning(f"NetworkStore: failed to clean up tethering backup {target_backup}") + raise OSError(f"failed to remove {source_path}") return True def save_network(self, ssid: str, psk: str | None = None, metered: int | None = None, hidden: bool | None = None, From 0af31eeac86ccfc9913e3dffb8417a2e825610e8 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 09:52:41 +0000 Subject: [PATCH 57/71] wifi: wait for DHCP teardown --- openpilot/system/ui/lib/dhcp_client.py | 57 +++++++++++++++++-- .../system/ui/lib/tests/test_dhcp_client.py | 42 +++++++++++++- 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/openpilot/system/ui/lib/dhcp_client.py b/openpilot/system/ui/lib/dhcp_client.py index 92c2e5d5eec2bd..b6a898ec8d147c 100644 --- a/openpilot/system/ui/lib/dhcp_client.py +++ b/openpilot/system/ui/lib/dhcp_client.py @@ -2,6 +2,7 @@ from pathlib import Path import subprocess import threading +import time from openpilot.common.swaglog import cloudlog @@ -16,6 +17,8 @@ class DhcpClient: # Match udhcpc's -T retry timeout DISCOVER_TIMEOUT_SECONDS = 3 DISCOVER_ATTEMPTS = 5 + STOP_TIMEOUT_SECONDS = 3 + STOP_POLL_SECONDS = 0.05 def __init__(self, iface: str = "wlan0"): self._iface = iface @@ -61,6 +64,48 @@ def _client_running(self) -> bool: return True return self._owned_pid() is not None + @staticmethod + def _process_group_running(pgid: int) -> bool: + try: + os.killpg(pgid, 0) + except ProcessLookupError: + return False + except PermissionError: + pass + return True + + def _owned_process_group(self, pid: int | None) -> int | None: + if pid is None: + return None + try: + pgid = os.getpgid(pid) + except OSError: + return None + if pgid <= 1 or pgid == os.getpgrp(): + return None + return pgid + + def _wait_for_process_group(self, pgid: int) -> bool: + deadline = time.monotonic() + self.STOP_TIMEOUT_SECONDS + wait = threading.Event() + while True: + if self._proc is not None: + self._proc.poll() + if not self._process_group_running(pgid): + return True + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + wait.wait(min(self.STOP_POLL_SECONDS, remaining)) + + def _terminate_process_group(self, pgid: int): + subprocess.run(["sudo", "kill", "-TERM", "--", f"-{pgid}"], check=False) + if self._wait_for_process_group(pgid): + return + subprocess.run(["sudo", "kill", "-KILL", "--", f"-{pgid}"], check=False) + if not self._wait_for_process_group(pgid): + cloudlog.warning(f"Failed to stop udhcpc process group {pgid}") + def _prepare_runtime(self): subprocess.run(["sudo", "install", "-d", "-o", "root", "-g", "root", "-m", "755", DHCP_RUNTIME_DIR], check=True) subprocess.run(["sudo", "rm", "-f", self._pid_file], check=False) @@ -147,7 +192,13 @@ def stop(self): self._client_thread.join(timeout=self.DISCOVER_TIMEOUT_SECONDS) self._client_thread = None owned_pid = self._owned_pid() - if self._proc is not None: + process_pid = owned_pid + if self._proc is not None and self._proc.poll() is None: + process_pid = self._proc.pid + pgid = self._owned_process_group(process_pid) + if pgid is not None: + self._terminate_process_group(pgid) + elif self._proc is not None: try: self._proc.terminate() self._proc.wait(timeout=3) @@ -157,9 +208,7 @@ def stop(self): self._proc.wait() except Exception: pass - self._proc = None - if owned_pid is not None: - subprocess.run(["sudo", "kill", str(owned_pid)], check=False) + self._proc = None subprocess.run(["sudo", "rm", "-f", self._pid_file], check=False) self._adopted = False self._flush_lease() diff --git a/openpilot/system/ui/lib/tests/test_dhcp_client.py b/openpilot/system/ui/lib/tests/test_dhcp_client.py index 38aef3ec1b51da..5162bdccb600b1 100644 --- a/openpilot/system/ui/lib/tests/test_dhcp_client.py +++ b/openpilot/system/ui/lib/tests/test_dhcp_client.py @@ -39,6 +39,7 @@ def test_start_flushes_stale_lease_and_detaches_udhcpc_from_ui_session(self): client = DhcpClient() events = [] with ( + patch.object(client, "_owned_pid", return_value=None), patch.object(dhcp_client_module.subprocess, "run", side_effect=lambda command, **_: events.append(command)) as run, patch.object(dhcp_client_module.subprocess, "Popen", side_effect=lambda *_, **__: events.append("spawn") or MagicMock()) as popen, patch.object(dhcp_client_module.threading, "Thread") as thread, @@ -97,6 +98,7 @@ def test_exited_client_is_restarted(self): with ( patch.object(client._client_stop, "wait", side_effect=[False, True]), + patch.object(client, "_owned_pid", return_value=None), patch.object( dhcp_client_module.subprocess, "run", @@ -192,7 +194,10 @@ def test_stop_cleans_wlan_dhcp_state_with_or_without_process_handle(self): with self.subTest(has_process_handle=proc is not None): client = DhcpClient() client._proc = proc - with patch.object(dhcp_client_module.subprocess, "run") as run: + with ( + patch.object(client, "_owned_pid", return_value=None), + patch.object(dhcp_client_module.subprocess, "run") as run, + ): client.stop() assert client._proc is None @@ -204,19 +209,50 @@ def test_stop_cleans_wlan_dhcp_state_with_or_without_process_handle(self): def test_stop_kills_only_pidfile_owned_adopted_client(self): client = DhcpClient() + events = [] with ( patch.object(client, "_owned_pid", return_value=123), - patch.object(dhcp_client_module.subprocess, "run") as run, + patch.object(dhcp_client_module.os, "getpgid", return_value=456), + patch.object(client, "_terminate_process_group", side_effect=lambda pgid: events.append(("terminate", pgid))), + patch.object(dhcp_client_module.subprocess, "run", side_effect=lambda command, **_: events.append(command)) as run, ): client.stop() + assert events[0] == ("terminate", 456) assert [call.args[0] for call in run.call_args_list] == [ - ["sudo", "kill", "123"], ["sudo", "rm", "-f", client._pid_file], ["sudo", "ip", "-4", "route", "flush", "dev", "wlan0"], ["sudo", "ip", "-4", "addr", "flush", "dev", "wlan0"], ] + def test_stop_terminates_spawned_client_process_group(self): + client = DhcpClient() + client._proc = MagicMock(pid=123) + client._proc.poll.return_value = None + with ( + patch.object(client, "_owned_pid", return_value=456), + patch.object(dhcp_client_module.os, "getpgid", return_value=123), + patch.object(client, "_terminate_process_group") as terminate, + patch.object(dhcp_client_module.subprocess, "run"), + ): + client.stop() + + terminate.assert_called_once_with(123) + + def test_process_group_termination_escalates_after_timeout(self): + client = DhcpClient() + with ( + patch.object(client, "_wait_for_process_group", side_effect=[False, True]) as wait, + patch.object(dhcp_client_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run, + ): + client._terminate_process_group(456) + + assert [call.args[0] for call in run.call_args_list] == [ + ["sudo", "kill", "-TERM", "--", "-456"], + ["sudo", "kill", "-KILL", "--", "-456"], + ] + assert wait.call_args_list == [((456,),), ((456,),)] + def test_clear_ipv6_state_cleans_global_addresses_and_routes(self): client = DhcpClient() with patch.object(dhcp_client_module.subprocess, "run", return_value=MagicMock(returncode=0)) as run: From 154d4e26c2120e583509fe84522441113b832cd6 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 09:56:26 +0000 Subject: [PATCH 58/71] wifi: normalize metering profile UUIDs --- openpilot/common/hardware/comma/hardware.py | 12 ++++- .../hardware/comma/tests/test_hardware.py | 46 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/openpilot/common/hardware/comma/hardware.py b/openpilot/common/hardware/comma/hardware.py index b9bf6097b63956..678875cc3301fb 100644 --- a/openpilot/common/hardware/comma/hardware.py +++ b/openpilot/common/hardware/comma/hardware.py @@ -4,6 +4,7 @@ import socket import subprocess import time +import uuid from functools import cached_property, lru_cache from pathlib import Path @@ -59,6 +60,12 @@ def get_default_route_iface(): routes = [(int(route[6]), route[0]) for line in f.readlines()[1:] if (route := line.split())[1] == "00000000" and int(route[3], 16) & 0x1] return min(routes)[1] if routes else None +def _normalize_uuid(value: str) -> str | None: + try: + return str(uuid.UUID(value)) + except ValueError: + return None + class HardwareComma(HardwareBase): """ This platform covers the Snapdragon 845-based comma devices: @@ -215,6 +222,9 @@ def get_network_metered(self, network_type) -> bool: if network_type == NetworkType.wifi: status = wpa_supplicant_cmd("STATUS") profile_uuid = status.get("id_str", "").strip('"') + normalized_profile_uuid = _normalize_uuid(profile_uuid) if profile_uuid else None + if profile_uuid and normalized_profile_uuid is None: + return super().get_network_metered(network_type) ssid = decode_wpa_ssid(status.get("ssid", "")) if profile_uuid or ssid: nm_dirs = ("/data/etc/NetworkManager/system-connections", "/run/NetworkManager/system-connections") @@ -226,7 +236,7 @@ def get_network_metered(self, network_type) -> bool: try: cp.read_string(raw) if profile_uuid: - if cp.get("connection", "uuid", fallback="") != profile_uuid: + if _normalize_uuid(cp.get("connection", "uuid", fallback="")) != normalized_profile_uuid: continue else: wifi_section = "wifi" if cp.has_section("wifi") else "802-11-wireless" diff --git a/openpilot/common/hardware/comma/tests/test_hardware.py b/openpilot/common/hardware/comma/tests/test_hardware.py index fda89888c90a98..8aa47c8b205375 100644 --- a/openpilot/common/hardware/comma/tests/test_hardware.py +++ b/openpilot/common/hardware/comma/tests/test_hardware.py @@ -67,6 +67,52 @@ def test_selected_profile_uuid_controls_metering(self): ): assert not HardwareComma().get_network_metered(NetworkType.wifi) + def test_selected_profile_uuid_is_normalized_for_metering(self): + active_uuid = "22222222-2222-4abc-8def-222222222222" + profiles = { + "first.nmconnection": """ +[connection] +uuid=11111111-1111-4111-8111-111111111111 +metered=2 + +[wifi] +ssid=Duplicate +""", + "second.nmconnection": "", + } + for raw_uuid in (active_uuid.upper(), f"{{{active_uuid.upper()}}}"): + with self.subTest(raw_uuid=raw_uuid): + profiles["second.nmconnection"] = f""" +[connection] +uuid={raw_uuid} +metered=1 + +[wifi] +ssid=Duplicate +""" + with ( + patch.object(hardware_module, "wpa_supplicant_cmd", return_value={"ssid": "Duplicate", "id_str": active_uuid}), + patch.object(Path, "glob", return_value=[Path(name) for name in profiles]), + patch.object(hardware_module, "sudo_read", side_effect=lambda path: profiles[path]), + ): + assert HardwareComma().get_network_metered(NetworkType.wifi) + + def test_malformed_active_profile_uuid_does_not_fall_back_to_ssid(self): + profile = """ +[connection] +uuid=22222222-2222-4abc-8def-222222222222 +metered=1 + +[wifi] +ssid=Duplicate +""" + with ( + patch.object(hardware_module, "wpa_supplicant_cmd", return_value={"ssid": "Duplicate", "id_str": "not-a-uuid"}), + patch.object(Path, "glob", return_value=[Path("profile.nmconnection")]), + patch.object(hardware_module, "sudo_read", return_value=profile), + ): + assert not HardwareComma().get_network_metered(NetworkType.wifi) + def test_hardware_uses_owned_control_socket(self): sock = MagicMock() sock.__enter__.return_value = sock From ba156c39198938b3c283ba6a107f46dc1c60453d Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 09:58:41 +0000 Subject: [PATCH 59/71] wifi: suppress hidden forget errors --- .../mici/layouts/settings/network/wifi_ui.py | 3 +- .../ui/lib/tests/test_standalone_wifi.py | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py index be6b6ab0758913..6f1345e7ba1948 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py @@ -395,7 +395,8 @@ def _on_forgotten(self, ssid): def _on_forget_failed(self, ssid): self._on_forgotten(ssid) - gui_app.push_widget(BigDialog("", tr("Failed to forget Wi-Fi network"))) + if self._shown: + gui_app.push_widget(BigDialog("", tr("Failed to forget Wi-Fi network"))) def _move_network_to_front(self, ssid: str | None, scroll: bool = False): # Move connecting/connected network to the front with animation diff --git a/openpilot/system/ui/lib/tests/test_standalone_wifi.py b/openpilot/system/ui/lib/tests/test_standalone_wifi.py index f6ce457907e858..e32d573ac34477 100644 --- a/openpilot/system/ui/lib/tests/test_standalone_wifi.py +++ b/openpilot/system/ui/lib/tests/test_standalone_wifi.py @@ -52,6 +52,7 @@ def __init__(self): button = WifiButton() wifi_ui = WifiUIMici.__new__(WifiUIMici) wifi_ui._scroller = MagicMock(items=[button]) + wifi_ui._shown = True dialog = MagicMock() with ( @@ -64,6 +65,33 @@ def __init__(self): button.on_forgotten.assert_called_once() push_widget.assert_called_once_with(dialog) + def test_mici_hidden_forget_failure_does_not_open_dialog(self): + try: + from openpilot.selfdrive.ui.mici.layouts.settings.network import wifi_ui as wifi_ui_module + from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici + except ImportError as e: + raise SkipTest("mici UI dependencies are unavailable") from e + + class WifiButton: + def __init__(self): + self.network = MagicMock(ssid="SavedNet") + self.on_forgotten = MagicMock() + + button = WifiButton() + wifi_ui = WifiUIMici.__new__(WifiUIMici) + wifi_ui._scroller = MagicMock(items=[button]) + wifi_ui._shown = False + + with ( + patch.object(wifi_ui_module, "WifiButton", WifiButton), + patch.object(wifi_ui_module, "BigDialog", return_value=MagicMock()), + patch.object(wifi_ui_module.gui_app, "push_widget") as push_widget, + ): + wifi_ui._on_forget_failed("SavedNet") + + button.on_forgotten.assert_called_once() + push_widget.assert_not_called() + def test_mici_wrong_password_opens_password_dialog(self): try: from openpilot.selfdrive.ui.mici.layouts.settings.network import wifi_ui as wifi_ui_module From 03d9f2e7312ce2ffc07c38270b5c8ce52a196097 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 11:59:25 +0000 Subject: [PATCH 60/71] wifi: clear IPv6 state across SSIDs --- .../ui/lib/tests/test_handle_state_change.py | 13 ++++++++ openpilot/system/ui/lib/wifi_manager.py | 31 +++++++++---------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 6754c498154d90..5b5560f217baff 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -144,6 +144,19 @@ def test_same_ssid_profile_switch_reapplies_ipv6_policy(self): self.manager._dhcp.start.assert_called_once() self.manager._poll_for_ip.assert_called_once() + def test_connected_to_another_ssid_clears_stale_ipv6_state(self): + self.manager._wifi_state = WifiState("PreviousNet", ConnectStatus.CONNECTED) + self.manager._associated_ssid = "PreviousNet" + self.manager._associated_epoch = self.manager._user_epoch + self.manager._ctrl.request.return_value = "wpa_state=COMPLETED\nssid=TestNet\n" + + self.manager._handle_event("CTRL-EVENT-CONNECTED") + + self.manager._dhcp.clear_ipv6_state.assert_called_once() + self.manager._dhcp.start.assert_called_once() + assert self.manager._associated_ssid == "TestNet" + assert self.manager._associated_epoch == self.manager._user_epoch + def test_connected_waits_for_metric_600_default_route(self): activated = MagicMock() self.manager.add_callbacks(activated=activated) diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 9935d6f51b6461..5fe55c344ba3f0 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -316,14 +316,6 @@ def station_reconfigured(ssid: str): if ctrl is not None: self._ctrl = ctrl - def _consume_dhcp_adoption(self, ssid: str) -> bool: - with self._radio_lock: - adoption_ssid = self._dhcp_adoption_ssid - self._dhcp_adoption_ssid = None - if adoption_ssid is not None and adoption_ssid != ssid: - self._dhcp.clear_ipv6_state() - return adoption_ssid == ssid - def _request(self, cmd: str) -> str: with self._radio_lock: ctrl = self._ctrl @@ -380,8 +372,7 @@ def worker(): return if connection_status == ConnectStatus.CONNECTED and ssid is not None: - adopt_dhcp = self._consume_dhcp_adoption(ssid) - self._handle_connected(ssid, adopt_dhcp=adopt_dhcp, expected_epoch=epoch, profile_uuid=status.get("id_str")) + self._handle_connected(ssid, expected_epoch=epoch, profile_uuid=status.get("id_str")) else: if connection_status == ConnectStatus.CONNECTING and self._last_connecting_at == 0.0: self._last_connecting_at = time.monotonic() @@ -783,7 +774,7 @@ def _ap_config_matches_password(self) -> bool: cloudlog.exception("Failed to read running AP configuration") return False - def _handle_connected(self, ssid: str, adopt_dhcp: bool = False, expected_epoch: int | None = None, + def _handle_connected(self, ssid: str, expected_epoch: int | None = None, profile_uuid: str | None = None): """Handle L2 association. CONNECTED and activation remain IP-ready states.""" with self._radio_lock: @@ -793,6 +784,17 @@ def _handle_connected(self, ssid: str, adopt_dhcp: bool = False, expected_epoch: if self._requested_ssid is not None and self._requested_ssid != ssid: return transition_epoch = self._user_epoch + previous_ssid = self._associated_ssid + if previous_ssid is None and self._wifi_state.status == ConnectStatus.CONNECTED: + previous_ssid = self._wifi_state.ssid + adoption_ssid = self._dhcp_adoption_ssid + adopt_dhcp = adoption_ssid == ssid + if ( + (previous_ssid is not None and previous_ssid != ssid) + or (adoption_ssid is not None and adoption_ssid != ssid) + ): + self._dhcp.clear_ipv6_state() + self._dhcp_adoption_ssid = None already_associated = self._connected_transition_is_current(ssid, transition_epoch) already_connected = self._wifi_state == WifiState(ssid, ConnectStatus.CONNECTED) previous_operation = self._station_operation @@ -897,8 +899,7 @@ def _handle_event(self, event: str): ssid = status.get("ssid") if ssid: - adopt_dhcp = self._consume_dhcp_adoption(ssid) - self._handle_connected(ssid, adopt_dhcp=adopt_dhcp, expected_epoch=epoch, profile_uuid=status.get("id_str")) + self._handle_connected(ssid, expected_epoch=epoch, profile_uuid=status.get("id_str")) elif "CTRL-EVENT-DISCONNECTED" in event: if self._tethering_active: @@ -1116,9 +1117,7 @@ def _reconcile_connecting_state(self): return if wpa_state == "COMPLETED" and status_ssid: # Preserve the lease when adopting a roam missed by the monitor - with self._radio_lock: - self._dhcp.clear_ipv6_state() - self._handle_connected(status_ssid, expected_epoch=epoch, profile_uuid=status.get("id_str")) + self._handle_connected(status_ssid, expected_epoch=epoch, profile_uuid=status.get("id_str")) return # Preserve the lease during transient roam and rekey states if wpa_state in ("SCANNING", "AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", From 73e343a10b4619f1cf347557e29718de55f192a9 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 12:05:01 +0000 Subject: [PATCH 61/71] wifi: update all tethering profiles atomically --- .../system/ui/lib/tests/test_network_store.py | 61 ++++++++++++++++ openpilot/system/ui/lib/wifi_network_store.py | 70 ++++++++++++------- 2 files changed, 105 insertions(+), 26 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index 6b5cca4276b14a..b760b81d49cad0 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -495,6 +495,46 @@ def test_updates_persistent_tethering_password(self): assert "psk = new-password" in path.read_text() + def test_updates_every_persistent_tethering_profile(self): + paths = [ + Path(write_profile(self.persistent, "hotspot-a.nmconnection", "weedle", file_uuid="hotspot-a", psk="old-password", mode="ap")), + Path(write_profile(self.persistent, "hotspot-b.nmconnection", "weedle", file_uuid="hotspot-b", psk="old-password", mode="ap")), + ] + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert store.set_tethering_password("weedle", "new-password") + restarted = self.make_store() + + assert all("psk = new-password" in path.read_text() for path in paths) + assert restarted.get_tethering_password("weedle") == "new-password" + + def test_multi_profile_tethering_update_rolls_back_earlier_profiles(self): + first_path = Path(write_profile( + self.persistent, "hotspot-a.nmconnection", "weedle", file_uuid="hotspot-a", psk="first-password", mode="ap", + )) + second_path = Path(write_profile( + self.persistent, "hotspot-b.nmconnection", "weedle", file_uuid="hotspot-b", psk="second-password", mode="ap", + )) + originals = {path: path.read_text() for path in (first_path, second_path)} + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + install = store._install_keyfile + install_count = 0 + + def fail_second_install(cp, path): + nonlocal install_count + install_count += 1 + if install_count == 2: + raise OSError("write failed") + install(cp, path) + + with patch.object(store, "_install_keyfile", side_effect=fail_second_install), self.assertRaises(OSError): + store.set_tethering_password("weedle", "new-password") + + assert {path: path.read_text() for path in (first_path, second_path)} == originals + def test_tethering_update_restores_existing_target_when_runtime_cleanup_fails(self): shared_uuid = "shared-hotspot-uuid" persistent_path = Path(write_profile( @@ -518,6 +558,27 @@ def run(command, **kwargs): assert persistent_path.read_text() == original assert runtime_path.exists() + def test_tethering_update_collapses_runtime_shadow_by_uuid(self): + shared_uuid = "shared-hotspot-uuid" + persistent_path = Path(write_profile( + self.persistent, "hotspot.nmconnection", "weedle", file_uuid=shared_uuid, psk="old-password", mode="ap", + )) + runtime_path = Path(write_profile( + self.runtime, "runtime-hotspot.nmconnection", "weedle", file_uuid=shared_uuid, psk="old-password", mode="ap", + )) + netplan_path = Path(self.netplan, f"90-NM-{profile_uuid(shared_uuid)}.yaml") + netplan_path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {profile_uuid(shared_uuid)}\n") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + with patch.object(store, "_install_keyfile", wraps=store._install_keyfile) as install: + assert store.set_tethering_password("weedle", "new-password") + + install.assert_called_once() + assert "psk = new-password" in persistent_path.read_text() + assert not runtime_path.exists() + assert not netplan_path.exists() + def test_persists_runtime_tethering_profile_for_rollback(self): runtime_path = Path(write_profile( self.runtime, "netplan-hotspot.nmconnection", "weedle", file_uuid="hotspot-uuid", psk="old-password", mode="ap", diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index e6896a96355219..6cf1f28fba9e02 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -651,34 +651,52 @@ def set_tethering_password(self, ssid: str, password: str) -> bool: if not profiles: return self._create_tethering_profile(ssid, password) - cp, source_directory, source_filename, file_uuid = profiles[0] - security_section = _keyfile_section(cp, "wifi-security", "802-11-wireless-security") - assert security_section is not None - cp[security_section]["psk"] = _encode_keyfile_string(password) + profiles_by_uuid: dict[str, list[tuple[configparser.ConfigParser, str, str, str]]] = {} + for profile in profiles: + profiles_by_uuid.setdefault(profile[3], []).append(profile) + + updates: list[tuple[configparser.ConfigParser, str, set[str]]] = [] + paths: set[str] = set() + for file_uuid, representations in sorted(profiles_by_uuid.items()): + persistent = sorted( + (profile for profile in representations if profile[1] == self._directory), + key=lambda profile: profile[2], + ) + source = persistent[0] if persistent else min(representations, key=lambda profile: (profile[1], profile[2])) + cp, _, source_filename, _ = source + security_section = _keyfile_section(cp, "wifi-security", "802-11-wireless-security") + assert security_section is not None + cp[security_section]["psk"] = _encode_keyfile_string(password) + + target_path = ( + os.path.join(self._directory, source_filename) + if persistent + else os.path.join(self._directory, _canonical_filename(file_uuid, ssid)) + ) + obsolete_paths = { + os.path.join(directory, filename) + for _, directory, filename, _ in representations + if os.path.join(directory, filename) != target_path + } + if any(directory == self._runtime_directory for _, directory, _, _ in representations): + netplan_filename = self._find_netplan_filename(file_uuid) + if self._netplan_directory is not None and netplan_filename is not None: + netplan_path = os.path.join(self._netplan_directory, netplan_filename) + if not os.path.exists(netplan_path): + return False + obsolete_paths.add(netplan_path) + + updates.append((cp, target_path, obsolete_paths)) + paths.add(target_path) + paths.update(obsolete_paths) - if source_directory == self._directory: - target_path = os.path.join(self._directory, source_filename) - else: - if not file_uuid: - return False - target_path = os.path.join(self._directory, _canonical_filename(file_uuid, ssid)) - runtime_profile = next((profile for profile in profiles - if profile[1] == self._runtime_directory and profile[3] == file_uuid), None) - runtime_path = os.path.join(self._runtime_directory, runtime_profile[2]) if self._runtime_directory is not None and runtime_profile is not None else None - netplan_filename = self._find_netplan_filename(file_uuid) if runtime_path is not None else None - netplan_path = os.path.join(self._netplan_directory, netplan_filename) if self._netplan_directory is not None and netplan_filename else None - if netplan_path is not None and not os.path.exists(netplan_path): - return False - - paths = {path for path in (target_path, runtime_path, netplan_path) if path is not None} with self._update_transaction(paths, f"tethering profile {ssid!r}"): - self._install_keyfile(cp, target_path) - for source_path in (runtime_path, netplan_path): - if source_path is None: - continue - result = subprocess.run(["sudo", "rm", "-f", source_path], check=False) - if result.returncode != 0: - raise OSError(f"failed to remove {source_path}") + for cp, target_path, obsolete_paths in updates: + self._install_keyfile(cp, target_path) + for source_path in sorted(obsolete_paths): + result = subprocess.run(["sudo", "rm", "-f", source_path], check=False) + if result.returncode != 0: + raise OSError(f"failed to remove {source_path}") return True def save_network(self, ssid: str, psk: str | None = None, metered: int | None = None, hidden: bool | None = None, From c65e0901cc15f8a32d2fd454d17ad1943b27ba9e Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 12:07:48 +0000 Subject: [PATCH 62/71] ui: suppress off-panel Wi-Fi errors --- .../ui/lib/tests/test_standalone_wifi.py | 26 +++++++++++++++++++ openpilot/system/ui/widgets/network.py | 12 ++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/openpilot/system/ui/lib/tests/test_standalone_wifi.py b/openpilot/system/ui/lib/tests/test_standalone_wifi.py index e32d573ac34477..56da6f7d7774b6 100644 --- a/openpilot/system/ui/lib/tests/test_standalone_wifi.py +++ b/openpilot/system/ui/lib/tests/test_standalone_wifi.py @@ -26,6 +26,8 @@ class TestStandaloneWifi(TestCase): def test_forget_failure_releases_wifi_controls(self): wifi_ui = WifiManagerUI.__new__(WifiManagerUI) wifi_ui.state = UIState.FORGETTING + wifi_ui._page_shown = True + wifi_ui._panel_active = True dialog = MagicMock() with ( @@ -37,6 +39,30 @@ def test_forget_failure_releases_wifi_controls(self): assert wifi_ui.state == UIState.IDLE push_widget.assert_called_once_with(dialog) + def test_forget_failure_on_advanced_panel_does_not_open_dialog(self): + wifi_ui = WifiManagerUI.__new__(WifiManagerUI) + wifi_ui.state = UIState.FORGETTING + wifi_ui._page_shown = True + wifi_ui._panel_active = False + + with ( + patch.object(network_module, "alert_dialog", return_value=MagicMock()), + patch.object(network_module.gui_app, "push_widget") as push_widget, + ): + wifi_ui._on_forget_failed("SavedNet") + + assert wifi_ui.state == UIState.IDLE + push_widget.assert_not_called() + + def test_network_ui_tracks_active_wifi_panel(self): + network_ui = network_module.NetworkUI.__new__(network_module.NetworkUI) + network_ui._wifi_panel = MagicMock() + + network_ui._set_current_panel(network_module.PanelType.ADVANCED) + + assert network_ui._current_panel == network_module.PanelType.ADVANCED + network_ui._wifi_panel.set_panel_active.assert_called_once_with(False) + def test_mici_forget_failure_restores_button_and_opens_dialog(self): try: from openpilot.selfdrive.ui.mici.layouts.settings.network import wifi_ui as wifi_ui_module diff --git a/openpilot/system/ui/widgets/network.py b/openpilot/system/ui/widgets/network.py index d9ef807222dfcf..15acaab66e8667 100644 --- a/openpilot/system/ui/widgets/network.py +++ b/openpilot/system/ui/widgets/network.py @@ -63,6 +63,7 @@ def __init__(self, wifi_manager: WifiManager): self._advanced_panel = self._child(AdvancedNetworkSettings(wifi_manager)) self._nav_button = self._child(NavButton(tr("Advanced"))) self._nav_button.set_click_callback(self._cycle_panel) + self._wifi_panel.set_panel_active(True) def show_event(self): super().show_event() @@ -91,6 +92,7 @@ def _render(self, _): def _set_current_panel(self, panel: PanelType): self._current_panel = panel + self._wifi_panel.set_panel_active(panel == PanelType.WIFI) class AdvancedNetworkSettings(Widget): @@ -272,6 +274,8 @@ def __init__(self, wifi_manager: WifiManager): super().__init__() self._wifi_manager = wifi_manager self.state: UIState = UIState.IDLE + self._page_shown = False + self._panel_active = True self._state_network: Network | None = None # for CONNECTING / NEEDS_AUTH / SHOW_FORGET_CONFIRM / FORGETTING self._password_retry: bool = False # for NEEDS_AUTH self.btn_width: int = 200 @@ -292,12 +296,17 @@ def __init__(self, wifi_manager: WifiManager): def show_event(self): super().show_event() + self._page_shown = True self._wifi_manager.set_active(True) def hide_event(self): super().hide_event() + self._page_shown = False self._wifi_manager.set_active(False) + def set_panel_active(self, active: bool): + self._panel_active = active + def _load_icons(self): for icon in STRENGTH_ICONS + ["icons/checkmark.png", "icons/circled_slash.png", "icons/lock_closed.png"]: gui_app.texture(icon, ICON_SIZE, ICON_SIZE) @@ -479,7 +488,8 @@ def _on_forgotten(self, _): def _on_forget_failed(self, _): if self.state == UIState.FORGETTING: self.state = UIState.IDLE - gui_app.push_widget(alert_dialog(tr("Failed to forget Wi-Fi network"))) + if self._page_shown and self._panel_active: + gui_app.push_widget(alert_dialog(tr("Failed to forget Wi-Fi network"))) def _on_disconnected(self): if self.state == UIState.CONNECTING: From c502f3d9c67d56f5e94c22d8c8293d3d95e34ec2 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 14:12:08 +0000 Subject: [PATCH 63/71] wifi: accept scoped route output --- openpilot/system/ui/lib/tests/test_dhcp_client.py | 2 +- openpilot/system/ui/lib/tests/test_handle_state_change.py | 1 + openpilot/system/ui/lib/udhcpc.script | 2 +- openpilot/system/ui/lib/wifi_manager.py | 5 ++--- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_dhcp_client.py b/openpilot/system/ui/lib/tests/test_dhcp_client.py index 5162bdccb600b1..d44031ad7a7e11 100644 --- a/openpilot/system/ui/lib/tests/test_dhcp_client.py +++ b/openpilot/system/ui/lib/tests/test_dhcp_client.py @@ -122,7 +122,7 @@ def test_dhcp_script_applies_metric_after_default_script(self): ip = root / "ip" ip.write_text("""#!/bin/sh printf "ip %s\\n" "$*" >> "$TRACE" -[ "$*" = "-4 route show default dev wlan0" ] && printf "default via 192.168.1.1 dev wlan0 metric 600\\n" +[ "$*" = "-4 route show default dev wlan0" ] && printf "default via 192.168.1.1 metric 600\\n" exit 0 """) ip.chmod(0o755) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 5b5560f217baff..171ca186db5451 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -201,6 +201,7 @@ def test_ip_poll_continues_until_default_route_is_ready(self): def test_wifi_default_route_ready_requires_one_metric_600_gateway(self): cases = ( ("default via 192.168.1.1 dev wlan0 metric 600\n", True), + ("default via 192.168.1.1 metric 600\n", True), ("", False), ("default via 192.168.1.1 dev wlan0 metric 0\n", False), ("default dev wlan0 metric 600\n", False), diff --git a/openpilot/system/ui/lib/udhcpc.script b/openpilot/system/ui/lib/udhcpc.script index c168fa3e8b367c..f6f1a164302229 100755 --- a/openpilot/system/ui/lib/udhcpc.script +++ b/openpilot/system/ui/lib/udhcpc.script @@ -26,7 +26,7 @@ case "$1" in if ($i == "via" && i < NF) via = $(i + 1) if ($i == "metric" && i < NF) metric = $(i + 1) } - if ($1 != "default" || dev != iface || via != router || metric != target) exit 1 + if ($1 != "default" || (dev != "" && dev != iface) || via != router || metric != target) exit 1 } END { if (count != 1) exit 1 } ' || exit $? diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 5fe55c344ba3f0..c159bd1a523037 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -609,16 +609,15 @@ def _wifi_default_route_ready(self) -> bool: route = routes[0] try: via_index = route.index("via") - dev_index = route.index("dev") metric_index = route.index("metric") except ValueError: return False + dev_index = route.index("dev") if "dev" in route else None return ( route[0] == "default" and via_index + 1 < len(route) and route[via_index + 1] not in ("dev", "metric") - and dev_index + 1 < len(route) - and route[dev_index + 1] == "wlan0" + and (dev_index is None or (dev_index + 1 < len(route) and route[dev_index + 1] == "wlan0")) and metric_index + 1 < len(route) and route[metric_index + 1] == "600" ) From 1d39588231edf7e28b0bb5fd312d01069355d81e Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 14:21:45 +0000 Subject: [PATCH 64/71] wifi: guard stale supplicant events --- .../ui/lib/tests/test_handle_state_change.py | 105 ++++++++++++ openpilot/system/ui/lib/wifi_manager.py | 155 +++++++++++------- 2 files changed, 199 insertions(+), 61 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 171ca186db5451..85523d888b3dab 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -442,6 +442,69 @@ def test_disconnected_event_does_not_override_user_connection(self): assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) self.manager._dhcp.stop.assert_not_called() + def test_disconnected_event_rechecks_epoch_before_committing(self): + self.manager._wifi_state = WifiState("PreviousNet", ConnectStatus.CONNECTED) + self.manager._associated_ssid = "PreviousNet" + self.manager._associated_epoch = self.manager._user_epoch + commit_started = threading.Event() + release_commit = threading.Event() + original_monotonic = time.monotonic + worker = None + + def monotonic(): + if threading.current_thread() is worker: + commit_started.set() + assert release_commit.wait(1) + return original_monotonic() + + with patch.object(wifi_manager_module.time, "monotonic", side_effect=monotonic): + worker = threading.Thread(target=self.manager._handle_event, args=("CTRL-EVENT-DISCONNECTED reason=3",)) + worker.start() + assert commit_started.wait(1) + + with patch.object(wifi_manager_module.threading.Thread, "start"): + self.manager.connect_to_network("NextNet", "next-password") + + release_commit.set() + worker.join(1) + + assert not worker.is_alive() + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + assert self.manager._pending_connection is not None + assert self.manager._pending_connection.ssid == "NextNet" + assert self.manager._dhcp_adoption_ssid is None + self.manager._dhcp.stop.assert_not_called() + + def test_association_progress_rechecks_epoch_before_committing(self): + self.manager._ctrl.request.return_value = "wpa_state=ASSOCIATING\nssid=PreviousNet\n" + commit_started = threading.Event() + release_commit = threading.Event() + original_monotonic = time.monotonic + worker = None + + def monotonic(): + if threading.current_thread() is worker: + commit_started.set() + assert release_commit.wait(1) + return original_monotonic() + + with patch.object(wifi_manager_module.time, "monotonic", side_effect=monotonic): + worker = threading.Thread(target=self.manager._handle_event, args=("Trying to associate with 00:11:22:33:44:55",)) + worker.start() + assert commit_started.wait(1) + + with patch.object(wifi_manager_module.threading.Thread, "start"): + self.manager.connect_to_network("NextNet", "next-password") + + release_commit.set() + worker.join(1) + + assert not worker.is_alive() + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + assert self.manager._pending_connection is not None + assert self.manager._pending_connection.ssid == "NextNet" + self.manager._dhcp.stop.assert_not_called() + def test_connected_event_rejects_unconfirmed_or_previous_network(self): cases = ( ("wpa_state=ASSOCIATING\nssid=NextNet\n", "wrong-password"), @@ -790,6 +853,48 @@ def test_wrong_key_ignores_same_ssid_event_for_other_profile(self): self.manager._dhcp.stop.assert_not_called() need_auth.assert_not_called() + def test_wrong_key_does_not_cancel_newer_connection_request(self): + need_auth = MagicMock() + disconnected = MagicMock() + self.manager.add_callbacks(need_auth=need_auth, disconnected=disconnected) + self.manager._set_connecting("PreviousNet") + self.manager._set_pending_connection("PreviousNet", "wrong-password", False, SecurityType.WPA) + self.manager._set_pending_network_id("0", self.manager._user_epoch) + self.manager._ctrl.request.return_value = "OK" + remove_started = threading.Event() + release_remove = threading.Event() + + def remove_network(_): + remove_started.set() + assert release_remove.wait(1) + + with ( + patch.object(self.manager, "_list_network_ids", return_value=["0"]), + patch.object(self.manager, "_remove_wpa_network_id", side_effect=remove_network), + ): + worker = threading.Thread( + target=self.manager._handle_event, + args=('CTRL-EVENT-SSID-TEMP-DISABLED id=0 ssid="PreviousNet" reason=WRONG_KEY',), + ) + worker.start() + assert remove_started.wait(1) + + with patch.object(wifi_manager_module.threading.Thread, "start"): + self.manager.connect_to_network("NextNet", "next-password") + + release_remove.set() + worker.join(1) + + self.manager.process_callbacks() + assert not worker.is_alive() + assert self.manager.wifi_state == WifiState("NextNet", ConnectStatus.CONNECTING) + assert self.manager._pending_connection is not None + assert self.manager._pending_connection.ssid == "NextNet" + assert self.manager._pending_connection.password == "next-password" + self.manager._dhcp.stop.assert_not_called() + need_auth.assert_not_called() + disconnected.assert_not_called() + def test_wrong_key_exhausts_same_ssid_profiles_before_auth_failure(self): need_auth = MagicMock() self.manager.add_callbacks(need_auth=need_auth) diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index c159bd1a523037..1749b0dfc6d876 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -901,53 +901,58 @@ def _handle_event(self, event: str): self._handle_connected(ssid, expected_epoch=epoch, profile_uuid=status.get("id_str")) elif "CTRL-EVENT-DISCONNECTED" in event: - if self._tethering_active: - return # Ignore disconnects during tethering transitions - - epoch = self._user_epoch - - if self._wifi_state.status == ConnectStatus.CONNECTING: - return - - if self._user_epoch != epoch: - return + with self._state_lock: + epoch = self._user_epoch + expected_state = self._wifi_state + now = time.monotonic() - ssid = self._wifi_state.ssid - if self._wifi_state.status == ConnectStatus.CONNECTED and ssid is not None: - self._dhcp_adoption_ssid = ssid - self._last_connecting_at = time.monotonic() - self._last_scanning_recheck = 0.0 - self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTING) - return + with self._radio_lock, self._state_lock: + if ( + self._tethering_active + or self._user_epoch != epoch + or self._wifi_state != expected_state + or self._wifi_state.status in (ConnectStatus.CONNECTING, ConnectStatus.DISCONNECTED) + ): + return - if self._wifi_state.status == ConnectStatus.DISCONNECTED: - return + ssid = self._wifi_state.ssid + if self._wifi_state.status == ConnectStatus.CONNECTED and ssid is not None: + self._dhcp_adoption_ssid = ssid + self._last_connecting_at = now + self._last_scanning_recheck = 0.0 + self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTING) + return - self._wifi_state = WifiState(ssid=None, status=ConnectStatus.DISCONNECTED) - self._clear_station_state() - self._enqueue_callbacks(self._disconnected) + self._wifi_state = WifiState() + self._clear_station_state() + self._enqueue_callbacks(self._disconnected) elif "TEMP-DISABLED" in event and "reason=WRONG_KEY" in event: event_ssid = parse_event_ssid(event) event_network_id = parse_event_network_id(event) if event_ssid is not None: with self._radio_lock: - current_ssid = self._wifi_state.ssid - # The event SSID is authoritative for auto-connect - connecting_unknown = ( - self._wifi_state.status == ConnectStatus.CONNECTING - and current_ssid is None - ) - if not connecting_unknown and (not current_ssid or event_ssid != current_ssid): - return - - pending = self._pending_connection - if pending is not None and pending.ssid == event_ssid: - if (pending.epoch != self._user_epoch - or pending.network_id is None - or event_network_id != pending.network_id): + with self._state_lock: + current_ssid = self._wifi_state.ssid + # The event SSID is authoritative for auto-connect + connecting_unknown = ( + self._wifi_state.status == ConnectStatus.CONNECTING + and current_ssid is None + ) + if not connecting_unknown and (not current_ssid or event_ssid != current_ssid): return + pending = self._pending_connection + if pending is not None and pending.ssid == event_ssid: + if (pending.epoch != self._user_epoch + or pending.network_id is None + or event_network_id != pending.network_id): + return + + failed_epoch = self._user_epoch + failed_operation = self._station_operation + failed_pending = pending + # Debounce WRONG_KEY per profile, not per SSID dispatch_key = (event_ssid, event_network_id) now = time.monotonic() @@ -974,45 +979,73 @@ def _handle_event(self, event: str): remaining_ids = [] if remaining_ids: self._select_network_ids(remaining_ids) - self._last_connecting_at = now - self._last_scanning_recheck = 0.0 - self._network_not_found_epoch = None - self._network_not_found_events = 0 + with self._state_lock: + if ( + self._user_epoch != failed_epoch + or self._station_operation is not failed_operation + or (failed_pending is not None and self._pending_connection is not failed_pending) + ): + return + self._last_connecting_at = now + self._last_scanning_recheck = 0.0 + self._network_not_found_epoch = None + self._network_not_found_events = 0 return response = self._request("ENABLE_NETWORK all").strip() if not response.startswith("OK"): raise RuntimeError(f"ENABLE_NETWORK all failed: {response}") except Exception: cloudlog.exception("Failed to update saved networks after WRONG_KEY") - self._clear_pending_connection(event_ssid) - self._enqueue_callbacks(self._need_auth, event_ssid) - self._set_connecting(None, kind=StationOperationKind.AUTH_FAILURE, operation_ssid=event_ssid) + + with self._state_lock: + if ( + self._user_epoch != failed_epoch + or self._station_operation is not failed_operation + or (failed_pending is not None and self._pending_connection is not failed_pending) + ): + return + self._clear_pending_connection(event_ssid, epoch=failed_epoch) + self._set_connecting(None, kind=StationOperationKind.AUTH_FAILURE, operation_ssid=event_ssid) + auth_epoch = self._user_epoch + auth_operation = self._station_operation # DISCONNECTED may arrive while CONNECTING and skip cleanup self._clear_station_state() - self._enqueue_callbacks(self._disconnected) + with self._state_lock: + if self._user_epoch != auth_epoch or self._station_operation is not auth_operation: + return + self._enqueue_callbacks(self._need_auth, event_ssid) + self._enqueue_callbacks(self._disconnected) elif "CTRL-EVENT-NETWORK-NOT-FOUND" in event: - if self._wifi_state.status != ConnectStatus.CONNECTING: - return - # Reconciliation disambiguates delayed NETWORK-NOT-FOUND events - if time.monotonic() - self._last_connecting_at >= CONNECTING_STALE_TIMEOUT_SECONDS: - self._network_not_found_events += 1 - if self._network_not_found_events >= NETWORK_NOT_FOUND_EVENTS_REQUIRED: - self._network_not_found_epoch = self._user_epoch + now = time.monotonic() + with self._state_lock: + if self._wifi_state.status != ConnectStatus.CONNECTING: + return + # Reconciliation disambiguates delayed NETWORK-NOT-FOUND events + if now - self._last_connecting_at >= CONNECTING_STALE_TIMEOUT_SECONDS: + self._network_not_found_events += 1 + if self._network_not_found_events >= NETWORK_NOT_FOUND_EVENTS_REQUIRED: + self._network_not_found_epoch = self._user_epoch elif "Trying to associate with" in event or "Associated with" in event: - if self._wifi_state.status == ConnectStatus.DISCONNECTED: + with self._state_lock: epoch = self._user_epoch - ssid = None - if self._ctrl: - try: - status = parse_status(self._request("STATUS")) - ssid = status.get("ssid") - except Exception: - pass - if self._user_epoch != epoch: + expected_state = self._wifi_state + if expected_state.status != ConnectStatus.DISCONNECTED: + return + + ssid = None + if self._ctrl: + try: + status = parse_status(self._request("STATUS")) + ssid = status.get("ssid") + except Exception: + pass + now = time.monotonic() + with self._radio_lock, self._state_lock: + if self._user_epoch != epoch or self._wifi_state != expected_state: return - self._last_connecting_at = time.monotonic() + self._last_connecting_at = now self._last_scanning_recheck = 0.0 self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTING) From a90024c35ebf5075ef68c933b0ecc212e2b96704 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 14:23:34 +0000 Subject: [PATCH 65/71] wifi: confirm disconnect before teardown --- .../ui/lib/tests/test_handle_state_change.py | 55 +++++++++++++++++++ openpilot/system/ui/lib/wifi_manager.py | 44 +++++++++++++-- 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 85523d888b3dab..fa870c5fd038d5 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -434,6 +434,61 @@ def test_missed_disconnect_clears_association_before_reconnect(self): self.manager._dhcp.start.assert_called_once() + def test_connected_reconciliation_rechecks_status_before_cleanup(self): + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") + self.manager._last_connected_recheck = 0.0 + self.manager._dhcp.stop.reset_mock() + self.manager._dhcp.clear_ipv6_state.reset_mock() + self.manager._request = MagicMock(return_value="wpa_state=DISCONNECTED\n") + + self.manager._reconcile_connecting_state() + + assert self.manager._request.call_args_list == [call("STATUS"), call("STATUS")] + assert self.manager.wifi_state == WifiState() + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + + def test_connected_reconciliation_does_not_clear_recovered_station(self): + self.manager._set_connecting("TestNet") + self.manager._handle_connected("TestNet") + complete_station_connection(self.manager, "TestNet") + epoch = self.manager._user_epoch + self.manager._last_connected_recheck = 0.0 + self.manager._dhcp.stop.reset_mock() + self.manager._dhcp.clear_ipv6_state.reset_mock() + status_started = threading.Event() + release_status = threading.Event() + status_requests = 0 + + def request(command): + nonlocal status_requests + if command != "STATUS": + return "OK" + status_requests += 1 + if status_requests == 1: + status_started.set() + assert release_status.wait(1) + return "wpa_state=DISCONNECTED\n" + return "wpa_state=COMPLETED\nssid=TestNet\n" + + self.manager._request = MagicMock(side_effect=request) + worker = threading.Thread(target=self.manager._reconcile_connecting_state) + worker.start() + assert status_started.wait(1) + + self.manager._handle_connected("TestNet", expected_epoch=epoch) + release_status.set() + worker.join(1) + + assert not worker.is_alive() + assert status_requests == 2 + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTED) + assert self.manager._associated_ssid == "TestNet" + self.manager._dhcp.stop.assert_not_called() + self.manager._dhcp.clear_ipv6_state.assert_not_called() + def test_disconnected_event_does_not_override_user_connection(self): self.manager._set_connecting("NextNet") diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 1749b0dfc6d876..8b9f467897a489 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -1134,7 +1134,10 @@ def _reconcile_connecting_state(self): if now - self._last_connected_recheck < SCAN_PERIOD_SECONDS: return self._last_connected_recheck = now - epoch = self._user_epoch + with self._state_lock: + epoch = self._user_epoch + expected_operation = self._station_operation + expected_association = (self._associated_ssid, self._associated_epoch) try: status = parse_status(self._request("STATUS")) except Exception: @@ -1156,12 +1159,41 @@ def _reconcile_connecting_state(self): "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): return with self._radio_lock: - if self._user_epoch != epoch: + with self._state_lock: + if ( + self._user_epoch != epoch + or self._station_operation is not expected_operation + or (self._associated_ssid, self._associated_epoch) != expected_association + ): + return + + try: + latest_status = parse_status(self._request("STATUS")) + except Exception: + cloudlog.exception("Failed to confirm disconnected wifi state from STATUS") return - self._wifi_state = WifiState() - self._dhcp_adoption_ssid = None - self._clear_station_state() - self._enqueue_callbacks(self._disconnected) + latest_wpa_state = latest_status.get("wpa_state", "") + latest_ssid = latest_status.get("ssid") + if latest_wpa_state == "COMPLETED" and latest_ssid: + self._handle_connected( + latest_ssid, expected_epoch=epoch, profile_uuid=latest_status.get("id_str"), + ) + return + if latest_wpa_state in ("SCANNING", "AUTHENTICATING", "ASSOCIATING", "ASSOCIATED", + "4WAY_HANDSHAKE", "GROUP_HANDSHAKE"): + return + + with self._state_lock: + if ( + self._user_epoch != epoch + or self._station_operation is not expected_operation + or (self._associated_ssid, self._associated_epoch) != expected_association + ): + return + self._wifi_state = WifiState() + self._dhcp_adoption_ssid = None + self._clear_station_state() + self._enqueue_callbacks(self._disconnected) return if current_state.status != ConnectStatus.CONNECTING: From a9698feecf8bc28e041de2228ce9114368d5dfd3 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 14:26:32 +0000 Subject: [PATCH 66/71] wifi: restart L3 on profile changes --- .../ui/lib/tests/test_handle_state_change.py | 26 ++++++++++++++++--- openpilot/system/ui/lib/wifi_manager.py | 24 ++++++++++++----- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index fa870c5fd038d5..1f487acf038ab2 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -124,7 +124,9 @@ def test_connected_retries_after_ipv6_policy_failure(self): assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) self.manager._dhcp.start.assert_not_called() - def test_same_ssid_profile_switch_reapplies_ipv6_policy(self): + def test_same_ssid_profile_switch_restarts_l3_state(self): + activated = MagicMock() + self.manager.add_callbacks(activated=activated) first_uuid = str(uuid.uuid4()) second_uuid = str(uuid.uuid4()) self.manager._store.get_ipv6_method.side_effect = ["ignore", "auto"] @@ -132,6 +134,12 @@ def test_same_ssid_profile_switch_reapplies_ipv6_policy(self): self.manager._handle_connected("TestNet", profile_uuid=first_uuid) complete_station_connection(self.manager, "TestNet") + self.manager.process_callbacks() + activated.reset_mock() + self.manager._ipv4_address = "192.168.1.20" + self.manager._current_network_metered = MeteredType.YES + self.manager._dhcp.stop.reset_mock() + self.manager._dhcp.clear_ipv6_state.reset_mock() self.manager._handle_connected("TestNet", profile_uuid=second_uuid) assert self.manager._store.get_ipv6_method.call_args_list == [ @@ -141,8 +149,20 @@ def test_same_ssid_profile_switch_reapplies_ipv6_policy(self): assert self.manager._dhcp.set_ipv6_enabled.call_args_list == [call(False), call(True)] assert self.manager._station_operation is not None assert self.manager._station_operation.profile_uuid == second_uuid - self.manager._dhcp.start.assert_called_once() - self.manager._poll_for_ip.assert_called_once() + assert self.manager.wifi_state == WifiState("TestNet", ConnectStatus.CONNECTING) + assert self.manager.ipv4_address == "" + assert self.manager.current_network_metered == MeteredType.UNKNOWN + self.manager._dhcp.stop.assert_called_once() + self.manager._dhcp.clear_ipv6_state.assert_called_once() + assert self.manager._dhcp.start.call_count == 2 + assert self.manager._poll_for_ip.call_count == 2 + self.manager.process_callbacks() + activated.assert_not_called() + + self.manager._ipv4_address = "192.168.2.20" + self.manager._complete_station_connection("TestNet", self.manager._user_epoch) + self.manager.process_callbacks() + activated.assert_called_once() def test_connected_to_another_ssid_clears_stale_ipv6_state(self): self.manager._wifi_state = WifiState("PreviousNet", ConnectStatus.CONNECTED) diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 8b9f467897a489..0ae654c11125a6 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -457,12 +457,16 @@ def _set_connecting(self, ssid: str | None, requested: bool = True, self._station_operation = StationOperation(self._user_epoch, kind, operation_ssid if operation_ssid is not None else ssid) self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.DISCONNECTED if ssid is None else ConnectStatus.CONNECTING) - def _clear_station_state(self): + def _clear_station_l3_state(self): with self._radio_lock: self._dhcp.stop() self._dhcp.clear_ipv6_state() self._ipv4_address = "" self._current_network_metered = MeteredType.UNKNOWN + + def _clear_station_state(self): + with self._radio_lock: + self._clear_station_l3_state() with self._state_lock: self._associated_ssid = None self._associated_epoch = None @@ -817,6 +821,16 @@ def _handle_connected(self, ssid: str, expected_epoch: int | None = None, ) self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTING) + if profile_changed: + self._clear_station_l3_state() + with self._state_lock: + if ( + not self._connected_transition_is_current(ssid, transition_epoch) + or self._station_operation is not previous_operation + ): + return + self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTING) + if not already_associated or profile_changed: try: ipv6_method = self._store.get_ipv6_method(ssid, active_profile_uuid) if self._store is not None else "auto" @@ -846,22 +860,20 @@ def _handle_connected(self, ssid: str, expected_epoch: int | None = None, runtime_network_id=previous_operation.runtime_network_id if previous_operation is not None else None, ) - if already_connected: + if already_connected and not profile_changed: # Retry persistence after transient filesystem failures. pending = self._pending_connection if pending is not None and pending.ssid == ssid: self._persist_pending_connection(ssid) - if profile_changed: - self._update_active_connection_info() return - if already_associated: + if already_associated and not profile_changed: self._persist_pending_connection(ssid) self._update_active_connection_info() self._complete_station_connection(ssid, transition_epoch) return - if not adopt_dhcp or not self._dhcp.adopt(): + if profile_changed or not adopt_dhcp or not self._dhcp.adopt(): self._ipv4_address = "" self._dhcp.start() if not self._connected_transition_is_current(ssid, transition_epoch): From b1d58dbfd190b5035ae1dc4be80aa23c0d03d65c Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 14:33:29 +0000 Subject: [PATCH 67/71] wifi: reject shared Netplan mutations --- .../system/ui/lib/tests/test_network_store.py | 125 +++++++++++++++--- openpilot/system/ui/lib/wifi_network_store.py | 74 +++++++---- 2 files changed, 153 insertions(+), 46 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index b760b81d49cad0..8e3967bbb13c78 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -579,6 +579,32 @@ def test_tethering_update_collapses_runtime_shadow_by_uuid(self): assert not runtime_path.exists() assert not netplan_path.exists() + def test_tethering_update_refuses_shared_netplan_source(self): + runtime_path = Path(write_profile( + self.runtime, "runtime-hotspot.nmconnection", "weedle", file_uuid="hotspot-uuid", psk="old-password", mode="ap", + )) + hotspot_uuid = profile_uuid("hotspot-uuid") + netplan_path = Path(self.netplan, f"90-NM-{hotspot_uuid}.yaml") + netplan_path.write_text(f"""\ +network: + version: 2 + wifis: + hotspot: + networkmanager: + uuid: {hotspot_uuid} + unrelated: + networkmanager: + uuid: {profile_uuid('other-hotspot-uuid')} +""") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert not store.set_tethering_password("weedle", "new-password") + + assert runtime_path.exists() + assert netplan_path.exists() + assert not list(Path(self.persistent).glob("*.nmconnection")) + def test_persists_runtime_tethering_profile_for_rollback(self): runtime_path = Path(write_profile( self.runtime, "netplan-hotspot.nmconnection", "weedle", file_uuid="hotspot-uuid", psk="old-password", mode="ap", @@ -620,8 +646,9 @@ def test_persistent_profile_wins_runtime_duplicate(self): def test_edit_persistent_profile_removes_shadowed_runtime_copy(self): write_profile(self.persistent, "persistent.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="persistent") runtime_path = Path(write_profile(self.runtime, "runtime.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="runtime-password")) - netplan_path = Path(self.netplan, f"90-NM-{profile_uuid('shared-uuid')}.yaml") - netplan_path.write_text("network:\n version: 2\n") + shared_uuid = profile_uuid("shared-uuid") + netplan_path = Path(self.netplan, f"90-NM-{shared_uuid}.yaml") + netplan_path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {shared_uuid}\n") with ( self.patch_reads(), @@ -639,8 +666,9 @@ def test_edit_persistent_profile_removes_shadowed_runtime_copy(self): def test_edit_persistent_profile_preserves_runtime_profile_with_different_uuid(self): write_profile(self.persistent, "persistent.nmconnection", "Duplicate", file_uuid="persistent-uuid", psk="persistent") runtime_path = Path(write_profile(self.runtime, "runtime.nmconnection", "Duplicate", file_uuid="runtime-uuid", psk="runtime")) - netplan_path = Path(self.netplan, f"90-NM-{profile_uuid('runtime-uuid')}.yaml") - netplan_path.write_text("network:\n version: 2\n") + runtime_uuid = profile_uuid("runtime-uuid") + netplan_path = Path(self.netplan, f"90-NM-{runtime_uuid}.yaml") + netplan_path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {runtime_uuid}\n") with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): store = self.make_store() @@ -840,8 +868,9 @@ def test_new_profile_uses_preallocated_uuid(self): def test_forget_runtime_profile_removes_netplan_source(self): write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") - netplan_path = Path(self.netplan, f"90-NM-{profile_uuid('runtime-uuid')}.yaml") - netplan_path.write_text("network:\n version: 2\n") + runtime_uuid = profile_uuid("runtime-uuid") + netplan_path = Path(self.netplan, f"90-NM-{runtime_uuid}.yaml") + netplan_path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {runtime_uuid}\n") with ( self.patch_reads(), @@ -865,7 +894,7 @@ def test_edit_runtime_profile_without_netplan_source(self): assert not Path(self.runtime, "runtime.nmconnection").exists() assert Path(self.persistent, f"{profile_uuid('runtime-uuid')}-Runtime.nmconnection").exists() - assert require_entry(store, "Runtime")["_netplan_filename"] is None + assert require_entry(store, "Runtime")["_netplan_source"] is None def test_forget_runtime_profile_without_netplan_source(self): write_profile(self.runtime, "runtime.nmconnection", "Runtime", file_uuid="runtime-uuid") @@ -888,7 +917,7 @@ def test_forget_finds_renamed_netplan_source_by_uuid(self): assert not netplan_path.exists() - def test_forget_preserves_shared_renamed_netplan_source(self): + def test_forget_refuses_shared_renamed_netplan_source(self): runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) netplan_path = Path(self.netplan, "provisioned-wifi.yaml") netplan_path.write_text(f"""\ @@ -905,11 +934,63 @@ def test_forget_preserves_shared_renamed_netplan_source(self): with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): store = self.make_store() - assert store.remove("Runtime") + assert not store.remove("Runtime") - assert not runtime_path.exists() + assert runtime_path.exists() assert netplan_path.exists() - assert not store.contains("Runtime") + assert store.contains("Runtime") + + def test_forget_refuses_shared_canonical_netplan_source(self): + runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) + runtime_uuid = profile_uuid("runtime-uuid") + netplan_path = Path(self.netplan, f"90-NM-{runtime_uuid}.yaml") + netplan_path.write_text(f"""\ +network: + version: 2 + wifis: + first: + networkmanager: + uuid: {runtime_uuid} + second: + networkmanager: + uuid: {profile_uuid('other-uuid')} +""") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert not store.remove("Runtime") + + assert runtime_path.exists() + assert netplan_path.exists() + assert store.contains("Runtime") + + def test_profile_updates_refuse_shared_netplan_source(self): + runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) + runtime_uuid = profile_uuid("runtime-uuid") + netplan_path = Path(self.netplan, "shared.yaml") + netplan_path.write_text(f"""\ +network: + version: 2 + wifis: + first: + networkmanager: + uuid: {runtime_uuid} + second: + networkmanager: + uuid: {profile_uuid('other-uuid')} +""") + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + with self.assertRaises(OSError): + store.set_metered("Runtime", 1) + with self.assertRaises(OSError): + store.save_network("Runtime", psk="replacement-password") + + assert runtime_path.exists() + assert netplan_path.exists() + assert require_entry(store, "Runtime")["metered"] == 0 + assert require_entry(store, "Runtime")["psk"] == "password123" def test_forget_ignores_unrelated_netplan_source(self): runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) @@ -949,7 +1030,8 @@ def test_forget_duplicate_runtime_profiles_removes_every_netplan_source(self): Path(self.netplan, f"90-NM-{profile_uuid('second-uuid')}.yaml"), } for path in netplan_paths: - path.write_text("network:\n version: 2\n") + source_uuid = path.stem.removeprefix("90-NM-") + path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {source_uuid}\n") with ( self.patch_reads(), @@ -1055,8 +1137,9 @@ def run(command, **kwargs): def test_edit_runtime_profile_installs_keyfile_before_removing_netplan(self): write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") - netplan_path = Path(self.netplan, f"90-NM-{profile_uuid('runtime-uuid')}.yaml") - netplan_path.write_text("network:\n version: 2\n") + runtime_uuid = profile_uuid("runtime-uuid") + netplan_path = Path(self.netplan, f"90-NM-{runtime_uuid}.yaml") + netplan_path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {runtime_uuid}\n") with ( self.patch_reads(), @@ -1076,12 +1159,13 @@ def test_edit_runtime_profile_installs_keyfile_before_removing_netplan(self): assert install_index < runtime_remove_index < remove_index assert require_entry(store, "Runtime")["psk"] == "replacement" assert require_entry(store, "Runtime")["_runtime_filename"] is None - assert require_entry(store, "Runtime")["_netplan_filename"] is None + assert require_entry(store, "Runtime")["_netplan_source"] is None def test_edit_runtime_profile_rolls_back_when_runtime_remove_fails(self): runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) - netplan_path = Path(self.netplan, f"90-NM-{profile_uuid('runtime-uuid')}.yaml") - netplan_path.write_text("network:\n version: 2\n") + runtime_uuid = profile_uuid("runtime-uuid") + netplan_path = Path(self.netplan, f"90-NM-{runtime_uuid}.yaml") + netplan_path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {runtime_uuid}\n") def run(command, **_): return MagicMock(returncode=1 if command[-1] == str(runtime_path) else 0) @@ -1161,8 +1245,9 @@ def run(command, **kwargs): def test_edit_runtime_profile_restores_sources_when_netplan_remove_fails(self): runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) original_runtime = runtime_path.read_text() - netplan_path = Path(self.netplan, f"90-NM-{profile_uuid('runtime-uuid')}.yaml") - netplan_path.write_text("network:\n version: 2\n") + runtime_uuid = profile_uuid("runtime-uuid") + netplan_path = Path(self.netplan, f"90-NM-{runtime_uuid}.yaml") + netplan_path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {runtime_uuid}\n") original_netplan = netplan_path.read_text() def run(command, **kwargs): @@ -1185,7 +1270,7 @@ def run(command, **kwargs): assert not list(Path(self.root).rglob("*.openpilot-update-*")) assert require_entry(store, "Runtime")["psk"] == "password123" assert require_entry(store, "Runtime")["_runtime_filename"] == "netplan.nmconnection" - assert require_entry(store, "Runtime")["_netplan_filename"] == f"90-NM-{profile_uuid('runtime-uuid')}.yaml" + assert require_entry(store, "Runtime")["_netplan_source"] == store_module.NetplanSource(str(netplan_path), True) def test_accepts_only_networkmanager_dns_priority(self): rollback_path = write_profile(self.persistent, "rollback.nmconnection", "Rollback") diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index 6cf1f28fba9e02..36e4f594c10cac 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -7,6 +7,7 @@ import uuid from collections.abc import Iterator from contextlib import contextmanager +from dataclasses import dataclass from enum import IntEnum from openpilot.common.swaglog import cloudlog @@ -45,6 +46,12 @@ class MeteredType(IntEnum): NO = 2 +@dataclass(frozen=True) +class NetplanSource: + path: str + exclusive: bool + + def _canonical_filename(file_uuid: str, ssid: str) -> str: """`-.nmconnection` matches netplan's runtime keyfile naming. UUID is the stable handle; the SSID suffix is purely cosmetic, so it gets sanitized lossily.""" @@ -211,12 +218,10 @@ def _load(self): for fname in filenames: self._load_keyfile(directory, fname, imported, persistent_uuids) - def _find_netplan_filename(self, file_uuid: str) -> str | None: + def _find_netplan_source(self, file_uuid: str) -> NetplanSource | None: if self._netplan_directory is None or not file_uuid: return None - expected = f"90-NM-{file_uuid}.yaml" - if os.path.exists(os.path.join(self._netplan_directory, expected)): - return expected + expected_path = os.path.join(self._netplan_directory, f"90-NM-{file_uuid}.yaml") try: filenames = sorted(os.listdir(self._netplan_directory)) except OSError: @@ -224,17 +229,28 @@ def _find_netplan_filename(self, file_uuid: str) -> str | None: pattern = re.compile(r"^\s*uuid\s*:\s*['\"]?([^'\"\s#]+)['\"]?\s*(?:#.*)?$", re.MULTILINE) yaml_filenames = [fname for fname in filenames if fname.endswith(".yaml")] read_failed = False + matches: list[tuple[str, set[str | None]]] = [] for fname in yaml_filenames: + path = os.path.join(self._netplan_directory, fname) try: - raw = sudo_read(os.path.join(self._netplan_directory, fname)) + raw = sudo_read(path) except OSError: read_failed = True continue if not raw: read_failed = True - elif {_parse_uuid(value) for value in pattern.findall(raw)} == {file_uuid}: - return fname - return expected if read_failed else None + continue + source_uuids = {_parse_uuid(value) for value in pattern.findall(raw)} + if file_uuid in source_uuids: + matches.append((path, source_uuids)) + + if matches: + path, source_uuids = matches[0] + return NetplanSource(path, not read_failed and len(matches) == 1 and source_uuids == {file_uuid}) + if os.path.exists(expected_path) or read_failed: + # A canonical filename or unreadable YAML is not proof of exclusive ownership. + return NetplanSource(expected_path, False) + return None def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_uuids: dict[str, set[str]]): if not fname.endswith(".nmconnection"): @@ -338,17 +354,17 @@ def _load_keyfile(self, directory: str, fname: str, imported: bool, persistent_u "_connection": connection, "_ipv4": ipv4, "_ipv6": ipv6, - # Track the source filename for noncanonical profiles + # Track persistent, runtime, and Netplan representations separately. "_filename": None if imported else fname, "_runtime_filename": fname if imported else None, - "_netplan_filename": self._find_netplan_filename(file_uuid) if imported else None, + "_netplan_source": self._find_netplan_source(file_uuid) if imported else None, } profiles = self._profiles.setdefault(ssid, []) if imported and file_uuid in persistent_uuids.get(ssid, set()): persistent = next((profile for profile in profiles if profile.get("uuid") == file_uuid), None) if persistent is not None and persistent.get("_runtime_filename") is None: persistent["_runtime_filename"] = fname - persistent["_netplan_filename"] = self._find_netplan_filename(file_uuid) + persistent["_netplan_source"] = self._find_netplan_source(file_uuid) return if any(profile.get("uuid") == file_uuid for profile in profiles): return @@ -450,9 +466,11 @@ def _profile_update(self, ssid: str, profiles: list[dict]) -> Iterator[None]: runtime_filename = profile.get("_runtime_filename") if self._runtime_directory is not None and runtime_filename: paths.add(os.path.join(self._runtime_directory, runtime_filename)) - netplan_filename = profile.get("_netplan_filename") - if self._netplan_directory is not None and netplan_filename: - paths.add(os.path.join(self._netplan_directory, netplan_filename)) + netplan_source = profile.get("_netplan_source") + if isinstance(netplan_source, NetplanSource): + if not netplan_source.exclusive: + raise OSError(f"refusing to mutate shared Netplan source {netplan_source.path}") + paths.add(netplan_source.path) with self._update_transaction(paths, repr(ssid)): yield @@ -516,15 +534,17 @@ def _render_nmconnection(self, ssid: str, entry: dict) -> tuple[str, dict]: raise OSError(f"failed to remove {runtime_path}") entry["_runtime_filename"] = None - netplan_filename = entry.get("_netplan_filename") - if self._netplan_directory is not None and netplan_filename: - netplan_path = os.path.join(self._netplan_directory, netplan_filename) + netplan_source = entry.get("_netplan_source") + if isinstance(netplan_source, NetplanSource): + if not netplan_source.exclusive: + raise OSError(f"refusing to mutate shared Netplan source {netplan_source.path}") + netplan_path = netplan_source.path if not os.path.exists(netplan_path): raise OSError(f"failed to find {netplan_path}") result = subprocess.run(["sudo", "rm", "-f", netplan_path], check=False) if result.returncode != 0: raise OSError(f"failed to remove {netplan_path}") - entry["_netplan_filename"] = None + entry["_netplan_source"] = None # Keep one canonical filename for noncanonical profiles if stored_fname and stored_fname != canonical_fname: @@ -679,12 +699,11 @@ def set_tethering_password(self, ssid: str, password: str) -> bool: if os.path.join(directory, filename) != target_path } if any(directory == self._runtime_directory for _, directory, _, _ in representations): - netplan_filename = self._find_netplan_filename(file_uuid) - if self._netplan_directory is not None and netplan_filename is not None: - netplan_path = os.path.join(self._netplan_directory, netplan_filename) - if not os.path.exists(netplan_path): + netplan_source = self._find_netplan_source(file_uuid) + if netplan_source is not None: + if not netplan_source.exclusive or not os.path.exists(netplan_source.path): return False - obsolete_paths.add(netplan_path) + obsolete_paths.add(netplan_source.path) updates.append((cp, target_path, obsolete_paths)) paths.add(target_path) @@ -782,9 +801,12 @@ def remove(self, ssid: str) -> bool: runtime_filename = profile.get("_runtime_filename") if self._runtime_directory is not None and runtime_filename: paths.add(os.path.join(self._runtime_directory, runtime_filename)) - netplan_filename = profile.get("_netplan_filename") - if self._netplan_directory is not None and netplan_filename: - netplan_paths.add(os.path.join(self._netplan_directory, netplan_filename)) + netplan_source = profile.get("_netplan_source") + if isinstance(netplan_source, NetplanSource): + if not netplan_source.exclusive: + cloudlog.warning(f"NetworkStore: refusing to remove shared Netplan source {netplan_source.path}") + return False + netplan_paths.add(netplan_source.path) for p in netplan_paths: if not os.path.exists(p): cloudlog.warning(f"NetworkStore: failed to find netplan source {p}") From 1eac756e7a00e31589ade093fe6a7d38dc6e0aaa Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 16:40:56 +0000 Subject: [PATCH 68/71] wifi: wait for supplicant teardown --- .../system/ui/lib/tests/test_wpa_ctrl.py | 59 ++++++++++++++++++- openpilot/system/ui/lib/wpa_ctrl.py | 36 ++++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_wpa_ctrl.py b/openpilot/system/ui/lib/tests/test_wpa_ctrl.py index 70adaeb34643ea..30c11ad442d99c 100644 --- a/openpilot/system/ui/lib/tests/test_wpa_ctrl.py +++ b/openpilot/system/ui/lib/tests/test_wpa_ctrl.py @@ -346,18 +346,58 @@ def test_pidfile_proves_exact_supplicant_ownership(self): assert wpa_ctrl_module.wpa_supplicant_running(wpa_ctrl_module.WPA_SUPPLICANT_CONF) assert not wpa_ctrl_module.wpa_supplicant_running(wpa_ctrl_module.WPA_AP_CONF) - def test_stop_targets_only_pidfile_owned_supplicant(self): + def test_stop_waits_for_owned_supplicant_before_releasing_paths(self): with ( patch.object(wpa_ctrl_module, "_owned_wpa_pid", return_value=123), + patch.object(wpa_ctrl_module, "_process_start_time", return_value="456"), + patch.object(wpa_ctrl_module, "_wait_for_process_exit", return_value=True) as wait_for_exit, patch.object(wpa_ctrl_module.subprocess, "run") as run, ): wpa_ctrl_module.stop_wpa_supplicant(wpa_ctrl_module.WPA_SUPPLICANT_CONF) + wait_for_exit.assert_called_once_with(123, "456") assert [item.args[0] for item in run.call_args_list] == [ - ["sudo", "kill", "123"], + ["sudo", "kill", "-TERM", "--", "123"], ["sudo", "rm", "-f", wpa_ctrl_module.WPA_PID_FILE, wpa_ctrl_module.WPA_CTRL_PATH], ] + def test_stop_escalates_to_sigkill_after_timeout(self): + with ( + patch.object(wpa_ctrl_module, "_owned_wpa_pid", return_value=123), + patch.object(wpa_ctrl_module, "_process_start_time", return_value="456"), + patch.object(wpa_ctrl_module, "_wait_for_process_exit", side_effect=[False, True]), + patch.object(wpa_ctrl_module.subprocess, "run") as run, + ): + wpa_ctrl_module.stop_wpa_supplicant(wpa_ctrl_module.WPA_SUPPLICANT_CONF) + + assert [item.args[0] for item in run.call_args_list] == [ + ["sudo", "kill", "-TERM", "--", "123"], + ["sudo", "kill", "-KILL", "--", "123"], + ["sudo", "rm", "-f", wpa_ctrl_module.WPA_PID_FILE, wpa_ctrl_module.WPA_CTRL_PATH], + ] + + def test_stop_retains_ownership_when_process_survives_sigkill(self): + with ( + patch.object(wpa_ctrl_module, "_owned_wpa_pid", return_value=123), + patch.object(wpa_ctrl_module, "_process_start_time", return_value="456"), + patch.object(wpa_ctrl_module, "_wait_for_process_exit", return_value=False), + patch.object(wpa_ctrl_module.subprocess, "run") as run, + self.assertRaises(RuntimeError), + ): + wpa_ctrl_module.stop_wpa_supplicant(wpa_ctrl_module.WPA_SUPPLICANT_CONF) + + assert [item.args[0] for item in run.call_args_list] == [ + ["sudo", "kill", "-TERM", "--", "123"], + ["sudo", "kill", "-KILL", "--", "123"], + ] + + def test_wait_rejects_pid_reuse(self): + with ( + patch.object(wpa_ctrl_module, "_process_start_time", return_value="new-start"), + self.assertRaises(RuntimeError), + ): + wpa_ctrl_module._wait_for_process_exit(123, "old-start") + def test_stop_targets_only_openpilot_tethering(self): with patch.object(wpa_ctrl_module.subprocess, "run") as run: wpa_ctrl_module.stop_tethering_dnsmasq() @@ -369,6 +409,21 @@ def test_stop_targets_only_openpilot_tethering(self): class TestSupplicantBringup(TestCase): + def test_bringup_aborts_when_owned_daemon_cannot_stop(self): + with ( + patch.object(wpa_ctrl_module.os.path, "exists", return_value=True), + patch.object(wpa_ctrl_module, "wpa_supplicant_running", return_value=False), + patch.object(wpa_ctrl_module, "_unmanage_wlan0", return_value=True), + patch.object(wpa_ctrl_module, "stop_wpa_supplicant", side_effect=RuntimeError("still running")), + patch.object(wpa_ctrl_module, "prepare_wpa_runtime") as prepare_runtime, + patch.object(wpa_ctrl_module.subprocess, "run") as run, + self.assertRaises(RuntimeError), + ): + wpa_ctrl_module.ensure_wpa_supplicant(lambda: False) + + prepare_runtime.assert_not_called() + assert not any(item.args[0][:2] == ["sudo", "wpa_supplicant"] for item in run.call_args_list) + def test_reconciles_existing_station_configuration(self): ctrl = MagicMock() ctrl.request.side_effect = lambda command: ( diff --git a/openpilot/system/ui/lib/wpa_ctrl.py b/openpilot/system/ui/lib/wpa_ctrl.py index 4f3e117ea3d1a8..5f8e9f1b9e3869 100644 --- a/openpilot/system/ui/lib/wpa_ctrl.py +++ b/openpilot/system/ui/lib/wpa_ctrl.py @@ -261,6 +261,8 @@ def dbm_to_percent(dbm: int) -> int: TEMP_DISABLED_SSID_RE = re.compile(r'\bssid="((?:\\.|[^"])*)"') EVENT_NETWORK_ID_RE = re.compile(r"\bid=(\d+)\b") +WPA_STOP_ATTEMPTS = 60 +WPA_STOP_POLL_INTERVAL_SECONDS = 0.05 def normalize_ssid(ssid: str) -> str: @@ -312,6 +314,27 @@ def wpa_supplicant_running(conf: str) -> bool: return _owned_wpa_pid(conf) is not None +def _process_start_time(pid: int) -> str | None: + try: + stat = Path(f"/proc/{pid}/stat").read_text() + except OSError: + return None + end_comm = stat.rfind(")") + fields = stat[end_comm + 2:].split() if end_comm != -1 else [] + return fields[19] if len(fields) > 19 else None + + +def _wait_for_process_exit(pid: int, start_time: str) -> bool: + for _ in range(WPA_STOP_ATTEMPTS): + current_start_time = _process_start_time(pid) + if current_start_time is None: + return True + if current_start_time != start_time: + raise RuntimeError(f"wpa_supplicant PID {pid} was reused before teardown completed") + time.sleep(WPA_STOP_POLL_INTERVAL_SECONDS) + return False + + def prepare_wpa_runtime() -> None: subprocess.run(["sudo", "install", "-d", "-o", "root", "-g", "netdev", "-m", "775", WPA_CTRL_DIR], check=True) subprocess.run(["sudo", "rm", "-f", WPA_PID_FILE, WPA_CTRL_PATH], check=False) @@ -321,8 +344,17 @@ def stop_wpa_supplicant(conf: str) -> None: pid = _owned_wpa_pid(conf) if pid is None: return - subprocess.run(["sudo", "kill", str(pid)], check=False) - subprocess.run(["sudo", "rm", "-f", WPA_PID_FILE, WPA_CTRL_PATH], check=False) + start_time = _process_start_time(pid) + if start_time is None: + if os.path.exists(f"/proc/{pid}"): + raise RuntimeError(f"failed to capture wpa_supplicant PID {pid} identity") + else: + subprocess.run(["sudo", "kill", "-TERM", "--", str(pid)], check=False) + if not _wait_for_process_exit(pid, start_time): + subprocess.run(["sudo", "kill", "-KILL", "--", str(pid)], check=False) + if not _wait_for_process_exit(pid, start_time): + raise RuntimeError(f"owned wpa_supplicant PID {pid} did not exit") + subprocess.run(["sudo", "rm", "-f", WPA_PID_FILE, WPA_CTRL_PATH], check=True) def tethering_dnsmasq_running() -> bool: From ba6647d8c1dae249024ea5c9e4536bb696f7b665 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 16:50:34 +0000 Subject: [PATCH 69/71] wifi: verify forget runtime cleanup --- .../ui/lib/tests/test_handle_state_change.py | 79 ++++++++-- openpilot/system/ui/lib/wifi_manager.py | 141 ++++++++++++++---- 2 files changed, 176 insertions(+), 44 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 1f487acf038ab2..01eec708ddf9a7 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -1354,6 +1354,7 @@ def add_network(ssid, *_, **__): self.manager._store.contains.return_value = True self.manager._store.remove.return_value = True + self.manager._ctrl.request.return_value = "OK\n" with ( patch.object(self.manager, "_list_network_ids", return_value=[]), @@ -1393,6 +1394,7 @@ def select_network(*_, **__): self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) self.manager._store.contains.return_value = True self.manager._store.remove.side_effect = remove_saved_network + self.manager._ctrl.request.return_value = "OK\n" with ( patch.object(self.manager, "_list_network_ids", return_value=[]), @@ -1416,43 +1418,88 @@ def select_network(*_, **__): assert call("DISCONNECT") not in self.manager._ctrl.request.call_args_list assert call("REASSOCIATE") not in self.manager._ctrl.request.call_args_list - def test_forget_reports_persistent_success_when_runtime_removal_fails(self): + def test_forget_reconfigures_when_targeted_runtime_removal_fails(self): forgotten = MagicMock() forget_failed = MagicMock() self.manager.add_callbacks(forgotten=forgotten, forget_failed=forget_failed) self.manager._store.contains.return_value = True self.manager._store.remove.return_value = True + self.manager._ctrl.request.return_value = "OK\n" - def request(command): - if command == "LIST_NETWORKS": - return "network id / ssid / bssid / flags\n0\tSavedNet\tany\t\n" - if command == "REMOVE_NETWORK 0": - return "FAIL\n" - return "OK\n" - - self.manager._ctrl.request.side_effect = request - with patch.object(wifi_manager_module, "generate_wpa_conf"): + with ( + patch.object(wifi_manager_module, "generate_wpa_conf"), + patch.object(self.manager, "_remove_wpa_network", side_effect=RuntimeError("REMOVE_NETWORK failed")), + patch.object(self.manager, "_list_network_ids", return_value=[]), + patch.object(self.manager, "_restart_station_supplicant") as restart, + ): self.manager.forget_connection("SavedNet", block=True) self.manager.process_callbacks() + assert call("RECONFIGURE") in self.manager._ctrl.request.call_args_list + restart.assert_not_called() forgotten.assert_called_once_with("SavedNet") forget_failed.assert_not_called() - def test_forget_removes_runtime_when_config_generation_fails(self): + def test_forget_restarts_station_when_control_reconciliation_fails(self): forgotten = MagicMock() self.manager.add_callbacks(forgotten=forgotten) self.manager._store.contains.return_value = True self.manager._store.remove.return_value = True + self.manager._ctrl.request.return_value = "FAIL\n" with ( - patch.object(wifi_manager_module, "generate_wpa_conf", side_effect=OSError("read-only")), - patch.object(self.manager, "_remove_wpa_network") as remove_wpa_network, + patch.object(wifi_manager_module, "generate_wpa_conf"), + patch.object(self.manager, "_remove_wpa_network", side_effect=RuntimeError("REMOVE_NETWORK failed")), + patch.object(self.manager, "_restart_station_supplicant", return_value=True) as restart, + patch.object(self.manager, "_list_network_ids", return_value=[]), + ): + self.manager.forget_connection("SavedNet", block=True) + + self.manager.process_callbacks() + restart.assert_called_once() + forgotten.assert_called_once_with("SavedNet") + + def test_forget_defers_success_until_configuration_can_be_generated(self): + forgotten = MagicMock() + self.manager.add_callbacks(forgotten=forgotten) + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = True + self.manager._ctrl.request.return_value = "OK\n" + + with ( + patch.object(wifi_manager_module, "generate_wpa_conf", side_effect=[OSError("read-only"), None]), + patch.object(self.manager, "_remove_wpa_network"), + patch.object(self.manager, "_list_network_ids", return_value=[]), ): self.manager.forget_connection("SavedNet", block=True) + self.manager.process_callbacks() + forgotten.assert_not_called() + assert "SavedNet" in self.manager._pending_forget_reconciliations + + self.manager._retry_pending_forget_reconciliations(force=True) self.manager.process_callbacks() - remove_wpa_network.assert_called_once_with("SavedNet") forgotten.assert_called_once_with("SavedNet") + assert "SavedNet" not in self.manager._pending_forget_reconciliations + + def test_forget_defers_success_when_runtime_verification_fails(self): + forgotten = MagicMock() + self.manager.add_callbacks(forgotten=forgotten) + self.manager._store.contains.return_value = True + self.manager._store.remove.return_value = True + + with ( + patch.object(wifi_manager_module, "generate_wpa_conf"), + patch.object(self.manager, "_remove_wpa_network"), + patch.object(self.manager, "_list_network_ids", return_value=["0"]), + patch.object(self.manager, "_restart_station_supplicant", return_value=True) as restart, + ): + self.manager.forget_connection("SavedNet", block=True) + + self.manager.process_callbacks() + restart.assert_called_once() + forgotten.assert_not_called() + assert "SavedNet" in self.manager._pending_forget_reconciliations def test_forget_allows_fallback_connection_after_disconnect_event(self): self.manager._wifi_state = WifiState("TestNet", ConnectStatus.CONNECTED) @@ -1468,6 +1515,7 @@ def request(command): with ( patch.object(wifi_manager_module, "generate_wpa_conf"), patch.object(self.manager, "_remove_wpa_network"), + patch.object(self.manager, "_list_network_ids", return_value=[]), ): self.manager.forget_connection("TestNet", block=True) @@ -1481,6 +1529,7 @@ def request(command): def test_forget_during_reconnect_clears_retained_station_state(self): self.manager._store.contains.return_value = True self.manager._store.remove.return_value = True + self.manager._ctrl.request.return_value = "OK\n" self.manager._set_connecting("TestNet") self.manager._handle_connected("TestNet") complete_station_connection(self.manager, "TestNet") @@ -1491,6 +1540,7 @@ def test_forget_during_reconnect_clears_retained_station_state(self): with ( patch.object(wifi_manager_module, "generate_wpa_conf"), patch.object(self.manager, "_remove_wpa_network"), + patch.object(self.manager, "_list_network_ids", return_value=[]), ): self.manager.forget_connection("TestNet", block=True) @@ -1526,6 +1576,7 @@ def select_network(*_, **__): self.manager._store.contains.return_value = True self.manager._store.remove.side_effect = remove_saved_network + self.manager._ctrl.request.return_value = "OK\n" self.manager._set_connecting("TestNet") self.manager._handle_connected("TestNet") complete_station_connection(self.manager, "TestNet") diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 0ae654c11125a6..6b6db258740368 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -44,6 +44,7 @@ SCAN_PERIOD_SECONDS = 5 CONNECTING_STALE_TIMEOUT_SECONDS = 5 NETWORK_NOT_FOUND_EVENTS_REQUIRED = 2 +FORGET_RECONCILIATION_RETRY_SECONDS = 5 # Ignore stale WRONG_KEY events after a fast retry WRONG_KEY_DEBOUNCE_SECONDS = 2.0 @@ -102,6 +103,14 @@ class PendingConnection: network_id: str | None = None +@dataclass(frozen=True) +class PendingForgetReconciliation: + ssid: str + epoch: int + forget_active: bool + cleanup_required: bool + + def _iptables_executable() -> str: # AGNOS 18.7 requires iptables-legacy for NAT return "iptables-legacy" if shutil.which("iptables-legacy") is not None else "iptables" @@ -212,6 +221,8 @@ def __init__(self): self._last_scanning_recheck: float = 0.0 self._last_connected_recheck: float = 0.0 self._last_wrong_key_dispatch: dict[tuple[str, str | None], float] = {} + self._pending_forget_reconciliations: dict[str, PendingForgetReconciliation] = {} + self._last_forget_reconciliation_attempt = 0.0 self._callback_queue: list[Callable] = [] self._callback_lock = threading.Lock() self._state_lock = threading.RLock() @@ -1063,6 +1074,7 @@ def _handle_event(self, event: str): def _network_scanner(self): while not self._exit: + self._retry_pending_forget_reconciliations() self._reconcile_connecting_state() if self._active and not self._tethering_active: if time.monotonic() - self._last_network_scan > SCAN_PERIOD_SECONDS: @@ -1511,37 +1523,11 @@ def transition(): cloudlog.warning(f"Trying to forget unknown connection: {ssid}") with self._radio_lock: - try: - generate_wpa_conf(store) - except Exception: - cloudlog.exception(f"Failed to regenerate configuration after forgetting {ssid}") - - self._clear_pending_connection(ssid, epoch=forget_epoch) + pending = PendingForgetReconciliation(ssid, forget_epoch, forget_active, cleanup_required) with self._state_lock: - owns_epoch = self._user_epoch == forget_epoch - cleanup_epoch = None - if forget_active and owns_epoch: - self._station_cleanup_pending |= cleanup_required - self._set_connecting(None, kind=StationOperationKind.FORGET, operation_ssid=ssid) - cleanup_epoch = self._user_epoch - owns_runtime = self._prepare_connection(cleanup_epoch) if cleanup_epoch is not None else owns_epoch - - try: - if self._ctrl: - with self._state_lock: - preserve_selection = self._wifi_state.status == ConnectStatus.CONNECTING and self._wifi_state.ssid != ssid - if forget_active and owns_runtime: - self._request("DISCONNECT") - self._remove_wpa_network(ssid) - if not preserve_selection: - self._request("ENABLE_NETWORK all") - # Reassociate only when forgetting the active profile - if forget_active and owns_runtime: - self._request("REASSOCIATE") - except Exception: - cloudlog.exception(f"Failed to remove runtime connection after forgetting {ssid}") - - self._enqueue_callbacks(self._forgotten, ssid) + self._pending_forget_reconciliations[ssid] = pending + self._last_forget_reconciliation_attempt = time.monotonic() + self._finish_forget_reconciliation(pending) def worker(): with self._radio_lock: @@ -1552,6 +1538,101 @@ def worker(): else: threading.Thread(target=worker, daemon=True).start() + def _restart_station_supplicant(self) -> bool: + with self._radio_lock: + ctrl, self._ctrl = self._ctrl, None + if ctrl is not None: + try: + ctrl.close() + except Exception: + cloudlog.exception("Failed to close station control socket before restart") + self._monitor_epoch += 1 + stop_wpa_supplicant(WPA_SUPPLICANT_CONF) + self._ensure_wpa_supplicant() + return self._ctrl is not None + + def _reconcile_forgotten_runtime(self, ssid: str) -> bool: + try: + generate_wpa_conf(self._require_store()) + except Exception: + cloudlog.exception(f"Failed to regenerate configuration after forgetting {ssid}") + return False + + restart_required = self._ctrl is None + if self._ctrl is not None: + try: + self._remove_wpa_network(ssid) + except Exception: + # RECONFIGURE below can still remove the profile from the running daemon. + cloudlog.exception(f"Targeted runtime removal failed after forgetting {ssid}") + + try: + response = self._request("RECONFIGURE").strip() + if not response.startswith("OK"): + raise RuntimeError(f"RECONFIGURE failed: {response}") + except Exception: + cloudlog.exception(f"Failed to reconfigure runtime profiles after forgetting {ssid}") + restart_required = True + + if not restart_required: + try: + if not self._list_network_ids(ssid): + return True + cloudlog.warning(f"Runtime profile remained after forgetting {ssid}; restarting station daemon") + except Exception: + cloudlog.exception(f"Failed to verify runtime profile removal after forgetting {ssid}") + restart_required = True + + if restart_required: + try: + if not self._restart_station_supplicant(): + return False + if self._list_network_ids(ssid): + cloudlog.error(f"Runtime profile remained after station restart for forgotten network {ssid}") + return False + return True + except Exception: + cloudlog.exception(f"Failed to restart station runtime after forgetting {ssid}") + return False + + def _finish_forget_reconciliation(self, pending: PendingForgetReconciliation) -> bool: + with self._radio_lock: + with self._state_lock: + if self._pending_forget_reconciliations.get(pending.ssid) is not pending: + return False + if not self._reconcile_forgotten_runtime(pending.ssid): + return False + + self._clear_pending_connection(pending.ssid, epoch=pending.epoch) + with self._state_lock: + if self._pending_forget_reconciliations.get(pending.ssid) is not pending: + return False + owns_epoch = self._user_epoch == pending.epoch + cleanup_epoch = None + if pending.forget_active and owns_epoch: + self._station_cleanup_pending |= pending.cleanup_required + self._set_connecting(None, kind=StationOperationKind.FORGET, operation_ssid=pending.ssid) + cleanup_epoch = self._user_epoch + del self._pending_forget_reconciliations[pending.ssid] + + if cleanup_epoch is not None: + self._prepare_connection(cleanup_epoch) + self._enqueue_callbacks(self._forgotten, pending.ssid) + return True + + def _retry_pending_forget_reconciliations(self, force: bool = False): + now = time.monotonic() + with self._state_lock: + if self._tethering_active or self._tethering_transition_pending or not self._pending_forget_reconciliations: + return + if not force and now - self._last_forget_reconciliation_attempt < FORGET_RECONCILIATION_RETRY_SECONDS: + return + self._last_forget_reconciliation_attempt = now + pending = list(self._pending_forget_reconciliations.values()) + + for operation in pending: + self._finish_forget_reconciliation(operation) + def activate_connection(self, ssid: str, block: bool = False): if self._tethering_active: cloudlog.warning(f"Ignoring activate {ssid!r} while tethering is active") From dcafb2be54b096b9c00351777d7ed48c5aba0467 Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 16:55:44 +0000 Subject: [PATCH 70/71] wifi: prove netplan profile ownership --- .../system/ui/lib/tests/test_network_store.py | 90 ++++++++++++++++--- openpilot/system/ui/lib/wifi_network_store.py | 75 ++++++++++++++-- 2 files changed, 147 insertions(+), 18 deletions(-) diff --git a/openpilot/system/ui/lib/tests/test_network_store.py b/openpilot/system/ui/lib/tests/test_network_store.py index 8e3967bbb13c78..6229468b8c8861 100644 --- a/openpilot/system/ui/lib/tests/test_network_store.py +++ b/openpilot/system/ui/lib/tests/test_network_store.py @@ -61,6 +61,20 @@ def require_entry(store: NetworkStore, ssid: str) -> dict: return entry +def write_netplan_profile(path: Path, file_uuid: str): + path.write_text(f"""\ +network: + version: 2 + wifis: + NM-{file_uuid}: + renderer: NetworkManager + match: {{}} + networkmanager: + uuid: "{file_uuid}" + name: "Test" +""") + + class TestNetworkStore(TestCase): def setUp(self): self.root = tempfile.mkdtemp() @@ -567,7 +581,7 @@ def test_tethering_update_collapses_runtime_shadow_by_uuid(self): self.runtime, "runtime-hotspot.nmconnection", "weedle", file_uuid=shared_uuid, psk="old-password", mode="ap", )) netplan_path = Path(self.netplan, f"90-NM-{profile_uuid(shared_uuid)}.yaml") - netplan_path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {profile_uuid(shared_uuid)}\n") + write_netplan_profile(netplan_path, profile_uuid(shared_uuid)) with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): store = self.make_store() @@ -611,7 +625,7 @@ def test_persists_runtime_tethering_profile_for_rollback(self): )) hotspot_uuid = profile_uuid("hotspot-uuid") netplan_path = Path(self.netplan, f"90-NM-{hotspot_uuid}.yaml") - netplan_path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {hotspot_uuid}\n") + write_netplan_profile(netplan_path, hotspot_uuid) with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): store = self.make_store() @@ -648,7 +662,7 @@ def test_edit_persistent_profile_removes_shadowed_runtime_copy(self): runtime_path = Path(write_profile(self.runtime, "runtime.nmconnection", "Duplicate", file_uuid="shared-uuid", psk="runtime-password")) shared_uuid = profile_uuid("shared-uuid") netplan_path = Path(self.netplan, f"90-NM-{shared_uuid}.yaml") - netplan_path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {shared_uuid}\n") + write_netplan_profile(netplan_path, shared_uuid) with ( self.patch_reads(), @@ -668,7 +682,7 @@ def test_edit_persistent_profile_preserves_runtime_profile_with_different_uuid(s runtime_path = Path(write_profile(self.runtime, "runtime.nmconnection", "Duplicate", file_uuid="runtime-uuid", psk="runtime")) runtime_uuid = profile_uuid("runtime-uuid") netplan_path = Path(self.netplan, f"90-NM-{runtime_uuid}.yaml") - netplan_path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {runtime_uuid}\n") + write_netplan_profile(netplan_path, runtime_uuid) with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): store = self.make_store() @@ -870,7 +884,7 @@ def test_forget_runtime_profile_removes_netplan_source(self): write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") runtime_uuid = profile_uuid("runtime-uuid") netplan_path = Path(self.netplan, f"90-NM-{runtime_uuid}.yaml") - netplan_path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {runtime_uuid}\n") + write_netplan_profile(netplan_path, runtime_uuid) with ( self.patch_reads(), @@ -909,7 +923,7 @@ def test_forget_runtime_profile_without_netplan_source(self): def test_forget_finds_renamed_netplan_source_by_uuid(self): write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") netplan_path = Path(self.netplan, "provisioned-wifi.yaml") - netplan_path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {profile_uuid('runtime-uuid')}\n") + write_netplan_profile(netplan_path, profile_uuid("runtime-uuid")) with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): store = self.make_store() @@ -940,6 +954,32 @@ def test_forget_refuses_shared_renamed_netplan_source(self): assert netplan_path.exists() assert store.contains("Runtime") + def test_forget_refuses_uuidless_sibling_in_renamed_netplan_source(self): + runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) + runtime_uuid = profile_uuid("runtime-uuid") + netplan_path = Path(self.netplan, "provisioned-wifi.yaml") + original_netplan = f"""\ +network: + version: 2 + wifis: + NM-{runtime_uuid}: + renderer: NetworkManager + networkmanager: + uuid: {runtime_uuid} + unrelated: + dhcp4: true +""" + netplan_path.write_text(original_netplan) + original_runtime = runtime_path.read_text() + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + assert not store.remove("Runtime") + + assert runtime_path.read_text() == original_runtime + assert netplan_path.read_text() == original_netplan + assert store.contains("Runtime") + def test_forget_refuses_shared_canonical_netplan_source(self): runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) runtime_uuid = profile_uuid("runtime-uuid") @@ -992,6 +1032,36 @@ def test_profile_updates_refuse_shared_netplan_source(self): assert require_entry(store, "Runtime")["metered"] == 0 assert require_entry(store, "Runtime")["psk"] == "password123" + def test_profile_updates_refuse_uuidless_sibling_in_canonical_netplan_source(self): + runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) + runtime_uuid = profile_uuid("runtime-uuid") + netplan_path = Path(self.netplan, f"90-NM-{runtime_uuid}.yaml") + original_netplan = f"""\ +network: + version: 2 + wifis: + NM-{runtime_uuid}: + renderer: NetworkManager + networkmanager: + uuid: {runtime_uuid} + unrelated: + dhcp4: true +""" + netplan_path.write_text(original_netplan) + original_runtime = runtime_path.read_text() + + with self.patch_reads(), patch.object(store_module.subprocess, "run", side_effect=self.run_file_command): + store = self.make_store() + with self.assertRaises(OSError): + store.set_metered("Runtime", 1) + with self.assertRaises(OSError): + store.save_network("Runtime", psk="replacement-password") + + assert runtime_path.read_text() == original_runtime + assert netplan_path.read_text() == original_netplan + assert require_entry(store, "Runtime")["metered"] == 0 + assert require_entry(store, "Runtime")["psk"] == "password123" + def test_forget_ignores_unrelated_netplan_source(self): runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) netplan_path = Path(self.netplan, "provisioned-wifi.yaml") @@ -1031,7 +1101,7 @@ def test_forget_duplicate_runtime_profiles_removes_every_netplan_source(self): } for path in netplan_paths: source_uuid = path.stem.removeprefix("90-NM-") - path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {source_uuid}\n") + write_netplan_profile(path, source_uuid) with ( self.patch_reads(), @@ -1139,7 +1209,7 @@ def test_edit_runtime_profile_installs_keyfile_before_removing_netplan(self): write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid") runtime_uuid = profile_uuid("runtime-uuid") netplan_path = Path(self.netplan, f"90-NM-{runtime_uuid}.yaml") - netplan_path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {runtime_uuid}\n") + write_netplan_profile(netplan_path, runtime_uuid) with ( self.patch_reads(), @@ -1165,7 +1235,7 @@ def test_edit_runtime_profile_rolls_back_when_runtime_remove_fails(self): runtime_path = Path(write_profile(self.runtime, "netplan.nmconnection", "Runtime", file_uuid="runtime-uuid")) runtime_uuid = profile_uuid("runtime-uuid") netplan_path = Path(self.netplan, f"90-NM-{runtime_uuid}.yaml") - netplan_path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {runtime_uuid}\n") + write_netplan_profile(netplan_path, runtime_uuid) def run(command, **_): return MagicMock(returncode=1 if command[-1] == str(runtime_path) else 0) @@ -1247,7 +1317,7 @@ def test_edit_runtime_profile_restores_sources_when_netplan_remove_fails(self): original_runtime = runtime_path.read_text() runtime_uuid = profile_uuid("runtime-uuid") netplan_path = Path(self.netplan, f"90-NM-{runtime_uuid}.yaml") - netplan_path.write_text(f"network:\n version: 2\n networkmanager:\n uuid: {runtime_uuid}\n") + write_netplan_profile(netplan_path, runtime_uuid) original_netplan = netplan_path.read_text() def run(command, **kwargs): diff --git a/openpilot/system/ui/lib/wifi_network_store.py b/openpilot/system/ui/lib/wifi_network_store.py index 36e4f594c10cac..c8b915dc6cee76 100644 --- a/openpilot/system/ui/lib/wifi_network_store.py +++ b/openpilot/system/ui/lib/wifi_network_store.py @@ -38,6 +38,7 @@ _UPDATE_COMMIT_MARKER_RE = re.compile(r"^\.openpilot-update-committed-(?P[0-9a-f]{32})$") _FORGET_REMNANT_RE = re.compile(r"^(?P.+)\.openpilot-forget-(?P[0-9a-f]{32})$") _FORGET_COMMIT_MARKER_RE = re.compile(r"^\.openpilot-forget-committed-(?P[0-9a-f]{32})$") +_NETPLAN_UUID_RE = re.compile(r"^\s*uuid\s*:\s*['\"]?([^'\"\s#]+)['\"]?\s*(?:#.*)?$", re.MULTILINE) class MeteredType(IntEnum): @@ -67,6 +68,56 @@ def _parse_uuid(value: str) -> str | None: return None +def _single_profile_netplan_uuid(raw: str) -> str | None: + """Return the UUID only when the YAML has one AGNOS-generated Wi-Fi profile. + + This intentionally recognizes a narrow structural subset instead of trying to + mutate arbitrary Netplan YAML. Any aliases, flow mappings, sibling profiles, + additional top-level sections, or malformed indentation fail closed. + """ + if "\t" in raw: + return None + + lines: list[tuple[int, str]] = [] + for line in raw.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + indent = len(line) - len(line.lstrip(" ")) + if indent % 2: + return None + lines.append((indent, line.strip())) + + if [text for indent, text in lines if indent == 0] != ["network:"]: + return None + level_two = [text for indent, text in lines if indent == 2] + if level_two.count("version: 2") != 1 or level_two.count("wifis:") != 1 or len(level_two) != 2: + return None + + wifis_index = lines.index((2, "wifis:")) + wifis_end = next((i for i in range(wifis_index + 1, len(lines)) if lines[i][0] <= 2), len(lines)) + profiles = [(i, text) for i, (indent, text) in enumerate(lines[wifis_index + 1:wifis_end], wifis_index + 1) + if indent == 4] + if len(profiles) != 1: + return None + profile_index, profile_line = profiles[0] + profile_match = re.fullmatch(r"NM-([0-9A-Fa-f-]+):", profile_line) + if profile_match is None or (profile_uuid := _parse_uuid(profile_match.group(1))) is None: + return None + + profile_end = next((i for i in range(profile_index + 1, wifis_end) if lines[i][0] <= 4), wifis_end) + networkmanager = [i for i in range(profile_index + 1, profile_end) + if lines[i] == (6, "networkmanager:")] + if len(networkmanager) != 1: + return None + nm_index = networkmanager[0] + nm_end = next((i for i in range(nm_index + 1, profile_end) if lines[i][0] <= 6), profile_end) + direct_uuids = [match.group(1) for i in range(nm_index + 1, nm_end) + if lines[i][0] == 8 and (match := _NETPLAN_UUID_RE.fullmatch(lines[i][1])) is not None] + if len(direct_uuids) != 1 or _parse_uuid(direct_uuids[0]) != profile_uuid: + return None + return profile_uuid + + def _encode_keyfile_string(value: str) -> str: """Encode GLib keyfile string escapes, including boundary spaces.""" leading_spaces = len(value) - len(value.lstrip(" ")) @@ -226,10 +277,10 @@ def _find_netplan_source(self, file_uuid: str) -> NetplanSource | None: filenames = sorted(os.listdir(self._netplan_directory)) except OSError: return None - pattern = re.compile(r"^\s*uuid\s*:\s*['\"]?([^'\"\s#]+)['\"]?\s*(?:#.*)?$", re.MULTILINE) yaml_filenames = [fname for fname in filenames if fname.endswith(".yaml")] read_failed = False - matches: list[tuple[str, set[str | None]]] = [] + exclusive_matches: list[str] = [] + ambiguous_matches: list[str] = [] for fname in yaml_filenames: path = os.path.join(self._netplan_directory, fname) try: @@ -240,13 +291,21 @@ def _find_netplan_source(self, file_uuid: str) -> NetplanSource | None: if not raw: read_failed = True continue - source_uuids = {_parse_uuid(value) for value in pattern.findall(raw)} + source_uuids = {_parse_uuid(value) for value in _NETPLAN_UUID_RE.findall(raw)} if file_uuid in source_uuids: - matches.append((path, source_uuids)) - - if matches: - path, source_uuids = matches[0] - return NetplanSource(path, not read_failed and len(matches) == 1 and source_uuids == {file_uuid}) + exclusive_uuid = _single_profile_netplan_uuid(raw) + if exclusive_uuid == file_uuid and source_uuids == {file_uuid}: + exclusive_matches.append(path) + else: + ambiguous_matches.append(path) + + if exclusive_matches: + return NetplanSource( + exclusive_matches[0], + not read_failed and len(exclusive_matches) == 1 and not ambiguous_matches, + ) + if ambiguous_matches: + return NetplanSource(ambiguous_matches[0], False) if os.path.exists(expected_path) or read_failed: # A canonical filename or unreadable YAML is not proof of exclusive ownership. return NetplanSource(expected_path, False) From 9f0356c2a5ffbde9494e7f66911a43e16d14a4ce Mon Sep 17 00:00:00 2001 From: Andrei Radulescu Date: Sat, 22 Aug 2026 16:58:18 +0000 Subject: [PATCH 71/71] common: decode keyfile ssids losslessly --- openpilot/common/nm_keyfile.py | 38 +++++++++++++---------- openpilot/common/tests/test_nm_keyfile.py | 33 ++++++++++++++++++++ 2 files changed, 55 insertions(+), 16 deletions(-) create mode 100644 openpilot/common/tests/test_nm_keyfile.py diff --git a/openpilot/common/nm_keyfile.py b/openpilot/common/nm_keyfile.py index 3210e2b9dc079f..6415473f7d6789 100644 --- a/openpilot/common/nm_keyfile.py +++ b/openpilot/common/nm_keyfile.py @@ -7,12 +7,14 @@ } -def decode_nm_keyfile_string(value: str) -> str: +def _decode_nm_keyfile_string(value: str, *, semicolon_escape: bool) -> str: decoded = [] i = 0 while i < len(value): if value[i] == "\\" and i + 1 < len(value): escaped = NM_KEYFILE_ESCAPES.get(value[i + 1]) + if escaped is None and semicolon_escape and value[i + 1] == ";": + escaped = ";" if escaped is not None: decoded.append(escaped) i += 2 @@ -22,20 +24,24 @@ def decode_nm_keyfile_string(value: str) -> str: return "".join(decoded) -def decode_nm_keyfile_ssid(ssid: str) -> str: - ssid = decode_nm_keyfile_string(ssid) - if r"\;" in ssid: - return ssid.replace(r"\;", ";") - if not ssid.endswith(";"): - return ssid +def decode_nm_keyfile_string(value: str) -> str: + return _decode_nm_keyfile_string(value, semicolon_escape=False) - try: - ssid_bytes = bytes(int(p) for p in ssid[:-1].split(";")) - except ValueError: - return ssid - if not ssid_bytes: - return ssid - if all(b == 0 for b in ssid_bytes): - return "" - return ssid_bytes.decode("utf-8", errors="surrogateescape") +def decode_nm_keyfile_ssid(ssid: str) -> str: + # Netplan uses a semicolon-terminated decimal byte list for SSIDs that do not + # round-trip as a plain keyfile string. Detect that representation before + # interpreting any keyfile escapes. + if ssid.endswith(";"): + parts = ssid[:-1].split(";") + if parts and all(part.isascii() and part.isdigit() for part in parts): + try: + ssid_bytes = bytes(int(part) for part in parts) + except ValueError: + pass + else: + if all(byte == 0 for byte in ssid_bytes): + return "" + return ssid_bytes.decode("utf-8", errors="surrogateescape") + + return _decode_nm_keyfile_string(ssid, semicolon_escape=True) diff --git a/openpilot/common/tests/test_nm_keyfile.py b/openpilot/common/tests/test_nm_keyfile.py new file mode 100644 index 00000000000000..2c82e953721550 --- /dev/null +++ b/openpilot/common/tests/test_nm_keyfile.py @@ -0,0 +1,33 @@ +import unittest + +from openpilot.common.nm_keyfile import decode_nm_keyfile_ssid + + +class TestNmKeyfile(unittest.TestCase): + def test_decodes_escaped_semicolon(self): + self.assertEqual(decode_nm_keyfile_ssid(r"Cafe\;Guest"), "Cafe;Guest") + + def test_decodes_escaped_backslash(self): + self.assertEqual(decode_nm_keyfile_ssid(r"Cafe\\Guest"), r"Cafe\Guest") + + def test_preserves_literal_backslash_before_semicolon(self): + self.assertEqual(decode_nm_keyfile_ssid(r"Cafe\\;Guest"), r"Cafe\;Guest") + + def test_decodes_boundary_spaces(self): + self.assertEqual(decode_nm_keyfile_ssid(r"\sCafe\s"), " Cafe ") + + def test_decodes_decimal_byte_list(self): + self.assertEqual(decode_nm_keyfile_ssid("65;66;67;"), "ABC") + + def test_preserves_non_utf8_decimal_byte_list(self): + self.assertEqual( + decode_nm_keyfile_ssid("255;65;"), + b"\xffA".decode("utf-8", errors="surrogateescape"), + ) + + def test_preserves_unknown_escape(self): + self.assertEqual(decode_nm_keyfile_ssid(r"Cafe\q"), r"Cafe\q") + + +if __name__ == "__main__": + unittest.main()