Files
matrix-screen-controller/核桃派软件源代码/app/network/manager.py
T

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/",
}