版本号:
- 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 例)
365 lines
15 KiB
Python
365 lines
15 KiB
Python
"""反向代理(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("/")
|