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

View file

@ -29,9 +29,24 @@ ANNOUNCER_TITLE=
# Full URL utenfra. Trengs bare for OIDC bak en omvendt proxy. # Full URL utenfra. Trengs bare for OIDC bak en omvendt proxy.
ANNOUNCER_PUBLIC_URL= ANNOUNCER_PUBLIC_URL=
# Mesh. Samme verdi på sentralen og alle soner. Tom = ingen mesh. # --- mesh: flere anlegg ---------------------------------------------------
# Samme nøkkel på sentralen og alle soner. Tom = ingen mesh.
# Lag en: openssl rand -hex 24 # Lag en: openssl rand -hex 24
ANNOUNCER_MESH_SECRET= ANNOUNCER_MESH_SECRET=
# auto = sentral hvis [zones] finnes i data/config.toml, ellers sone hvis
# nøkkelen er satt. Sett eksplisitt når sentralen ikke har statiske soner.
ANNOUNCER_ROLE=auto
# Denne maskinas sonenavn på sentralen. Tom = hostname.
ANNOUNCER_NAME=
# For en sone: hvor sentralen er. Tom = finn den med mDNS (samme LAN).
# Over NetBird må den settes: http://sentral.skynet.corp:8090
ANNOUNCER_SENTRAL=
# Hvordan sentralen når denne sonen. Tom = regnes ut fra ruten til sentralen.
ANNOUNCER_URL=
# mDNS av/på. Krever at sentral og soner er i samme VLAN.
ANNOUNCER_MDNS=1
# Har sentralen eget anlegg? 0 hvis den bare fordeler.
ANNOUNCER_LOCAL=1
# --- hemmeligheter ------------------------------------------------------- # --- hemmeligheter -------------------------------------------------------
# Tom = av. Se config.example.toml og docs/crew-integration.md. # Tom = av. Se config.example.toml og docs/crew-integration.md.

View file

@ -19,12 +19,13 @@ Filer:
| `auth.py` | `Principal`, leverandørene, OIDC-rutene | | `auth.py` | `Principal`, leverandørene, OIDC-rutene |
| `config.py` | `config.toml` + miljøvariabler, standardverdier | | `config.py` | `config.toml` + miljøvariabler, standardverdier |
| `schedule.py` | planlagte sendinger, lagret i `data/scheduled.json` | | `schedule.py` | planlagte sendinger, lagret i `data/scheduled.json` |
| `mesh.py` | sentral + soner: fordeling, transport, statuspolling | | `mesh.py` | sentral + soner: fordeling, transport, statuspolling, register og hjerteslag |
| `discovery.py` | mDNS: sentralen annonserer, sonene finner den |
| `index.html` | hele sida, inline CSS/JS, ingen byggesteg | | `index.html` | hele sida, inline CSS/JS, ingen byggesteg |
| `config.example.toml` | kommentert mal; kopier til `config.toml` | | `config.example.toml` | kommentert mal; kopier til `config.toml` |
| `docs/crew-integration.md` | kontrakten mot crew-panelet | | `docs/crew-integration.md` | kontrakten mot crew-panelet |
| `docs/mesh.md` | flere anlegg: oppsett, bruk, feiltilfeller, protokoll | | `docs/mesh.md` | flere anlegg: oppsett, bruk, feiltilfeller, protokoll |
| `tests/` | `uv run pytest` – 148 tester: auth, planlegging, mesh og HTTP-laget | | `tests/` | `uv run pytest` – 173 tester: auth, planlegging, mesh og HTTP-laget |
| `Dockerfile` + `compose.yaml` | **drift** – appen kjøres i Docker | | `Dockerfile` + `compose.yaml` | **drift** – appen kjøres i Docker |
| `.env.example` | bruker-ID, portbinding og hemmeligheter | | `.env.example` | bruker-ID, portbinding og hemmeligheter |
| `compose.test.yaml` | testoppsett med dummy-IdP, se under | | `compose.test.yaml` | testoppsett med dummy-IdP, se under |
@ -216,11 +217,31 @@ transporten (`send_to_zone` sin håndrullede multipart mot Flask sin parser)
prøves i `test_routes.py` mot en levende werkzeug-server – det er den prøves i `test_routes.py` mot en levende werkzeug-server – det er den
testen som fanger en feil i selve HTTP-en. testen som fanger en feil i selve HTTP-en.
- **Sentral eller sone avgjøres av config, ikke kode.** `[zones]` satt → - **Rollen avgjøres av config, ikke kode** (`Config.role`): `sentral` hvis
`DISPATCHER` og `POLLER` finnes, `/api/announce` tar `zones`. Ellers er `[zones]` finnes eller `ANNOUNCER_ROLE=sentral`; `sone` hvis bare nøkkelen
begge `None` og alt går i lokal kø nøyaktig som før. `ANNOUNCER_MESH_SECRET` er satt; ellers `none` og alt er som før. En sentral har `REGISTRY`,
satt → `MeshProvider` er aktiv og `/api/play` tar imot. Begge kan være `DISPATCHER` og `POLLER`; en sone har `HEARTBEAT`. `MeshProvider` er aktiv
satt på samme maskin (sentral med eget anlegg). hos begge, så `/api/play` og `/api/mesh/register` tar imot.
- **Sonene finnes i `Registry`, ikke i et dict.** `Dispatcher` og `ZonePoller`
får `REGISTRY.zones` som *funksjon* og slår opp ved hver bruk, fordi soner
kommer og går. Lokal først, så `[zones]`, så de som har meldt seg, sortert.
Statisk vinner over et navn som melder seg – `409` – så en skrivefeil i
config ikke kan overstyres fra nettet. En sone som er meldt av mellom
innlevering og fordeling får `unreachable: sonen er borte`.
- **Hjerteslag** (`Heartbeat`, sonens side) finner sentralen via
`ANNOUNCER_SENTRAL` eller `discovery.Browser` (mDNS), regner ut egen
adresse som *ruten mot sentralen* (`local_ip_towards` – UDP-connect uten
pakke; over NetBird blir det wt0-adressen), og melder seg hvert 30. s –
hvert 5. til første OK, så oppstart ikke tar et halvt minutt. Sentralen tar
sonen ut etter `stale_seconds` (`_pruner_loop`). Alt som trenger host/port
startes i `start_mesh()` fra `main()`, ikke ved import.
- **`network_mode: host`** er en forutsetning, ikke et valg: mDNS-multicast
når ikke LAN-et fra bridge-nettet. Bieffekt som er en fordel: ufw styrer
porten igjen, og `BIND_IP` er blitt appens eget `--host`. Appen lytter
alltid også på `127.0.0.1` (helsesjekken).
- **`app.json.sort_keys = False`.** Flask sorterer nøkler alfabetisk som
standard, og da hadde sonene i `/api/status` kommet i feil rekkefølge – UI
og tester antar konfigurert rekkefølge med sentralens eget anlegg først.
- **Egen tråd og egen kø for fordeling**, atskilt fra `Player`. Hadde - **Egen tråd og egen kø for fordeling**, atskilt fra `Player`. Hadde
fordelingen gått gjennom sentralens avspillingskø, ville en melding til fordelingen gått gjennom sentralens avspillingskø, ville en melding til
resepsjonen ventet på at hovedscenen ble ferdig. Dispatcheren resepsjonen ventet på at hovedscenen ble ferdig. Dispatcheren
@ -229,9 +250,10 @@ testen som fanger en feil i selve HTTP-en.
- **`Item.wav`** er hele endringen i avspillingen: `run_once()` bruker - **`Item.wav`** er hele endringen i avspillingen: `run_once()` bruker
`item.wav or synthesize(...)`. En sone syntetiserer aldri det den får fra `item.wav or synthesize(...)`. En sone syntetiserer aldri det den får fra
sentralen. sentralen.
- **`play` følger aldri med «alt åpent».** `Authenticator.anonymous()` - **`play` og `register` følger aldri med «alt åpent».**
trekker den fra. Uten det kunne hvem som helst POSTe en WAV til en åpen `Authenticator.anonymous()` trekker dem fra. Uten det kunne hvem som helst
sone og få den spilt. Og en legitimasjon som vises fram og er feil (PIN, POSTe en WAV til en åpen sone og få den spilt, eller melde en falsk sone
hos en åpen sentral. Testen for åpen modus lister unntakene eksplisitt. Og en legitimasjon som vises fram og er feil (PIN,
token, mesh-nøkkel) gir nå 401 også der gjester ellers har lov – token, mesh-nøkkel) gir nå 401 også der gjester ellers har lov –
`require()` sjekker `error()` først. Det var ikke slik før; en feil PIN `require()` sjekker `error()` først. Det var ikke slik før; en feil PIN
i åpen modus slapp stille gjennom som gjest. i åpen modus slapp stille gjennom som gjest.
@ -362,6 +384,12 @@ Vil Kent ha enda bedre lyd: ElevenLabs (nøkkel, best norsk) eller OpenAI
- **Én prosess.** `main()` kjører waitress med mange tråder, ikke gunicorn - **Én prosess.** `main()` kjører waitress med mange tråder, ikke gunicorn
med flere workere: `Player` og planleggeren er singletons i prosessen, og med flere workere: `Player` og planleggeren er singletons i prosessen, og
flere workere ville gitt flere køer som snakker i munnen på hverandre. flere workere ville gitt flere køer som snakker i munnen på hverandre.
- **NetBird-provisjonering** i `install.sh` krever `NETBIRD_SETUP_KEY`;
management-URL leses fra `/etc/netbird/config.json` med sudo hvis den
finnes. Peer-navnet blir `announcer-<ANNOUNCER_NAME>`, og det er den
konvensjonen som gjør `ANNOUNCER_SENTRAL=http://announcer-her.skynet.corp:8090`
stabil. `netbird status --json` gir **ikke** grupper – derfor melder sonene
seg selv i stedet for at sentralen leter.
- **Mesh-nøkkelen må være lik** på sentral og sone, ellers `✗ avvist` med - **Mesh-nøkkelen må være lik** på sentral og sone, ellers `✗ avvist` med
`401 Feil mesh-nøkkel` på fordelingen. Og sonens `BIND_IP` må gjøre den `401 Feil mesh-nøkkel` på fordelingen. Og sonens `BIND_IP` må gjøre den
nåbar fra sentralen – med NetBird er det NetBird-adressen som skal stå i nåbar fra sentralen – med NetBird er det NetBird-adressen som skal stå i

View file

@ -39,4 +39,6 @@ ENV ANNOUNCER_VOICES=/data/voices \
ANNOUNCER_PORT=8090 ANNOUNCER_PORT=8090
EXPOSE 8090 EXPOSE 8090
CMD ["python", "announcer.py", "--host", "0.0.0.0"] # Ingen --host her: ANNOUNCER_HOST (BIND_IP i .env) bestemmer. Med host-nett
# er det den bindingen som avgjør hvem som når sida.
CMD ["python", "announcer.py"]

View file

