from __future__ import annotations import subprocess import threading 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.""" class NetworkBackend(Protocol): 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 _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 if include_secret: password = self._connection_value( connection_uuid, "802-11-wireless-security.psk", include_secret=True, ) password_configured = bool(password and password != "--") else: key_mgmt = self._connection_value( connection_uuid, "802-11-wireless-security.key-mgmt", ) 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, "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": [item.strip() for item in raw_dns.splitlines() if 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"], "802-11-wireless-security.key-mgmt", "wpa-psk", ] if settings.get("password") is not None: 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: 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)) self._run([ "--wait", str(bounded), "connection", "up", "uuid", connection_uuid, ], timeout=bounded + 2) class MockNetworkManager: 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: 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/", }