manager.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. """
  2. WiFi management via NetworkManager (nmcli).
  3. Handles scanning, connecting, and managing WiFi connections.
  4. In Docker, nmcli communicates with the host's NetworkManager over the
  5. mounted D-Bus system socket — same mechanism systemctl uses for shutdown.
  6. """
  7. import subprocess
  8. import os
  9. import logging
  10. import asyncio
  11. logger = logging.getLogger(__name__)
  12. HOST_HOSTNAME_FILE = "/etc/host-hostname"
  13. HOTSPOT_CON_NAME = "DuneWeaver-Hotspot"
  14. def is_docker() -> bool:
  15. """Check if running inside a Docker container."""
  16. return os.path.exists("/.dockerenv") or os.getenv("DOCKER_CONTAINER") == "1"
  17. def run_nmcli(*args: str, timeout: int = 30) -> str:
  18. """Run nmcli and return stdout.
  19. In both Docker and venv modes, nmcli runs directly. In Docker, it
  20. communicates with the host's NetworkManager via the mounted D-Bus
  21. system bus socket (/var/run/dbus/system_bus_socket).
  22. """
  23. cmd = ["nmcli"] + list(args)
  24. logger.debug(f"Running nmcli: {' '.join(cmd)}")
  25. result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
  26. if result.returncode != 0:
  27. logger.warning(f"nmcli failed (rc={result.returncode}): {' '.join(cmd)}")
  28. if result.stderr:
  29. logger.warning(f" stderr: {result.stderr.strip()}")
  30. return result.stdout
  31. def run_nmcli_check(*args: str, timeout: int = 30) -> subprocess.CompletedProcess:
  32. """Run nmcli and return the full CompletedProcess (for checking returncode)."""
  33. cmd = ["nmcli"] + list(args)
  34. logger.debug(f"Running nmcli: {' '.join(cmd)}")
  35. result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
  36. if result.returncode != 0:
  37. logger.warning(f"nmcli failed (rc={result.returncode}): {' '.join(cmd)}")
  38. if result.stderr:
  39. logger.warning(f" stderr: {result.stderr.strip()}")
  40. return result
  41. def get_wifi_mode() -> str:
  42. """Detect WiFi mode by querying NetworkManager state.
  43. Checks which connection is active on wlan0 — if it's the hotspot
  44. profile, we're in hotspot mode; if it's another wifi connection,
  45. we're in client mode.
  46. """
  47. try:
  48. output = run_nmcli("-t", "-f", "GENERAL.CONNECTION", "dev", "show", "wlan0")
  49. for line in output.strip().splitlines():
  50. if "GENERAL.CONNECTION" in line:
  51. con_name = line.split(":", 1)[1] if ":" in line else ""
  52. if con_name == HOTSPOT_CON_NAME:
  53. return "hotspot"
  54. elif con_name and con_name != "--":
  55. return "client"
  56. return "unknown"
  57. except (subprocess.TimeoutExpired, Exception) as e:
  58. logger.debug(f"Error detecting WiFi mode: {e}")
  59. return "unknown"
  60. def get_current_ssid() -> str:
  61. """Get the SSID of the currently connected WiFi network."""
  62. try:
  63. output = run_nmcli("-t", "-f", "GENERAL.CONNECTION", "dev", "show", "wlan0")
  64. for line in output.strip().splitlines():
  65. if "GENERAL.CONNECTION" in line:
  66. ssid = line.split(":", 1)[1] if ":" in line else ""
  67. if ssid and ssid != "--" and ssid != HOTSPOT_CON_NAME:
  68. return ssid
  69. except (subprocess.TimeoutExpired, Exception) as e:
  70. logger.debug(f"Error getting SSID: {e}")
  71. return ""
  72. def get_current_ip() -> str:
  73. """Get the current IP address of the wlan0 interface."""
  74. try:
  75. output = run_nmcli("-t", "-f", "IP4.ADDRESS", "dev", "show", "wlan0")
  76. for line in output.strip().splitlines():
  77. if "IP4.ADDRESS" in line:
  78. addr = line.split(":", 1)[1] if ":" in line else ""
  79. if addr:
  80. return addr.split("/")[0]
  81. except (subprocess.TimeoutExpired, Exception) as e:
  82. logger.debug(f"Error getting IP: {e}")
  83. return ""
  84. def get_hostname() -> str:
  85. """Get the host system hostname.
  86. In Docker, reads from the mounted /etc/host-hostname file to get the
  87. real host identity instead of the container ID.
  88. """
  89. try:
  90. if is_docker() and os.path.exists(HOST_HOSTNAME_FILE):
  91. with open(HOST_HOSTNAME_FILE, "r") as f:
  92. name = f.read().strip()
  93. if name:
  94. return name
  95. # Fallback: use nmcli to query NM's hostname
  96. output = run_nmcli("general", "hostname")
  97. name = output.strip()
  98. if name:
  99. return name
  100. except Exception:
  101. pass
  102. return "duneweaver"
  103. def get_wifi_status() -> dict:
  104. """Get comprehensive WiFi status."""
  105. mode = get_wifi_mode()
  106. ssid = get_current_ssid()
  107. ip = get_current_ip()
  108. hostname = get_hostname()
  109. return {
  110. "mode": mode,
  111. "ssid": ssid,
  112. "ip": ip,
  113. "hostname": hostname,
  114. }
  115. def scan_networks() -> list[dict]:
  116. """Scan for available WiFi networks."""
  117. try:
  118. # Trigger rescan
  119. run_nmcli("dev", "wifi", "rescan", "ifname", "wlan0")
  120. except Exception:
  121. pass
  122. # Brief wait for scan results
  123. import time
  124. time.sleep(2)
  125. try:
  126. output = run_nmcli("-t", "-f", "SSID,SIGNAL,SECURITY,ACTIVE", "dev", "wifi", "list", "ifname", "wlan0")
  127. except subprocess.TimeoutExpired:
  128. logger.error("WiFi scan timed out")
  129. return []
  130. # Get saved connections for cross-reference
  131. saved = get_saved_connections()
  132. saved_ssids = {c["ssid"] for c in saved}
  133. networks = []
  134. seen_ssids = set()
  135. for line in output.strip().splitlines():
  136. if not line.strip():
  137. continue
  138. # nmcli -t uses : as delimiter, but SSID can contain colons
  139. # Format: SSID:SIGNAL:SECURITY:ACTIVE
  140. # Parse from the right since SSID is the only field that can contain ':'
  141. parts = line.rsplit(":", 3)
  142. if len(parts) < 4:
  143. continue
  144. ssid = parts[0].strip()
  145. if not ssid or ssid in seen_ssids:
  146. continue
  147. seen_ssids.add(ssid)
  148. try:
  149. signal = int(parts[1])
  150. except (ValueError, IndexError):
  151. signal = 0
  152. security = parts[2] if len(parts) > 2 else ""
  153. active = parts[3].strip().lower() == "yes" if len(parts) > 3 else False
  154. networks.append({
  155. "ssid": ssid,
  156. "signal": signal,
  157. "security": security if security and security != "--" else "Open",
  158. "saved": ssid in saved_ssids,
  159. "active": active,
  160. })
  161. # Sort by signal strength (strongest first)
  162. networks.sort(key=lambda n: n["signal"], reverse=True)
  163. return networks
  164. def get_saved_connections() -> list[dict]:
  165. """Get list of saved WiFi connections."""
  166. try:
  167. output = run_nmcli("-t", "-f", "NAME,TYPE", "con", "show")
  168. except subprocess.TimeoutExpired:
  169. return []
  170. connections = []
  171. for line in output.strip().splitlines():
  172. if "wireless" not in line:
  173. continue
  174. name = line.split(":")[0]
  175. if name == HOTSPOT_CON_NAME:
  176. continue
  177. # Get the SSID for this connection
  178. try:
  179. detail = run_nmcli("-t", "-f", "802-11-wireless.ssid", "con", "show", name)
  180. ssid = ""
  181. for detail_line in detail.strip().splitlines():
  182. if "802-11-wireless.ssid" in detail_line:
  183. ssid = detail_line.split(":", 1)[1] if ":" in detail_line else name
  184. break
  185. if not ssid:
  186. ssid = name
  187. except Exception:
  188. ssid = name
  189. connections.append({
  190. "name": name,
  191. "ssid": ssid,
  192. })
  193. return connections
  194. async def connect_to_network(ssid: str, password: str) -> dict:
  195. """Connect to a WiFi network and schedule reboot.
  196. Uses explicit connection profile creation (nmcli con add) instead of
  197. 'nmcli dev wifi connect' because the latter fails on Pi Trixie with
  198. 'key-mgmt: property is missing' for WPA networks.
  199. """
  200. try:
  201. # Delete any stale connection profile for this SSID
  202. run_nmcli_check("con", "delete", ssid, timeout=10)
  203. # Create connection profile with explicit security settings
  204. if password:
  205. result = run_nmcli_check(
  206. "con", "add",
  207. "type", "wifi",
  208. "ifname", "wlan0",
  209. "con-name", ssid,
  210. "ssid", ssid,
  211. "wifi-sec.key-mgmt", "wpa-psk",
  212. "wifi-sec.psk", password,
  213. timeout=15,
  214. )
  215. else:
  216. result = run_nmcli_check(
  217. "con", "add",
  218. "type", "wifi",
  219. "ifname", "wlan0",
  220. "con-name", ssid,
  221. "ssid", ssid,
  222. timeout=15,
  223. )
  224. if result.returncode != 0:
  225. error_msg = result.stderr.strip() or "Failed to create connection"
  226. logger.error(f"WiFi connection add failed: {error_msg}")
  227. return {"success": False, "message": error_msg}
  228. # Activate the connection
  229. result = run_nmcli_check("con", "up", ssid, timeout=30)
  230. if result.returncode != 0:
  231. error_msg = result.stderr.strip() or "Failed to connect"
  232. logger.error(f"WiFi connect failed: {error_msg}")
  233. # Clean up the failed connection profile
  234. run_nmcli_check("con", "delete", ssid, timeout=10)
  235. return {"success": False, "message": error_msg}
  236. logger.info(f"WiFi connection to '{ssid}' successful, scheduling reboot...")
  237. # Schedule reboot so the response can be sent first
  238. asyncio.get_event_loop().call_later(3, _schedule_reboot)
  239. return {
  240. "success": True,
  241. "message": f"Connected to '{ssid}'. Rebooting in 3 seconds...",
  242. }
  243. except subprocess.TimeoutExpired:
  244. return {"success": False, "message": "Connection timed out"}
  245. except Exception as e:
  246. logger.error(f"WiFi connect error: {e}")
  247. return {"success": False, "message": str(e)}
  248. def _schedule_reboot():
  249. """Trigger a system reboot via systemctl (communicates over D-Bus)."""
  250. try:
  251. logger.info("Rebooting system for WiFi mode change...")
  252. subprocess.run(["systemctl", "reboot"], check=True)
  253. except FileNotFoundError:
  254. logger.error("systemctl not found — ensure systemd is installed in container")
  255. except Exception as e:
  256. logger.error(f"Reboot failed: {e}")
  257. def forget_network(ssid: str) -> dict:
  258. """Delete a saved WiFi connection by SSID."""
  259. saved = get_saved_connections()
  260. con_name = None
  261. for con in saved:
  262. if con["ssid"] == ssid:
  263. con_name = con["name"]
  264. break
  265. if not con_name:
  266. return {"success": False, "message": f"No saved connection found for '{ssid}'"}
  267. try:
  268. result = run_nmcli_check("con", "delete", con_name, timeout=15)
  269. if result.returncode == 0:
  270. logger.info(f"Forgot WiFi network '{ssid}' (connection: {con_name})")
  271. return {"success": True, "message": f"Forgot '{ssid}'"}
  272. else:
  273. error_msg = result.stderr.strip() or "Failed to delete connection"
  274. return {"success": False, "message": error_msg}
  275. except Exception as e:
  276. return {"success": False, "message": str(e)}