"""HTTP-Proxy (HTTP/1.1, CONNECT-Tunnel für HTTPS und beliebige TCP-Protokolle).""" import asyncio import base64 import html import time from urllib.parse import urlsplit from .auth import users from .core import Blocked, Conn, history, open_upstream, register, relay MAX_HEAD = 64 * 1024 HOP_BY_HOP = {"connection", "keep-alive", "proxy-authorization", "proxy-connection", "proxy-authenticate", "te", "trailer", "upgrade", "expect"} class BadRequest(Exception): pass def parse_head(head: bytes) -> tuple[str, list[tuple[str, str]]]: lines = head.decode("latin-1").split("\r\n") first = lines[0] headers = [] for line in lines[1:]: if not line: continue if ":" not in line: raise BadRequest("Ungültige Kopfzeile") k, v = line.split(":", 1) headers.append((k.strip(), v.strip())) return first, headers def get_header(headers, name: str) -> str | None: name = name.lower() for k, v in headers: if k.lower() == name: return v return None def split_hostport(value: str, default_port: int) -> tuple[str, int]: value = value.strip() if value.startswith("["): end = value.index("]") host, rest = value[1:end], value[end + 1:] port = int(rest[1:]) if rest.startswith(":") else default_port return host, port if value.count(":") == 1: host, port = value.rsplit(":", 1) return host, int(port) return value, default_port def response(code: int, reason: str, body: str = "", extra: list[str] | None = None, close: bool = False) -> bytes: data = body.encode() lines = [f"HTTP/1.1 {code} {reason}", "Content-Type: text/html; charset=utf-8", f"Content-Length: {len(data)}", "Connection: close" if close else "Connection: keep-alive", *(extra or [])] return ("\r\n".join(lines) + "\r\n\r\n").encode() + data def error_page(title: str, text: str) -> str: return (f"{html.escape(title)}" "" "
" f"

{html.escape(title)}

" f"

{html.escape(text)}

") # --------------------------------------------------------------------------- Body-Übertragung async def copy_exact(reader, writer, n: int, counter) -> None: while n > 0: data = await reader.read(min(n, 65536)) if not data: raise ConnectionError("Verbindung vorzeitig beendet") n -= len(data) if writer: writer.write(data) await writer.drain() counter(len(data)) async def copy_chunked(reader, writer, counter) -> None: while True: line = await reader.readuntil(b"\r\n") size = int(line.split(b";", 1)[0].strip() or b"0", 16) if writer: writer.write(line) counter(len(line)) if size == 0: # Trailer bis zur Leerzeile while True: t = await reader.readuntil(b"\r\n") if writer: writer.write(t) counter(len(t)) if t == b"\r\n": break if writer: await writer.drain() return await copy_exact(reader, writer, size + 2, counter) async def copy_until_eof(reader, writer, counter) -> None: while True: data = await reader.read(65536) if not data: return writer.write(data) counter(len(data)) await writer.drain() async def transfer_body(headers, reader, writer, counter) -> None: te = (get_header(headers, "transfer-encoding") or "").lower() if "chunked" in te: await copy_chunked(reader, writer, counter) return cl = get_header(headers, "content-length") if cl: await copy_exact(reader, writer, int(cl), counter) def body_is_discardable(headers) -> bool: if "chunked" in (get_header(headers, "transfer-encoding") or "").lower(): return False cl = get_header(headers, "content-length") return not cl or int(cl) <= 1_000_000 # --------------------------------------------------------------------------- Handler async def authenticate(headers, client_ip: str): """(user, anonym erlaubt?, Zugangsdaten angegeben?)""" from .policy import policy value = get_header(headers, "proxy-authorization") if value and value.lower().startswith("basic "): try: raw = base64.b64decode(value[6:].strip()).decode("utf-8", "replace") username, _, password = raw.partition(":") except (ValueError, UnicodeError): return None, False, True user = await users.authenticate(username, password, client_ip) return user, False, True if policy.is_anonymous_allowed(client_ip): return None, True, False return None, False, False async def handle_http(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, prefix: bytes, client_ip: str): from .policy import policy realm = policy.settings.get("auth_realm", "Proxy") while True: try: head = prefix + await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=300) except (asyncio.IncompleteReadError, asyncio.TimeoutError, ConnectionError): return except asyncio.LimitOverrunError: writer.write(response(431, "Request Header Fields Too Large", close=True)) return prefix = b"" try: first, headers = parse_head(head[:-4]) method, target, version = first.split(" ", 2) except (BadRequest, ValueError): writer.write(response(400, "Bad Request", error_page("Ungültige Anfrage", "Anfrage nicht lesbar."), close=True)) return client_close = (get_header(headers, "proxy-connection") or get_header(headers, "connection") or "").lower() \ == "close" or version == "HTTP/1.0" user, anonymous, gave_credentials = await authenticate(headers, client_ip) if not user and not anonymous: if gave_credentials: history.add(username=None, client_ip=client_ip, proto="http", method=method, url=target[:2000], status="auth_failed", detail="Anmeldung fehlgeschlagen") if not body_is_discardable(headers): client_close = True else: await transfer_body(headers, reader, None, lambda n: None) writer.write(response(407, "Proxy Authentication Required", error_page("Anmeldung erforderlich", "Bitte am Proxy anmelden."), [f'Proxy-Authenticate: Basic realm="{realm}"'], close=client_close)) await writer.drain() if client_close: return continue username = user["username"] if user else "anonym" if method.upper() == "CONNECT": keep = await handle_connect(reader, writer, user, username, client_ip, target) else: keep = await handle_plain(reader, writer, user, username, client_ip, method, target, version, headers) if not keep or client_close: return async def handle_connect(reader, writer, user, username, client_ip, target) -> bool: try: host, port = split_hostport(target, 443) except (ValueError, IndexError): writer.write(response(400, "Bad Request", close=True)) return False conn = Conn(username, client_ip, "https" if port == 443 else "connect", host, port, "CONNECT", f"{host}:{port}") try: r_reader, r_writer = await open_upstream(user, host, port) except Blocked as b: conn.finish("blocked", b.decision.reason) writer.write(response(403, "Forbidden", error_page("Zugriff gesperrt", f"{host}:{port} ist durch die Proxy-Richtlinie gesperrt."))) await writer.drain() return True except OSError as e: conn.finish("error", str(e)) writer.write(response(502, "Bad Gateway", error_page("Ziel nicht erreichbar", f"{host}:{port} – {e}"), close=True)) await writer.drain() return False writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n") register(conn) try: await writer.drain() await relay(conn, reader, writer, r_reader, r_writer) finally: conn.finish("ok") return False async def handle_plain(reader, writer, user, username, client_ip, method, target, version, headers) -> bool: url = urlsplit(target) if not url.scheme: body = error_page("Proxy-Server", "Dies ist ein Proxy-Server. Bitte im Browser als HTTP-Proxy eintragen.") writer.write(response(400, "Bad Request", body, close=True)) return False if url.scheme.lower() != "http": writer.write(response(501, "Not Implemented", error_page("Nicht unterstützt", f"Protokoll {url.scheme} wird nicht unterstützt."), close=True)) return False host = url.hostname or "" port = url.port or 80 conn = Conn(username, client_ip, "http", host, port, method, target[:2000]) try: r_reader, r_writer = await open_upstream(user, host, port) except Blocked as b: conn.finish("blocked", b.decision.reason) keep = body_is_discardable(headers) if keep: await transfer_body(headers, reader, None, lambda n: None) writer.write(response(403, "Forbidden", error_page("Zugriff gesperrt", f"{host} ist durch die Proxy-Richtlinie gesperrt."), close=not keep)) await writer.drain() return keep except OSError as e: conn.finish("error", str(e)) writer.write(response(502, "Bad Gateway", error_page("Ziel nicht erreichbar", f"{host}:{port} – {e}"), close=True)) await writer.drain() return False register(conn) status = "ok" detail = "" def up(n): conn.bytes_up += n def down(n): conn.bytes_down += n conn.closers += [writer.close, r_writer.close] try: # Hop-by-Hop-Header entfernen (inkl. der in "Connection" genannten) conn_tokens = {t.strip().lower() for t in (get_header(headers, "connection") or "").split(",")} out_headers = [(k, v) for k, v in headers if k.lower() not in HOP_BY_HOP and k.lower() not in conn_tokens] if not get_header(out_headers, "host"): out_headers.insert(0, ("Host", url.netloc.rsplit("@", 1)[-1])) path = url.path or "/" if url.query: path += "?" + url.query head = f"{method} {path} HTTP/1.1\r\n" + "".join(f"{k}: {v}\r\n" for k, v in out_headers) + \ "Connection: close\r\n\r\n" r_writer.write(head.encode("latin-1")) if (get_header(headers, "expect") or "").lower() == "100-continue": writer.write(b"HTTP/1.1 100 Continue\r\n\r\n") await transfer_body(headers, reader, r_writer, up) await r_writer.drain() # Antwort lesen (1xx durchreichen) while True: rhead = await asyncio.wait_for(r_reader.readuntil(b"\r\n\r\n"), timeout=120) rfirst, rheaders = parse_head(rhead[:-4]) parts = rfirst.split(" ", 2) code = int(parts[1]) if 100 <= code < 200 and code != 101: writer.write(rhead) continue break detail = str(code) kept = [(k, v) for k, v in rheaders if k.lower() not in ("connection", "keep-alive", "proxy-connection")] writer.write((rfirst + "\r\n" + "".join(f"{k}: {v}\r\n" for k, v in kept) + "Connection: close\r\n\r\n") .encode("latin-1")) down(len(rhead)) no_body = method.upper() == "HEAD" or code in (204, 304) if not no_body: te = (get_header(rheaders, "transfer-encoding") or "").lower() if "chunked" in te or get_header(rheaders, "content-length"): await transfer_body(rheaders, r_reader, writer, down) else: await copy_until_eof(r_reader, writer, down) await writer.drain() except asyncio.CancelledError: status, detail = "error", "Verbindung beendet" raise except (OSError, asyncio.IncompleteReadError, asyncio.LimitOverrunError, asyncio.TimeoutError, ValueError, BadRequest) as e: status, detail = "error", (str(e) or e.__class__.__name__)[:300] if conn.bytes_down == 0: try: writer.write(response(502, "Bad Gateway", error_page("Fehler", detail), close=True)) except Exception: pass finally: try: r_writer.close() except Exception: pass conn.finish(status, detail) return False