diff --git a/.gitignore b/.gitignore index 2e5b5d8..f2d22f9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ config.toml logs/ instance/ .pytest_cache/ +.docker-data/ +# onnxruntime slenger fra seg denne når telemetri-ID ikke kan lagres +:memory:.ses diff --git a/AGENTS.md b/AGENTS.md index 8a4cab4..5a66ce4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,8 @@ Filer: | `config.example.toml` | kommentert mal; kopier til `config.toml` | | `docs/crew-integration.md` | kontrakten mot crew-panelet | | `tests/test_auth.py` | `uv run pytest` | +| `compose.yaml` + `Dockerfile` | testoppsett, se under. Ikke produksjon | +| `docker/dummy_idp.py` | falsk crew-panel, så OIDC kan prøves | ## Kjøring @@ -49,6 +51,39 @@ shebangen til hvert konsollskript, så `uv sync` alene er ikke nok. Da må `rm -rf .venv && uv sync` til, og tjenesten startes om. +## Testing i Docker + +`docker compose up --build` gir to containere: + +| | | +|---|---| +| `http://localhost:8091` | announcer med PIN, JWT **og** OIDC slått på (`docker/config.test.toml`) | +| `http://localhost:9000` | `docker/dummy_idp.py`, som later som den er crew-panelet | + +Port 8091 med vilje, så den ikke kolliderer med tjenesten på 8090. + +Dummy-IdP-en finnes fordi crew-panelet ikke har OIDC ennå: uten den kan +OIDC-veien ikke prøves i det hele tatt. Den kjører en ekte +autorisasjonskode-runde med PKCE (og avviser feil `code_verifier`), signerer +`id_token` med klienthemmeligheten, og lar deg velge hvem du logger inn som – +Kari (`crew`), Kent (`admin`) eller Ola (ingen roller, skal gi 403, ikke 401). +`http://localhost:9000/token?who=kari&go=1` logger deg rett inn via +token-lenka, slik panelet skal gjøre. + +Merk i `docker/config.test.toml`: `authorization_endpoint` peker på +`localhost:9000` fordi nettleseren skal dit, mens `token_endpoint` peker på +`idp:9000` fordi announcer selv snakker med den inne i compose-nettet. Delt +horisont, og samtidig en test av at manuelle endepunkter virker når et panel +mangler discovery. + +Lyd: `/run/user/1000` er montert inn, så `pw-play` i containeren spiller på +vertens anlegg. Kommenter ut den linja i `compose.yaml` hvis du bare skal +teste innlogging. + +`UV_PROJECT_ENVIRONMENT=/opt/venv` i bildet er ikke pynt: `/app` er et +bind-mount av repoet, og uten det ville containerens `uv sync` overskrevet +`.venv` som den kjørende tjenesten bruker. + ## Konfigurasjon Tre lag, siste vinner: `DEFAULTS` i `config.py` → `config.toml` → @@ -161,8 +196,9 @@ Vil Kent ha enda bedre lyd: ElevenLabs (nøkkel, best norsk) eller OpenAI ned stemmer. Harmløst, men greit å vite. - `tests/conftest.py` nuller ut alle `ANNOUNCER_*`-variabler før hver test. Uten den arver testene maskinens miljø – systemd-enheten setter - `ANNOUNCER_PORT` – og man får røde tester med feilmeldinger som ikke peker - på noe galt. + `ANNOUNCER_PORT`, og compose setter JWT- og OIDC-hemmeligheter, noe som + gjorde 28 tester røde i containeren med feilmeldinger som ikke pekte på noe + galt. - `pyproject.toml` har `[tool.uv] package = false`. Appen er løse `.py`-filer som kjøres på plass, ikke en pakke; uten den vil `uv sync` bygge et wheel og klage på at `announcer/__init__.py` mangler. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dd40177 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Testbilde for announcer. Ikke ment for produksjon – på en ekte maskin +# kjører appen nativt med install.sh, fordi lyden skal ut på maskinens egen +# lydutgang og det er vondt å få til gjennom et containerlag. +# +# Her er poenget å kunne prøve innlogging (PIN, JWT, OIDC) uten å røre +# tjenesten som kjører, og uten sudo eller brannmurendringer. +FROM python:3.13-slim + +# ffmpeg trengs av edge_pcm() og av egne jinglefiler. pw-play/paplay er bare +# nyttig hvis vertens PipeWire-socket monteres inn; uten den feiler selve +# avspillingen, men resten av appen virker. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg ca-certificates curl \ + && (apt-get install -y --no-install-recommends pipewire-bin pulseaudio-utils || true) \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Venv utenfor /app: /app er et bind-mount av repoet, og en .venv bygget her +# ville ellers overskrevet den verten bruker til den kjørende tjenesten. +ENV UV_PROJECT_ENVIRONMENT=/opt/venv \ + UV_COMPILE_BYTECODE=1 \ + PATH=/opt/venv/bin:$PATH \ + PYTHONUNBUFFERED=1 + +WORKDIR /app +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen + +EXPOSE 8090 +CMD ["python", "announcer.py", "--host", "0.0.0.0", "--port", "8090"] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..34629b6 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,45 @@ +# Testoppsett, ikke produksjon: docker compose up --build +# +# http://localhost:8091 announcer (PIN + JWT + OIDC slått på) +# http://localhost:9000 dummy-IdP som later som den er crew-panelet +# +# Porten er 8091 med vilje, så den ikke kolliderer med tjenesten på 8090. +name: announcer-test + +services: + announcer: + build: . + ports: ["8091:8090"] + user: "1000:1000" + environment: + ANNOUNCER_CONFIG: /app/docker/config.test.toml + # Buffer, logg og sesjonsnøkkel holdes utenfor repoet, så testing ikke + # blander seg med det den kjørende tjenesten eier. + ANNOUNCER_CACHE: /data/cache + ANNOUNCER_LOG: /data/logs/announcements.jsonl + ANNOUNCER_STATE: /data/instance + ANNOUNCER_VOICES: /app/voices + ANNOUNCER_JWT_SECRET: test-hemmelighet-kun-for-docker-ikke-bruk-ekte + ANNOUNCER_OIDC_CLIENT_SECRET: test-client-secret-kun-for-docker + # Må peke dit nettleseren ser appen, ellers blir OIDC-redirecten feil. + ANNOUNCER_PUBLIC_URL: http://localhost:8091 + XDG_RUNTIME_DIR: /run/user/1000 + volumes: + - .:/app + - ./.docker-data:/data + # Lyd: gir containeren vertens PipeWire. Kommenter ut hvis du bare + # skal teste innlogging og UI – da feiler avspilling, men ikke annet. + - /run/user/1000:/run/user/1000 + depends_on: [idp] + + idp: + build: . + ports: ["9000:9000"] + user: "1000:1000" + environment: + IDP_ISSUER: http://localhost:9000 + IDP_CLIENT_ID: announcer + IDP_CLIENT_SECRET: test-client-secret-kun-for-docker + volumes: + - .:/app + command: ["python", "docker/dummy_idp.py"] diff --git a/docker/config.test.toml b/docker/config.test.toml new file mode 100644 index 0000000..cb911f1 --- /dev/null +++ b/docker/config.test.toml @@ -0,0 +1,39 @@ +# Konfigurasjon for docker compose-testingen. Alle tre innloggingsmetodene er +# på samtidig, noe man nok ikke vil i virkeligheten, men her er poenget å +# kunne prøve dem. +# +# Hemmelighetene settes som miljøvariabler i compose.yaml, ikke her. + +[app] +title = "Announcer Test" +subtitle = "Docker-oppsett – PIN, JWT og OIDC er alle slått på" +accent = "#f5a623" +quick_phrases = ["Dette er en test", "Pizzaen er kommet. Hent i kantina!"] + +[auth] +providers = ["oidc", "jwt", "pin"] + +[auth.pin] +pin = "1234" +role = "crew" +hint = "1234 i dette testoppsettet" + +[auth.jwt] +issuer = "http://localhost:9000" +audience = "announcer" +role_claim = "roles" + +[auth.oidc] +issuer = "http://localhost:9000" +client_id = "announcer" +label = "Logg inn med dummy-IdP" +# Delt horisont: nettleseren ser IdP-en som localhost:9000, mens announcer +# selv snakker med den som idp:9000 inne i compose-nettet. Derfor settes +# endepunktene manuelt istedenfor å bruke discovery. +authorization_endpoint = "http://localhost:9000/oauth/authorize" +token_endpoint = "http://idp:9000/oauth/token" + +[roles] +anonymous = ["view"] +crew = ["view", "preview", "announce"] +admin = ["*"] diff --git a/docker/dummy_idp.py b/docker/dummy_idp.py new file mode 100644 index 0000000..b1dfdfa --- /dev/null +++ b/docker/dummy_idp.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Dummy identitetsleverandør for testing. Later som den er crew-panelet. + +Crew-panelet har ikke OIDC ennå, så uten noe slikt kan ikke OIDC-veien prøves +i det hele tatt. Denne gjør det mulig: full autorisasjonskode-runde med PKCE, +og du velger selv hvilken rolle du vil logge inn med. + +Den er *ikke* en IdP. Den signerer med klienthemmeligheten (HS256), lagrer +koder i minnet og spør ikke om passord. Bruk den bare bak compose. +""" + +from __future__ import annotations + +import base64 +import hashlib +import os +import secrets +import time +import urllib.parse + +import jwt +from flask import Flask, abort, jsonify, redirect, request + +ISSUER = os.environ.get("IDP_ISSUER", "http://localhost:9000") +CLIENT_ID = os.environ.get("IDP_CLIENT_ID", "announcer") +CLIENT_SECRET = os.environ.get("IDP_CLIENT_SECRET", "test-client-secret-kun-for-docker") + +# Folk man kan logge inn som. Rollenavnene må finnes i announcers [roles]. +USERS = { + "kari": {"sub": "101", "name": "Kari Nordmann", "email": "kari@crew.test", "roles": ["crew"]}, + "kent": {"sub": "1", "name": "Kent", "email": "kent@crew.test", "roles": ["admin"]}, + "ola": {"sub": "102", "name": "Ola Utenfor", "email": "ola@crew.test", "roles": []}, +} + +app = Flask(__name__) +codes: dict[str, dict] = {} + + +@app.get("/.well-known/openid-configuration") +def discovery(): + return jsonify({ + "issuer": ISSUER, + "authorization_endpoint": f"{ISSUER}/oauth/authorize", + "token_endpoint": f"{ISSUER}/oauth/token", + "response_types_supported": ["code"], + "id_token_signing_alg_values_supported": ["HS256"], + "code_challenge_methods_supported": ["S256"], + }) + + +@app.get("/oauth/authorize") +def authorize(): + if request.args.get("client_id") != CLIENT_ID: + abort(400, "ukjent client_id") + for required in ("redirect_uri", "state", "code_challenge"): + if not request.args.get(required): + abort(400, f"mangler {required}") + if request.args.get("code_challenge_method") != "S256": + abort(400, "krever PKCE med S256") + + who = request.args.get("who") + if who not in USERS: + # Ingen innloggingsside, bare en liste å trykke på. + rows = "".join( + f'
Står for crew-panelet. Velg hvem du vil logge inn som:
+Ola har ingen roller – han skal få 403, ikke 401.