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:
parent
c4a15799dd
commit
7c521f8d1f
21 changed files with 1106 additions and 137 deletions
17
.env.example
17
.env.example
|
|
@ -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.
|
||||||
|
|
|
||||||
48
AGENTS.md
48
AGENTS.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
114
announcer.py
114
announcer.py
|
|
@ -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__":
|
||||||
|
|
|
||||||
2
auth.py
2
auth.py
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
36
compose.yaml
36
compose.yaml
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
38
config.py
38
config.py
|
|
@ -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
165
discovery.py
Normal 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
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
85
docs/mesh.md
85
docs/mesh.md
|
|
@ -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 | |
|
||||||
|
|
|
||||||
26
index.html
26
index.html
|
|
@ -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];
|
||||||
|
|
|
||||||
61
install.sh
61
install.sh
|
|
@ -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
240
mesh.py
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
63
uv.lock
generated
|
|
@ -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" },
|
||||||
|
]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue