Mesh: sonene melder seg selv; mDNS på LAN, NetBird-provisjonering

Sonene trenger ikke stå i [zones] på sentralen. En sone sender hjerteslag
hvert 30. s (POST /api/mesh/register med navn, egen adresse og
mesh-nøkkelen), dukker opp i lista med en gang, og tas ut etter 90 s uten
slag. Riktig nøkkel er godkjenningen. Statisk [zones] vinner over et navn
som melder seg (409) – en skrivefeil i config skal ikke kunne overstyres av
en boks på nettet.

Hvorfor push og ikke pull fra NetBird: netbird status --json gir navn, IP og
status per peer, men ikke grupper. Pull ville krevd en service-token mot
management-serveren og virket bare over NetBird. Push virker likt over
NetBird og LAN, uten token, og gir liveness gratis.

Sonen finner sentralen lagvis: ANNOUNCER_SENTRAL hvis satt (NetBird-DNS-navn,
konvensjon announcer-<navn>.skynet.corp), ellers mDNS (_announcer._tcp,
discovery.py). Egen adresse regnes ut som ruten mot sentralen – over NetBird
blir det wt0-adressen, på LAN LAN-adressen – med ANNOUNCER_URL som
overstyring. Hjerteslag hvert 5. s til første OK, så oppstart ikke tar et
halvt minutt.

Containeren bruker nå vertens nett. mDNS-multicast når ikke LAN-et fra
bridge-nettet, så det var ingen vei utenom – og det ga tilbake ufw: det
gamle Docker-smutthullet er borte, portregler virker igjen, og BIND_IP er
appens eget --host. Appen lytter alltid også på 127.0.0.1 for helsesjekken.

install.sh tar NETBIRD_SETUP_KEY: installerer NetBird, melder maskina inn
som announcer-<navn>, venter på wt0, binder til NetBird-adressen.
Management-URL leses fra /etc/netbird/config.json når den finnes.

Rollen avgjøres av config (Config.role): sentral hvis [zones] eller
ANNOUNCER_ROLE=sentral, sone hvis bare nøkkelen er satt, ellers som før.
Dispatcher og ZonePoller slår opp soner ved hver bruk, siden de kommer og
går. register følger, som play, aldri med «alt åpent». Flask sorterte
JSON-nøkler alfabetisk, så sonene kom i feil rekkefølge i /api/status –
app.json.sort_keys = False.

25 nye tester, 173 totalt. Verifisert i compose på host-nett: to soner
meldte seg innen 4 s uten noe [zones], fordeling til en av dem havnet i kø,
avahi-browse på verten ser sentralen, en stoppet sone ble tatt ut etter 90 s
og var tilbake 2 s etter start. Underveis fant live-testen at testkonfigen
fortsatt hadde statiske a/b – og sentralen avviste hjerteslagene med 409,
som den skal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kent 2026-10-09 02:50:25 +02:00
parent c4a15799dd
commit 7c521f8d1f
21 changed files with 1106 additions and 137 deletions

165
discovery.py Normal file
View file

@ -0,0 +1,165 @@
#!/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