334 lines
13 KiB
Python
334 lines
13 KiB
Python
"""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"<!doctype html><html lang=de><meta charset=utf-8><title>{html.escape(title)}</title>"
|
||
"<body style='font-family:system-ui,sans-serif;background:#f4f5f7;color:#1d2330;display:flex;"
|
||
"align-items:center;justify-content:center;min-height:90vh'>"
|
||
"<div style='background:#fff;border-radius:12px;padding:32px 40px;max-width:560px;"
|
||
"box-shadow:0 4px 24px rgba(0,0,0,.08)'>"
|
||
f"<h1 style='margin:0 0 12px;font-size:22px'>{html.escape(title)}</h1>"
|
||
f"<p style='margin:0;color:#4a5263;line-height:1.5'>{html.escape(text)}</p></div></body></html>")
|
||
|
||
|
||
# --------------------------------------------------------------------------- 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
|