#!/usr/bin/env python3 """Round-robin reverse proxy met healthchecks. Stdlib only, single file, 3.6+. Run: python3 reverse-proxy.py --backends http://127.0.0.1:9001,http://127.0.0.1:9002 python3 reverse-proxy.py --backends http://127.0.0.1:9001 --port 8080 --health-path /healthz Spec (dit bestand is de spec): Gedrag: - Round-robin over gezonde backends (alleen http, geen https-targets). - Actieve healthcheck: GET {health-path} elke --health-interval s; 2xx/3xx is gezond. Ongezonde backend tijdelijk uit rotatie. - Passieve healthcheck: connect/read-fout markeert backend direct down en de request failovert naar de volgende gezonde backend. - Retries: maximaal N pogingen (N = aantal backends), nooit een body tweemaal sturen bij POST/PUT tenzij klein genoeg om te bufferen. - Hop-header hygiene: Connection, Keep-Alive, Proxy-*, TE, Trailer, Transfer-Encoding en Upgrade worden nooit doorgestuurd. - X-Forwarded-For (client-IP appended), X-Forwarded-Proto, X-Forwarded-Host en Via worden gezet. - GET /__proxy__/status: JSON met backend-staten (alleen dit pad is van de proxy zelf; de rest wordt 1-op-1 doorgezet). Caps: MAX_BODY 10 MiB, TIMEOUT 10 s (connect) / 30 s (read), HEALTH_INTERVAL 10 s, HEALTH_TIMEOUT 3 s, MAX_BACKENDS 32. """ import argparse import json import socketserver import threading import time from http.client import HTTPConnection, HTTPException from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import urlparse HOP_HEADERS = frozenset([ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", ]) MAX_BODY = 10 * 1024 * 1024 CONNECT_TIMEOUT = 10 READ_TIMEOUT = 30 class Backend(object): """Een upstream + zijn gezondheidsstaat.""" def __init__(self, url): parsed = urlparse(url.strip()) if parsed.scheme != "http" or not parsed.hostname: raise ValueError("backend must be http://host[:port][/prefix]: %s" % url) self.name = url.strip() self.host = parsed.hostname self.port = parsed.port or 80 self.prefix = parsed.path.rstrip("/") self._lock = threading.RLock() self.healthy = True self.last_check = 0.0 self.last_error = "" self.requests = 0 self.failures = 0 def mark(self, healthy, error=""): with self._lock: self.healthy = healthy self.last_check = time.time() self.last_error = error[:200] if error else "" if healthy: self.requests += 1 else: self.failures += 1 def status(self): with self._lock: return {"name": self.name, "healthy": self.healthy, "last_check": self.last_check, "last_error": self.last_error, "requests": self.requests, "failures": self.failures} class BackendPool(object): """Round-robin over gezonde backends. Thread-safe.""" def __init__(self, backends): self.backends = backends self._lock = threading.RLock() self._next = 0 def pick_order(self): """Volgorde voor deze request: rotatie-start, gezonde eerst.""" with self._lock: backends = list(self.backends) start = self._next % len(backends) self._next += 1 ordered = backends[start:] + backends[:start] healthy = [b for b in ordered if b.healthy] return healthy + [b for b in ordered if not b.healthy] def status(self): return {"backends": [b.status() for b in self.backends]} class HealthChecker(threading.Thread): """Achtergrondlus: pollt elke interval het health-path per backend.""" daemon = True def __init__(self, pool, path="/", interval=10, timeout=3): super(HealthChecker, self).__init__(name="healthchecker") self.pool = pool self.path = path or "/" self.interval = max(1, interval) self.timeout = timeout self._stop = threading.Event() def stop(self): self._stop.set() def run(self): while not self._stop.wait(self.interval): for backend in self.pool.backends: self.check_once(backend) def check_once(self, backend): try: conn = HTTPConnection(backend.host, backend.port, timeout=self.timeout) try: conn.request("GET", backend.prefix + self.path, headers={"Connection": "close", "Host": backend.host}) resp = conn.getresponse() resp.read(65536) ok = 200 <= resp.status < 400 backend.mark(True if ok else False, "" if ok else "health %d" % resp.status) finally: conn.close() except Exception as exc: backend.mark(False, "%s: %s" % (type(exc).__name__, exc)) class ProxyHandler(BaseHTTPRequestHandler): server_version = "TaiProxy/1.0" protocol_version = "HTTP/1.1" def log_message(self, fmt, *args): pass def _send(self, code, body=b"", ctype="text/plain; charset=utf-8"): if isinstance(body, str): body = body.encode("utf-8") self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.end_headers() if self.command != "HEAD" and body: self.wfile.write(body) def _forward_headers(self): client_ip = self.client_address[0] fwd = self.headers.get("X-Forwarded-For", "") out = {} for key, value in self.headers.items(): if key.lower() in HOP_HEADERS: continue out[key] = value out["X-Forwarded-For"] = (fwd + ", " + client_ip) if fwd else client_ip out["X-Forwarded-Proto"] = "http" out["X-Forwarded-Host"] = self.headers.get("Host", "") via = self.headers.get("Via", "") out["Via"] = (via + ", " if via else "") + "1.1 TaiProxy" out["Connection"] = "close" return out def _read_body(self): try: length = int(self.headers.get("Content-Length", "0") or "0") except ValueError: return None if length < 0 or length > MAX_BODY: return None if length == 0: return b"" return self.rfile.read(length) def _try_backend(self, backend, path, body, headers): target = backend.prefix + path conn = HTTPConnection(backend.host, backend.port, timeout=CONNECT_TIMEOUT) try: out_headers = dict(headers) out_headers["Host"] = backend.host + ( "" if backend.port == 80 else ":%d" % backend.port) conn.request(self.command, target, body=body, headers=out_headers) conn.sock.settimeout(READ_TIMEOUT) resp = conn.getresponse() self.send_response(resp.status, resp.reason) for key, value in resp.getheaders(): if key.lower() in HOP_HEADERS: continue self.send_header(key, value) self.send_header("Via", "1.1 TaiProxy") self.end_headers() if self.command != "HEAD": while True: chunk = resp.read(65536) if not chunk: break self.wfile.write(chunk) backend.mark(True) return True finally: try: conn.close() except Exception: pass def _proxy(self): pool = self.server.pool # type: BackendPool if self.path == "/__proxy__/status" and self.command == "GET": self._send(200, json.dumps(pool.status()), "application/json") return body = self._read_body() if body is None: self._send(413, "body too large or bad length (max 10 MiB)") return headers = self._forward_headers() errors = [] for backend in pool.pick_order(): try: if self._try_backend(backend, self.path, body, headers): return except (HTTPException, OSError) as exc: backend.mark(False, "%s: %s" % (type(exc).__name__, exc)) errors.append("%s: %s" % (backend.name, exc)) continue except BrokenPipeError: return self._send(502, "no healthy backend (%s)" % "; ".join(errors)) def _dispatch(self): try: self._proxy() except BrokenPipeError: pass except Exception as exc: try: self._send(500, "proxy error: %s" % exc) except (BrokenPipeError, OSError): pass do_GET = _dispatch do_HEAD = _dispatch do_POST = _dispatch do_PUT = _dispatch do_PATCH = _dispatch do_DELETE = _dispatch do_OPTIONS = _dispatch class ProxyServer(socketserver.ThreadingMixIn, HTTPServer): daemon_threads = True allow_reuse_address = True def main(argv=None): parser = argparse.ArgumentParser( description="Round-robin reverse proxy met healthchecks.") parser.add_argument("--backends", required=True, help="comma-separated http://host:port[/prefix]") parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8080) parser.add_argument("--health-path", default="/", help="pad voor healthchecks (default /)") parser.add_argument("--health-interval", type=int, default=10, help="seconden tussen healthchecks (default 10)") args = parser.parse_args(argv) urls = [u for u in args.backends.split(",") if u.strip()] if not urls or len(urls) > 32: parser.error("need 1..32 backends") try: backends = [Backend(u) for u in urls] except ValueError as exc: parser.error(str(exc)) pool = BackendPool(backends) server = ProxyServer((args.host, args.port), ProxyHandler) server.pool = pool checker = HealthChecker(pool, path=args.health_path, interval=args.health_interval) for backend in backends: checker.check_once(backend) checker.start() print("proxy: http://%s:%d/ -> %s" % (args.host, args.port, ", ".join(urls))) try: server.serve_forever() except KeyboardInterrupt: pass finally: checker.stop() if __name__ == "__main__": main()