# -*- 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= """ 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())