"""反向代理(IIS / ARR / nginx)支持:可信代理判定、真实客户端 IP、协议与 Host 感知。 KTV 场所的常见形态:对外只暴露一台 Windows 上的 IIS(HTTPS + 域名),由它把 `api.php` / `i.php` / `music/do.php` / `media/*` / `push_download` / `healthz` 反代到内网 Linux 上的本服务(6677);**管理端口 6678 绝不对外**(配置与步骤见 `deploy/iis/`)。 反向代理带来两个必须解决的问题: ① **来源 IP 变了**:服务端看到的 TCP 对端变成代理地址 → `/push_download` 的限流会把所有 设备算成同一个来源(一台设备刷满,其他设备全被 429);日志里的客户端 IP 也全是代理地址。 ② **协议/Host 变了**:代理对外是 HTTPS、内网是 HTTP,生成绝对地址(`public_media_base`、 `/music/do.php` 返回的媒体地址)时会退化成 `http://内网主机名` —— App 拒收,还泄露内网拓扑。 安全前提(**默认全关,绝不默认采信**): * `trust_proxy_headers=false`(默认)→ 一个代理头都不看,一律用 TCP 对端地址; * 即使打开,也**只有**直连对端 IP 命中 `trusted_proxies`(CIDR 或 IP 列表)时才采信 `X-Forwarded-For` / `X-Real-IP` / `X-Forwarded-Proto` / `X-Forwarded-Host`; 否则一个头都不看(防伪造:任何人都能自己塞一个 `X-Forwarded-For`)。 * `X-Forwarded-For` 取**最左侧的非代理 IP**:这样"客户端自己伪造一个内网/回环 IP 放在最左" 也不会被采信(那一段会被当成代理跳过去);整条链都是可信代理时退回最左侧(原始客户端)。 **双站点宿主判定**(`mdmuse.kasugano.cn` 只给 API、`md.kasugano.cn:4433` 只给文件下载): `effective_host()` 用**可信的** `X-Forwarded-Host`(不可信时退回 `Host` 头), `host_matches()` 做**端口容错**(`md.kasugano.cn` 命中 `md.kasugano.cn:4433`)。 出口在 `ktv/apptoken.py` 的 `host_guard()`(配置项 `media_hosts` / `api_hosts`,默认空 = 不校验)。 本模块**不依赖 web.py**(web.py 依赖本模块),只吃"对端 IP + 头字典",因此可以直接单测。 """ from __future__ import annotations import ipaddress import re import urllib.parse from functools import lru_cache from typing import Any, Mapping, NamedTuple # `X-Forwarded-Proto` 只认这两个值(其余(含 IIS 的 `on`/`off`)一律忽略,退回配置里的 scheme) _OK_PROTO = ("http", "https") # 域名 / IPv4[:端口] / 主机名探针:只允许这些字符,杜绝把 "/"、空串、带 CRLF 的脏值拼进 URL _HOSTNAME_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?(?::\d{1,5})?$") _HOST_V6_RE = re.compile(r"^\[[0-9A-Fa-f:.]+\](?::\d{1,5})?$") class ClientInfo(NamedTuple): """一次请求的"来源认定"结果(`web.make_handler` 注入到 `Request`)。""" ip: str = "" # **真实客户端 IP**(限流与日志都用它) peer_ip: str = "" # TCP 直连对端(代理地址) via_proxy: bool = False # 直连对端命中 trusted_proxies(= 请求确实经由可信代理) source: str = "tcp" # 这个 IP 是从哪来的:tcp / x-forwarded-for / x-real-ip proto: str = "" # 可信的 X-Forwarded-Proto(http/https),空 = 不可信/没给 host: str = "" # 可信的 X-Forwarded-Host(含端口),空 = 不可信/没给 xff: str = "" # 原始 X-Forwarded-For(调试用) trusted: bool = False # 同 via_proxy,语义化别名(调试接口用着顺手) # ── 可信代理列表(CIDR / IP)── def normalize_trusted(values: Any) -> list[str]: """把 `trusted_proxies` 归一化成字符串列表(字符串按逗号/空白/分号拆,去空项)。 配置层(`Config.update()`)用它把用户填的 `"127.0.0.1, 192.168.100.0/24"` 落成 JSON 列表。 """ return list(_normalize_list(values)) def parse_trusted(values: Any) -> tuple: """把配置里的 `trusted_proxies` 解析成 `ipaddress` 网络对象元组(非法项直接丢弃)。 支持:`192.168.100.10`(单 IP)、`192.168.100.0/24`(CIDR)、`2001:db8::/32`(IPv6)。 字符串也可以(按逗号/空白拆开),方便用户直接手写 JSON 或走设置页。 """ return _parse_cached(_normalize_list(values)) def _normalize_list(values: Any) -> tuple: if values is None: return () if isinstance(values, str): items = [x for x in re.split(r"[,\s;]+", values) if x] elif isinstance(values, (list, tuple, set)): items = [str(x) for x in values] else: items = [str(values)] return tuple(x.strip() for x in items if x and str(x).strip()) @lru_cache(maxsize=64) def _parse_cached(items: tuple) -> tuple: out = [] for raw in items: try: out.append(ipaddress.ip_network(raw, strict=False)) except Exception: try: out.append(ipaddress.ip_network(ipaddress.ip_address(raw))) except Exception: continue # 非法项丢弃(配置校验会给警告) return tuple(out) def invalid_trusted(values: Any) -> list[str]: """返回**解析不了**的条目(给配置校验提示用,运行时它们会被丢弃)。""" bad = [] for raw in _normalize_list(values): try: ipaddress.ip_network(raw, strict=False) except Exception: try: ipaddress.ip_address(raw) except Exception: bad.append(raw) return bad def norm_ip(text: Any) -> str: """归一化一个 IP 字符串:去引号/方括号/端口,校验合法性;认不出来返回空串。 代理头里常见 `1.2.3.4`、`1.2.3.4:5678`、`[2001:db8::1]`、`unknown`、`-` 这些写法, 只接受真正能解析成 IP 的值(其余一律丢弃,绝不当成"客户端")。 """ s = str(text or "").strip().strip('"').strip("'") if not s or s.lower() in ("unknown", "-", "null", "none"): return "" if s.startswith("[") and "]" in s: # [IPv6]:port s = s[1:s.index("]")] elif s.count(":") == 1 and "." in s: # IPv4:port s = s.split(":", 1)[0] try: return str(ipaddress.ip_address(s)) except Exception: return "" def is_trusted(ip: str, networks: tuple) -> bool: addr = norm_ip(ip) if not addr or not networks: return False try: a = ipaddress.ip_address(addr) except Exception: return False for net in networks: try: if a.version == net.version and a in net: return True except Exception: continue return False def trusted_networks(cfg) -> tuple: """当前配置的可信代理网络(`cfg` 每次现读 → 设置页改完立刻生效)。""" try: return parse_trusted(cfg.get("trusted_proxies")) except Exception: return () def proxy_enabled(cfg) -> bool: """是否"真的可能采信代理头":开关打开 **且** 至少配了一个可信代理。""" try: if not bool(cfg.get("trust_proxy_headers")): return False return bool(trusted_networks(cfg)) except Exception: return False # ── 头解析 ── def _header(headers: Mapping, name: str) -> str: """取头(大小写不敏感):`HTTPMessage` 本身不敏感,普通 dict 这里补一下。""" try: v = headers.get(name) # type: ignore[union-attr] except Exception: v = None if v is None: for k, val in dict(headers or {}).items(): if str(k).lower() == name: v = val break return str(v or "").strip() def _xff_list(xff: str) -> list[str]: return [x for x in (norm_ip(part) for part in str(xff or "").split(",")) if x] def _first_non_proxy(xff: str, networks: tuple) -> str: """`X-Forwarded-For` 里最左侧的**非代理** IP;整条链都是可信代理时退回最左侧。""" chain = _xff_list(xff) for ip in chain: if not is_trusted(ip, networks): return ip return chain[0] if chain else "" def valid_host(value: str) -> str: """`X-Forwarded-Host` 白名单校验:域名 / IPv4[:端口] / [IPv6][:端口],否则丢弃。 绝不能把没校验的值拼进返回给 App 的地址(`/`、空格、CRLF 都会造成地址错乱甚至头注入)。 """ s = str(value or "").strip().split(",")[0].strip() if not s or len(s) > 255: return "" if _HOST_V6_RE.match(s): return s return s if _HOSTNAME_RE.match(s) else "" def valid_proto(value: str) -> str: s = str(value or "").strip().split(",")[0].strip().lower() return s if s in _OK_PROTO else "" # ── 宿主(Host)判定:双站点反代(API 站 / 媒体站)隔离用 ── def normalize_hosts(values: Any) -> list[str]: """把 `media_hosts` / `api_hosts` 归一化成字符串列表(逗号/空白/分号拆分)。 与 `trusted_proxies` 同一套拆分口径;**不复用 IP 解析**(宿主是域名,不是 IP)。 """ return [x for x in _normalize_list(values)] def strip_host_port(value: str) -> str: """去掉宿主里的端口:`md.kasugano.cn:4433` → `md.kasugano.cn`;IPv6 保留方括号内部分。 只处理单冒号(IPv4/域名带端口)与 `[IPv6]:port` 两种写法;裸 IPv6(多冒号)原样返回。 """ s = str(value or "").strip() if not s: return "" if s.startswith("["): return s[1:s.index("]")] if "]" in s else s if s.count(":") == 1: return s.split(":", 1)[0] return s def host_matches(host: str, patterns: Any) -> bool: """宿主是否命中列表(**端口容错**、大小写不敏感、忽略结尾的点)。 ⚠️ 反代场景下 `X-Forwarded-Host` 常常带端口(媒体站是 `md.kasugano.cn:4433`), 所以 `media_hosts=["md.kasugano.cn"]` 必须能命中 `md.kasugano.cn:4433`; 反过来配置里写了端口、头里没带端口也要能命中(两侧都按"去端口"比一次)。 """ h = str(host or "").strip().lower().rstrip(".") if not h: return False bare = strip_host_port(h).lower().rstrip(".") for raw in normalize_hosts(patterns): p = str(raw).strip().lower().rstrip(".") if not p: continue if h == p: return True if bare and bare == strip_host_port(p).lower().rstrip("."): return True return False def effective_host(req) -> tuple[str, str]: """本次请求"用于宿主判定"的 Host(返回 `(host, 来源)`)。 来源只有两种,且**优先可信的 `X-Forwarded-Host`**: * `x-forwarded-host` —— 仅当 `resolve()` 认定经可信代理时才可能非空 (不可信时 `req.forwarded_host` 恒为空,天然防伪造); * `host` —— 退回 HTTP 的 `Host` 头(IIS 按站点绑定写死,直连时就是客户端给的)。 ⚠️ 注意顺序:**先看可信代理头,再退回 Host**。绝不能反过来 —— 否则任何直连客户端 只要自己塞一个 `X-Forwarded-Host: md.kasugano.cn:4433` 就能绕过媒体站隔离。 """ fwd = str(getattr(req, "forwarded_host", "") or "") if bool(getattr(req, "via_proxy", False)) and fwd: return fwd, "x-forwarded-host" host = valid_host(_header(getattr(req, "headers", {}) or {}, "host")) return (host, "host") if host else ("", "") def resolve(peer_ip: str, headers: Mapping, cfg) -> ClientInfo: """认定本次请求的真实客户端 IP(以及可信的 Proto/Host)。 判定顺序(**先看开关,再看对端是否可信**): ① 开关关 / 没配可信代理 / 对端不在可信列表 → 全部代理头一律不采信,用 TCP 对端; ② `X-Forwarded-For` 最左侧非代理 IP → 采信; ③ 没有 XFF(或全是垃圾)→ `X-Real-IP` → 采信; ④ 都没有 → TCP 对端。 """ peer = norm_ip(peer_ip) xff = _header(headers, "x-forwarded-for") x_real = _header(headers, "x-real-ip") xfp = _header(headers, "x-forwarded-proto") xfh = _header(headers, "x-forwarded-host") def _plain() -> ClientInfo: return ClientInfo(ip=peer, peer_ip=peer, via_proxy=False, source="tcp", xff=xff, trusted=False) try: if not bool(cfg.get("trust_proxy_headers")): return _plain() nets = trusted_networks(cfg) except Exception: return _plain() if not nets or not peer or not is_trusted(peer, nets): return _plain() # 不可信:连 Proto/Host 也不看(防伪造/防泄露) ip, source = _first_non_proxy(xff, nets), "x-forwarded-for" if not ip: ip, source = norm_ip(x_real), "x-real-ip" if not ip: ip, source = peer, "tcp" return ClientInfo(ip=ip, peer_ip=peer, via_proxy=True, source=source, proto=valid_proto(xfp), host=valid_host(xfh), xff=xff, trusted=True) # ── 协议 / Host 感知的绝对地址 ── def public_base(cfg, req=None, key: str = "public_media_base") -> str: """生成对外的绝对地址前缀(**不带**结尾斜杠)。 默认取配置 `public_media_base`;当请求确实经由可信代理、且带了**可信的** `X-Forwarded-Proto`/`X-Forwarded-Host` 时,用它们覆盖 scheme/host —— 这样 HTTPS 场景下 永远不会把 `http://内网主机:6677/...` 返回给 App(`/music/do.php` 的媒体地址、 以及将来任何重定向都走这里)。 ⚠️ 只有 `resolve()` 认定可信(`req.via_proxy`)时才会覆盖,头本身也已经过白名单校验。 """ base = str(cfg.get(key) or "").rstrip("/") parts = urllib.parse.urlsplit(base) scheme = (parts.scheme or "http").lower() host = parts.netloc path = parts.path.rstrip("/") if req is not None and getattr(req, "via_proxy", False): fwd_proto = str(getattr(req, "forwarded_proto", "") or "") fwd_host = str(getattr(req, "forwarded_host", "") or "") if fwd_proto: scheme = fwd_proto if fwd_host: host = fwd_host if not host: # `public_media_base` 只写了路径(少见):用 `public_host` 补主机,避免拼出 "http:///media" host = str(cfg.get("public_host") or "") if not parts.path: path = "/media" return f"{scheme}://{host}{path}" if host else base def public_media_url(cfg, req, tid: int) -> str: """`/music/do.php` 返回给 App 的媒体绝对地址(经可信代理时会自动升级成 https + 公网域名)。""" return f"{public_base(cfg, req)}/{int(tid)}.ts" def absolute_url(cfg, req, path: str) -> str: """把站内路径拼成对外绝对地址(重定向等场景统一走这里,避免漏掉 Proto/Host 感知)。 只取 scheme + host(丢掉 `public_media_base` 里的 `/media` 路径段),再拼上传入路径。 """ parts = urllib.parse.urlsplit(public_base(cfg, req)) return f"{parts.scheme}://{parts.netloc}/" + str(path or "").lstrip("/")