501 lines
18 KiB
Python
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())
|