mdns_discovery.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. """mDNS (zeroconf) advertisement and discovery of Dune Weaver tables.
  2. Each backend advertises itself as a `_dune-weaver._tcp.local.` service and
  3. browses for peers on the LAN. Discovered peers are exposed to the frontend
  4. via GET /api/discovered-tables so users never have to type IP addresses.
  5. Discovery is best-effort: if the zeroconf package is missing or the network
  6. doesn't support multicast, the app runs normally without it (graceful
  7. degradation, same pattern as the optional LED libraries).
  8. """
  9. import asyncio
  10. import logging
  11. import re
  12. import socket
  13. from typing import Dict, List, Optional
  14. logger = logging.getLogger(__name__)
  15. SERVICE_TYPE = "_dune-weaver._tcp.local."
  16. RESOLVE_TIMEOUT_MS = 3000
  17. try:
  18. from zeroconf import IPVersion, ServiceInfo, ServiceStateChange
  19. from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
  20. ZEROCONF_AVAILABLE = True
  21. except ImportError:
  22. ZEROCONF_AVAILABLE = False
  23. def _get_local_ip() -> Optional[str]:
  24. """Best-effort LAN IP detection (no packets are actually sent)."""
  25. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  26. try:
  27. sock.connect(("8.8.8.8", 80))
  28. return sock.getsockname()[0]
  29. except OSError:
  30. return None
  31. finally:
  32. sock.close()
  33. def _decode_properties(properties: Dict) -> Dict[str, str]:
  34. """Zeroconf TXT records arrive as bytes; decode keys/values to str."""
  35. decoded = {}
  36. for key, value in (properties or {}).items():
  37. if isinstance(key, bytes):
  38. key = key.decode("utf-8", errors="replace")
  39. if isinstance(value, bytes):
  40. value = value.decode("utf-8", errors="replace")
  41. if value is not None:
  42. decoded[key] = value
  43. return decoded
  44. def service_info_to_table(info) -> Optional[dict]:
  45. """Convert a resolved ServiceInfo into a table dict for the API.
  46. Returns None if the service lacks the fields needed to reach it
  47. (an address, a port, and a table id in its TXT records).
  48. """
  49. props = _decode_properties(info.properties)
  50. table_id = props.get("id")
  51. addresses = [a for a in info.parsed_addresses() if ":" not in a] # IPv4 only
  52. if not table_id or not addresses or not info.port:
  53. return None
  54. host = addresses[0]
  55. port = info.port
  56. url = f"http://{host}" if port == 80 else f"http://{host}:{port}"
  57. return {
  58. "id": table_id,
  59. "name": props.get("name") or "Dune Weaver",
  60. "url": url,
  61. "host": host,
  62. "port": port,
  63. "version": props.get("version") or None,
  64. }
  65. class TableDiscovery:
  66. """Advertises this table via mDNS and tracks peer tables on the LAN."""
  67. def __init__(self):
  68. self._aiozc = None
  69. self._browser = None
  70. self._service_info = None
  71. self._own_id: Optional[str] = None
  72. # Keyed by mDNS service name so Removed events can evict entries
  73. self._discovered: Dict[str, dict] = {}
  74. @property
  75. def is_running(self) -> bool:
  76. return self._aiozc is not None
  77. def get_tables(self) -> List[dict]:
  78. """Currently visible peer tables (excludes this table)."""
  79. return list(self._discovered.values())
  80. async def start(self, table_id: str, table_name: str, port: int, version: Optional[str] = None):
  81. if not ZEROCONF_AVAILABLE:
  82. logger.warning("zeroconf package not installed - mDNS table discovery disabled")
  83. return
  84. if self._aiozc:
  85. return
  86. local_ip = _get_local_ip()
  87. if not local_ip:
  88. logger.warning("Could not determine LAN IP - mDNS table discovery disabled")
  89. return
  90. self._own_id = table_id
  91. self._aiozc = AsyncZeroconf(ip_version=IPVersion.V4Only)
  92. # Instance names must be unique per network; the table id suffix
  93. # keeps two tables both named "Dune Weaver" from colliding.
  94. instance = f"{self._instance_label(table_name)}-{table_id[:8]}.{SERVICE_TYPE}"
  95. self._service_info = ServiceInfo(
  96. SERVICE_TYPE,
  97. instance,
  98. addresses=[socket.inet_aton(local_ip)],
  99. port=port,
  100. properties=self._properties(table_id, table_name, version),
  101. server=f"dune-weaver-{table_id[:8]}.local.",
  102. )
  103. try:
  104. await self._aiozc.async_register_service(self._service_info)
  105. self._browser = AsyncServiceBrowser(
  106. self._aiozc.zeroconf, SERVICE_TYPE, handlers=[self._on_service_state_change]
  107. )
  108. logger.info(f"mDNS: advertising '{table_name}' at {local_ip}:{port} and browsing for peer tables")
  109. except Exception as e:
  110. logger.warning(f"mDNS discovery failed to start: {e}")
  111. await self.stop()
  112. async def update_name(self, table_name: str):
  113. """Re-advertise with a new display name after the table is renamed."""
  114. if not (self._aiozc and self._service_info):
  115. return
  116. try:
  117. # Keep the instance name stable; only the TXT records change
  118. self._service_info = ServiceInfo(
  119. SERVICE_TYPE,
  120. self._service_info.name,
  121. addresses=self._service_info.addresses,
  122. port=self._service_info.port,
  123. properties=self._properties(self._own_id, table_name, self._decoded_own_version()),
  124. server=self._service_info.server,
  125. )
  126. await self._aiozc.async_update_service(self._service_info)
  127. except Exception as e:
  128. logger.warning(f"mDNS: failed to update advertised name: {e}")
  129. async def stop(self):
  130. if not self._aiozc:
  131. return
  132. try:
  133. if self._browser:
  134. await self._browser.async_cancel()
  135. if self._service_info:
  136. await self._aiozc.async_unregister_service(self._service_info)
  137. await self._aiozc.async_close()
  138. except Exception as e:
  139. logger.debug(f"mDNS shutdown error: {e}")
  140. finally:
  141. self._aiozc = None
  142. self._browser = None
  143. self._service_info = None
  144. self._discovered.clear()
  145. @staticmethod
  146. def _properties(table_id: str, table_name: str, version: Optional[str]) -> Dict[str, str]:
  147. return {"id": table_id or "", "name": table_name or "Dune Weaver", "version": version or ""}
  148. def _decoded_own_version(self) -> Optional[str]:
  149. props = _decode_properties(self._service_info.properties) if self._service_info else {}
  150. return props.get("version") or None
  151. @staticmethod
  152. def _instance_label(table_name: str) -> str:
  153. # mDNS instance names allow most characters, but dots would be parsed
  154. # as label separators - keep it to a safe subset.
  155. label = re.sub(r"[^A-Za-z0-9 _-]", "", table_name or "").strip() or "Dune Weaver"
  156. return label[:40]
  157. def _on_service_state_change(self, zeroconf, service_type, name, state_change):
  158. """Sync callback from AsyncServiceBrowser (runs on the event loop)."""
  159. if state_change is ServiceStateChange.Removed:
  160. removed = self._discovered.pop(name, None)
  161. if removed:
  162. logger.info(f"mDNS: table '{removed['name']}' left the network")
  163. return
  164. # Added/Updated: resolve the service asynchronously
  165. asyncio.ensure_future(self._resolve_service(zeroconf, service_type, name))
  166. async def _resolve_service(self, zeroconf, service_type, name):
  167. try:
  168. info = AsyncServiceInfo(service_type, name)
  169. if not await info.async_request(zeroconf, RESOLVE_TIMEOUT_MS):
  170. return
  171. table = service_info_to_table(info)
  172. if not table or table["id"] == self._own_id:
  173. return
  174. is_new = name not in self._discovered
  175. self._discovered[name] = table
  176. if is_new:
  177. logger.info(f"mDNS: discovered table '{table['name']}' at {table['url']}")
  178. except Exception as e:
  179. logger.debug(f"mDNS: failed to resolve {name}: {e}")
  180. # Module-level singleton, mirroring how other core services are exposed
  181. discovery = TableDiscovery()