298 lines
12 KiB
Python
298 lines
12 KiB
Python
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/",
|
|
}
|