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:
parent
eb27a162d5
commit
c4a15799dd
17 changed files with 1486 additions and 23 deletions
48
auth.py
48
auth.py
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue