from __future__ import annotations import subprocess import threading import time import re from copy import deepcopy from dataclasses import dataclass from typing import Any, Protocol, Sequence from uuid import uuid4 class NetworkManagerError(RuntimeError): """A sanitized NetworkManager operation failure.""" def __init__(self, message: str, *, error_code: str = "UNKNOWN"): super().__init__(message) self.error_code = error_code class NetworkBackend(Protocol): def scan(self) -> list[dict[str, Any]]: ... def discover_connection_uuid(self) -> str | None: ... def read_saved(self, connection_uuid: str, *, include_secret: bool = False) -> dict[str, Any]: ... def read_active(self) -> dict[str, Any]: ... def create_connection(self, settings: dict[str, Any]) -> str: ... def save_connection(self, connection_uuid: str, settings: dict[str, Any]) -> None: ... def activate(self, connection_uuid: str, *, timeout: int = 5) -> None: ... def _decode_terse(value: str) -> str: output: list[str] = [] escaped = False for character in value: if escaped: output.append(character) escaped = False elif character == "\\": escaped = True else: output.append(character) if escaped: output.append("\\") return "".join(output) @dataclass class NmcliNetworkManager: executable: str = "/usr/bin/nmcli" interface: str = "wlan0" def scan(self) -> list[dict[str, Any]]: rows = self._run(['--terse', '--escape', 'yes', '--fields', 'IN-USE,SSID,SECURITY,SIGNAL', 'device', 'wifi', 'list', 'ifname', self.interface, '--rescan', 'yes'], timeout=15) strongest = {} for row in rows.splitlines(): fields, current, escaped = [], [], False for character in row: if escaped: current.append(character) escaped = False elif character == '\\': escaped = True elif character == ':': fields.append(''.join(current)) current = [] else: current.append(character) fields.append(''.join(current)) if len(fields) != 4 or not fields[1]: continue active, ssid, security, signal = fields if not signal.isdecimal(): continue kind = 'open' if security in ('', '--') else ( 'wpa-psk' if ('WPA1' in security or 'WPA2' in security) and '802.1X' not in security else 'unsupported') item = dict(ssid=ssid, security=kind, signal_percent=min(100, max(0, int(signal))), connected=active == '*') key = (ssid, kind) if key not in strongest or item['signal_percent'] > strongest[key]['signal_percent']: item['connected'] = item['connected'] or strongest.get(key, {}).get('connected', False) strongest[key] = item elif item['connected']: strongest[key]['connected'] = True return sorted(strongest.values(), key=lambda item: -item['signal_percent'])[:100] def _run( self, arguments: Sequence[str], *, timeout: int = 10, ) -> str: try: result = subprocess.run( [self.executable, *arguments], check=True, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, ) return result.stdout.strip() except FileNotFoundError as exc: raise NetworkManagerError("NetworkManager nmcli is unavailable") from exc except subprocess.TimeoutExpired as exc: raise NetworkManagerError("NetworkManager operation timed out") from exc except subprocess.CalledProcessError as exc: # stderr may echo a secret on some nmcli failures, so never include it. raise NetworkManagerError("NetworkManager rejected the requested operation") from exc def _connection_value( self, connection_uuid: str, field: str, *, include_secret: bool = False, ) -> str: arguments = [] if include_secret: arguments.append("--show-secrets") arguments.extend(["--get-values", field, "connection", "show", "uuid", connection_uuid]) return self._run(arguments) def discover_connection_uuid(self) -> str | None: active = self._run([ "--get-values", "GENERAL.CON-UUID", "device", "show", self.interface, ]) if active and active != "--": return active.splitlines()[0].strip() rows = self._run([ "--terse", "--fields", "UUID,TYPE,AUTOCONNECT", "connection", "show", ]) candidates = [] for row in rows.splitlines(): parts = row.split(":") if len(parts) == 3 and parts[1] == "802-11-wireless" and parts[2] == "yes": candidates.append(parts[0]) return candidates[0] if len(candidates) == 1 else None def read_saved(self, connection_uuid: str, *, include_secret: bool = False) -> dict[str, Any]: ssid = self._connection_value(connection_uuid, "802-11-wireless.ssid") method = self._connection_value(connection_uuid, "ipv4.method") or "auto" raw_addresses = self._connection_value(connection_uuid, "ipv4.addresses") address = raw_addresses.splitlines()[0].strip() if raw_addresses else "" raw_dns = self._connection_value(connection_uuid, "ipv4.dns") password = None password_configured = False key_mgmt = self._connection_value(connection_uuid, "802-11-wireless-security.key-mgmt") if include_secret: password = self._connection_value( connection_uuid, "802-11-wireless-security.psk", include_secret=True, ) password_configured = bool(password and password != "--") else: password_configured = bool(key_mgmt and key_mgmt != "--") prefix = None plain_address = address if "/" in address: plain_address, raw_prefix = address.rsplit("/", 1) try: prefix = int(raw_prefix) except ValueError: prefix = None return { "connection_uuid": connection_uuid, "ssid": ssid, "security": "open" if key_mgmt in ("", "--") else "wpa-psk" if key_mgmt == "wpa-psk" else "unsupported", "password": password if password_configured else None, "password_configured": password_configured, "ipv4_mode": "dhcp" if method == "auto" else "manual", "address": plain_address or None, "prefix": prefix, "gateway": self._connection_value(connection_uuid, "ipv4.gateway") or None, "dns_servers": list(dict.fromkeys( item.strip() for line in raw_dns.splitlines() for item in line.split(",") if item.strip() and item.strip() != "--" )), } def read_active(self) -> dict[str, Any]: raw_state = self._run([ "--get-values", "GENERAL.STATE", "device", "show", self.interface, ]) connected = raw_state.startswith("100") connection_uuid = self._run([ "--get-values", "GENERAL.CON-UUID", "device", "show", self.interface, ]) or None addresses = [ item.strip() for item in self._run([ "--get-values", "IP4.ADDRESS", "device", "show", self.interface, ]).splitlines() if item.strip() ] if connected else [] ssid = None if connected: rows = self._run([ "--terse", "--fields", "IN-USE,SSID", "device", "wifi", "list", "ifname", self.interface, ]) for row in rows.splitlines(): if row.startswith("*:"): ssid = _decode_terse(row[2:]) break ipv4 = addresses[0].split("/", 1)[0] if addresses else None return { "connected": bool(connected and ipv4), "connection_uuid": connection_uuid, "ssid": ssid, "ipv4_address": ipv4, "control_url": f"http://{ipv4}:8080/" if ipv4 else None, } @staticmethod def _modify_arguments(settings: dict[str, Any]) -> list[str]: arguments = [ "802-11-wireless.ssid", settings["ssid"], ] if settings.get('security', 'wpa-psk') != 'open': arguments.extend(['802-11-wireless-security.key-mgmt', 'wpa-psk']) if settings.get("password") is not None and settings.get('security') != 'open': arguments.extend(["802-11-wireless-security.psk", settings["password"]]) if settings["ipv4_mode"] == "dhcp": arguments.extend([ "ipv4.method", "auto", "ipv4.addresses", "", "ipv4.gateway", "", "ipv4.dns", "", ]) else: dns = settings["dns_servers"] or [settings["gateway"]] arguments.extend([ "ipv4.method", "manual", "ipv4.addresses", f"{settings['address']}/{settings['prefix']}", "ipv4.gateway", settings["gateway"], "ipv4.dns", ",".join(dns), ]) return arguments def create_connection(self, settings: dict[str, Any]) -> str: name = "matrix-screen-controller-wifi" self._run([ "connection", "add", "type", "wifi", "ifname", self.interface, "con-name", name, "ssid", settings["ssid"], ]) connection_uuid = self._run([ "--get-values", "connection.uuid", "connection", "show", "id", name, ]) self.save_connection(connection_uuid, settings) return connection_uuid def save_connection(self, connection_uuid: str, settings: dict[str, Any]) -> None: if settings.get('security') == 'open': self._run(['connection', 'modify', 'uuid', connection_uuid, 'remove', '802-11-wireless-security']) self._run([ "connection", "modify", "uuid", connection_uuid, *self._modify_arguments(settings), ]) def activate(self, connection_uuid: str, *, timeout: int = 5) -> None: bounded = max(1, min(int(timeout), 30)) started = int(time.time()) try: self._run([ "--wait", str(bounded), "connection", "up", "uuid", connection_uuid, ], timeout=bounded + 2) except NetworkManagerError: code = "UNKNOWN" try: reason = self._run(["--get-values", "GENERAL.REASON", "device", "show", self.interface]) number = int(reason.split(" ", 1)[0]) if number == 53: code = "NETWORK_UNAVAILABLE" elif number in (5, 6, 15, 16, 17): code = "IP_CONFIG_FAILED" # A timeout or missing-secret request alone is not proof of a wrong password. # Only the supplicant's explicit WRONG_KEY event from this attempt qualifies. if code == "UNKNOWN": result = subprocess.run(["journalctl", "-u", "wpa_supplicant.service", "--since", f"@{started}", "--output=cat", "--no-pager"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=3) pattern = re.compile(r"^" + re.escape(self.interface) + r": CTRL-EVENT-SSID-TEMP-DISABLED .*\breason=WRONG_KEY\b", re.MULTILINE) if result.returncode == 0 and pattern.search(result.stdout): code = "AUTH_FAILED" except (NetworkManagerError, ValueError, OSError, subprocess.SubprocessError): pass raise NetworkManagerError("WiFi activation failed", error_code=code) from None class MockNetworkManager: def scan(self): return [] def __init__( self, saved: dict[str, Any] | None = None, active: dict[str, Any] | None = None, ) -> None: self._lock = threading.RLock() self.saved = deepcopy(saved) self.active = deepcopy(active) if active is not None else { "connected": False, "connection_uuid": None, "ssid": None, "ipv4_address": None, "control_url": None, } self.activations: list[str] = [] self.fail_save = False self.fail_activate = False def discover_connection_uuid(self) -> str | None: with self._lock: if self.active.get("connection_uuid"): return self.active["connection_uuid"] return self.saved.get("connection_uuid") if self.saved else None def read_saved(self, connection_uuid: str, *, include_secret: bool = False) -> dict[str, Any]: with self._lock: if not self.saved or self.saved.get("connection_uuid") != connection_uuid: raise NetworkManagerError("managed WiFi connection is unavailable") value = deepcopy(self.saved) if not include_secret: value["password"] = None return value def read_active(self) -> dict[str, Any]: with self._lock: return deepcopy(self.active) def create_connection(self, settings: dict[str, Any]) -> str: connection_uuid = str(uuid4()) with self._lock: self.saved = {"connection_uuid": connection_uuid, **deepcopy(settings)} self.saved["password_configured"] = bool(settings.get("password")) return connection_uuid def save_connection(self, connection_uuid: str, settings: dict[str, Any]) -> None: if self.fail_save: raise NetworkManagerError("NetworkManager rejected the requested operation") with self._lock: previous_password = self.saved.get("password") if self.saved else None self.saved = {"connection_uuid": connection_uuid, **deepcopy(settings)} if settings.get("password") is None and settings.get('security') != 'open': self.saved["password"] = previous_password self.saved["password_configured"] = bool(self.saved.get("password")) def activate(self, connection_uuid: str, *, timeout: int = 5) -> None: del timeout if self.fail_activate: raise NetworkManagerError("NetworkManager rejected the requested operation") with self._lock: self.activations.append(connection_uuid) if self.saved and self.saved.get("connection_uuid") == connection_uuid: existing_address = None if ( self.saved.get("ipv4_mode") == "dhcp" and self.active.get("connected") and self.active.get("connection_uuid") == connection_uuid ): existing_address = self.active.get("ipv4_address") address = self.saved.get("address") or existing_address or "192.168.1.100" self.active = { "connected": True, "connection_uuid": connection_uuid, "ssid": self.saved.get("ssid"), "ipv4_address": address, "control_url": f"http://{address}:8080/", }