diff --git a/.env.example b/.env.example index 6a34369..c2d13dd 100644 --- a/.env.example +++ b/.env.example @@ -86,6 +86,10 @@ LEITSTAND_PORT=8088 LEITSTAND_USER=praxis LEITSTAND_PASS= +# Optional: geteiltes Geheimnis fuer signierte, 1h gueltige Testzugangslinks +# (z.B. per Chatbot-Kommando ausgegeben). Leer = Feature aus. +TESTZUGANG_SECRET= + # --- Telefonanlage (nur Stufe 2) --- # Event-Socket-Port von FreeSWITCH. Muss zu listen-port in # autoload_configs/event_socket.conf.xml passen, sonst antwortet fs_cli nicht diff --git a/pipe/config.py b/pipe/config.py index 0f0741e..c62176b 100644 --- a/pipe/config.py +++ b/pipe/config.py @@ -106,6 +106,12 @@ LEITSTAND_PORT = int(os.environ.get("LEITSTAND_PORT", "8088")) LEITSTAND_USER = os.environ.get("LEITSTAND_USER", "praxis") LEITSTAND_PASS = os.environ.get("LEITSTAND_PASS", "") +# Geteiltes Geheimnis mit dilles-agent.php ("agent-one test"-Kommando auf +# dillenberg.net) fuer signierte, 1h gueltige Testzugangslinks - siehe +# pipe.testzugang. Leer lassen deaktiviert das Feature (kein Fallback-Wert, +# ein leeres Secret darf niemals gueltige Signaturen erzeugen). +TESTZUGANG_SECRET = os.environ.get("TESTZUGANG_SECRET", "") + def leitstand_oeffentlich() -> bool: return LEITSTAND_HOST not in {"127.0.0.1", "localhost", "::1"} diff --git a/pipe/server.py b/pipe/server.py index e277b32..977f947 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -19,11 +19,12 @@ import time import urllib.error import urllib.request from datetime import datetime +from http.cookies import SimpleCookie from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from urllib.parse import unquote, urlparse +from urllib.parse import parse_qs, unquote, urlparse -from . import bewertung, config, dashboard, export, modellwahl, protokoll, stapel, store +from . import bewertung, config, dashboard, export, modellwahl, protokoll, stapel, store, testzugang # Statusabfragen sind teuer (Netz, Unterprozesse) - kurz zwischenspeichern, # damit haeufiges Nachfragen den Rechner nicht belastet. @@ -237,9 +238,27 @@ class Handler(BaseHTTPRequestHandler): Ohne Passwort bleibt der Leitstand offen - das ist nur zulässig, wenn er an 127.0.0.1 hängt; darauf besteht main() beim Start. + + Zusätzlich akzeptiert: ein gültiges signiertes Testzugangs-Token + (siehe pipe.testzugang) - per ?zugang=... in der URL (so kommt es aus + dilles-agent.php's "agent-one test"-Kommando) oder per zugang-Cookie + für Folgeanfragen (fetch()-Aufrufe der Seite tragen die URL nicht + mit). Ein frisches URL-Token setzt das Cookie für den Rest der Stunde. """ + self._zusatz_header: list[tuple[str, str]] = [] if not config.LEITSTAND_PASS: return True + + token = parse_qs(urlparse(self.path).query).get("zugang", [None])[0] + if token and testzugang.gueltig(token): + rest_s = max(0, int(token.split(".", 1)[0]) - int(time.time())) + self._zusatz_header.append( + ("Set-Cookie", f"zugang={token}; Max-Age={rest_s}; Path=/; HttpOnly; Secure; SameSite=Lax")) + return True + cookie_wert = SimpleCookie(self.headers.get("Cookie", "")).get("zugang") + if cookie_wert and testzugang.gueltig(cookie_wert.value): + return True + kopf = self.headers.get("Authorization", "") if not kopf.startswith("Basic "): return False @@ -262,6 +281,8 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Content-Type", typ) self.send_header("Content-Length", str(len(inhalt))) self.send_header("Cache-Control", "no-store") + for schluessel, wert in getattr(self, "_zusatz_header", []): + self.send_header(schluessel, wert) self.end_headers() self.wfile.write(inhalt) @@ -395,6 +416,8 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Accept-Ranges", "bytes") self.send_header("Content-Length", str(groesse)) self.send_header("Cache-Control", "no-store") + for schluessel, wert in getattr(self, "_zusatz_header", []): + self.send_header(schluessel, wert) self.end_headers() with ziel.open("rb") as datei: self.wfile.write(datei.read()) @@ -418,6 +441,8 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Content-Range", f"bytes {start}-{ende}/{groesse}") self.send_header("Content-Length", str(laenge)) self.send_header("Cache-Control", "no-store") + for schluessel, wert in getattr(self, "_zusatz_header", []): + self.send_header(schluessel, wert) self.end_headers() with ziel.open("rb") as datei: datei.seek(start) diff --git a/pipe/testzugang.py b/pipe/testzugang.py new file mode 100644 index 0000000..381c60b --- /dev/null +++ b/pipe/testzugang.py @@ -0,0 +1,35 @@ +"""Signierte, zeitlich begrenzte Testzugangs-Links zum Leitstand. + +Gegenstueck zu dilles_agent_one_test_command() in dillenberg.net's +dilles-agent.php ("agent-one test"-Kommando im Alice-Chat). Beide Seiten +teilen sich TESTZUGANG_SECRET (config.py) - der Link wird komplett auf der +PHP-Seite erzeugt, ohne Live-Aufruf hierher; hier wird nur die Signatur +geprueft. Stateless: kein Server-seitiger Speicher fuer ausgegebene Tokens, +die Gueltigkeit steckt komplett im signierten Ablauf-Zeitstempel. + +Token-Format: "." +""" +from __future__ import annotations + +import hmac +import time +from hashlib import sha256 + +from . import config + + +def gueltig(token: str) -> bool: + """Prueft ein Token aus ?zugang=... oder dem zugang-Cookie. Wirft nie.""" + if not config.TESTZUGANG_SECRET or not token: + return False + try: + ablauf_str, signatur = token.split(".", 1) + ablauf = int(ablauf_str) + except (ValueError, AttributeError): + return False + erwartet = hmac.new( + config.TESTZUGANG_SECRET.encode("utf-8"), ablauf_str.encode("utf-8"), sha256 + ).hexdigest() + if not hmac.compare_digest(erwartet, signatur): + return False + return time.time() < ablauf