#!/usr/bin/env python3 """mDNS: soner finner sentralen selv når de står i samme VLAN. Sentralen annonserer `_announcer._tcp.local.` med role=sentral. En sone lytter etter det og får en URL å melde seg hos – null config på sonen. Over NetBird virker ikke dette (multicast går ikke gjennom tunnelen); da settes ANNOUNCER_SENTRAL til sentralens NetBird-navn i stedet. Og det virker bare med host-nett i Docker, for på bridge-nettet når ikke multicast LAN-et. zeroconf er en vanlig avhengighet, men alt her tåler at den mangler, så en installasjon med ANNOUNCER_MDNS=0 ikke drar den inn i tankene engang. """ from __future__ import annotations import logging import re import socket import threading import time log = logging.getLogger("announcer.mdns") SERVICE = "_announcer._tcp.local." try: from zeroconf import ServiceBrowser, ServiceInfo, ServiceListener, Zeroconf AVAILABLE = True except ImportError: # pragma: no cover AVAILABLE = False def primary_ip() -> str: """Adressen maskina går ut på nettet med. Ingen pakke sendes.""" try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: s.connect(("10.255.255.255", 1)) return s.getsockname()[0] except OSError: return "127.0.0.1" def _instance(name: str) -> str: safe = re.sub(r"[^A-Za-z0-9_-]", "-", name)[:40] or "announcer" return f"{safe}.{SERVICE}" class Advertiser: """Sier «her er en announcer» på LAN-et.""" def __init__(self, *, name: str, port: int, role: str, ip: str | None = None, version: str = "") -> None: self.name = name self.port = int(port) self.role = role self.ip = ip if ip and ip not in ("0.0.0.0", "::") else None self.version = version self._zc = None self._info = None def start(self) -> bool: if not AVAILABLE: log.warning("mDNS: zeroconf er ikke installert, annonserer ikke") return False ip = self.ip or primary_ip() self._info = ServiceInfo( SERVICE, _instance(self.name), addresses=[socket.inet_aton(ip)], port=self.port, properties={"role": self.role, "name": self.name, "version": self.version}, server=f"{re.sub(r'[^A-Za-z0-9-]', '-', self.name)}.local.", ) try: self._zc = Zeroconf() self._zc.register_service(self._info) except OSError as e: log.warning("mDNS: fikk ikke annonsert (%s)", e) return False log.info("mDNS: annonserer %s som %s på %s:%d", self.name, self.role, ip, self.port) return True def stop(self) -> None: if self._zc and self._info: try: self._zc.unregister_service(self._info) self._zc.close() except Exception: # noqa: BLE001 pass class Browser: """Lytter etter announcere med en gitt rolle. current() gir URL-en til den sist sette, eller None.""" def __init__(self, *, role: str = "sentral") -> None: self.role = role self.found: dict[str, tuple[str, float]] = {} # instansnavn -> (url, sett) self.lock = threading.Lock() self._zc = None self._browser = None def start(self) -> bool: if not AVAILABLE: log.warning("mDNS: zeroconf er ikke installert, leter ikke") return False outer = self class Listener(ServiceListener): def add_service(self, zc, type_, name): # noqa: ANN001 outer._seen(zc, type_, name) def update_service(self, zc, type_, name): # noqa: ANN001 outer._seen(zc, type_, name) def remove_service(self, zc, type_, name): # noqa: ANN001 with outer.lock: if name in outer.found: log.info("mDNS: %s forsvant", name) del outer.found[name] try: self._zc = Zeroconf() self._browser = ServiceBrowser(self._zc, SERVICE, Listener()) except OSError as e: log.warning("mDNS: fikk ikke lyttet (%s)", e) return False log.info("mDNS: leter etter %s", self.role) return True def _seen(self, zc, type_, name) -> None: # noqa: ANN001 info = zc.get_service_info(type_, name, timeout=2000) if not info: return props = {k.decode() if isinstance(k, bytes) else k: (v.decode() if isinstance(v, bytes) else v) for k, v in (info.properties or {}).items()} if props.get("role") != self.role: return addrs = [a for a in info.parsed_addresses() if ":" not in a] if not addrs: return url = f"http://{addrs[0]}:{info.port}" with self.lock: new = name not in self.found self.found[name] = (url, time.time()) if new: log.info("mDNS: fant %s %s på %s", self.role, props.get("name", name), url) def current(self) -> str | None: with self.lock: if not self.found: return None return max(self.found.values(), key=lambda t: t[1])[0] def all(self) -> dict[str, str]: with self.lock: return {n: u for n, (u, _) in self.found.items()} def stop(self) -> None: if self._zc: try: self._zc.close() except Exception: # noqa: BLE001 pass