Files
tai/examples/reverse-proxy.py
T

311 lines
11 KiB
Python
Raw Normal View History

#!/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()