Files
maidong-ktv/selfhost-source/server/ktv/proxy.py
T
zijunchiang 87aa979abd 合并自建歌曲源支线(selfhost-source)+ 版本号按约定修正为 1.6.0
版本号:
- 1.5.11 越界(规则 3 规定 patch ≤ 10)→ 修正为 1.6.0(进位到 minor、patch 归 0)
- versionCode 43 不变:43 从未对外发布,本次修正只是把命名拉回约定

合并支线(原在工作区根目录、App 仓库之外,DESIGN.md 自称"支线"):
- selfhost-source/server/   自建歌曲源服务器(纯 Python 标准库、零 pip 依赖)
- selfhost-source/deploy/、DESIGN.md、LIBRARY.md、build_inventory.py
- selfhost-source/inventory/ 小清单(playlists.csv、song-stats.md 等)
- ⚠️ inventory 生成物(songs-*.csv / singers-top.csv,共 84MB)不入库,
  已写入 .gitignore;需要时用 build_inventory.py 依据 muse.db 重新生成

同时入库的 App 侧支线改动(此前为工作树未提交状态):
- Whoami.kt(whoami 设备心跳)、DeviceIdentity.kt(设备身份伪装)
- PushDownload.kt / PushDownloadClient.kt(点歌推送)
- AdVideoFilter 带 query/fragment 的地址闸门;KTV_BRIDGE_API 11 → 13
- 修 ktv_api.js 的 global.KtvBridgeApiVersion(12 → 13,与注释及 Kotlin 两处校验一致)
- 对应单测:WhoamiTest / DeviceIdentityTest / PushDownloadTest / AdVideoFilterTest(共 39 例)

文档:
- DESIGN.md 的"在 App 仓库之外"约定改为"已并入仓库统一版本化"
- AGENTS.md 版本号行更新为 1.6.0/43,并新增支线并入说明

验证:assembleRelease + testReleaseUnitTest 全过(39 例)
2026-10-14 02:06:28 +08:00

365 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""反向代理(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("/")