"""极简 HTTP 框架(纯标准库):路由 + JSON/表单解析 + 静态文件(支持 Range/206)。 为什么不用框架:Debian 上零依赖即可部署(`apt install python3` 就够), 省掉 pip/venv 与版本漂移问题。性能对 KTV 场景(1~3 台设备)足够。 **反向代理友好**(IIS/ARR,见 `deploy/iis/`): * 每个响应都带精确 `Content-Length`(HTTP/1.1 keep-alive 的前提,缺了代理会挂住); * 媒体流式发送(256KB 分块),**绝不整块读进内存**,发完连接照常复用; * `HEAD`/`Range` 与直连时行为一致(206 + `Content-Length` + `Accept-Ranges`); * 请求体超过 `max_body_bytes` → **413 + 中文说明**(并主动关闭连接,不让管道错位); * 监听队列长度可配(`http_backlog`,默认 128):探活/突发请求不会被拒。 """ from __future__ import annotations import json import os import re import threading import urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Callable from . import proxy # 请求体上限的**兜底值**(实际以配置 `max_body_bytes` 为准,见 make_handler) MAX_BODY = 8 << 20 # 监听队列长度兜底(实际以配置 `http_backlog` 为准,见 start_server) DEFAULT_BACKLOG = 128 # 媒体流式发送的分块大小(75MB 的文件整块读进内存会拖垮 2GB 的机器 —— 部署实测) CHUNK = 256 * 1024 class Request: def __init__(self, method: str, path: str, query: dict, headers: dict, body: bytes, client_ip: str = "", peer_ip: str = "", via_proxy: bool = False, proxy_source: str = "", forwarded_proto: str = "", forwarded_host: str = "", forwarded_for: str = ""): self.method = method self.path = path self.query = query # ⚠️ 统一小写:`dict(HTTPMessage)` 之后失去大小写不敏感特性, # 若保留原样,`Range` 永远匹配不到 `range` → 媒体分发退回全量 200(部署实测踩过)。 self.headers = {(k or "").lower(): v for k, v in dict(headers).items()} self.body = body self.params: dict = {} # ── 来源 IP 的三层口径(见 ktv/proxy.py)── # `client_ip` = **真实客户端 IP**(限流与日志都用它;经可信代理时来自 XFF/X-Real-IP); # `peer_ip` = TCP 直连对端(反向代理部署时就是代理地址); # 手工构造 Request(测试)时通常只给 `client_ip`,其余为空 —— 与直连语义一致。 self.client_ip = client_ip self.peer_ip = peer_ip or client_ip self.via_proxy = bool(via_proxy) self.proxy_source = proxy_source self.forwarded_proto = forwarded_proto self.forwarded_host = forwarded_host self.forwarded_for = forwarded_for def text(self) -> str: return self.body.decode("utf-8", "replace") def json(self) -> dict: try: data = json.loads(self.text() or "{}") return data if isinstance(data, dict) else {} except Exception: return {} def form(self) -> dict: ctype = (self.headers.get("content-type") or "").lower() if "application/json" in ctype: return self.json() parsed = urllib.parse.parse_qs(self.text(), keep_blank_values=True) return {k: v[0] for k, v in parsed.items()} def q(self, key: str, default: str = "") -> str: return self.query.get(key, default) def qi(self, key: str, default: int = 0) -> int: try: return int(self.query.get(key, default)) except Exception: return default def header(self, key: str, default: str = "") -> str: return self.headers.get(key, default) class Response: def __init__(self, body: bytes = b"", status: int = 200, content_type: str = "application/json; charset=utf-8", headers: dict | None = None, file_path: str | None = None, file_start: int = 0, file_len: int = 0): self.body = body self.status = status self.content_type = content_type self.headers = headers or {} # 媒体走流式发送:75MB 的文件整块读进内存会拖垮 2GB 的机器(部署实测) self.file_path = file_path self.file_start = file_start self.file_len = file_len def json_response(data, status: int = 200) -> Response: return Response(json.dumps(data, ensure_ascii=False).encode(), status) def text_response(text: str, status: int = 200) -> Response: return Response(str(text).encode(), status, "text/plain; charset=utf-8") def html_response(html: str, status: int = 200) -> Response: return Response(str(html).encode(), status, "text/html; charset=utf-8") class Router: def __init__(self): self.routes: list[tuple[str, re.Pattern, Callable, bool]] = [] # **前置守卫**(可选):`guard(req)` 返回 Response 就短路(不再走路由),返回 None 放行。 # 用途:App 令牌校验(ktv/apptoken.py)与双站点宿主隔离 —— 都必须发生在**路由匹配之前** # (否则 `/media/x.ts` 会先被媒体路由读到文件,隔离就形同虚设)。 self.guard: Callable[[Request], Response | None] | None = None def add(self, method: str, pattern: str, handler: Callable, auth: bool = False) -> None: self.routes.append((method.upper(), re.compile("^" + pattern + "$"), handler, auth)) def get(self, pattern: str, auth: bool = False): def deco(fn): self.add("GET", pattern, fn, auth) return fn return deco def post(self, pattern: str, auth: bool = False): def deco(fn): self.add("POST", pattern, fn, auth) return fn return deco def dispatch(self, req: Request) -> Response: # 前置守卫(App 令牌 / 宿主隔离):返回响应即短路;**必须在路由之前**。 # 守卫自身出错也不能打死请求 —— 按"放行"处理并在响应里体现(见 apptoken 的防御式写法)。 if self.guard is not None: early = self.guard(req) if early is not None: return early # ⚠️ HEAD 按 GET 路由(curl -I / OkDownload 的探测都会用到), # 否则 HEAD 一律 404(部署实测);响应体在 _send 里对 HEAD 主动跳过。 want = "GET" if req.method == "HEAD" else req.method for method, rx, handler, _auth in self.routes: if method != want: continue m = rx.match(req.path) if m: req.params = {k: v for k, v in m.groupdict().items()} return handler(req) return json_response({"error": "not found", "path": req.path}, 404) def serve_file(path: str, req: Request, content_type: str = "video/mp2t") -> Response: """支持 Range/206 的文件响应(App 的 OkDownload 断点续传依赖它)。""" if not os.path.isfile(path): return text_response("not found", 404) size = os.path.getsize(path) rng = req.header("range") start, end = 0, size - 1 status = 200 headers = {"Accept-Ranges": "bytes", "Cache-Control": "public, max-age=86400"} if rng: m = re.match(r"bytes=(\d*)-(\d*)", rng.strip()) if m: if m.group(1): start = int(m.group(1)) end = int(m.group(2)) if m.group(2) else size - 1 else: # bytes=-N 后缀范围 start = max(0, size - int(m.group(2) or 0)) if start >= size or start > end: return Response(b"", 416, content_type, {"Content-Range": f"bytes */{size}"}) status = 206 headers["Content-Range"] = f"bytes {start}-{end}/{size}" length = end - start + 1 # Content-Length 由 _send 统一发送(这里再写一次会出现重复头) return Response(b"", status, content_type, headers, file_path=path, file_start=start, file_len=length) def make_handler(router: Router, auth_token: str = "", log_fn: Callable[[str], None] | None = None, cfg=None, max_body_bytes: int | None = None): """构造 handler;`cfg` 非空时按配置认定真实客户端 IP(反向代理)与请求体上限。""" limit = max_body_bytes if limit is None and cfg is not None: try: limit = int(cfg.get("max_body_bytes") or 0) except Exception: limit = 0 limit = int(limit if limit else MAX_BODY) if limit <= 0: limit = MAX_BODY class Handler(BaseHTTPRequestHandler): server_version = "KtvSource/1.0" protocol_version = "HTTP/1.1" def log_message(self, fmt, *args): # 交给上层日志,不直接写 stderr;**用真实客户端 IP**(经可信代理时是设备 IP, # 否则反代后面所有日志都是代理地址,排查设备问题等于没日志)。 if log_fn: who = str(getattr(self, "_log_client_ip", "") or self.address_string()) log_fn(f"{who} {fmt % args}") # ── 入口 ── def _handle(self): try: parsed = urllib.parse.urlparse(self.path) query = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query, keep_blank_values=True).items()} # 真实客户端 IP:直连 = TCP 对端;经**可信**代理 = XFF 最左非代理 / X-Real-IP # (未注入 cfg 时退回"直连"口径,行为与改造前一致) peer = str(getattr(self, "client_address", ("", 0))[0] or "") if cfg is not None: info = proxy.resolve(peer, self.headers, cfg) else: info = proxy.ClientInfo(ip=proxy.norm_ip(peer), peer_ip=proxy.norm_ip(peer)) self._log_client_ip = info.ip or info.peer_ip if not (self.headers.get("Content-Length") or "").strip() \ and str(self.headers.get("Transfer-Encoding") or "").strip(): # 分块请求体(chunked)本框架不支持:**必须明确拒绝并关连接**,否则那串 chunk # 会被当成下一个请求解析(keep-alive 管道错位,经代理时表现为"偶发乱响应")。 self.close_connection = True resp = json_response({ "ok": False, "error": "不支持分块请求体(Transfer-Encoding: chunked),请带 Content-Length"}, 411) resp.headers["Connection"] = "close" self._send(resp) return length = self._content_length() if length is None: self.close_connection = True self._send(json_response({"ok": False, "error": "Content-Length 非法"}, 400)) return limit_now = self._body_limit() if length > limit_now: # 超过上限**不读进内存**:直接 413 + 中文说明,并关掉连接(否则剩余字节 # 会被当成下一个请求解析 —— keep-alive 管道错位)。 self.close_connection = True resp = json_response({ "ok": False, "error": f"请求体过大:{length} 字节,超过上限 {limit_now} 字节" f"(配置项 max_body_bytes)"}, 413) resp.headers["Connection"] = "close" self._send(resp) return body = self.rfile.read(length) if length > 0 else b"" req = Request(self.command, urllib.parse.unquote(parsed.path), query, dict(self.headers), body, client_ip=info.ip, peer_ip=info.peer_ip, via_proxy=info.via_proxy, proxy_source=info.source, forwarded_proto=info.proto, forwarded_host=info.host, forwarded_for=info.xff) if auth_token and self._needs_auth(parsed.path): if not self._authorized(req): self._send(json_response({"error": "unauthorized"}, 401)) return resp = router.dispatch(req) self._send(resp) except BrokenPipeError: pass except Exception as exc: # pragma: no cover if log_fn: log_fn(f"handler error: {type(exc).__name__}: {exc}") try: self._send(json_response({"error": f"{type(exc).__name__}: {exc}"}, 500)) except Exception: pass def _content_length(self) -> int | None: raw = self.headers.get("Content-Length") if raw is None or str(raw).strip() == "": return 0 try: n = int(str(raw).strip()) except Exception: return None return n if n >= 0 else None def _body_limit(self) -> int: """请求体上限:**每次现读配置**(改完立刻生效);读不到就用构造时的兜底值。""" if cfg is not None: try: v = int(cfg.get("max_body_bytes") or 0) if v > 0: return v except Exception: pass return limit def _authorized(self, req: Request) -> bool: if req.header("x-auth-token") == auth_token: return True if req.query.get("token") == auth_token: return True auth = req.header("authorization") return auth == f"Bearer {auth_token}" def _needs_auth(self, path: str) -> bool: return path.startswith("/api/") and not path.startswith("/api/public") def _send(self, resp: Response): self.send_response(resp.status) self.send_header("Content-Type", resp.content_type) if resp.file_path: # 精确长度 = keep-alive 的前提;HEAD 也要带(App/代理的探测靠它) self.send_header("Content-Length", str(resp.file_len)) else: self.send_header("Content-Length", str(len(resp.body))) for k, v in resp.headers.items(): self.send_header(k, v) self.end_headers() if self.command == "HEAD": return if resp.file_path: # 流式分块发送:绝不整块读进内存;发完刚好等于 Content-Length,连接可复用 remaining = resp.file_len with open(resp.file_path, "rb") as fh: fh.seek(resp.file_start) while remaining > 0: chunk = fh.read(min(CHUNK, remaining)) if not chunk: break self.wfile.write(chunk) remaining -= len(chunk) return if resp.body: self.wfile.write(resp.body) def do_GET(self): self._handle() def do_HEAD(self): self._handle() def do_POST(self): self._handle() def do_PUT(self): self._handle() def do_DELETE(self): self._handle() return Handler def start_server(host: str, port: int, router: Router, auth_token: str = "", log_fn: Callable[[str], None] | None = None, cfg=None, backlog: int | None = None) -> ThreadingHTTPServer: """起一个监听线程并返回 server(`shutdown()` 关闭)。 `backlog` = `request_queue_size`(监听队列长度,配置项 `http_backlog`,默认 128): IIS/负载均衡的探活 + 设备突发请求并发时,队列太短会直接 Connection refused。 """ size = int(backlog if backlog is not None else DEFAULT_BACKLOG) size = max(5, min(4096, size)) class _Server(ThreadingHTTPServer): request_queue_size = size # 必须在 __init__(= server_activate/listen)之前生效 srv = _Server((host, port), make_handler(router, auth_token, log_fn, cfg=cfg)) srv.daemon_threads = True t = threading.Thread(target=srv.serve_forever, name=f"http-{port}", daemon=True) t.start() return srv