Files
aza/AzA march 2026/aza_empfang_test_html_proxy.py
2026-06-13 22:47:31 +02:00

501 lines
18 KiB
Python

# -*- coding: utf-8 -*-
"""Lokaler Test-Proxy: liefert web/empfang.html, leitet /empfang/* API an Upstream weiter.
Nur aktiv bei AZA_DOKU_PROMPT_TEST=1 — damit V5-Sichttests lokale HTML-Aenderungen
sehen, ohne Server-Deploy. Session-Cookies bleiben auf 127.0.0.1 erhalten.
Standalone-Start (vom Startscript):
python aza_empfang_test_html_proxy.py --serve
Der Prozess bleibt im Vordergrund aktiv und gibt nach Readiness aus:
READY port=<PORT>
"""
from __future__ import annotations
import json
import os
import socket
import ssl
import sys
import threading
import time
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse, urlunparse
_LOCK = threading.Lock()
_SERVER: Optional[HTTPServer] = None
_PORT: Optional[int] = None
_PROJECT_ROOT = Path(__file__).resolve().parent
_PORT_RANGE_START = 18765
_PORT_RANGE_END = 18820
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Verhindert serverseitiges Folgen von 3xx-Redirects.
Wichtig fuer den Auth-Handoff: /empfang/shell/launch antwortet mit 302 +
Set-Cookie (aza_session). Der Default-urllib-Opener wuerde dem Redirect
selbst folgen, dabei den Einmal-Token verbrauchen und das Set-Cookie
verschlucken -> die WebView erhielte nie die Session und zeigte die
Loginmaske. Mit return None wird der 3xx als HTTPError durchgereicht und
unveraendert (Location lokal umgeschrieben, Set-Cookie weitergeleitet) an
die WebView zurueckgegeben, die dem Redirect selbst folgt.
"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def _build_proxy_opener() -> urllib.request.OpenerDirector:
return urllib.request.build_opener(
_NoRedirectHandler,
urllib.request.HTTPSHandler(context=ssl.create_default_context()),
)
_PROXY_OPENER = _build_proxy_opener()
def _append_sso_diag(line: str) -> None:
"""Secret-freier Diagnoselog fuer den realen SSO-Pfad (nur im Testmodus).
Protokolliert ausschliesslich Pfad (ohne Query), Methode, Status, ob ein
eingehender Cookie-Header vorhanden war (ja/nein) und Set-Cookie-NAMEN.
NIEMALS Cookie-Werte oder Tokens.
"""
if not _doku_prompt_test_active():
return
try:
d = _PROJECT_ROOT / ".aza_test_proxy_logs"
d.mkdir(parents=True, exist_ok=True)
ts = time.strftime("%Y-%m-%dT%H:%M:%S")
with open(d / "sso_diag.log", "a", encoding="utf-8") as f:
f.write(f"{ts} {line}\n")
except Exception:
pass
def _doku_prompt_test_active() -> bool:
return os.environ.get("AZA_DOKU_PROMPT_TEST", "").strip().lower() in ("1", "true", "yes")
def set_project_root(path: str | Path) -> None:
global _PROJECT_ROOT
_PROJECT_ROOT = Path(path).resolve()
def html_path() -> Path:
return _PROJECT_ROOT / "web" / "empfang.html"
def _read_upstream_base() -> str:
env = (os.environ.get("AZA_EMPFANG_TEST_UPSTREAM") or "").strip().rstrip("/")
if env:
return env
for base in (_PROJECT_ROOT, Path.cwd()):
p = base / "backend_url.txt"
if not p.is_file():
continue
try:
for ln in p.read_text(encoding="utf-8").splitlines():
s = ln.strip()
if s and not s.startswith("#"):
return s.rstrip("/")
except Exception:
pass
return "https://api.aza-medwork.ch"
def _is_local_empfang_html_page(path: str) -> bool:
p = urlparse(path or "")
norm = (p.path or "").rstrip("/")
if "/shell/launch" in norm:
return False
return norm in ("/empfang", "") or norm.endswith("/empfang")
def _pick_free_port() -> int:
for port in range(_PORT_RANGE_START, _PORT_RANGE_END):
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
if sys.platform == "win32":
# SO_REUSEADDR erlaubt unter Windows die Mehrfachbindung an einen
# bereits belegten Port -> ein frischer Proxy koennte mit einem
# veralteten (anderer Code im Speicher) co-existieren und Anfragen
# nichtdeterministisch an den alten Proxy leiten. SO_EXCLUSIVEADDRUSE
# erzwingt einen wirklich freien Port.
s.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
else:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", port))
return port
except OSError:
continue
raise OSError(f"Kein freier Port in {_PORT_RANGE_START}-{_PORT_RANGE_END - 1}")
class _TestProxyServer(ThreadingHTTPServer):
# Unter Windows KEIN SO_REUSEADDR: verhindert Co-Binding mit einem veralteten
# Proxy-Prozess auf demselben Port (siehe _pick_free_port).
allow_reuse_address = (sys.platform != "win32")
class _EmpfangTestProxyHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt: str, *args) -> None:
pass
def _upstream_url(self) -> str:
return f"{_read_upstream_base()}{self.path}"
def _send_json(self, status: int, payload: dict) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _local_origin(self) -> str:
host = (self.headers.get("Host") or "").strip()
if not host:
port = int(getattr(self.server, "server_address", ("127.0.0.1", 0))[1] or 0)
host = f"127.0.0.1:{port}" if port else "127.0.0.1"
return f"http://{host}"
def _rewrite_location_to_local(self, value: str) -> str:
loc = (value or "").strip()
if not loc:
return loc
local = self._local_origin()
if loc.startswith("/"):
return f"{local}{loc}"
try:
p = urlparse(loc)
up = urlparse(_read_upstream_base())
empfang_hosts = {
(up.netloc or "").lower(),
"api.aza-medwork.ch",
"empfang.aza-medwork.ch",
}
if (p.netloc or "").lower() in empfang_hosts:
return urlunparse(p._replace(scheme="http", netloc=urlparse(local).netloc))
except Exception:
pass
return loc
_SSO_DIAG_PATHS = ("/auth/me", "/shell/context/me", "/shell/launch")
def _maybe_sso_diag(self, method: str, status: int, resp_headers) -> None:
try:
path_only = urlparse(self.path or "").path or ""
if not any(p in path_only for p in self._SSO_DIAG_PATHS):
return
cookie_in = "yes" if ("Cookie" in self.headers) else "no"
names = []
try:
for k, v in resp_headers.items():
if k.lower() == "set-cookie":
# nur der Name vor dem ersten '=' (kein Wert!)
names.append((str(v).split("=", 1)[0] or "").strip())
except Exception:
pass
sc = ",".join([n for n in names if n]) or "-"
_append_sso_diag(
f"path={path_only} method={method} status={status} "
f"cookie_in={cookie_in} set_cookie={sc}"
)
except Exception:
pass
def _localize_set_cookie(self, value: str) -> str:
"""Macht ein Upstream-Set-Cookie fuer die lokale Test-Origin annehmbar.
Nur im Testproxy (127.0.0.1 ueber HTTP): entfernt ``Domain=`` (sonst wird
ein Cookie fuer api/empfang.aza-medwork.ch unter 127.0.0.1 verworfen) und
``Secure`` (sonst wird das Cookie ueber lokales HTTP nicht gesetzt).
Name/Wert, HttpOnly, SameSite, Path und Max-Age bleiben unveraendert.
Produktion nutzt diesen Proxy nicht -> Produktionscookies bleiben gleich.
"""
raw = (value or "").strip()
if not raw:
return raw
parts = raw.split(";")
kept = [parts[0]] # name=value immer behalten
for attr in parts[1:]:
a = attr.strip()
if not a:
continue
name = a.split("=", 1)[0].strip().lower()
if name == "domain":
continue
if name == "secure":
continue
kept.append(a)
return "; ".join(kept)
def _proxy(self, method: str) -> None:
url = self._upstream_url()
body = None
try:
clen = int(self.headers.get("Content-Length") or 0)
except Exception:
clen = 0
if clen > 0:
body = self.rfile.read(clen)
req = urllib.request.Request(url, data=body, method=method)
for h in (
"Content-Type", "Cookie", "Authorization", "Accept",
"Accept-Language", "X-Requested-With",
):
if h in self.headers:
req.add_header(h, self.headers[h])
try:
# _PROXY_OPENER folgt 3xx NICHT (siehe _NoRedirectHandler): Redirects
# samt Set-Cookie gehen 1:1 (Location lokal) an die WebView zurueck.
with _PROXY_OPENER.open(req, timeout=90) as resp:
self._maybe_sso_diag(method, resp.status, resp.headers)
self.send_response(resp.status)
for k, v in resp.headers.items():
lk = k.lower()
if lk in ("transfer-encoding", "connection", "content-encoding"):
continue
if lk == "location":
v = self._rewrite_location_to_local(v)
if lk == "set-cookie":
v = self._localize_set_cookie(v)
self.send_header(k, v)
self.end_headers()
data = resp.read()
if data:
self.wfile.write(data)
except urllib.error.HTTPError as resp:
# 3xx (z.B. /shell/launch 302) UND echte Fehler landen hier.
self._maybe_sso_diag(method, resp.code, resp.headers)
self.send_response(resp.code)
data = resp.read()
for k, v in resp.headers.items():
lk = k.lower()
if lk in ("transfer-encoding", "connection", "content-encoding", "content-length"):
continue
if lk == "location":
v = self._rewrite_location_to_local(v)
if lk == "set-cookie":
v = self._localize_set_cookie(v)
self.send_header(k, v)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
if data:
self.wfile.write(data)
except Exception as exc:
msg = str(exc).encode("utf-8", errors="replace")
self.send_response(502)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(msg)))
self.end_headers()
self.wfile.write(msg)
def _serve_local_html(self) -> None:
hp = html_path()
if not hp.is_file():
self._send_json(500, {
"ok": False,
"error": "empfang_html_missing",
"path": str(hp),
})
return
data = hp.read_bytes()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
self.end_headers()
self.wfile.write(data)
def do_GET(self) -> None:
p = urlparse(self.path or "")
if (p.path or "").rstrip("/") == "/health":
self._send_json(200, {"ok": True})
return
if _doku_prompt_test_active() and _is_local_empfang_html_page(self.path):
try:
self._serve_local_html()
return
except Exception as exc:
self._send_json(500, {"ok": False, "error": str(exc)})
return
self._proxy("GET")
def do_POST(self) -> None:
self._proxy("POST")
def do_PUT(self) -> None:
self._proxy("PUT")
def do_DELETE(self) -> None:
self._proxy("DELETE")
def do_OPTIONS(self) -> None:
self._proxy("OPTIONS")
def _start_server_on_port(port: int = 0) -> tuple[HTTPServer, int]:
# ThreadingHTTPServer: jede (Keep-Alive-)Verbindung erhaelt einen eigenen
# Worker-Thread. Der single-threaded HTTPServer blockierte bei den vielen
# gleichzeitigen WebView-Requests (Chat + Kontakt + Assets/API) -> ein
# haengender Upstream-/Keep-Alive-Request fuellte den Accept-Backlog und
# neue Verbindungen wurden refused (ERR_CONNECTION_REFUSED).
srv = _TestProxyServer(("127.0.0.1", port), _EmpfangTestProxyHandler)
bound_port = int(srv.server_address[1])
t = threading.Thread(target=srv.serve_forever, daemon=True, name="aza-empfang-test-proxy")
t.start()
return srv, bound_port
def wait_until_ready(port: int, *, timeout_sec: float = 15.0, interval_sec: float = 0.25) -> bool:
"""TCP + HTTP /health + /empfang/ bis timeout."""
deadline = time.time() + timeout_sec
base = f"http://127.0.0.1:{port}"
while time.time() < deadline:
try:
with socket.create_connection(("127.0.0.1", port), timeout=1.0):
pass
except OSError:
time.sleep(interval_sec)
continue
try:
with urllib.request.urlopen(f"{base}/health", timeout=2.0) as r:
if r.status != 200:
time.sleep(interval_sec)
continue
body = r.read(256).decode("utf-8", errors="replace")
if '"ok"' not in body:
time.sleep(interval_sec)
continue
except (urllib.error.URLError, OSError, TimeoutError):
time.sleep(interval_sec)
continue
try:
with urllib.request.urlopen(f"{base}/empfang/", timeout=3.0) as r:
if r.status == 200:
return True
except (urllib.error.URLError, OSError, TimeoutError):
pass
time.sleep(interval_sec)
return False
def shutdown_test_proxy_server() -> None:
"""In-Process-Singleton beenden (Tests / Cleanup)."""
global _SERVER, _PORT
with _LOCK:
if _SERVER is not None:
try:
_SERVER.shutdown()
except Exception:
pass
_SERVER = None
_PORT = None
def ensure_test_proxy_server() -> Optional[int]:
"""In-Process-Singleton (nur fuer Tests / eingebetteten Desktop). Nicht fuer PS-Start."""
if not _doku_prompt_test_active():
return None
global _SERVER, _PORT
with _LOCK:
if _PORT is not None:
return _PORT
port = _pick_free_port()
_SERVER, bound_port = _start_server_on_port(port)
_PORT = bound_port
os.environ["AZA_EMPFANG_TEST_PROXY_PORT"] = str(bound_port)
if not wait_until_ready(bound_port, timeout_sec=10.0):
try:
_SERVER.shutdown()
except Exception:
pass
_SERVER = None
_PORT = None
return None
return bound_port
def test_proxy_base_url() -> Optional[str]:
env_port = (os.environ.get("AZA_EMPFANG_TEST_PROXY_PORT") or "").strip()
if env_port.isdigit():
return f"http://127.0.0.1:{env_port}"
port = ensure_test_proxy_server()
if port is None:
return None
return f"http://127.0.0.1:{port}"
def run_standalone_server() -> int:
"""Blockierender Proxy-Prozess fuer start_doku_prompt_test.ps1."""
if not _doku_prompt_test_active():
print("ERROR AZA_DOKU_PROMPT_TEST nicht gesetzt", flush=True)
return 2
hp = html_path()
if not hp.is_file():
print(f"ERROR empfang_html_missing path={hp}", flush=True)
return 3
try:
port = _pick_free_port()
except OSError as exc:
print(f"ERROR {exc}", flush=True)
return 4
srv = _TestProxyServer(("127.0.0.1", port), _EmpfangTestProxyHandler)
thread = threading.Thread(target=srv.serve_forever, daemon=False, name="aza-empfang-test-proxy-main")
thread.start()
if not wait_until_ready(port, timeout_sec=15.0):
print(f"ERROR proxy_readiness_timeout port={port}", flush=True)
try:
srv.shutdown()
except Exception:
pass
return 5
print(f"READY port={port}", flush=True)
try:
thread.join()
except KeyboardInterrupt:
pass
try:
srv.shutdown()
except Exception:
pass
return 0
def main(argv: list[str] | None = None) -> int:
args = list(argv if argv is not None else sys.argv[1:])
if "--serve" in args:
return run_standalone_server()
if args and args[0] in ("--health-check", "--wait-ready"):
port_s = (os.environ.get("AZA_EMPFANG_TEST_PROXY_PORT") or "").strip()
if len(args) > 1 and args[1].isdigit():
port_s = args[1]
if not port_s.isdigit():
print("ERROR missing port", flush=True)
return 1
ok = wait_until_ready(int(port_s))
print("OK" if ok else "FAIL", flush=True)
return 0 if ok else 1
port = ensure_test_proxy_server()
if port is None:
return 1
print(port, flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())