«Send denne kl. 18:00.» Engangs, ikke gjentakende. Dette er den første delen av appen som må ligge på disk. Kø og historikk har alltid bare levd i minnet, og det har gått bra fordi en melding spilles med en gang. En melding planlagt til 18:00 må overleve at containeren startes om 17:45, så schedule.py lagrer i data/scheduled.json – skrevet til side og flyttet på plass, så fila aldri er halvskrevet. Ingen database, med vilje: dette er en håndfull meldinger på et arrangement, og en fil man kan lese med cat er verdt mer her enn indekser. En ødelagt fil hindrer ikke oppstart; da er det bedre å miste planene og si det i loggen. Modulen vet ingenting om Flask eller avspilling. Den lagrer, og den svarer på «hva skal sendes nå?». _scheduler() i announcer.py eier tråden som spør hvert 20. sekund og legger forfalte meldinger i den vanlige køen – derfra oppfører de seg nøyaktig som en melding sendt for hånd. Gikk den glipp av, spilles den ikke. due() skiller på grace_seconds (120): innenfor den sendes meldingen, utenfor markeres den missed og vises som det. Nådefristen er slingringsmonn for at tråden våkner med mellomrom og for en kjapp omstart, ikke en policy om å ta igjen etterslep – «turneringen starter om ti minutter» skal ikke ut på anlegget en time etter at maskina kom tilbake. due() endrer ingenting selv; markeringen skjer først etter at meldingen faktisk er i kø, så ingenting kan stå som «sendt» uten å ha blitt det. Klienten sender absolutt tidspunkt (epoch), så tidssoner spiller ingen rolle for selve planleggingen. TZ settes likevel fra .env, ellers står loggen i UTC mens man selv står i CEST. Planlegging er låst til samme forhåndslytt-nøkkel som «Send ut»: man planlegger bare det man har hørt. Det gir en bonus – forhåndslytt varmer cache/, så WAV-en ligger ferdig på disk, og en planlagt edge-tts-melding spiller kl. 18:00 selv om nettet er nede da. Sine egne kan man alltid avbryte. Andres krever clear, samme rett som å tømme køen, siden det er samme slags inngrep i noe andre har satt i gang. 23 nye tester. Verifisert i container: sending fyrer og spiller, overlever en omstart av containeren, og en melding som forfalt mens maskina var av blir markert missed uten å bli spilt. Merk ved oppgradering: schedule er en ny handling, og et oppsett med eksplisitt [roles] i config.toml får den ikke automatisk. Den må legges inn selv. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
205 lines
6.4 KiB
Markdown
205 lines
6.4 KiB
Markdown
# Koble announcer til crew-panelet
|
||
|
||
Announcer har ingen brukerdatabase og skal ikke ha det. Den stoler på
|
||
crew-panelet og leser identitet og roller ut av det panelet sier. Dette
|
||
dokumentet er kontrakten: hva panelet må levere, og i hvilken rekkefølge det er
|
||
verdt å bygge det.
|
||
|
||
Panelet har i dag verken JWT eller OIDC. **Bygg JWT først** – det er noen titalls
|
||
linjer i panelet og dekker behovet. OIDC kan komme etterpå uten at noe i
|
||
announcer må skrives om; begge veier ender i samme kode.
|
||
|
||
---
|
||
|
||
## Trinn 1: JWT (anbefalt start)
|
||
|
||
Panelet signerer et kortlevd token som sier hvem brukeren er og hvilke roller
|
||
han har. Announcer verifiserer signaturen og stoler på innholdet.
|
||
|
||
### Hva tokenet skal inneholde
|
||
|
||
```json
|
||
{
|
||
"iss": "https://crew.example.no",
|
||
"aud": "announcer",
|
||
"sub": "142",
|
||
"name": "Kari Nordmann",
|
||
"roles": ["crew"],
|
||
"exp": 1760000000
|
||
}
|
||
```
|
||
|
||
| Claim | Krav | Merknad |
|
||
|---|---|---|
|
||
| `iss` | må stemme med `auth.jwt.issuer` | sjekkes bare hvis den er satt i config |
|
||
| `aud` | må stemme med `auth.jwt.audience` | bruk `"announcer"` |
|
||
| `sub` | påkrevd i praksis | stabil bruker-id; havner i loggen som `jwt:<sub>` |
|
||
| `exp` | **påkrevd** | announcer avviser tokens uten utløpstid |
|
||
| `name` | valgfri | vises i køen. Faller tilbake på `preferred_username`, `email`, så `sub` |
|
||
| `roles` | valgfri | se under |
|
||
|
||
`roles` kan være en liste (`["crew"]`), en streng (`"crew"`), mellomroms- eller
|
||
kommadelt (`"crew admin"`), og den kan ligge nestet – da peker du på den med
|
||
`role_claim = "realm_access.roles"`. Mangler den helt, får brukeren
|
||
`auth.jwt.default_role`, eller ingen rettigheter hvis den ikke er satt.
|
||
|
||
Rollenavnene betyr ingenting for announcer i seg selv; `[roles]` i `config.toml`
|
||
bestemmer hva de får lov til.
|
||
|
||
### Signering
|
||
|
||
To varianter. Begge virker, den andre er bedre:
|
||
|
||
**HS256 med delt hemmelighet** – enklest. Samme streng i panelet og i
|
||
announcer. Ulempen er at announcer dermed har en nøkkel som *kan lage* gyldige
|
||
tokens.
|
||
|
||
```toml
|
||
[auth.jwt]
|
||
issuer = "https://crew.example.no"
|
||
audience = "announcer"
|
||
# settes som ANNOUNCER_JWT_SECRET i systemd-enheten, ikke her
|
||
```
|
||
|
||
**RS256 med JWKS** – panelet signerer med sin private nøkkel og publiserer den
|
||
offentlige på `/.well-known/jwks.json`. Announcer henter den og cacher den en
|
||
time. Da kan announcer bare verifisere, aldri signere, og nøkkelrullering
|
||
skjer av seg selv.
|
||
|
||
```toml
|
||
[auth.jwt]
|
||
issuer = "https://crew.example.no"
|
||
audience = "announcer"
|
||
jwks_url = "https://crew.example.no/.well-known/jwks.json"
|
||
```
|
||
|
||
### Hvordan brukeren kommer inn
|
||
|
||
To måter, og panelet kan bruke begge:
|
||
|
||
**Lenke (det du vil ha for et crew-medlem).** Panelet lager en knapp «Åpne
|
||
announcer» som peker på
|
||
|
||
```
|
||
http://<announcer>:8090/auth/token?t=<jwt>
|
||
```
|
||
|
||
Announcer verifiserer tokenet, setter en sesjonscookie og sender brukeren til
|
||
forsida. Ett klikk, ingen PIN å huske. Lag tokenet kortlevd (`exp` et par
|
||
minutter fram) – det er bare en billett inn; sesjonen varer så lenge tokenets
|
||
`exp` sier, eller `session_seconds` hvis det er lengre.
|
||
|
||
**Header (for skript og integrasjoner).** Send tokenet på hver forespørsel:
|
||
|
||
```bash
|
||
curl -X POST http://announcer:8090/api/announce \
|
||
-H "Authorization: Bearer $JWT" \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"text":"Testmelding","voice":"finn"}'
|
||
```
|
||
|
||
### Minimum i panelet (Python-eksempel)
|
||
|
||
```python
|
||
import time, jwt # pyjwt
|
||
|
||
def announcer_url(user) -> str:
|
||
token = jwt.encode(
|
||
{
|
||
"iss": "https://crew.example.no",
|
||
"aud": "announcer",
|
||
"sub": str(user.id),
|
||
"name": user.full_name,
|
||
"roles": ["admin" if user.is_crew_lead else "crew"],
|
||
"exp": time.time() + 120,
|
||
},
|
||
ANNOUNCER_JWT_SECRET,
|
||
algorithm="HS256",
|
||
)
|
||
return f"http://announcer.lan:8090/auth/token?t={token}"
|
||
```
|
||
|
||
---
|
||
|
||
## Trinn 2: OIDC
|
||
|
||
Når panelet har fått `authorize`-, `token`- og gjerne `userinfo`-endepunkter,
|
||
kan announcer bli en vanlig OIDC-klient. Da logger brukeren inn i panelet i
|
||
nettleseren og trenger ingen lenke med token i.
|
||
|
||
Announcer bruker autorisasjonskode med PKCE (S256). Dette må registreres i
|
||
panelet:
|
||
|
||
| | |
|
||
|---|---|
|
||
| `client_id` | f.eks. `announcer` |
|
||
| `redirect_uri` | `http://<vert>:8090/auth/callback` – **eksakt**, inkludert port |
|
||
| `grant_type` | `authorization_code` |
|
||
| `scope` | `openid profile email` (eller bare `openid`) |
|
||
| PKCE | `S256` |
|
||
|
||
Announcer leser `{issuer}/.well-known/openid-configuration` ved første
|
||
innlogging. Har ikke panelet discovery, settes endepunktene direkte:
|
||
|
||
```toml
|
||
[auth.oidc]
|
||
issuer = "https://crew.example.no"
|
||
client_id = "announcer"
|
||
authorization_endpoint = "https://crew.example.no/oauth/authorize"
|
||
token_endpoint = "https://crew.example.no/oauth/token"
|
||
jwks_url = "https://crew.example.no/.well-known/jwks.json"
|
||
```
|
||
|
||
`client_secret` settes som `ANNOUNCER_OIDC_CLIENT_SECRET`. Rollene leses ut av
|
||
`id_token` på samme måte som over.
|
||
|
||
Står announcer bak en omvendt proxy, sett `server.public_url` så
|
||
`redirect_uri` blir riktig, og `server.behind_tls = true`.
|
||
|
||
---
|
||
|
||
## Roller og rettigheter
|
||
|
||
Fem handlinger. `config.toml` kobler rollenavn til dem:
|
||
|
||
| Handling | Hva den gir |
|
||
|---|---|
|
||
| `view` | se køen og hva som spilles nå |
|
||
| `preview` | forhåndslytte (koster en syntese) |
|
||
| `announce` | sende ut på anlegget |
|
||
| `schedule` | planlegge en sending fram i tid, og avbryte sine egne |
|
||
| `stop` | stoppe det som spilles |
|
||
| `clear` | tømme køen (og se IP-adresser i køen) |
|
||
|
||
```toml
|
||
[roles]
|
||
anonymous = [] # ["view"] for en statusskjerm
|
||
crew = ["view", "preview", "announce", "schedule"]
|
||
admin = ["*"]
|
||
```
|
||
|
||
Endrer du `[roles]` slår det inn ved neste forespørsel – ingen må logge inn på
|
||
nytt. (Nye `[auth.*]`-verdier krever omstart.)
|
||
|
||
## Hva announcer svarer
|
||
|
||
| Kode | Betyr | Hva klienten skal gjøre |
|
||
|---|---|---|
|
||
| 401 | vi vet ikke hvem du er | logg inn |
|
||
| 403 | vi vet hvem du er, rollen din får ikke dette | ikke prøv igjen |
|
||
|
||
`GET /api/config` svarer alltid 200 og forteller i `auth`-blokka hvilke metoder
|
||
som er aktive, hvem du er og hva du får gjøre. Det er den sida bruker for å
|
||
vise eller skjule knapper.
|
||
|
||
## Loggen
|
||
|
||
Hvert utrop havner i `logs/announcements.jsonl`, én JSON-linje per hendelse:
|
||
|
||
```json
|
||
{"ts":1760000000.12,"action":"announce","subject":"jwt:142","name":"Kari Nordmann",
|
||
"method":"jwt","ip":"10.20.70.55","id":7,"text":"Pizzaen er kommet","voice":"finn"}
|
||
```
|
||
|
||
`action` er `announce`, `played`, `stop` eller `clear`. Fila rullerer til
|
||
`.jsonl.1` ved 5 MB.
|