Mesh: én sentral fordeler til flere soner

Flere anlegg – hovedscene, resepsjon, crew – og ett sted å sende fra, med
avkryssing for hvilke soner meldingen skal til.

Sentralen er en vanlig announcer med [zones] i config. En sone er en vanlig
announcer med ANNOUNCER_MESH_SECRET. Samme image, ingen mode, og en sone kan
brukes alene hvis sentralen faller ut. Hver avhengighet med C-kode har
aarch64-wheel i uv.lock, så det samme bildet går på en Raspberry Pi uten å
kompilere noe.

Sentralen syntetiserer én gang og sender ferdig WAV (POST /api/play,
multipart). Sonen trenger da verken nett, edge-tts eller Pipers 670 MB for
det den får – Item.wav er hele endringen i avspillingen. Køene forblir per
sone: dispatcheren har egen tråd og egen kø, leverer til hver sone i hver sin
tråd med timeout, og går videre. En opptatt hovedscene holder ikke igjen
resepsjonen, og en sone som er nede feiler alene – synlig per sone i UI og
revisjonslogg.

Auth gjenbrukes: ny leverandør mesh på header X-Mesh-Key (ikke Bearer – den
leses av JWT-leverandøren, som avviser alt som ikke er et JWT før neste får
prøve), med én handling, play. To regler som fulgte med:

- play følger aldri med «alt åpent». Uten det kunne hvem som helst POSTe en
  WAV til en åpen sone og få den spilt. Testen for åpen modus fanget det.
- En legitimasjon som vises fram og er feil gir 401 også der gjester ellers
  har lov. Før slapp en feil PIN i åpen modus stille gjennom som gjest.

mesh teller ikke som innlogging for folk: en sone med bare nøkkelen har
fortsatt åpen side lokalt.

Planlagte sendinger husker sonene sine og fordeles gjennom dispatcheren;
planen ender som sent hvis minst én sone tok imot, med «hovedscene: kø #3,
crew: nede» som note.

Hodeløs Pi: install.sh kjører loginctl enable-linger, ellers finnes ikke
lydsocketen når ingen logger inn. docs/mesh.md tar også raspi-config for
jack framfor HDMI.

38 nye tester, 148 totalt. Fordelingen testes uten nett med stubbet
transport; selve HTTP-en testes mot en levende werkzeug-server, så den
håndrullede multiparten møter Flask sin parser. Verifisert i compose med
sentral og to soner: til alle, bare til én, og én sone stoppet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kent 2026-10-09 02:22:05 +02:00
parent eb27a162d5
commit c4a15799dd
17 changed files with 1486 additions and 23 deletions

48
auth.py
View file

@ -214,6 +214,29 @@ class PinProvider(Provider):
return {"method": "pin", "label": self.label, "hint": self.hint}
class MeshProvider(Provider):
"""Sentralen som leverer en WAV til denne sonen.
Egen header, ikke Authorization: Bearer – JwtProvider ville avvist en
nøkkel som ikke er et JWT før neste leverandør fikk prøve.
"""
name = "mesh"
def __init__(self, cfg: Config) -> None:
self.secret = str(cfg.get("mesh", "secret", "")).strip()
self.cfg = cfg
def authenticate(self, req) -> Principal | None: # noqa: ANN001
given = req.headers.get("X-Mesh-Key", "").strip()
if not given:
return None
if not hmac.compare_digest(given, self.secret):
raise AuthError("Feil mesh-nøkkel")
roles = ("mesh",)
return Principal("mesh", "sentral", roles, "mesh", self.cfg.permissions(roles))
class JwtProvider(Provider):
name = "jwt"
@ -372,7 +395,7 @@ class OidcProvider(Provider):
return {"method": "oidc", "label": self.label, "login_url": "/auth/login"}
PROVIDERS = {"pin": PinProvider, "jwt": JwtProvider, "oidc": OidcProvider}
PROVIDERS = {"mesh": MeshProvider, "pin": PinProvider, "jwt": JwtProvider, "oidc": OidcProvider}
# --------------------------------------------------------------------------
@ -412,7 +435,10 @@ class Authenticator:
self.providers: list[Provider] = [
PROVIDERS[name](cfg) for name in cfg.active_providers()
]
self.enabled = bool(self.providers)
# mesh er maskin-til-maskin og teller ikke som innlogging for folk:
# en sone som bare har mesh-nøkkel skal fortsatt ha åpen side lokalt.
self.human: list[Provider] = [p for p in self.providers if p.name != "mesh"]
self.enabled = bool(self.human)
def provider(self, name: str) -> Provider | None:
return next((p for p in self.providers if p.name == name), None)
@ -426,8 +452,11 @@ class Authenticator:
veggen uten innlogging."""
if not self.enabled:
# Tomt navn: da står køen og loggen som før, uten en «bruker» som
# i praksis bare betyr «hvem som helst».
return Principal("anonymous", "", ("anonymous",), "none", frozenset(ACTIONS))
# i praksis bare betyr «hvem som helst». Og aldri «play»: den er
# for en sentral med mesh-nøkkel, ikke for hvem som helst som
# finner en åpen sone og vil spille lyd på den.
return Principal("anonymous", "", ("anonymous",), "none",
frozenset(ACTIONS) - {"play"})
roles = ("anonymous",)
return Principal("anonymous", "ikke innlogget", roles, "anonymous", self.cfg.permissions(roles))
@ -479,7 +508,7 @@ class Authenticator:
def describe(self) -> dict[str, Any]:
return {
"enabled": self.enabled,
"methods": [p.describe() for p in self.providers],
"methods": [p.describe() for p in self.human],
"logout_url": "/auth/logout" if self.enabled else None,
}
@ -507,16 +536,21 @@ def require(action: str) -> Principal:
hvem det er men rollen mangler handlingen.
"""
p = current()
if error():
# Noen viste fram en PIN, et token eller en mesh-nøkkel som ikke
# holdt. Det er en feil, ikke en anonym bruker – også på en åpen
# sone, der anonym ellers hadde fått lov.
abort(401, error())
if p.can(action):
return p
if p.anonymous:
abort(401, error() or _login_hint())
abort(401, _login_hint())
abort(403, f"Rollen din ({', '.join(p.roles) or 'ingen'}) har ikke lov til dette.")
def _login_hint() -> str:
assert _auth is not None
names = {p.name for p in _auth.providers}
names = {p.name for p in _auth.human}
if names == {"pin"}:
return "Feil eller manglende PIN."
if "oidc" in names: