mirror of
https://github.com/Jeuners/Logpy-AgentOne.git
synced 2026-09-09 15:02:33 +02:00
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:
parent
fa55204767
commit
af67ee89e3
4 changed files with 72 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
35
pipe/testzugang.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue