366 lines
15 KiB
Python
366 lines
15 KiB
Python
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/",
|
|
}
|