@ -21,6 +21,7 @@ import queue
import re import re
import secrets import secrets
import shutil import shutil
import socket
import struct import struct
import subprocess import subprocess
import threading import threading
@ -33,11 +34,13 @@ from flask import Flask, Response, abort, jsonify, request, send_file
import auth import auth
import config import config
import discovery
import mesh import mesh
import schedule as sched import schedule as sched
from auth import require from auth import require
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
VERSION = "0.3"
CFG = config.load() CFG = config.load()
VOICE_DIR = CFG.path("voices") VOICE_DIR = CFG.path("voices")
CACHE_DIR = CFG.path("cache") CACHE_DIR = CFG.path("cache")
@ -90,6 +93,9 @@ app = Flask(__name__, static_folder=None)
# En WAV fra sentralen: et par MB for en normal melding. Grensen er romslig # En WAV fra sentralen: et par MB for en normal melding. Grensen er romslig
# bare for å stoppe det som åpenbart ikke er lyd. # bare for å stoppe det som åpenbart ikke er lyd.
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024 app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024
# Rekkefølgen i dict-ene betyr noe her: soner vises i konfigurert rekkefølge,
# med sentralens eget anlegg først. Flask sorterer nøkler alfabetisk ellers.
app.json.sort_keys = False
AUTH = auth.install(app, CFG) AUTH = auth.install(app, CFG)
@ -542,8 +548,12 @@ try:
except ValueError as e: except ValueError as e:
raise SystemExit(f"config: {e}") from e raise SystemExit(f"config: {e}") from e
MESH_SECRET = str(CFG.get("mesh", "secret", "")).strip() MESH_SECRET = str(CFG.get("mesh", "secret", "")).strip()
if ZONES and not MESH_SECRET and any(not z.local for z in ZONES.values()): ROLE = CFG.role # sentral | sone | none
raise SystemExit("config: [zones] har eksterne soner, men ANNOUNCER_MESH_SECRET er ikke satt") MESH_NAME = str(CFG.get("mesh", "name", "")).strip() or socket.gethostname().split(".")[0]
if ROLE == "sentral" and not MESH_SECRET and (any(not z.local for z in ZONES.values()) or not ZONES):
raise SystemExit("config: sentral uten ANNOUNCER_MESH_SECRET – sonene kan ikke melde seg")
if ROLE == "sone" and not MESH_SECRET:
raise SystemExit("config: sone uten ANNOUNCER_MESH_SECRET")
def _play_local(d: mesh.Dispatch, path: Path) -> int: def _play_local(d: mesh.Dispatch, path: Path) -> int:
@ -567,12 +577,26 @@ def _dispatch_finished(d: mesh.Dispatch) -> None:
schedule.mark(d.schedule_id, "failed", d.error or d.summary()) schedule.mark(d.schedule_id, "failed", d.error or d.summary())
REGISTRY: mesh.Registry | None = None
DISPATCHER: mesh.Dispatcher | None = None DISPATCHER: mesh.Dispatcher | None = None
POLLER: mesh.ZonePoller | None = None POLLER: mesh.ZonePoller | None = None
if ZONES: HEARTBEAT: mesh.Heartbeat | None = None
ADVERTISER: discovery.Advertiser | None = None
BROWSER: discovery.Browser | None = None
if ROLE == "sentral":
# Eget anlegg er en sone som alle andre. Navnet kommer fra [zones] hvis
# noen der står som "local" (gammel stil: her = "local"), ellers fra
# mesh.name. local = false på en sentral som bare fordeler.
_locals = [z for z in ZONES.values() if z.local]
_local = _locals[0] if _locals else (mesh.Zone(MESH_NAME) if CFG.get("mesh", "local", True) else None)
REGISTRY = mesh.Registry(
{n: z for n, z in ZONES.items() if not z.local},
local=_local,
stale_seconds=float(CFG.get("mesh", "stale_seconds", 90)),
)
_timeout = float(CFG.get("mesh", "timeout_seconds", 5)) _timeout = float(CFG.get("mesh", "timeout_seconds", 5))
DISPATCHER = mesh.Dispatcher( DISPATCHER = mesh.Dispatcher(
ZONES, REGISTRY.zones,
synthesize=lambda *a: synthesize(*a), synthesize=lambda *a: synthesize(*a),
play_local=_play_local, play_local=_play_local,
send=lambda zone, path, d: mesh.send_to_zone(zone, path, d, secret=MESH_SECRET, timeout=_timeout), send=lambda zone, path, d: mesh.send_to_zone(zone, path, d, secret=MESH_SECRET, timeout=_timeout),
@ -580,7 +604,7 @@ if ZONES:
on_finished=_dispatch_finished, on_finished=_dispatch_finished,
) )
POLLER = mesh.ZonePoller( POLLER = mesh.ZonePoller(
ZONES, REGISTRY.zones,
fetch=lambda zone: mesh.fetch_zone_status(zone, secret=MESH_SECRET), fetch=lambda zone: mesh.fetch_zone_status(zone, secret=MESH_SECRET),
interval=float(CFG.get("mesh", "poll_seconds", 2)), interval=float(CFG.get("mesh", "poll_seconds", 2)),
local_status=lambda: { local_status=lambda: {
@ -652,6 +676,45 @@ def start_scheduler() -> None:
threading.Thread(target=_scheduler_loop, daemon=True, name="scheduler").start() threading.Thread(target=_scheduler_loop, daemon=True, name="scheduler").start()
def _pruner_loop() -> None:
while True:
time.sleep(10)
try:
for name in REGISTRY.prune():
audit("zone_lost", zone=name)
except Exception: # noqa: BLE001
log.exception("pruneren feilet")
def start_mesh(host: str, port: int) -> None:
"""Trådene og annonseringen som trenger å vite hvor vi lytter."""
global HEARTBEAT, ADVERTISER, BROWSER
mdns = bool(CFG.get("mesh", "mdns", True))
if ROLE == "sentral":
DISPATCHER.start()
POLLER.start()
threading.Thread(target=_pruner_loop, daemon=True, name="pruner").start()
if mdns:
ADVERTISER = discovery.Advertiser(name=MESH_NAME, port=port, role="sentral",
ip=host, version=VERSION)
ADVERTISER.start()
log.info("mesh: sentral «%s», soner nå: %s", MESH_NAME, ", ".join(REGISTRY.zones()) or "ingen")
elif ROLE == "sone":
fixed = str(CFG.get("mesh", "sentral", "")).strip().rstrip("/")
if not fixed and mdns:
BROWSER = discovery.Browser(role="sentral")
BROWSER.start()
HEARTBEAT = mesh.Heartbeat(
name=MESH_NAME, port=port, secret=MESH_SECRET,
sentral=lambda: fixed or (BROWSER.current() if BROWSER else None),
url=str(CFG.get("mesh", "url", "")),
interval=float(CFG.get("mesh", "heartbeat_seconds", 30)),
version=VERSION,
)
HEARTBEAT.start()
log.info("mesh: sone «%s», sentral: %s", MESH_NAME, fixed or ("søker med mDNS" if BROWSER else "ikke satt"))
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# HTTP # HTTP
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@ -840,6 +903,23 @@ def play() -> Response:
return jsonify({"id": item.id}) return jsonify({"id": item.id})
@app.post("/api/mesh/register")
def mesh_register() -> Response:
"""Hjerteslag fra en sone. Riktig nøkkel er godkjenningen."""
me = require("register")
if REGISTRY is None:
abort(404, "Denne maskina er ikke sentral")
data = request.get_json(silent=True) or {}
try:
zone = REGISTRY.register(data.get("name", ""), data.get("url", ""),
str(data.get("version", ""))[:40])
except ValueError as e:
msg = str(e)
abort(409 if ("er satt" in msg or "eget navn" in msg) else 400, msg)
return jsonify({"ok": True, "name": zone.name, "stale_seconds": REGISTRY.stale,
"sentral": MESH_NAME})
@app.post("/api/schedule") @app.post("/api/schedule")
def schedule_add() -> Response: def schedule_add() -> Response:
me = require("schedule") me = require("schedule")
@ -906,8 +986,11 @@ def status() -> Response:
"now": time.time(), # klienten regner «om 20 min» mot serverens klokke "now": time.time(), # klienten regner «om 20 min» mot serverens klokke
} }
if DISPATCHER is not None: if DISPATCHER is not None:
out["zones"] = POLLER.snapshot() info = REGISTRY.info() if REGISTRY else {}
out["zones"] = {n: {**z, **info.get(n, {})} for n, z in POLLER.snapshot().items()}
out["dispatches"] = DISPATCHER.snapshot(show_ip=me.can("clear")) out["dispatches"] = DISPATCHER.snapshot(show_ip=me.can("clear"))
out["mesh"] = {"role": ROLE, "name": MESH_NAME,
"heartbeat": HEARTBEAT.status() if HEARTBEAT else None}
return jsonify(out) return jsonify(out)
@ -931,6 +1014,11 @@ def forbidden(e): # noqa: ANN001
return jsonify({"error": e.description}), 403 return jsonify({"error": e.description}), 403
@app.errorhandler(409)
def conflict(e): # noqa: ANN001
return jsonify({"error": e.description}), 409
@app.errorhandler(404) @app.errorhandler(404)
def not_found(e): # noqa: ANN001 def not_found(e): # noqa: ANN001
# Klienten snakker JSON med /api og /auth, og skal ikke få en HTML-side # Klienten snakker JSON med /api og /auth, og skal ikke få en HTML-side
@ -982,12 +1070,7 @@ def main() -> None:
venter = len(schedule.snapshot()["pending"]) venter = len(schedule.snapshot()["pending"])
player.start() player.start()
start_scheduler() start_scheduler()
if DISPATCHER is not None: start_mesh(args.host, args.port)
DISPATCHER.start()
POLLER.start()
log.info("mesh: sentral for %s", ", ".join(ZONES))
elif MESH_SECRET:
log.info("mesh: sone, tar imot fra sentralen")
log.info("klar på http://%s:%d (auth: %s, sink: %s, planlagt: %d)", log.info("klar på http://%s:%d (auth: %s, sink: %s, planlagt: %d)",
args.host, args.port, methods, SINK or "standard", venter) args.host, args.port, methods, SINK or "standard", venter)
# waitress, ikke Flask sin utviklingsserver – og ikke gunicorn med flere # waitress, ikke Flask sin utviklingsserver – og ikke gunicorn med flere
@ -996,7 +1079,12 @@ def main() -> None:
# Én prosess, mange tråder. # Én prosess, mange tråder.
from waitress import serve from waitress import serve
serve(app, host=args.host, port=args.port, threads=8, ident="announcer") # Alltid også 127.0.0.1, så helsesjekk og lokale verktøy virker selv når
# BIND_IP er NetBird-adressen.
listen = f"{args.host}:{args.port}"
if args.host not in ("0.0.0.0", "127.0.0.1", "::", "localhost"):
listen += f" 127.0.0.1:{args.port}"
serve(app, listen=listen, threads=8, ident="announcer")
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -456,7 +456,7 @@ class Authenticator:
# for en sentral med mesh-nøkkel, ikke for hvem som helst som # for en sentral med mesh-nøkkel, ikke for hvem som helst som
# finner en åpen sone og vil spille lyd på den. # finner en åpen sone og vil spille lyd på den.
return Principal("anonymous", "", ("anonymous",), "none", return Principal("anonymous", "", ("anonymous",), "none",
frozenset(ACTIONS) - {"play"}) frozenset(ACTIONS) - {"play", "register"})
roles = ("anonymous",) roles = ("anonymous",)
return Principal("anonymous", "ikke innlogget", roles, "anonymous", self.cfg.permissions(roles)) return Principal("anonymous", "ikke innlogget", roles, "anonymous", self.cfg.permissions(roles))

View file

@ -2,80 +2,91 @@
# #
# docker compose -f compose.test.yaml up --build # docker compose -f compose.test.yaml up --build
# #
# http://localhost:8091 sentralen: PIN, JWT og OIDC slått på, soner «her», «a», «b» # http://localhost:8091 sentralen «her»: PIN, JWT og OIDC slått på
# http://localhost:9000 dummy-IdP som står for crew-panelet # http://localhost:9000 dummy-IdP som står for crew-panelet
# http://localhost:8093 sone-a (uten lyd – bare kø, så fordeling kan ses) # http://localhost:8093 sone «a» – melder seg hos sentralen med hjerteslag
# http://localhost:8094 sone-b (samme) # http://localhost:8094 sone «b» – samme
# #
# Stopp sone-b og send til alle, så ser du «nede» på den og «kø» på resten: # Sonene har ingen [zones] og ingen statisk config på sentralen: de dukker
# docker compose -f compose.test.yaml stop sone-b # opp fordi de melder seg. Stopp sone-b og se den forsvinne etter 90 s;
# start den igjen og se den komme tilbake innen 5 s.
# #
# Egen fil og eget prosjektnavn, så den ikke kan forveksles med eller # Alt går på vertens nett (som i drift), bundet til 127.0.0.1, så ingenting
# forstyrre den som kjører i drift. Porten er 8091 av samme grunn. # ligger åpent på LAN-et. Sonene har ikke lyd – det er fordelingen som skal
# Koden bind-monteres her, så endringer slår inn med en restart. # ses. mDNS er av på sonene her, så testen er deterministisk; sentralen
# annonserer, så `avahi-browse -rt _announcer._tcp` på verten skal vise den.
name: announcer-test name: announcer-test
x-common: &common
build: .
network_mode: host
user: "${PUID:-1000}:${PGID:-1000}"
volumes:
- .:/app
services: services:
announcer: announcer:
build: . <<: *common
ports: ["127.0.0.1:8091:8090"]
user: "${PUID:-1000}:${PGID:-1000}"
environment: environment:
ANNOUNCER_HOST: 127.0.0.1
ANNOUNCER_PORT: 8091
ANNOUNCER_CONFIG: /app/docker/config.test.toml ANNOUNCER_CONFIG: /app/docker/config.test.toml
ANNOUNCER_CACHE: /data/cache ANNOUNCER_CACHE: /data/cache
ANNOUNCER_LOG: /data/logs/announcements.jsonl ANNOUNCER_LOG: /data/logs/announcements.jsonl
ANNOUNCER_STATE: /data/instance ANNOUNCER_STATE: /data/instance
# Gjenbruker stemmene i repoet (bind-mountet på /app), så testriggen ANNOUNCER_SCHEDULE: /data/scheduled.json
# ikke laster ned 670 MB til en gang.
ANNOUNCER_VOICES: /app/voices ANNOUNCER_VOICES: /app/voices
ANNOUNCER_CHIMES: /data/chimes ANNOUNCER_CHIMES: /data/chimes
ANNOUNCER_ROLE: sentral
ANNOUNCER_NAME: her
ANNOUNCER_MESH_SECRET: mesh-test-nokkel-kun-for-docker
ANNOUNCER_JWT_SECRET: test-hemmelighet-kun-for-docker-ikke-bruk-ekte ANNOUNCER_JWT_SECRET: test-hemmelighet-kun-for-docker-ikke-bruk-ekte
ANNOUNCER_OIDC_CLIENT_SECRET: test-client-secret-kun-for-docker ANNOUNCER_OIDC_CLIENT_SECRET: test-client-secret-kun-for-docker
ANNOUNCER_PUBLIC_URL: http://localhost:8091 ANNOUNCER_PUBLIC_URL: http://localhost:8091
ANNOUNCER_MESH_SECRET: mesh-test-nokkel-kun-for-docker
XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR:-/run/user/1000} XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR:-/run/user/1000}
TZ: ${TZ:-Europe/Oslo} TZ: ${TZ:-Europe/Oslo}
volumes: volumes:
- .:/app - .:/app
- ./data:/data - ./data:/data
- ${XDG_RUNTIME_DIR:-/run/user/1000}:${XDG_RUNTIME_DIR:-/run/user/1000} - ${XDG_RUNTIME_DIR:-/run/user/1000}:${XDG_RUNTIME_DIR:-/run/user/1000}
depends_on: [idp, sone-a, sone-b] depends_on: [idp]
# Soner: samme image, bare mesh-nøkkelen satt. Ingen lyd montert inn, så
# avspilling feiler der – med vilje, det er fordelingen som skal ses.
# tmpfs på /data, så ingen rot-eide mapper dukker opp på verten.
sone-a: &sone
build: .
ports: ["127.0.0.1:8093:8090"]
user: "${PUID:-1000}:${PGID:-1000}"
environment: &sone-env
ANNOUNCER_CONFIG: /finnes-ikke.toml
ANNOUNCER_MESH_SECRET: mesh-test-nokkel-kun-for-docker
ANNOUNCER_VOICES: /app/voices
ANNOUNCER_TITLE: "Sone A"
TZ: ${TZ:-Europe/Oslo}
volumes:
- .:/app
tmpfs:
- /data:mode=1777
sone-b:
<<: *sone
ports: ["127.0.0.1:8094:8090"]
environment:
<<: *sone-env
ANNOUNCER_TITLE: "Sone B"
idp: idp:
build: . <<: *common
ports: ["127.0.0.1:9000:9000"]
user: "${PUID:-1000}:${PGID:-1000}"
environment: environment:
IDP_HOST: 127.0.0.1
IDP_ISSUER: http://localhost:9000 IDP_ISSUER: http://localhost:9000
IDP_CLIENT_ID: announcer IDP_CLIENT_ID: announcer
IDP_CLIENT_SECRET: test-client-secret-kun-for-docker IDP_CLIENT_SECRET: test-client-secret-kun-for-docker
ANNOUNCER_JWT_SECRET: test-hemmelighet-kun-for-docker-ikke-bruk-ekte ANNOUNCER_JWT_SECRET: test-hemmelighet-kun-for-docker-ikke-bruk-ekte
ANNOUNCER_PUBLIC_URL: http://localhost:8091 ANNOUNCER_PUBLIC_URL: http://localhost:8091
volumes:
- .:/app
command: ["python", "docker/dummy_idp.py"] command: ["python", "docker/dummy_idp.py"]
sone-a:
<<: *common
environment: &sone-env
ANNOUNCER_HOST: 127.0.0.1
ANNOUNCER_PORT: 8093
ANNOUNCER_CONFIG: /finnes-ikke.toml
ANNOUNCER_VOICES: /app/voices
ANNOUNCER_ROLE: sone
ANNOUNCER_NAME: a
ANNOUNCER_TITLE: "Sone A"
ANNOUNCER_SENTRAL: http://127.0.0.1:8091
ANNOUNCER_URL: http://127.0.0.1:8093
ANNOUNCER_MDNS: "0"
ANNOUNCER_MESH_SECRET: mesh-test-nokkel-kun-for-docker
TZ: ${TZ:-Europe/Oslo}
tmpfs:
- /data:mode=1777
sone-b:
<<: *common
environment:
<<: *sone-env
ANNOUNCER_PORT: 8094
ANNOUNCER_NAME: b
ANNOUNCER_TITLE: "Sone B"
ANNOUNCER_URL: http://127.0.0.1:8094
tmpfs:
- /data:mode=1777

