""" PrefixStrippingProxy — minimal in-process HTTP reverse proxy for e2e tests. Mirrors the production Apache vhost contract (see `templates/projects-*.conf`): GET /projects//foo → GET /foo with `X-Forwarded-Prefix: /projects/` Used by the e2e password-setup test to exercise the same path-rewriting flow that broke twice in production (oatpp-authkit query-string 401, and the VITE_BASE blank-page bug). Plain stdlib only — no third-party deps. """ import threading import urllib.error import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Optional _HOP_BY_HOP = { "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", "content-length", "host", } class PrefixStrippingProxy: """Forwards every request from `:proxy_port//...` to the backend. Backend sees the un-prefixed path plus an `X-Forwarded-Prefix` header, matching what Apache's `ProxyPass /projects// http://127.0.0.1:N/` does in production. """ def __init__(self, target_host: str, target_port: int, prefix: str): self.target = f"http://{target_host}:{target_port}" self.prefix = "/" + prefix.strip("/") self.server: Optional[ThreadingHTTPServer] = None self.thread: Optional[threading.Thread] = None self.port: Optional[int] = None def start(self) -> int: proxy = self class Handler(BaseHTTPRequestHandler): def do_GET(self): proxy._forward(self) def do_POST(self): proxy._forward(self) def do_PUT(self): proxy._forward(self) def do_DELETE(self): proxy._forward(self) def do_PATCH(self): proxy._forward(self) def log_message(self, *a, **kw): pass self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) self.port = self.server.server_address[1] self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) self.thread.start() return self.port def stop(self) -> None: if self.server: self.server.shutdown() self.server.server_close() def _strip_prefix(self, path: str) -> str: if path.startswith(self.prefix + "/"): return path[len(self.prefix):] if path == self.prefix: return "/" return path def _forward(self, h: BaseHTTPRequestHandler) -> None: upstream_path = self._strip_prefix(h.path) body_len = int(h.headers.get("Content-Length") or 0) body = h.rfile.read(body_len) if body_len else None req = urllib.request.Request(self.target + upstream_path, method=h.command, data=body) for k, v in h.headers.items(): if k.lower() in _HOP_BY_HOP: continue req.add_header(k, v) req.add_header("X-Forwarded-Prefix", self.prefix) req.add_header("X-Forwarded-Host", h.headers.get("Host", "127.0.0.1")) req.add_header("X-Forwarded-Proto", "http") try: r = urllib.request.urlopen(req, timeout=15) status, headers, content = r.status, r.headers, r.read() except urllib.error.HTTPError as e: status, headers, content = e.code, e.headers, e.read() except Exception as exc: h.send_response(502) msg = f"proxy upstream error: {exc}".encode() h.send_header("Content-Type", "text/plain; charset=utf-8") h.send_header("Content-Length", str(len(msg))) h.end_headers() h.wfile.write(msg) return h.send_response(status) for k, v in headers.items(): if k.lower() in _HOP_BY_HOP: continue h.send_header(k, v) h.send_header("Content-Length", str(len(content))) h.end_headers() h.wfile.write(content)