feat: signierte 1h-Testzugangslinks fuer den Leitstand ("agent-one test")

Gegenstueck zum "agent-one test"-Kommando in Alice (dillenberg.net,
dilles-agent.php). Kein Live-Aufruf zwischen den Systemen noetig - beide
teilen sich TESTZUGANG_SECRET, das Token ist ein HMAC-signierter
Ablauf-Zeitstempel ("ablauf.signatur"), stateless geprueft.

pipe/testzugang.py: gueltig(token) prueft Signatur (hmac.compare_digest)
und Ablauf. _angemeldet() in server.py akzeptiert das Token zusaetzlich zum
Passwort - per ?zugang=... in der URL oder per zugang-Cookie fuer
Folgeanfragen (fetch()-Aufrufe der Seite tragen die URL nicht mit). Ein
frisches URL-Token setzt das Cookie (HttpOnly, Secure, SameSite=Lax) fuer
den Rest der Stunde.

End-to-End getestet: PHP-generiertes Token wird von Python akzeptiert
(Cross-Language-HMAC-Kompatibilitaet bestaetigt), ungueltige Signatur und
abgelaufenes Token werden korrekt abgelehnt (401), Cookie-Folgeanfrage ohne
erneutes Token/Passwort funktioniert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vtvcyNkapuDh583643hNs
This commit is contained in:
Jeuner 2026-08-25 01:33:46 +02:00
parent fa55204767
commit af67ee89e3
4 changed files with 72 additions and 2 deletions

View file

@ -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"}

View file

@ -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)

35
pipe/testzugang.py Normal file
View file

@ -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: "<ablauf-unixzeit>.<hmac-sha256-hexdigest>"
"""
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