View file

@ -3,7 +3,7 @@
# ./install.sh setter opp alt, inkludert .env # ./install.sh setter opp alt, inkludert .env
# docker compose up -d senere, for hånd # docker compose up -d senere, for hånd
# #
# Testoppsettet med dummy-IdP ligger i compose.test.yaml, ikke her. # Testoppsettet med dummy-IdP og to soner ligger i compose.test.yaml.
name: announcer name: announcer
services: services:
@ -12,34 +12,41 @@ services:
image: announcer:local image: announcer:local
restart: unless-stopped restart: unless-stopped
# BIND_IP er sikkerhetsmekanismen her, ikke ufw. Dockers publiserte # Vertens nett, ikke bridge. Tre grunner:
# porter går forbi ufw, så en ufw-regel ville ikke stengt noen ute. # - mDNS: multicast når ikke LAN-et fra bridge-nettet
# install.sh setter den til NetBird-adressen når wt0 finnes. # - ufw: Docker-smutthullet forsvinner, portregler virker igjen
ports: # - NetBird: wt0 er synlig direkte, så BIND_IP kan være NetBird-adressen
- "${BIND_IP:-0.0.0.0}:${ANNOUNCER_PORT:-8090}:8090" # Appen binder selv til BIND_IP (-> ANNOUNCER_HOST); ingen ports: her.
network_mode: host
# Må være brukeren som eier lydsocketen på verten, ellers kommer det # Må være brukeren som eier lydsocketen på verten, ellers kommer det
# ingen lyd. install.sh fyller inn id -u / id -g. # ingen lyd. install.sh fyller inn id -u / id -g.
user: "${PUID:-1000}:${PGID:-1000}" user: "${PUID:-1000}:${PGID:-1000}"
environment: environment:
ANNOUNCER_HOST: ${BIND_IP:-0.0.0.0}
ANNOUNCER_PORT: ${ANNOUNCER_PORT:-8090}
ANNOUNCER_SINK: ${ANNOUNCER_SINK:-} ANNOUNCER_SINK: ${ANNOUNCER_SINK:-}
ANNOUNCER_TITLE: ${ANNOUNCER_TITLE:-} ANNOUNCER_TITLE: ${ANNOUNCER_TITLE:-}
ANNOUNCER_PUBLIC_URL: ${ANNOUNCER_PUBLIC_URL:-} ANNOUNCER_PUBLIC_URL: ${ANNOUNCER_PUBLIC_URL:-}
# Mesh. Se docs/mesh.md.
ANNOUNCER_ROLE: ${ANNOUNCER_ROLE:-auto}
ANNOUNCER_NAME: ${ANNOUNCER_NAME:-}
ANNOUNCER_SENTRAL: ${ANNOUNCER_SENTRAL:-}
ANNOUNCER_URL: ${ANNOUNCER_URL:-}
ANNOUNCER_MDNS: ${ANNOUNCER_MDNS:-1}
ANNOUNCER_LOCAL: ${ANNOUNCER_LOCAL:-1}
# Hemmeligheter: hører i .env, ikke i config.toml. # Hemmeligheter: hører i .env, ikke i config.toml.
ANNOUNCER_MESH_SECRET: ${ANNOUNCER_MESH_SECRET:-}
ANNOUNCER_PIN: ${ANNOUNCER_PIN:-} ANNOUNCER_PIN: ${ANNOUNCER_PIN:-}
ANNOUNCER_JWT_SECRET: ${ANNOUNCER_JWT_SECRET:-} ANNOUNCER_JWT_SECRET: ${ANNOUNCER_JWT_SECRET:-}
ANNOUNCER_OIDC_CLIENT_SECRET: ${ANNOUNCER_OIDC_CLIENT_SECRET:-} ANNOUNCER_OIDC_CLIENT_SECRET: ${ANNOUNCER_OIDC_CLIENT_SECRET:-}
ANNOUNCER_SECRET_KEY: ${ANNOUNCER_SECRET_KEY:-} ANNOUNCER_SECRET_KEY: ${ANNOUNCER_SECRET_KEY:-}
ANNOUNCER_MESH_SECRET: ${ANNOUNCER_MESH_SECRET:-}
XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR:-/run/user/1000} XDG_RUNTIME_DIR: ${XDG_RUNTIME_DIR:-/run/user/1000}
# Planlagte sendinger lagres som absolutt tid, så TZ påvirker dem ikke.
# Men uten den står loggen i UTC mens du står i CEST, og da blir det
# vanskelig å se om noe gikk som det skulle.
TZ: ${TZ:-Europe/Oslo} TZ: ${TZ:-Europe/Oslo}
volumes: volumes:
# Stemmer, buffer, logg, sesjonsnøkkel, egne jingler og config.toml. # Stemmer, buffer, logg, sesjonsnøkkel, egne jingler, config.toml, planer.
- ./data:/data - ./data:/data
# Lyden. Hele XDG_RUNTIME_DIR, ikke bare én socket, så både # Lyden. Hele XDG_RUNTIME_DIR, ikke bare én socket, så både
# PipeWire (pipewire-0) og PulseAudio (pulse/native + cookie) virker. # PipeWire (pipewire-0) og PulseAudio (pulse/native + cookie) virker.
@ -47,11 +54,12 @@ services:
# Containeren starter ved boot, altså før noen er logget inn grafisk. # Containeren starter ved boot, altså før noen er logget inn grafisk.
# Da finnes ikke lydsocketen ennå, og avspilling feiler. Det retter seg # Da finnes ikke lydsocketen ennå, og avspilling feiler. Det retter seg
# selv: player_cmd() slås opp per melding, så første utrop etter # selv: player_cmd() slås opp per melding. På en hodeløs boks sørger
# innlogging virker uten at noe må startes om. # install.sh for loginctl enable-linger, så socketen finnes fra start.
# Appen lytter alltid også på 127.0.0.1, så denne virker uansett BIND_IP.
healthcheck: healthcheck:
test: ["CMD", "python", "-c", test: ["CMD", "python", "-c",
"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8090/api/config',timeout=5)"] "import os,urllib.request;urllib.request.urlopen('http://127.0.0.1:%s/api/config'%os.environ.get('ANNOUNCER_PORT','8090'),timeout=5)"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
start_period: 180s # første oppstart laster ned ~670 MB stemmer start_period: 180s # første oppstart laster ned ~670 MB stemmer

View file

@ -41,7 +41,16 @@ quick_phrases = [
# crew = "http://10.20.70.13:8090" # crew = "http://10.20.70.13:8090"
[mesh] [mesh]
# secret settes som ANNOUNCER_MESH_SECRET i .env, ikke her # secret settes som ANNOUNCER_MESH_SECRET i .env, ikke her.
# Sonene trenger ikke stå i [zones]: de melder seg hos sentralen selv med
# hjerteslag, og tas ut når de slutter. [zones] er for faste adresser.
role = "auto" # auto | sentral | sone
name = "" # denne maskinas sonenavn. Tom = hostname
sentral = "" # for en sone: sentralens URL. Tom = mDNS
local = true # sentralen har eget anlegg
mdns = true
heartbeat_seconds = 30
stale_seconds = 90 # uten hjerteslag så lenge = sonen er borte
timeout_seconds = 5 timeout_seconds = 5
poll_seconds = 2 poll_seconds = 2
@ -144,6 +153,7 @@ label = "Logg inn med crew-kontoen"
# announce sende ut på anlegget # announce sende ut på anlegget
# schedule planlegge en sending fram i tid, og avbryte sine egne # schedule planlegge en sending fram i tid, og avbryte sine egne
# play ta imot ferdig lyd fra sentralen (mesh). Bare rollen «mesh». # play ta imot ferdig lyd fra sentralen (mesh). Bare rollen «mesh».
# register melde seg som sone hos sentralen (mesh). Bare rollen «mesh».
# stop stoppe det som spilles # stop stoppe det som spilles
# clear tømme køen # clear tømme køen
# "*" betyr alt. # "*" betyr alt.
@ -154,4 +164,4 @@ anonymous = []
crew = ["view", "preview", "announce", "schedule"] crew = ["view", "preview", "announce", "schedule"]
admin = ["*"] admin = ["*"]
# Sentralen, når den snakker med denne maskina som sone. Ikke endre. # Sentralen, når den snakker med denne maskina som sone. Ikke endre.
mesh = ["play", "view"] mesh = ["play", "view", "register"]

View file

@ -27,7 +27,9 @@ HERE = Path(__file__).resolve().parent
# Alt man kan få lov til å gjøre. Roller er navngitte knipper av disse. # Alt man kan få lov til å gjøre. Roller er navngitte knipper av disse.
# «play» er bare for mesh: en sentral som leverer en ferdig WAV til en sone. # «play» er bare for mesh: en sentral som leverer en ferdig WAV til en sone.
ACTIONS = ("view", "preview", "announce", "schedule", "stop", "clear", "play") # «play» og «register» er bare for mesh: en sentral som leverer lyd til en
# sone, og en sone som melder seg hos sentralen.
ACTIONS = ("view", "preview", "announce", "schedule", "stop", "clear", "play", "register")
DEFAULTS: dict[str, Any] = { DEFAULTS: dict[str, Any] = {
"app": { "app": {
@ -79,6 +81,20 @@ DEFAULTS: dict[str, Any] = {
"zones": {}, "zones": {},
"mesh": { "mesh": {
"secret": "", "secret": "",
# auto: sentral hvis [zones] er satt, ellers sone hvis secret er satt.
"role": "auto",
# Denne maskinas sonenavn, slik det vises på sentralen. Tom = hostname.
"name": "",
# Sentralens URL, for en sone. Tom = finn den med mDNS.
"sentral": "",
# Hvordan sentralen når denne sonen. Tom = regnes ut fra ruten til sentralen.
"url": "",
# Har sentralen eget anlegg? Da er den selv en sone.
"local": True,
"mdns": True,
"heartbeat_seconds": 30,
# Så mange sekunder uten hjerteslag før en sone regnes som borte.
"stale_seconds": 90,
"timeout_seconds": 5, "timeout_seconds": 5,
"poll_seconds": 2, "poll_seconds": 2,
}, },
@ -128,7 +144,7 @@ DEFAULTS: dict[str, Any] = {
"crew": ["view", "preview", "announce", "schedule"], "crew": ["view", "preview", "announce", "schedule"],
"admin": ["*"], "admin": ["*"],
# Sentralen når den snakker med en sone. view er for statuspollingen. # Sentralen når den snakker med en sone. view er for statuspollingen.
"mesh": ["play", "view"], "mesh": ["play", "view", "register"],
}, },
} }
@ -163,6 +179,12 @@ ENV: dict[str, tuple[tuple[str, str], Callable[[str], Any]]] = {
"ANNOUNCER_PUBLIC_URL": (("server", "public_url"), str), "ANNOUNCER_PUBLIC_URL": (("server", "public_url"), str),
"ANNOUNCER_AUTH": (("auth", "providers"), _list), "ANNOUNCER_AUTH": (("auth", "providers"), _list),
"ANNOUNCER_MESH_SECRET": (("mesh", "secret"), str), "ANNOUNCER_MESH_SECRET": (("mesh", "secret"), str),
"ANNOUNCER_ROLE": (("mesh", "role"), str),
"ANNOUNCER_NAME": (("mesh", "name"), str),
"ANNOUNCER_SENTRAL": (("mesh", "sentral"), str),
"ANNOUNCER_URL": (("mesh", "url"), str),
"ANNOUNCER_LOCAL": (("mesh", "local"), _bool),
"ANNOUNCER_MDNS": (("mesh", "mdns"), _bool),
"ANNOUNCER_PIN": (("auth.pin", "pin"), str), "ANNOUNCER_PIN": (("auth.pin", "pin"), str),
"ANNOUNCER_PIN_ROLE": (("auth.pin", "role"), str), "ANNOUNCER_PIN_ROLE": (("auth.pin", "role"), str),
"ANNOUNCER_JWT_ISSUER": (("auth.jwt", "issuer"), str), "ANNOUNCER_JWT_ISSUER": (("auth.jwt", "issuer"), str),
@ -264,6 +286,18 @@ class Config:
return bool(str(self.get("mesh", "secret", "")).strip()) return bool(str(self.get("mesh", "secret", "")).strip())
return False return False
@property
def role(self) -> str:
"""sentral | sone | none. «auto» avgjøres av hva som er satt."""
role = str(self.get("mesh", "role", "auto")).strip().lower()
if role in ("sentral", "sone", "none"):
return role
if self.zones:
return "sentral"
if self.configured("mesh"):
return "sone"
return "none"
@property @property
def zones(self) -> dict[str, str]: def zones(self) -> dict[str, str]:
"""Sonene på en sentral, i konfigurert rekkefølge. Tom = ingen mesh.""" """Sonene på en sentral, i konfigurert rekkefølge. Tom = ingen mesh."""

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

View file

@ -10,12 +10,8 @@ subtitle = "Docker-oppsett – PIN, JWT og OIDC er alle slått på"
accent = "#f5a623" accent = "#f5a623"
quick_phrases = ["Dette er en test", "Pizzaen er kommet. Hent i kantina!"] quick_phrases = ["Dette er en test", "Pizzaen er kommet. Hent i kantina!"]
# Mesh: denne er sentral for sitt eget anlegg og to soner i compose-nettet. # Mesh: denne er sentral (ANNOUNCER_ROLE=sentral i compose.test.yaml). Ingen
# Nøkkelen settes som ANNOUNCER_MESH_SECRET i compose.test.yaml. # [zones] her med vilje – sonene a og b skal dukke opp fordi de melder seg.
[zones]
her = "local"
a = "http://sone-a:8090"
b = "http://sone-b:8090"
[auth] [auth]
providers = ["oidc", "jwt", "pin"] providers = ["oidc", "jwt", "pin"]

View file

@ -141,4 +141,4 @@ def mint():
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=9000, threaded=True) app.run(host=os.environ.get("IDP_HOST", "0.0.0.0"), port=9000, threaded=True)

View file

@ -24,38 +24,78 @@ Soner som får samme melding starter innen ~1 s av hverandre. Fint for
adskilte rom. Lekker rommene inn i hverandre hører man det; synkron start adskilte rom. Lekker rommene inn i hverandre hører man det; synkron start
mot NTP er en påbygging som ikke finnes ennå. mot NTP er en påbygging som ikke finnes ennå.
## Sonene melder seg selv
Du trenger ikke skrive inn sonene på sentralen. En sone sender et hjerteslag
til sentralen hvert 30. sekund (`POST /api/mesh/register` med navn, egen
adresse og mesh-nøkkelen). Riktig nøkkel er godkjenningen: sonen dukker opp i
lista med en gang og kan krysses av. Slutter den å slå – strøm, nett, flyttet
– tas den ut etter 90 sekunder. Starter den igjen, er den tilbake innen fem.
Det eneste en sone må vite er hvor sentralen er, og det løses lagvis:
| Nett | Hvordan sonen finner sentralen |
|---|---|
| Samme LAN/VLAN | **mDNS.** Sentralen annonserer `_announcer._tcp`, sonen finner den selv. Null config |
| NetBird | `ANNOUNCER_SENTRAL=http://<sentralens navn>.skynet.corp:8090` – multicast går ikke gjennom tunnelen, men NetBird-DNS gjør navnet stabilt |
| Begge satt | `ANNOUNCER_SENTRAL` vinner; mDNS brukes bare når den er tom |
Faste adresser i `[zones]` virker fortsatt, og vinner over et navn som
melder seg – en skrivefeil i config skal ikke kunne overstyres av en boks
på nettet.
## Sette opp ## Sette opp
### Sentralen ### Sentralen
```toml
# data/config.toml
[zones]
her = "local" # eget anlegg. Utelat hvis sentralen ikke har høyttalere
hovedscene = "http://10.20.70.11:8090"
resepsjon = "http://10.20.70.12:8090"
crew = "http://10.20.70.13:8090"
```
```bash ```bash
# .env # .env
ANNOUNCER_MESH_SECRET=<openssl rand -hex 24> ANNOUNCER_MESH_SECRET=<openssl rand -hex 24>
ANNOUNCER_ROLE=sentral
ANNOUNCER_NAME=her # navnet på sentralens eget anlegg i lista
``` ```
`docker compose restart`. Loggen sier `mesh: sentral for her, hovedscene, …`. `docker compose restart`. Loggen sier `mesh: sentral «her», soner nå: her`
og `mDNS: annonserer her som sentral`. Sonene kommer etter hvert som de
melder seg. Har sentralen ikke eget anlegg: `ANNOUNCER_LOCAL=0`.
### Hver sone ### Hver sone, på samme LAN
```bash ```bash
git clone https://git.1x1.no/kent/announcer.git && cd announcer git clone https://git.1x1.no/kent/announcer.git && cd announcer
ANNOUNCER_MESH_SECRET=<samme nøkkel> ./install.sh ANNOUNCER_MESH_SECRET=<samme nøkkel> ANNOUNCER_NAME=hovedscene ./install.sh
``` ```
Loggen sier `mesh: sone, tar imot fra sentralen`. Ingen `[zones]` på sonen. Det er alt. Loggen sier `mDNS: fant sentral her på http://…` og
`meldt hos sentralen … som hovedscene`.
Sonen må nås fra sentralen på adressen i `[zones]`. Med NetBird setter ### Hver sone, over NetBird
`install.sh` `BIND_IP` til sonens NetBird-adresse – bruk den i `[zones]`.
Uten NetBird: sett `BIND_IP=0.0.0.0` (eller LAN-adressen) i sonens `.env`. Lag én setup-nøkkel i NetBird (Setup Keys → reusable, auto-gruppe
`announcer`), og én policy `announcer → announcer, TCP 8090`, så sentral og
soner når hverandre. Så, på hver boks:
```bash
NETBIRD_SETUP_KEY=<nøkkel> \
ANNOUNCER_MESH_SECRET=<samme nøkkel> \
ANNOUNCER_NAME=resepsjon \
ANNOUNCER_SENTRAL=http://announcer-her.skynet.corp:8090 \
./install.sh
```
`install.sh` installerer NetBird om den mangler, melder maskina inn som
`announcer-resepsjon`, venter på `wt0`, og binder appen til
NetBird-adressen. Sentralen heter `announcer-<navn>` i NetBird av samme
regel, så `ANNOUNCER_SENTRAL` kan stå fast fra arrangement til arrangement.
Selvhostet management-server leses fra `/etc/netbird/config.json` hvis den
finnes fra før; ellers `NETBIRD_MANAGEMENT_URL=…`.
### Vertens nett
Containeren bruker `network_mode: host`. Det er det som gjør at mDNS når
LAN-et, at `wt0` er synlig, og at **ufw styrer porten igjen** – det gamle
Docker-smutthullet er borte. `BIND_IP` i `.env` avgrenser i tillegg hvilke
adresser appen svarer på; `127.0.0.1` er alltid med, så helsesjekken virker.
### Raspberry Pi uten skjerm ### Raspberry Pi uten skjerm
@ -89,6 +129,10 @@ Planlagte sendinger husker sonene sine og overlever omstart av sentralen.
| | Hva du ser | Hva det betyr | | | Hva du ser | Hva det betyr |
|---|---|---| |---|---|---|
| Sone nede ved sending | `✗ nede` på den sonen, `✓` på resten | nådde ikke fram innen 5 s. Resten gikk | | Sone nede ved sending | `✗ nede` på den sonen, `✓` på resten | nådde ikke fram innen 5 s. Resten gikk |
| Sonen står ikke i lista | sonens egen side sier «ingen sentral funnet» | mDNS når ikke fram (annet VLAN, eller `ANNOUNCER_MDNS=0`), og `ANNOUNCER_SENTRAL` er tom. Sett den |
| Sonen sier «401 Feil mesh-nøkkel» | i sonens logg, hvert 5. s | nøkkelen er ulik. Sentralen avviser, og sonen kommer aldri i lista |
| Sonen forsvant fra lista | `sone x har ikke meldt seg på 90 s – tatt ut` i sentralens logg | strøm, nett, eller sonen fikk ny IP og melder seg på nytt derfra om et øyeblikk |
| Sentralen ser sonen, men `✗ nede` | adressen sonen meldte er ikke nåbar fra sentralen | sonen regnet ut feil egen adresse (to nett). Sett `ANNOUNCER_URL` på sonen |
| `✗ avvist` | `401 Feil mesh-nøkkel` i merket | `ANNOUNCER_MESH_SECRET` er ulik på sentral og sone | | `✗ avvist` | `401 Feil mesh-nøkkel` i merket | `ANNOUNCER_MESH_SECRET` er ulik på sentral og sone |
| Rød prikk på en sone | «nede: …» i sonelista | statuspollingen når den ikke. Sjekk adresse i `[zones]` og `BIND_IP` på sonen | | Rød prikk på en sone | «nede: …» i sonelista | statuspollingen når den ikke. Sjekk adresse i `[zones]` og `BIND_IP` på sonen |
| Alle soner `✗ feilet` | feilmelding på fordelingen | syntesen feilet på sentralen (nettet nede og nevral stemme valgt). Velg en lokal stemme | | Alle soner `✗ feilet` | feilmelding på fordelingen | syntesen feilet på sentralen (nettet nede og nevral stemme valgt). Velg en lokal stemme |
@ -101,6 +145,15 @@ sones logg som `play`.
## Protokoll, for den som vil skrive sin egen sone ## Protokoll, for den som vil skrive sin egen sone
Melde seg: `POST /api/mesh/register`, header `X-Mesh-Key`, JSON
`{"name": "resepsjon", "url": "http://100.75.12.3:8090", "version": "0.3"}`
hvert 30. sekund. Svar `200 {"ok": true, "stale_seconds": 90}`. `409` hvis
navnet er opptatt av config på sentralen. Sentralen kaller så `url` tilbake
for lyd og status – den må altså være nåbar *fra sentralen*.
mDNS: tjenestetypen er `_announcer._tcp.local.`, TXT `role=sentral`,
`name=<navn>`, `version=<v>`. Soner trenger ikke annonsere; de melder seg.
`POST /api/play`, header `X-Mesh-Key: <hemmelighet>`, multipart: `POST /api/play`, header `X-Mesh-Key: <hemmelighet>`, multipart:
| felt | | | felt | |

View file

@ -104,6 +104,7 @@
</div> </div>
<div class="who" id="who"></div> <div class="who" id="who"></div>
</div> </div>
<p class="sub hidden" id="meshline"></p>
<div class="card" id="loginbox"> <div class="card" id="loginbox">
<div id="loginbtns" class="actions" style="margin-top:0"></div> <div id="loginbtns" class="actions" style="margin-top:0"></div>
@ -197,17 +198,25 @@ function sendPayload() {
if (hasZones()) p.zones = selectedZones(); if (hasZones()) p.zones = selectedZones();
return p; return p;
} }
let zoneNames = [];
function renderZones(names) { function renderZones(names) {
const box = $('#zones'); const box = $('#zones');
if (JSON.stringify(names) === JSON.stringify(zoneNames)) return; // uendret
const was = new Set(selectedZones());
const known = new Set(zoneNames);
zoneNames = names;
cfg.zones = names;
box.classList.toggle('hidden', !names.length); box.classList.toggle('hidden', !names.length);
if (!names.length) return; if (!names.length) { box.replaceChildren(); return; }
let saved = null; let saved = null;
try { saved = JSON.parse(localStorage.getItem('zones') || 'null'); } catch {} try { saved = JSON.parse(localStorage.getItem('zones') || 'null'); } catch {}
const head = document.createElement('span'); head.className = 'zl'; head.textContent = 'Til'; const head = document.createElement('span'); head.className = 'zl'; head.textContent = 'Til';
box.replaceChildren(head, ...names.map(name => { box.replaceChildren(head, ...names.map(name => {
const l = document.createElement('label'); const l = document.createElement('label');
const i = document.createElement('input'); i.type = 'checkbox'; i.value = name; const i = document.createElement('input'); i.type = 'checkbox'; i.value = name;
i.checked = !Array.isArray(saved) || saved.includes(name); // alle valgt som standard // Kjent fra før: behold valget. Ny sone: valgt, med mindre den er
// huskes bortvalgt fra en tidligere økt.
i.checked = known.has(name) ? was.has(name) : (!Array.isArray(saved) || saved.includes(name));
i.onchange = () => localStorage.setItem('zones', JSON.stringify(selectedZones())); i.onchange = () => localStorage.setItem('zones', JSON.stringify(selectedZones()));
l.append(i, document.createTextNode(name)); l.append(i, document.createTextNode(name));
return l; return l;
@ -521,6 +530,9 @@ function zoneLi(name, z) {
const zn = document.createElement('span'); zn.className = 'zn'; zn.textContent = name; const zn = document.createElement('span'); zn.className = 'zn'; zn.textContent = name;
const x = document.createElement('span'); x.className = 'x'; const x = document.createElement('span'); x.className = 'x';
const st = document.createElement('span'); st.className = 's'; const st = document.createElement('span'); st.className = 's';
if (z.source === 'registered' && z.last_seen) {
zn.title = `Meldte seg selv · sist ${hhmm(z.last_seen)} · ${z.url || ''}`;
} else if (z.source === 'static') { zn.title = `Fast i config · ${z.url || ''}`; }
if (!z.reachable) { x.textContent = ''; st.textContent = 'nede' + (z.error ? ' · ' + z.error : ''); } if (!z.reachable) { x.textContent = ''; st.textContent = 'nede' + (z.error ? ' · ' + z.error : ''); }
else if (z.current) { else if (z.current) {
x.textContent = z.current.text; x.textContent = z.current.text;
@ -559,9 +571,19 @@ async function poll() {
$('#zonelist').classList.toggle('hidden', !meshMode); $('#zonelist').classList.toggle('hidden', !meshMode);
now.classList.toggle('hidden', meshMode); now.classList.toggle('hidden', meshMode);
if (meshMode) { if (meshMode) {
renderZones(Object.keys(s.zones));
$('#zonelist').replaceChildren(...Object.entries(s.zones).map(([n, z]) => zoneLi(n, z))); $('#zonelist').replaceChildren(...Object.entries(s.zones).map(([n, z]) => zoneLi(n, z)));
$('#history').replaceChildren(...(s.dispatches || []).map(dispLi)); $('#history').replaceChildren(...(s.dispatches || []).map(dispLi));
} }
const ml = $('#meshline');
if (s.mesh && s.mesh.role === 'sone') {
const hb = s.mesh.heartbeat || {};
ml.classList.remove('hidden');
ml.textContent = hb.sentral
? `Sone «${s.mesh.name}» · melder seg hos ${hb.sentral}` + (hb.last_ok ? ` · sist ${hhmm(hb.last_ok)}` : '')
: `Sone «${s.mesh.name}» · ingen sentral funnet` + (hb.error ? ` (${hb.error})` : '');
ml.style.color = hb.sentral && !hb.error ? '' : 'var(--warn)';
} else { ml.classList.add('hidden'); }
if (s.now) skew = s.now - Date.now() / 1000; if (s.now) skew = s.now - Date.now() / 1000;
const sc = s.scheduled || { pending: [], done: [] }; const sc = s.scheduled || { pending: [], done: [] };
const rows = [...sc.pending, ...sc.done]; const rows = [...sc.pending, ...sc.done];

View file

@ -13,6 +13,13 @@
# ANNOUNCER_TITLE="Utrop" ./install.sh annen tittel # ANNOUNCER_TITLE="Utrop" ./install.sh annen tittel
# BIND_IP=127.0.0.1 ./install.sh bare maskina selv # BIND_IP=127.0.0.1 ./install.sh bare maskina selv
# ANNOUNCER_MESH_SECRET=… ./install.sh sone i et mesh (samme nøkkel som sentralen) # ANNOUNCER_MESH_SECRET=… ./install.sh sone i et mesh (samme nøkkel som sentralen)
# NETBIRD_SETUP_KEY=… ./install.sh meld maskina inn i NetBird først
# ANNOUNCER_SENTRAL=http://sentral.skynet.corp:8090 ./install.sh
# sone over NetBird: hvor sentralen er
# ANNOUNCER_NAME=hovedscene ./install.sh sonenavnet slik det vises på sentralen
#
# Soner på samme LAN finner sentralen selv med mDNS. Over NetBird virker ikke
# mDNS, så der settes ANNOUNCER_SENTRAL. Se docs/mesh.md.
# #
# Kan kjøres på nytt; da bygges bildet om og containeren startes om. En .env # Kan kjøres på nytt; da bygges bildet om og containeren startes om. En .env
# som finnes blir ikke overskrevet. # som finnes blir ikke overskrevet.
@ -44,6 +51,33 @@ done
systemctl --user daemon-reload 2>/dev/null || true systemctl --user daemon-reload 2>/dev/null || true
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
step "NetBird"
if [[ -n "${NETBIRD_SETUP_KEY:-}" ]]; then
if ! command -v netbird >/dev/null 2>&1; then
echo "installerer NetBird …"
curl -fsSL https://pkgs.netbird.io/install.sh | sh
fi
# Selvhostet management-server: les URL-en fra en eksisterende config hvis
# NETBIRD_MANAGEMENT_URL ikke er satt, så man slipper å huske den.
MGMT="${NETBIRD_MANAGEMENT_URL:-}"
if [[ -z "$MGMT" && -r /etc/netbird/config.json ]] || sudo test -r /etc/netbird/config.json 2>/dev/null; then
MGMT="${MGMT:-$(sudo python3 -c 'import json;print(json.load(open("/etc/netbird/config.json")).get("ManagementURL") or "")' 2>/dev/null || true)}"
fi
nb_args=(up --setup-key "$NETBIRD_SETUP_KEY" --hostname "announcer-${ANNOUNCER_NAME:-$(hostname -s)}")
[[ -n "$MGMT" ]] && nb_args+=(--management-url "$MGMT")
sudo netbird "${nb_args[@]}"
for _ in $(seq 1 30); do ip link show wt0 >/dev/null 2>&1 && break; sleep 1; done
if ip link show wt0 >/dev/null 2>&1; then
echo "på NetBird som $(sudo netbird status 2>/dev/null | awk -F': ' '/FQDN/{print $2}')"
else
warn "wt0 kom ikke opp innen 30 s. Se: sudo netbird status"
fi
elif command -v netbird >/dev/null 2>&1; then
echo "NetBird er installert fra før; bruker den som den er."
else
echo "ingen NETBIRD_SETUP_KEY – LAN-modus."
fi
step "Docker" step "Docker"
if ! command -v docker >/dev/null 2>&1; then if ! command -v docker >/dev/null 2>&1; then
echo "installerer Docker …" echo "installerer Docker …"
@ -94,7 +128,15 @@ ANNOUNCER_PIN=${ANNOUNCER_PIN:-}
ANNOUNCER_JWT_SECRET=${ANNOUNCER_JWT_SECRET:-} ANNOUNCER_JWT_SECRET=${ANNOUNCER_JWT_SECRET:-}
ANNOUNCER_OIDC_CLIENT_SECRET=${ANNOUNCER_OIDC_CLIENT_SECRET:-} ANNOUNCER_OIDC_CLIENT_SECRET=${ANNOUNCER_OIDC_CLIENT_SECRET:-}
ANNOUNCER_SECRET_KEY= ANNOUNCER_SECRET_KEY=
# Mesh. auto = sentral hvis [zones] i data/config.toml, ellers sone hvis
# nøkkelen er satt. Se docs/mesh.md.
ANNOUNCER_MESH_SECRET=${ANNOUNCER_MESH_SECRET:-} ANNOUNCER_MESH_SECRET=${ANNOUNCER_MESH_SECRET:-}
ANNOUNCER_ROLE=${ANNOUNCER_ROLE:-auto}
ANNOUNCER_NAME=${ANNOUNCER_NAME:-$(hostname -s)}
ANNOUNCER_SENTRAL=${ANNOUNCER_SENTRAL:-}
ANNOUNCER_URL=
ANNOUNCER_MDNS=${ANNOUNCER_MDNS:-1}
ANNOUNCER_LOCAL=${ANNOUNCER_LOCAL:-1}
ENV ENV
chmod 600 .env # PIN og nøkler står i klartekst chmod 600 .env # PIN og nøkler står i klartekst
echo "skrev .env (PUID=$(id -u), BIND_IP=$BIND_IP)" echo "skrev .env (PUID=$(id -u), BIND_IP=$BIND_IP)"
@ -139,20 +181,25 @@ done
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
step "Brannmur" step "Brannmur"
# Merk at ufw *ikke* styrer tilgangen til announcer – det gjør BIND_IP i # Containeren bruker vertens nett, så ufw gjelder porten som for enhver annen
# .env. ufw settes her bare for maskinens øvrige porter. # tjeneste. BIND_IP avgrenser i tillegg hvilke adresser appen svarer på.
if [[ -n "$NB_IP" ]]; then if [[ -n "$NB_IP" ]]; then
sudo ufw default deny incoming sudo ufw default deny incoming
sudo ufw default allow outgoing sudo ufw default allow outgoing
sudo ufw allow in on wt0 to any port 22 proto tcp comment "ssh via netbird" sudo ufw allow in on wt0 to any port 22 proto tcp comment "ssh via netbird"
sudo ufw allow in on wt0 to any port 22022 proto tcp comment "netbird ssh-server" sudo ufw allow in on wt0 to any port 22022 proto tcp comment "netbird ssh-server"
sudo ufw allow in on wt0 to any port "$PORT" proto tcp comment "announcer via netbird"
sudo ufw delete allow "$PORT/tcp" >/dev/null 2>&1 || true
# mDNS på LAN-et, hvis sentral og soner også skal finne hverandre der
sudo ufw allow 5353/udp comment "mdns" >/dev/null 2>&1 || true
sudo ufw --force enable sudo ufw --force enable
echo "ufw aktiv: ssh kun på wt0." echo "ufw aktiv: port $PORT og ssh kun på wt0 (NetBird). Appen er bundet til $BIND_IP."
echo "announcer er bundet til $BIND_IP:$PORT – det er NetBird-adressen," echo "Vil du også nå sida fra lokalt LAN uten VPN: sett BIND_IP=0.0.0.0 i .env og"
echo "så sida nås bare over VPN-et. ufw er ikke involvert i det." echo " sudo ufw allow from <lan-subnett> to any port $PORT proto tcp"
elif command -v ufw >/dev/null 2>&1 && sudo ufw status 2>/dev/null | grep -q "Status: active"; then elif command -v ufw >/dev/null 2>&1 && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
warn "ufw er aktiv, men styrer ikke Docker-porter." sudo ufw allow "$PORT/tcp" comment "announcer"
warn "Vil du begrense tilgangen: sett BIND_IP i .env og kjør docker compose up -d." sudo ufw allow 5353/udp comment "mdns" >/dev/null 2>&1 || true
echo "ufw aktiv: åpnet $PORT/tcp og mDNS."
else else
echo "ufw er ikke aktiv." echo "ufw er ikke aktiv."
[[ "$BIND_IP" == "0.0.0.0" ]] && warn "Sida er åpen på alle nett maskina er på." [[ "$BIND_IP" == "0.0.0.0" ]] && warn "Sida er åpen på alle nett maskina er på."

240
mesh.py
View file

@ -25,6 +25,7 @@ import logging
import queue import queue
import re import re
import secrets import secrets
import socket
import threading import threading
import time import time
import urllib.error import urllib.error
@ -144,9 +145,12 @@ class Dispatcher:
send(zone, path, dispatch) -> (status, item_id, error) send(zone, path, dispatch) -> (status, item_id, error)
""" """
def __init__(self, zones: dict[str, Zone], *, synthesize: Callable, play_local: Callable, def __init__(self, zones: "dict[str, Zone] | Callable[[], dict[str, Zone]]", *,
send: Callable, timeout: float = 5.0, on_finished: Callable | None = None) -> None: synthesize: Callable, play_local: Callable, send: Callable,
self.zones = zones timeout: float = 5.0, on_finished: Callable | None = None) -> None:
# Soner kommer og går (hjerteslag), så de slås opp ved hver bruk.
# Et fast dict godtas også, for tester og statisk config.
self._zones = zones if callable(zones) else (lambda: zones)
self.synthesize = synthesize self.synthesize = synthesize
self.play_local = play_local self.play_local = play_local
self.send = send self.send = send
@ -158,13 +162,18 @@ class Dispatcher:
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
# -- oppslag ----------------------------------------------------------- # -- oppslag -----------------------------------------------------------
@property
def zones(self) -> dict[str, Zone]:
return self._zones()
def names(self) -> list[str]: def names(self) -> list[str]:
return list(self.zones) return list(self.zones)
def resolve(self, requested: Any) -> list[str]: def resolve(self, requested: Any) -> list[str]:
"""Tom/None = alle. Ukjent navn kaster ValueError før noe sendes.""" """Tom/None = alle. Ukjent navn kaster ValueError før noe sendes."""
zones = self.zones
if not requested: if not requested:
return self.names() return list(zones)
if isinstance(requested, str): if isinstance(requested, str):
requested = [requested] requested = [requested]
if not isinstance(requested, (list, tuple)): if not isinstance(requested, (list, tuple)):
@ -172,7 +181,7 @@ class Dispatcher:
out: list[str] = [] out: list[str] = []
for name in requested: for name in requested:
name = str(name).strip() name = str(name).strip()
if name not in self.zones: if name not in zones:
raise ValueError(f"Ukjent sone: {name}") raise ValueError(f"Ukjent sone: {name}")
if name not in out: if name not in out:
out.append(name) out.append(name)
@ -227,8 +236,13 @@ class Dispatcher:
return d return d
threads: list[threading.Thread] = [] threads: list[threading.Thread] = []
zones = self.zones
for name, result in d.zones.items(): for name, result in d.zones.items():
zone = self.zones[name] zone = zones.get(name)
if zone is None:
# Meldt av mellom innlevering og fordeling.
result.status, result.error = "unreachable", "sonen er borte"
continue
if zone.local: if zone.local:
try: try:
result.item_id = self.play_local(d, path) result.item_id = self.play_local(d, path)
@ -357,19 +371,21 @@ class ZonePoller:
eget anlegg aldri er to sekunder på etterskudd. eget anlegg aldri er to sekunder på etterskudd.
""" """
def __init__(self, zones: dict[str, Zone], *, fetch: Callable, interval: float = 2.0, def __init__(self, zones: "dict[str, Zone] | Callable[[], dict[str, Zone]]", *,
fetch: Callable, interval: float = 2.0,
local_status: Callable[[], dict] | None = None) -> None: local_status: Callable[[], dict] | None = None) -> None:
self.zones = zones self._zones = zones if callable(zones) else (lambda: zones)
self.fetch = fetch self.fetch = fetch
self.interval = float(interval) self.interval = float(interval)
self.local_status = local_status self.local_status = local_status
self.lock = threading.Lock() self.lock = threading.Lock()
self.cache: dict[str, dict] = { self.cache: dict[str, dict] = {}
z.name: {"reachable": False, "error": "ikke spurt ennå"}
for z in zones.values() if not z.local
}
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
@property
def zones(self) -> dict[str, Zone]:
return self._zones()
def poll_once(self) -> None: def poll_once(self) -> None:
remote = [z for z in self.zones.values() if not z.local] remote = [z for z in self.zones.values() if not z.local]
results: dict[str, dict] = {} results: dict[str, dict] = {}
@ -389,6 +405,9 @@ class ZonePoller:
for z in remote: for z in remote:
self.cache[z.name] = results.get(z.name, {"reachable": False, "error": "svarte ikke"}) self.cache[z.name] = results.get(z.name, {"reachable": False, "error": "svarte ikke"})
self.cache[z.name]["checked"] = time.time() self.cache[z.name]["checked"] = time.time()
# Soner som er meldt av skal ikke bli liggende i cachen.
for gone in set(self.cache) - {z.name for z in remote}:
del self.cache[gone]
def snapshot(self) -> dict[str, dict]: def snapshot(self) -> dict[str, dict]:
out: dict[str, dict] = {} out: dict[str, dict] = {}
@ -397,7 +416,7 @@ class ZonePoller:
out[z.name] = {"reachable": True, **(self.local_status() if self.local_status else {})} out[z.name] = {"reachable": True, **(self.local_status() if self.local_status else {})}
else: else:
with self.lock: with self.lock:
out[z.name] = dict(self.cache.get(z.name, {"reachable": False})) out[z.name] = dict(self.cache.get(z.name, {"reachable": False, "error": "ikke spurt ennå"}))
return out return out
def start(self) -> None: def start(self) -> None:
@ -412,3 +431,198 @@ class ZonePoller:
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
log.exception("sonepolleren feilet") log.exception("sonepolleren feilet")
time.sleep(self.interval) time.sleep(self.interval)
# --------------------------------------------------------------------------
# Register: sonene sentralen kjenner akkurat nå
# --------------------------------------------------------------------------
@dataclass
class Registered:
zone: Zone
first_seen: float
last_seen: float
version: str = ""
class Registry:
"""Sonene en sentral kjenner: eget anlegg, statisk [zones], og de som har
meldt seg med hjerteslag. En som slutter å slå, tas ut etter stale_seconds.
Statisk config vinner alltid over et navn som melder seg – en skrivefeil
i config skal ikke kunne overstyres av en boks på nettet.
"""
def __init__(self, static: dict[str, Zone], *, local: Zone | None = None,
stale_seconds: float = 90.0) -> None:
self.local = local
self.static = dict(static)
self.stale = float(stale_seconds)
self.registered: dict[str, Registered] = {}
self.lock = threading.Lock()
def register(self, name: str, url: str, version: str = "") -> Zone:
name = str(name).strip()
if not ZONE_NAME.fullmatch(name):
raise ValueError(f"ugyldig sonenavn {name!r}")
if self.local and name == self.local.name:
raise ValueError(f"{name} er sentralens eget navn")
if name in self.static:
raise ValueError(f"{name} er satt i [zones] på sentralen")
url = str(url).strip().rstrip("/")
if not url.startswith(("http://", "https://")):
raise ValueError("url må være http(s)://…")
zone = Zone(name, url)
now = time.time()
with self.lock:
prev = self.registered.get(name)
if prev and prev.zone == zone:
prev.last_seen, prev.version = now, version
else:
if prev:
log.info("sone %s byttet adresse: %s -> %s", name, prev.zone.url, url)
else:
log.info("ny sone meldte seg: %s på %s", name, url)
self.registered[name] = Registered(zone, now, now, version)
return zone
def prune(self, now: float | None = None) -> list[str]:
now = time.time() if now is None else now
with self.lock:
gone = [n for n, r in self.registered.items() if now - r.last_seen > self.stale]
for n in gone:
del self.registered[n]
for n in gone:
log.warning("sone %s har ikke meldt seg på %d s – tatt ut", n, int(self.stale))
return gone
def zones(self) -> dict[str, Zone]:
out: dict[str, Zone] = {}
if self.local:
out[self.local.name] = self.local
out.update(self.static)
with self.lock:
for n in sorted(self.registered):
out[n] = self.registered[n].zone
return out
def info(self) -> dict[str, dict]:
"""Til UI-et: hvor sonen kommer fra, og når den sist sa noe."""
out: dict[str, dict] = {}
if self.local:
out[self.local.name] = {"source": "local", "url": ""}
for n, z in self.static.items():
out[n] = {"source": "static", "url": z.url}
with self.lock:
for n in sorted(self.registered):
r = self.registered[n]
out[n] = {"source": "registered", "url": r.zone.url,
"last_seen": r.last_seen, "version": r.version}
return out
# --------------------------------------------------------------------------
# Hjerteslag: sonens side
# --------------------------------------------------------------------------
def local_ip_towards(host: str, port: int = 8090) -> str:
"""IP-en denne maskina bruker for å nå host – altså den sentralen må
svare til. Ingen pakke sendes; connect() på en UDP-socket velger bare
rute. Over NetBird blir det wt0-adressen, på LAN LAN-adressen."""
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.connect((host, port))
return sock.getsockname()[0]
def post_register(sentral_url: str, *, name: str, url: str, secret: str,
version: str = "", timeout: float = 5.0) -> tuple[bool, str]:
body = json.dumps({"name": name, "url": url, "version": version}).encode()
req = urllib.request.Request(
f"{sentral_url.rstrip('/')}/api/mesh/register",
data=body,
headers={"Content-Type": "application/json", "X-Mesh-Key": secret, "Accept": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310
r.read()
return True, ""
except urllib.error.HTTPError as e:
try:
msg = json.loads(e.read().decode()).get("error", "")
except Exception: # noqa: BLE001
msg = ""
return False, f"{e.code} {msg or e.reason}".strip()
except (urllib.error.URLError, TimeoutError, OSError) as e:
return False, str(getattr(e, "reason", e))[:120]
class Heartbeat:
"""Melder denne sonen hos sentralen med jevne mellomrom.
sentral() gir sentralens URL akkurat nå – fra config, eller fra mDNS –
eller None når ingen er kjent. Da venter vi og prøver igjen; en sone skal
kunne starte før sentralen finnes.
"""
def __init__(self, *, name: str, port: int, secret: str, sentral: Callable[[], str | None],
url: str = "", interval: float = 30.0, version: str = "",
post: Callable = post_register) -> None:
self.name = name
self.port = int(port)
self.secret = secret
self.sentral = sentral
self.fixed_url = url.strip().rstrip("/")
self.interval = float(interval)
self.version = version
self.post = post
self.last_ok: float | None = None
self.last_error = ""
self.last_sentral = ""
self._thread: threading.Thread | None = None
def my_url(self, sentral_url: str) -> str:
if self.fixed_url:
return self.fixed_url
host = urllib.request.urlparse(sentral_url).hostname or "127.0.0.1"
return f"http://{local_ip_towards(host, self.port)}:{self.port}"
def beat_once(self) -> bool:
sentral = self.sentral()
if not sentral:
self.last_error = "ingen sentral kjent"
return False
try:
url = self.my_url(sentral)
except OSError as e:
self.last_error = f"finner ikke egen adresse mot {sentral}: {e}"
return False
ok, err = self.post(sentral, name=self.name, url=url, secret=self.secret, version=self.version)
if ok:
if self.last_sentral != sentral:
log.info("meldt hos sentralen %s som %s (%s)", sentral, self.name, url)
self.last_ok, self.last_error, self.last_sentral = time.time(), "", sentral
else:
if err != self.last_error:
log.warning("fikk ikke meldt meg hos %s: %s", sentral, err)
self.last_error = err
return ok
def status(self) -> dict:
return {"sentral": self.last_sentral or None, "last_ok": self.last_ok, "error": self.last_error}
def start(self) -> None:
if self._thread is None:
self._thread = threading.Thread(target=self._loop, daemon=True, name="heartbeat")
self._thread.start()
def _loop(self) -> None:
while True:
try:
self.beat_once()
except Exception: # noqa: BLE001
log.exception("hjerteslaget feilet")
# Raskere når vi ikke har fått kontakt ennå, så oppstart ikke tar et halvt minutt.
time.sleep(self.interval if self.last_ok else min(self.interval, 5.0))

View file

@ -9,6 +9,7 @@ dependencies = [
"piper-tts>=1.8.0", "piper-tts>=1.8.0",
"pyjwt[crypto]>=2.10", "pyjwt[crypto]>=2.10",
"waitress>=3.0", "waitress>=3.0",
"zeroconf>=0.150",
] ]
[dependency-groups] [dependency-groups]

View file

@ -72,12 +72,14 @@ def test_uten_konfig_er_alt_apent(tmp_path):
c, a = build(tmp_path) c, a = build(tmp_path)
assert a.enabled is False assert a.enabled is False
for action in config.ACTIONS: for action in config.ACTIONS:
if action == "play": if action in ("play", "register"):
continue continue
assert c.get(f"/do/{action}").status_code == 200, action assert c.get(f"/do/{action}").status_code == 200, action
# Det ene unntaket: «play» er for en sentral med mesh-nøkkel. En åpen # Unntakene: «play» og «register» er for maskiner med mesh-nøkkel. En
# sone skal ikke spille lyd for hvem som helst som finner den. # åpen sone skal ikke spille lyd for hvem som helst som finner den, og
# en åpen sentral skal ikke ta imot soner fra hvem som helst.
assert c.get("/do/play").status_code == 401 assert c.get("/do/play").status_code == 401
assert c.get("/do/register").status_code == 401
def test_feil_legitimasjon_er_feil_ogsa_pa_apen_sone(tmp_path): def test_feil_legitimasjon_er_feil_ogsa_pa_apen_sone(tmp_path):

View file

@ -238,3 +238,138 @@ def test_send_til_sone_som_ikke_finnes(tmp_path):
def test_status_fra_sone_som_ikke_finnes(): def test_status_fra_sone_som_ikke_finnes():
r = mesh.fetch_zone_status(mesh.Zone("z", "http://127.0.0.1:1"), secret="s", timeout=1) r = mesh.fetch_zone_status(mesh.Zone("z", "http://127.0.0.1:1"), secret="s", timeout=1)
assert r["reachable"] is False and r["error"] assert r["reachable"] is False and r["error"]
# --------------------------------------------------------------------------
# Registry: sonene som melder seg
# --------------------------------------------------------------------------
def test_register_og_rekkefolge():
r = mesh.Registry({"fast": mesh.Zone("fast", "http://fast")}, local=mesh.Zone("her"), stale_seconds=90)
r.register("b", "http://b:8090/", "0.3")
r.register("a", "http://a:8090")
z = r.zones()
assert list(z) == ["her", "fast", "a", "b"] # lokal, statisk, så registrerte sortert
assert z["b"].url == "http://b:8090" # uten skråstrek
info = r.info()
assert info["her"]["source"] == "local"
assert info["fast"]["source"] == "static"
assert info["b"]["source"] == "registered" and info["b"]["version"] == "0.3" and info["b"]["last_seen"]
def test_statisk_og_lokal_vinner_over_den_som_melder_seg():
r = mesh.Registry({"fast": mesh.Zone("fast", "http://fast")}, local=mesh.Zone("her"))
with pytest.raises(ValueError, match="er satt i"):
r.register("fast", "http://ond")
with pytest.raises(ValueError, match="eget navn"):
r.register("her", "http://ond")
assert r.zones()["fast"].url == "http://fast"
@pytest.mark.parametrize("name, url, feil", [
("", "http://x", "ugyldig sonenavn"),
("har mellomrom", "http://x", "ugyldig sonenavn"),
("ok", "10.0.0.5:8090", "http"),
("ok", "", "http"),
])
def test_register_avviser_tull(name, url, feil):
with pytest.raises(ValueError, match=feil):
mesh.Registry({}).register(name, url)
def test_hjerteslag_oppdaterer_sist_sett_og_bytte_av_adresse():
r = mesh.Registry({}, stale_seconds=90)
r.register("a", "http://a")
t1 = r.registered["a"].last_seen
time.sleep(0.01)
r.register("a", "http://a")
assert r.registered["a"].last_seen > t1
assert r.registered["a"].first_seen <= t1
r.register("a", "http://a-ny") # sonen fikk ny IP
assert r.zones()["a"].url == "http://a-ny"
def test_sone_som_slutter_a_sla_tas_ut():
r = mesh.Registry({}, stale_seconds=90)
r.register("a", "http://a")
r.register("b", "http://b")
now = time.time()
r.registered["a"].last_seen = now - 100
assert r.prune(now) == ["a"]
assert list(r.zones()) == ["b"]
assert r.prune(now) == []
def test_dispatcher_med_dynamiske_soner(tmp_path):
"""Sonen forsvinner mellom innlevering og fordeling."""
r = mesh.Registry({}, local=None)
r.register("a", "http://a")
wav = tmp_path / "x.wav"; wav.write_bytes(b"RIFF")
disp = mesh.Dispatcher(r.zones, synthesize=lambda *a: wav, play_local=lambda d, p: 1,
send=lambda z, p, d: ("queued", 1, ""), timeout=1)
assert disp.names() == ["a"]
d = disp.submit("x", "finn", 1.0, "none", ["a"])
r.registered.clear() # meldt av
disp.run_once(timeout=1)
assert d.zones["a"].status == "unreachable" and "borte" in d.zones["a"].error
def test_poller_glemmer_soner_som_er_borte():
r = mesh.Registry({})
r.register("a", "http://a")
p = mesh.ZonePoller(r.zones, fetch=lambda z: {"reachable": True, "queued": 0, "current": None}, interval=99)
p.poll_once()
assert "a" in p.snapshot()
r.registered.clear()
p.poll_once()
assert p.snapshot() == {} and p.cache == {}
# --------------------------------------------------------------------------
# Heartbeat: sonens side
# --------------------------------------------------------------------------
def test_local_ip_towards_loopback():
assert mesh.local_ip_towards("127.0.0.1", 8090) == "127.0.0.1"
def test_hjerteslag_uten_kjent_sentral_venter():
calls = []
hb = mesh.Heartbeat(name="a", port=8093, secret="s", sentral=lambda: None,
post=lambda *a, **k: calls.append(k) or (True, ""))
assert hb.beat_once() is False
assert calls == [] and "ingen sentral" in hb.last_error
assert hb.status()["sentral"] is None
def test_hjerteslag_melder_seg_med_riktig_adresse():
calls = []
hb = mesh.Heartbeat(name="a", port=8093, secret="s", sentral=lambda: "http://127.0.0.1:8091/",
version="0.3", post=lambda sentral, **k: calls.append((sentral, k)) or (True, ""))
assert hb.beat_once() is True
url, k = calls[0]
assert url == "http://127.0.0.1:8091/"
assert k == {"name": "a", "url": "http://127.0.0.1:8093", "secret": "s", "version": "0.3"}
assert hb.last_ok and hb.last_error == "" and hb.status()["sentral"] == "http://127.0.0.1:8091/"
def test_hjerteslag_med_fast_egen_url():
calls = []
hb = mesh.Heartbeat(name="a", port=8093, secret="s", sentral=lambda: "http://s:8090",
url="http://10.9.9.9:8093/", post=lambda sentral, **k: calls.append(k) or (True, ""))
hb.beat_once()
assert calls[0]["url"] == "http://10.9.9.9:8093"
def test_hjerteslag_husker_feil():
hb = mesh.Heartbeat(name="a", port=8093, secret="s", sentral=lambda: "http://127.0.0.1:8091",
post=lambda *a, **k: (False, "401 Feil mesh-nøkkel"))
assert hb.beat_once() is False
assert hb.last_ok is None and "401" in hb.status()["error"]
def test_post_register_mot_noe_som_ikke_finnes():
ok, err = mesh.post_register("http://127.0.0.1:1", name="a", url="http://x", secret="s", timeout=1)
assert ok is False and err

View file

@ -571,3 +571,78 @@ def test_ekte_runde_med_feil_nokkel(live_sone, tmp_path):
assert status == "rejected" and item_id is None assert status == "rejected" and item_id is None
assert err.startswith("401") and "mesh-nøkkel" in err assert err.startswith("401") and "mesh-nøkkel" in err
assert mesh.fetch_zone_status(live_sone, secret="feil", timeout=3)["reachable"] is False assert mesh.fetch_zone_status(live_sone, secret="feil", timeout=3)["reachable"] is False
# --------------------------------------------------------------------------
# Hjerteslag inn på sentralen
# --------------------------------------------------------------------------
@pytest.fixture
def registry(app, tmp_path, monkeypatch):
with_auth(tmp_path, MESH_CFG)
reg = mesh.Registry({"fast": mesh.Zone("fast", "http://fast")}, local=mesh.Zone("her"), stale_seconds=90)
disp = mesh.Dispatcher(reg.zones, synthesize=lambda *a: announcer.synthesize(*a),
play_local=announcer._play_local, send=lambda z, p, d: ("queued", 1, ""),
timeout=1, on_finished=announcer._dispatch_finished)
poller = mesh.ZonePoller(reg.zones, fetch=lambda z: {"reachable": True, "queued": 0, "current": None},
interval=99, local_status=lambda: {"queued": 0, "current": None})
monkeypatch.setattr(announcer, "REGISTRY", reg)
monkeypatch.setattr(announcer, "DISPATCHER", disp)
monkeypatch.setattr(announcer, "POLLER", poller)
monkeypatch.setattr(announcer, "ROLE", "sentral")
return reg
def test_register_krever_nokkel(c, registry):
assert c.post("/api/mesh/register", json={"name": "a", "url": "http://a"}).status_code == 401
r = c.post("/api/mesh/register", json={"name": "a", "url": "http://a"}, headers={"X-Mesh-Key": "feil"})
assert r.status_code == 401
def test_register_tar_sonen_i_bruk_med_en_gang(c, registry):
r = c.post("/api/mesh/register", json={"name": "a", "url": "http://10.0.0.7:8090", "version": "0.3"}, headers=KEY)
assert r.status_code == 200
assert r.get_json()["name"] == "a" and r.get_json()["stale_seconds"] == 90
assert registry.zones()["a"].url == "http://10.0.0.7:8090"
s = c.get("/api/status").get_json()
assert list(s["zones"]) == ["her", "fast", "a"]
assert s["zones"]["a"]["source"] == "registered" and s["zones"]["a"]["last_seen"]
assert s["zones"]["fast"]["source"] == "static"
assert s["mesh"] == {"role": "sentral", "name": announcer.MESH_NAME, "heartbeat": None}
# og kan sendes til
assert c.post("/api/announce", json={"text": "x", "zones": ["a"]}).status_code == 200
@pytest.mark.parametrize("body, status, feil", [
({"name": "fast", "url": "http://x"}, 409, "er satt i"),
({"name": "her", "url": "http://x"}, 409, "eget navn"),
({"name": "", "url": "http://x"}, 400, "ugyldig sonenavn"),
({"name": "a", "url": "ikke-url"}, 400, "http"),
({}, 400, "ugyldig sonenavn"),
])
def test_register_avviser(c, registry, body, status, feil):
r = c.post("/api/mesh/register", json=body, headers=KEY)
assert r.status_code == status and feil in r.get_json()["error"]
def test_register_pa_en_som_ikke_er_sentral(c, tmp_path):
with_auth(tmp_path, MESH_CFG)
r = c.post("/api/mesh/register", json={"name": "a", "url": "http://a"}, headers=KEY)
assert r.status_code == 404 and "ikke sentral" in r.get_json()["error"]
def test_register_gjennom_ekte_hjerteslag(live_sone, tmp_path, monkeypatch):
"""Heartbeat -> post_register -> levende /api/mesh/register."""
reg = mesh.Registry({}, local=mesh.Zone("her"))
monkeypatch.setattr(announcer, "REGISTRY", reg)
hb = mesh.Heartbeat(name="pi-i-skapet", port=8093, secret="mesh-hemmelighet-for-test",
sentral=lambda: live_sone.url, version="0.3")
assert hb.beat_once() is True, hb.last_error
assert reg.zones()["pi-i-skapet"].url == "http://127.0.0.1:8093"
assert reg.info()["pi-i-skapet"]["version"] == "0.3"
hb_feil = mesh.Heartbeat(name="x", port=1, secret="feil", sentral=lambda: live_sone.url)
assert hb_feil.beat_once() is False and "401" in hb_feil.last_error

63
uv.lock generated
View file

@ -156,6 +156,7 @@ dependencies = [
{ name = "piper-tts" }, { name = "piper-tts" },
{ name = "pyjwt", extra = ["crypto"] }, { name = "pyjwt", extra = ["crypto"] },
{ name = "waitress" }, { name = "waitress" },
{ name = "zeroconf" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@ -170,6 +171,7 @@ requires-dist = [
{ name = "piper-tts", specifier = ">=1.8.0" }, { name = "piper-tts", specifier = ">=1.8.0" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.10" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10" },
{ name = "waitress", specifier = ">=3.0" }, { name = "waitress", specifier = ">=3.0" },
{ name = "zeroconf", specifier = ">=0.150" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@ -478,6 +480,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
] ]
[[package]]
name = "ifaddr"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/fb4c578f4a3256561548cd825646680edcadb9440f3f68add95ade1eb791/ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4", size = 10485, upload-time = "2022-06-15T21:40:27.561Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" },
]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.3.1" version = "2.3.1"
@ -1151,3 +1162,55 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" }, { url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" },
{ url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" },
] ]
[[package]]
name = "zeroconf"
version = "0.151.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ifaddr" },
]
sdist = { url = "https://files.pythonhosted.org/packages/93/20/69744d9de9d375dae2639dda9add7dae85d52d690b68c61b23b8a6468434/zeroconf-0.151.5.tar.gz", hash = "sha256:28c2ec9d772007eedf11b41a9c9fd3d5c684c17b00721ff8f1ee31b20ad286a1", size = 223583, upload-time = "2026-09-28T14:42:06.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/cd/4f68446818bd593bc69c705c4ccc430bad6fd9e266029d4178d024eb55fc/zeroconf-0.151.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44b34921217c4387cdb0b4b08ae510d9adbf76e80a3b84ae3f7c1f8cdc60c436", size = 1191993, upload-time = "2026-09-28T14:58:33.79Z" },
{ url = "https://files.pythonhosted.org/packages/67/27/0cacf5efeb9009efce1a10e7aefad942673f775f43aea0e425eb767da37e/zeroconf-0.151.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f346b414b882ac0dee5beb55bc8bd6ad8c7415f26d6ab4629d91bfd6c5f30f62", size = 1356576, upload-time = "2026-09-28T14:58:35.485Z" },
{ url = "https://files.pythonhosted.org/packages/92/46/7256041b6d46a9170adf4a6de88911284683f8b66b2dc0eb170b63135a02/zeroconf-0.151.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:56060316f3464c86c1fbd32287ffab28fbb9c539ffd6a6e070251c455252c50c", size = 1303303, upload-time = "2026-09-28T14:58:37.121Z" },
{ url = "https://files.pythonhosted.org/packages/c4/1a/1fa076936c37d192c36ef122d46c6a9bf584c6f8eb693e8f1eabb814bd9e/zeroconf-0.151.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92d5f219f09a45bebf28ce40776dbc1f8a0697871ceda8b6b9f2ffe8209826fe", size = 1412263, upload-time = "2026-09-28T14:58:38.71Z" },
{ url = "https://files.pythonhosted.org/packages/d7/09/06695f29fc4eab04e36921e0d8b508b0858bd3884b27a6afc82f02acf012/zeroconf-0.151.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a48fedd887c3bafe45cf24e08aaa06a1b641ec3403696f4a01701ae64c9088a0", size = 1377997, upload-time = "2026-09-28T14:58:40.495Z" },
{ url = "https://files.pythonhosted.org/packages/ca/a6/b2b82b9ad1a1dc015636a05059ee43408be3f2952f50090f62a68dfdd5be/zeroconf-0.151.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:23f5d281c130af8f4976a611a1db196d17395d69815aeff3537a599430b9ca5a", size = 1319070, upload-time = "2026-09-28T14:58:42.387Z" },
{ url = "https://files.pythonhosted.org/packages/0b/29/6d925d5f616ced1c75496e4ee1fad31a9355cee1a778a967abef1b804760/zeroconf-0.151.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:667d8fb8f6cb02d9e28085759f5c336b26fc596ce427a36dbb20ecd440dfe029", size = 1429747, upload-time = "2026-09-28T14:58:44.333Z" },
{ url = "https://files.pythonhosted.org/packages/92/f0/8e7ec5591e32d7ca2b92526f965d88c449adedd74f634eac828421b97de6/zeroconf-0.151.5-cp313-cp313-win32.whl", hash = "sha256:475e527d371fdc8d29d10cd2300140e735a8562df041041465704cff63452d9f", size = 943311, upload-time = "2026-09-28T14:58:46.236Z" },
{ url = "https://files.pythonhosted.org/packages/22/51/e4370355554e9e15f59abae41c42bbbe95b979f57cf1fc4cb16ad1bc97f3/zeroconf-0.151.5-cp313-cp313-win_amd64.whl", hash = "sha256:05cdec63c6bc2fde2086a174339b40947bc30eead775effaab90c20558835f89", size = 1062306, upload-time = "2026-09-28T14:58:47.801Z" },
{ url = "https://files.pythonhosted.org/packages/72/e2/36f043cdf3970bc9f07cbd0af0263e40681d5a4693e9bb250b29aa425fae/zeroconf-0.151.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cba88b0fb77ea1b4dceaebae577c8245852159c525201b2453cd3bd5715cf56", size = 1204720, upload-time = "2026-09-28T14:58:49.354Z" },
{ url = "https://files.pythonhosted.org/packages/90/c3/fc795bfc5450ea339f212162a76548424f8e860bf043ba041634aab53aa1/zeroconf-0.151.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b9f3f49860cb60dd5d1453c681dbcced4cac96960935f1b9f7c24d7f3993ce", size = 1379162, upload-time = "2026-09-28T14:58:50.895Z" },
{ url = "https://files.pythonhosted.org/packages/3d/c9/54155cd16ba7247ee171be2d95c49273445aa8e3c9517e7a7d861922c65c/zeroconf-0.151.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2304c3fd4717422242cc78315b37d48613109963900e0c9a2b784a91fd6e9c0e", size = 1290560, upload-time = "2026-09-28T14:58:52.542Z" },
{ url = "https://files.pythonhosted.org/packages/16/d3/9b6fa4f94a5be0145730f998bdf44c642db159d7a7f2f9fb5a1ed4adce04/zeroconf-0.151.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5846416ae2bddacb1bbb755233dd13c4bd15d94dfd0feb23a4b1abe1e2312302", size = 1417844, upload-time = "2026-09-28T14:58:54.382Z" },
{ url = "https://files.pythonhosted.org/packages/c3/31/43c707f69d6118766a5950e86f23b7e2130eea119c9d491460d663db4b06/zeroconf-0.151.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c1f45df00d20918fc7a0c2a6229ae1f9b44fbbef6a161df96c7710098c82e019", size = 1398885, upload-time = "2026-09-28T14:58:56.167Z" },
{ url = "https://files.pythonhosted.org/packages/2d/68/f0b29845b18e2436e169e3d2a4889da6802077115a67289abac1b14b4976/zeroconf-0.151.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f8f7ec289a07acb9e272d0f2998759b76ec81dd23b15a7506e05689f630fafd1", size = 1303148, upload-time = "2026-09-28T14:58:57.91Z" },
{ url = "https://files.pythonhosted.org/packages/99/84/566e4a3f104b0a49f119056d9ba60c4ff9e0ed88cbf22214d995aa1751dd/zeroconf-0.151.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dc7d125bd01eca9f63e7c65a3e52a81e385ef6ac25721bc67e15b99efc3ce6b0", size = 1436151, upload-time = "2026-09-28T14:58:59.608Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6e/939e5e9fe9fef9c0b2dc2ad13e2cb843f9d1019de5196e22a0489c8cb01f/zeroconf-0.151.5-cp314-cp314-win32.whl", hash = "sha256:965d9e92ad34896a2537352a37affcff2daeb8c7af2072451220e753f0de990d", size = 965883, upload-time = "2026-09-28T14:59:01.278Z" },
{ url = "https://files.pythonhosted.org/packages/db/0b/4ff510db9f43b08a95907a82602017c5a58b2bf470663a755d1fae38c437/zeroconf-0.151.5-cp314-cp314-win_amd64.whl", hash = "sha256:3d3d2f0eb51e605f2010e83ab49db34c70c51304ee88a980ed1c59c0ea20b82f", size = 1088620, upload-time = "2026-09-28T14:59:02.906Z" },
{ url = "https://files.pythonhosted.org/packages/ef/ed/c26550e38ecf81cb83fbfbe41c6b7fa3737db0f435f9b3a0a80ae3da499e/zeroconf-0.151.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6a4bde05914a6dc6a4de83fb9f85ebcbf7fb1c89d9021122b689ebb6aa9d717c", size = 2380718, upload-time = "2026-09-28T14:59:04.743Z" },
{ url = "https://files.pythonhosted.org/packages/77/e8/fc6f3d0335340ec5ba5e137014f46d6d752a8d60c897950883bab21453c3/zeroconf-0.151.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5daadc4306a6def67187dd8a4ee15978d0c4d425bad4dfef7b2131e909edce02", size = 2662627, upload-time = "2026-09-28T14:59:06.467Z" },
{ url = "https://files.pythonhosted.org/packages/31/8e/94f1f3664a53a53eee7ff86a39ca507430cddba4c7dc1c7cc40992cd66c9/zeroconf-0.151.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5cfb9d625a12bb6788c142bd238479444ba43b8cbb56d6e0fd2becc17ef8e4a3", size = 2462679, upload-time = "2026-09-28T14:59:08.337Z" },
{ url = "https://files.pythonhosted.org/packages/9c/4f/b9274656c182405cfd4e3c3385ab209a7dda039ac3b4c0c72a47dd90e391/zeroconf-0.151.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd773d22ddd60f696e0be063a925a9d34f8de7bcc40e4b9c9360c9e081f97ab0", size = 2728078, upload-time = "2026-09-28T14:59:10.401Z" },
{ url = "https://files.pythonhosted.org/packages/27/39/2188581d88daebc349eb0519cebd6a951766201de510b57355f3b60f10c3/zeroconf-0.151.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99729ee6109f08ebee6ce7ef7bd4280747848550462d40275b059e3eaca57", size = 2706423, upload-time = "2026-09-28T14:59:12.439Z" },
{ url = "https://files.pythonhosted.org/packages/77/f3/86aaca253b7eee26f3990e547a88590481a02847e7a2d46a320289efd377/zeroconf-0.151.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:faecc5a1f318a920a4f7052a14391ae6def8fd6035313886e88f53705ad9dc8e", size = 2507468, upload-time = "2026-09-28T14:59:14.285Z" },
{ url = "https://files.pythonhosted.org/packages/ae/db/26d680ccf78e9af53710069e3303ebd063f194f74ab6a05f384f85b1d57a/zeroconf-0.151.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b124e30e18f29690907f7f7ed8b79ab34fa8f40fe300a6f5a8f21b04148497e5", size = 2767519, upload-time = "2026-09-28T14:59:16.261Z" },
{ url = "https://files.pythonhosted.org/packages/0c/df/0b23bf3e3a27bda2a71e70c0ce7eb15e81f4cf4ee292f2c55b562a937ef7/zeroconf-0.151.5-cp314-cp314t-win32.whl", hash = "sha256:6385a69307ee9ccd71d0d2374f0a822590e2468919acbd4ae6bf3ab5f501a1f3", size = 1901603, upload-time = "2026-09-28T14:59:18.053Z" },
{ url = "https://files.pythonhosted.org/packages/d7/6f/8809f921f46fa1f32115bcaa702752fb028af347f751baf5648c15c43454/zeroconf-0.151.5-cp314-cp314t-win_amd64.whl", hash = "sha256:9583f52f51f08f7b14e9f09d99cc0e5ed29afd02bfef6f9d89f175a3e3582a33", size = 2147378, upload-time = "2026-09-28T14:59:19.809Z" },
{ url = "https://files.pythonhosted.org/packages/a8/67/7094dfc74bd9b7263a71a1a5706965750600d7cf740795ce123860986206/zeroconf-0.151.5-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09a933cd3a4bb6a5a0eaf84b58aa0d32af330aa81aa75fc0e300551ccb9b775b", size = 1196545, upload-time = "2026-09-28T14:59:21.559Z" },
{ url = "https://files.pythonhosted.org/packages/07/48/8d99f04bdede4441fd99b86fc499d6f7a0791a1a21fd6c67b70f70f71c28/zeroconf-0.151.5-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:532e28946f6b384369747771e71094f76aa75b4bc2dfba6c099286d90111caf0", size = 1377817, upload-time = "2026-09-28T14:59:23.688Z" },
{ url = "https://files.pythonhosted.org/packages/c7/f0/1d8615d4ac47002b75072b0f4cb2101a7608ee9f6f94f1bd62717c1d7452/zeroconf-0.151.5-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:720a872df2924faedd33c737c2b8a4ac43c045b0b278c58d8b32e1eadff4fa9f", size = 1421642, upload-time = "2026-09-28T14:59:25.796Z" },
{ url = "https://files.pythonhosted.org/packages/7a/3e/2288a1d5f4e100bbc732f01ad6e842dd69c8ff53c8fcca0ee8374bb69888/zeroconf-0.151.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c6f98dafac6bacb715fd6644921a9220c09499fcaedc308d27e6fd4fd157837e", size = 1399793, upload-time = "2026-09-28T14:59:27.556Z" },
{ url = "https://files.pythonhosted.org/packages/72/53/80921aefeca275228412d50b8d1c613d25d538b301f8d6fcb014aa6b581e/zeroconf-0.151.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:bf19169739ee8608748db3533670f1f8622727e4cf189a7619eadf0895ccf2dd", size = 1440276, upload-time = "2026-09-28T14:59:29.578Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c8/c2c1dc5d457923978e6b014afadb1a35ece20da9085487b3a2df8b48ba41/zeroconf-0.151.5-cp315-cp315-win32.whl", hash = "sha256:422592b2d7dbe8c82fb0ec38f33898fa4a580587a8ee9a1554d1d5c477cf7903", size = 964513, upload-time = "2026-09-28T14:59:31.355Z" },
{ url = "https://files.pythonhosted.org/packages/cf/bf/d0cdb84ac3372388bc4ec6f2c2007e41ec687ec4e716da20f70fb5865dbb/zeroconf-0.151.5-cp315-cp315-win_amd64.whl", hash = "sha256:1ab36f829be85f6546476be525463879e11ce67c6eb18d0b01b1c2b989ba35d0", size = 1085993, upload-time = "2026-09-28T14:59:33.03Z" },
{ url = "https://files.pythonhosted.org/packages/77/82/446e9cc745f82d1ff7c937041a18c98505ee2d665012c5da8ca9869b6ef1/zeroconf-0.151.5-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3da652fc2d7ba788651b8713340b6470e7218f6fd8392755666a49636d9d3383", size = 2361921, upload-time = "2026-09-28T14:59:34.927Z" },
{ url = "https://files.pythonhosted.org/packages/58/2d/e74fd03904503bbf16ea3501c7998565bf6f196d69fbdc4bcf5bb4a7bae7/zeroconf-0.151.5-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c24366d4e60b72adbf6fc954fb8df6165dae44d137f5c8afd8f7272995580681", size = 2654810, upload-time = "2026-09-28T14:59:37.065Z" },
{ url = "https://files.pythonhosted.org/packages/fe/c9/336b6830f4126cbf5f4817e9b070f4a43da222ba456986bc1c558931658d/zeroconf-0.151.5-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99162e67862c2f17aeb5a432c70757419573e33cfab925eed566f8bc2a346bcb", size = 2725607, upload-time = "2026-09-28T14:59:39.051Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0f/f9f89e9f0715c4037f9878eafd0fae2ce82b3e73e934c008d8489380ae6c/zeroconf-0.151.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c3f34b0e863f20af95fca7ef5871536015c964ce07462b6462c3246f6a385177", size = 2702284, upload-time = "2026-09-28T14:59:40.967Z" },
{ url = "https://files.pythonhosted.org/packages/b2/c3/3518c9ebc6ea9c620b7d4b21f4947c54fa4613f7543a42e9a72565fbaf10/zeroconf-0.151.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:37f8a073825a208878e994300c0feb7d6b253e2af3d258678a8beec6b020506a", size = 2765808, upload-time = "2026-09-28T14:59:43.156Z" },
{ url = "https://files.pythonhosted.org/packages/b4/86/ab74084ccce454ef897cf1e39d09be6d43aa799f1deeaf816296c5dca023/zeroconf-0.151.5-cp315-cp315t-win32.whl", hash = "sha256:a382205700aa8ea63634b4a92b41176a6033bef7ec2380bfd0503e3e11e6d598", size = 1894687, upload-time = "2026-09-28T14:59:44.952Z" },
{ url = "https://files.pythonhosted.org/packages/29/93/263fdd075d310e220d53e77f252862bd50cf69f98d10c7e0b0e1e9679e78/zeroconf-0.151.5-cp315-cp315t-win_amd64.whl", hash = "sha256:00ee9d456519c45c94201334d5cbd2699d5de9cc9ceeb647203b85295640abab", size = 2134526, upload-time = "2026-09-28T14:59:46.913Z" },
]