合并自建歌曲源支线(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 例)
This commit is contained in:
zijunchiang
2026-10-14 02:06:28 +08:00
parent e060ae5ebe
commit 87aa979abd
68 changed files with 21305 additions and 105 deletions
@@ -0,0 +1,660 @@
#!/usr/bin/env python3
"""App 令牌(`GET /token`)与**双站点反代**(API 站 / 媒体站)自测。
覆盖:
① `/token`:随机且每次不同、`expires_in=600`、`Cache-Control: no-store`、
TTL 过期后校验失败(注入时钟,不真等 10 分钟)、绑定客户端 IP、单次令牌、
可选持久化(重启仍有效)、每分钟签发限流、聚合日志(按分钟一条);
② 校验与兼容:`app_token_enforce=false` 时老 App 不带令牌照常可用、带**错误**令牌报 401;
`enforce=true` 时没令牌 → 401、带合法令牌 → 通过;`/i.php` 同口径;
老协议自己的 `token=local` **不算** App 令牌(否则已发布 App 会被全线 401);
③ `/push_download`:合法 App 令牌 **或** 合法 `push_token` 任一即可,都没有 → 401;
④ 宿主隔离:媒体宿主只能下文件、API 宿主不能批量下载、**不可信代理头一律忽略**(防伪造);
⑤ HTTP 层端到端(真起 127.0.0.1 端口):取令牌 → 带 `X-App-Token` 调 do.php 成功;
`enforce=true` 不带令牌 401;伪造 `X-Forwarded-Host` 走媒体站 → 200 + Range 206、
走 do.php → 404;不可信代理头伪造宿主无效;
⑥ `deploy/iis/` 两份站点配置静态校验(可解析、各管各的、都不含管理端口)。
不联网、不起外网连接(socket 用例只绑 127.0.0.1 的临时端口)。
"""
import http.client
import json
import os
import re
import shutil
import sys
import tempfile
import unittest
import urllib.parse
import xml.etree.ElementTree as ET
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ktv import apptoken, proxy, service, web
from ktv.config import Config
from ktv.store import Store
from ktv.web import Request
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
IIS_DIR = os.path.join(os.path.dirname(HERE), "deploy", "iis")
API_CFG = os.path.join(IIS_DIR, "api-site", "web.config")
MEDIA_CFG = os.path.join(IIS_DIR, "media-site", "web.config")
TID = 7300123
PUSH_URL = f"http://cdn.example.com/480p/{TID}.ts?token=abc&t=1700000000"
def req(method, path, query=None, body=b"", headers=None, client_ip="", peer_ip="",
via_proxy=False, proto="", host="", xff=""):
"""构造 Request;反代场景用 `via_proxy=True` + `proto/host/xff`(= web.py 注入的口径)。"""
return Request(method, path, query or {}, headers or {}, body, client_ip=client_ip,
peer_ip=peer_ip or client_ip, via_proxy=via_proxy,
proxy_source="x-forwarded-for" if (via_proxy and xff) else "tcp",
forwarded_proto=proto, forwarded_host=host, forwarded_for=xff)
def form(**kv) -> bytes:
return urllib.parse.urlencode(kv).encode()
def via_proxy_req(cfg, method, path, query=None, body=b"", peer="127.0.0.1",
xff="203.0.113.9", xfh="", headers=None, client_ip=None):
"""按**真实认定链路**(proxy.resolve)构造一条"经可信代理到达"的请求。"""
raw = {"X-Forwarded-For": xff}
if xfh:
raw["X-Forwarded-Host"] = xfh
info = proxy.resolve(peer, raw, cfg)
hdrs = {"Host": "127.0.0.1:6677", "X-Forwarded-For": xff}
if xfh:
hdrs["X-Forwarded-Host"] = xfh
hdrs.update(headers or {})
return req(method, path, query, body, hdrs,
client_ip=info.ip if client_ip is None else client_ip,
peer_ip=info.peer_ip, via_proxy=info.via_proxy,
proto=info.proto, host=info.host, xff=info.xff)
class TokenCase(unittest.TestCase):
"""公共脚手架:一份配置 + 一个库 + 服务端口路由(含守卫)。"""
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.media = os.path.join(self.tmp, "media")
os.makedirs(self.media, exist_ok=True)
self.cfg = Config(os.path.join(self.tmp, "c.json"))
self.cfg.update({"data_dir": self.tmp, "media_dir": self.media,
"public_media_base": "https://md.kasugano.cn:4433/media"})
self.store = Store(os.path.join(self.tmp, "t.db"))
self.router = service.build_router(self.cfg, self.store, None)
self.tokens = apptoken.registry_for(self.cfg, self.store)
# 注入时钟:TTL / 限流窗口 / 分钟聚合都按它走,测试不真等 10 分钟
self.now = [1_700_000_000.0]
self.tokens._clock = lambda: self.now[0]
def tearDown(self):
try:
self.store.conn.close()
except Exception:
pass
shutil.rmtree(self.tmp, ignore_errors=True)
# ── 小工具 ──
def call(self, r):
return self.router.dispatch(r)
def get_token(self, ip="", method="GET"):
resp = self.call(req(method, "/token", client_ip=ip))
return resp, json.loads(resp.body)
def ready_song(self, tid=TID, size=7 << 20):
"""把某首歌做成"已下载完成"(do.php 才可能回 code:200)。"""
self.store.import_library([(tid, "测试歌", "国语", "", "歌手", f"{tid}.ts", 10, "")], "v")
self.store.enqueue([tid])
self.store.claim_one(tid)
path = os.path.join(self.media, f"{tid}.ts")
with open(path, "wb") as fh:
fh.write(b"\x47" * 300)
self.store.finish(tid, True, bytes_=size, path=path)
return tid
def do_php(self, tid=TID, **kw):
return self.call(req("GET", "/music/do.php", {"musicno": str(tid)}, **kw))
def advance(self, seconds):
self.now[0] += float(seconds)
# ══════════════════════ ① /token 本身 ══════════════════════
class TestTokenEndpoint(TokenCase):
def test_returns_random_token_with_ttl_and_no_store(self):
"""随机、每次不同、expires_in=600、禁止缓存。"""
self.assertEqual(self.cfg.get("app_token_ttl_sec"), 600)
resp, d = self.get_token(ip="203.0.113.9")
self.assertEqual(resp.status, 200)
self.assertTrue(d["ok"])
self.assertEqual(d["expires_in"], 600)
self.assertEqual(d["expires_at"], int(self.now[0]) + 600)
self.assertEqual(resp.headers.get("Cache-Control"), "no-store")
# 32~48 位、URL-safe(hex/urlsafe 都在这个字符集里)
self.assertRegex(d["token"], r"^[A-Za-z0-9_\-=]{32,48}$")
self.assertGreaterEqual(len(d["token"]), 32)
self.assertLessEqual(len(d["token"]), 48)
seen = {d["token"]}
for _ in range(20):
_r, d2 = self.get_token(ip="203.0.113.9")
self.assertNotIn(d2["token"], seen, "令牌必须每次不同")
seen.add(d2["token"])
# POST 也接受(有些客户端不方便发 GET)
rp, dp = self.get_token(ip="203.0.113.9", method="POST")
self.assertEqual(rp.status, 200)
self.assertTrue(dp["ok"])
def test_ttl_expiry_uses_injected_clock(self):
"""TTL 过期后校验失败 —— 用注入时钟,不真等。"""
self.cfg.update({"app_token_ttl_sec": 30}) # 最小合法值
_r, d = self.get_token(ip="203.0.113.9")
tok = d["token"]
self.assertEqual(d["expires_in"], 30)
self.ready_song()
self.assertEqual(self.do_php(headers={"X-App-Token": tok},
client_ip="203.0.113.9").status, 200)
self.advance(31)
resp = self.do_php(headers={"X-App-Token": tok}, client_ip="203.0.113.9")
self.assertEqual(resp.status, 401)
self.assertEqual(json.loads(resp.body), {"ok": False, "error": apptoken.AUTH_ERROR})
def test_ttl_clamped_config(self):
warns = self.cfg.update({"app_token_ttl_sec": 3})
self.assertEqual(self.cfg.get("app_token_ttl_sec"), 30)
self.assertTrue(any("有效期" in w for w in warns))
self.cfg.update({"app_token_ttl_sec": 10 ** 9})
self.assertEqual(self.cfg.get("app_token_ttl_sec"), 86400)
def test_bind_ip_rejects_other_client(self):
"""令牌绑定签发时的客户端 IP;换 IP 用同一令牌 → 401。"""
self.assertTrue(self.cfg.get("app_token_bind_ip"))
_r, d = self.get_token(ip="203.0.113.9")
tok = d["token"]
self.ready_song()
self.assertEqual(self.tokens.verify(tok, "203.0.113.9"), True)
self.assertEqual(self.tokens.verify(tok, "203.0.113.10"), False)
self.assertEqual(self.do_php(headers={"X-App-Token": tok},
client_ip="203.0.113.10").status, 401)
# 关掉绑定 → 换 IP 也放行
self.cfg.update({"app_token_bind_ip": False})
self.assertEqual(self.tokens.verify(tok, "203.0.113.10"), True)
def test_single_use_consumes_token(self):
"""`app_token_single_use=true` 用过即失效(默认必须是 False:取址是多次调用)。"""
_r, d = self.get_token(ip="203.0.113.9")
tok = d["token"]
self.assertTrue(self.tokens.verify(tok, "203.0.113.9"))
self.assertTrue(self.tokens.verify(tok, "203.0.113.9"), "默认不是单次,应可多次使用")
self.cfg.update({"app_token_single_use": True})
_r, d2 = self.get_token(ip="203.0.113.9")
tok2 = d2["token"]
self.assertTrue(self.tokens.verify(tok2, "203.0.113.9"))
self.assertFalse(self.tokens.verify(tok2, "203.0.113.9"))
def test_optional_persistence(self):
"""`app_token_persist=true` → 落 meta 表,重启(新实例)后仍有效;默认纯内存不落库。"""
self.assertFalse(self.cfg.get("app_token_persist"))
_r, d = self.get_token(ip="203.0.113.9")
fresh = apptoken.TokenService(self.cfg, self.store, clock=lambda: self.now[0])
self.assertFalse(fresh.verify(d["token"], "203.0.113.9"), "默认纯内存:重启即失效")
self.cfg.update({"app_token_persist": True})
_r, d2 = self.get_token(ip="203.0.113.9")
self.assertTrue(self.store.get_meta(apptoken.META_KEY, ""), "应该落了一行 meta")
fresh2 = apptoken.TokenService(self.cfg, self.store, clock=lambda: self.now[0])
self.assertTrue(fresh2.verify(d2["token"], "203.0.113.9"), "持久化后重启应仍有效")
# 过期后不该被"复活"
self.advance(601)
fresh3 = apptoken.TokenService(self.cfg, self.store, clock=lambda: self.now[0])
self.assertFalse(fresh3.verify(d2["token"], "203.0.113.9"))
def test_rate_limit_per_minute_per_ip(self):
"""同一来源 IP 每分钟签发数受限(默认 60,防止被刷)。"""
self.cfg.update({"token_rate_limit_per_min": 2})
self.assertEqual(self.get_token(ip="203.0.113.9")[0].status, 200)
self.assertEqual(self.get_token(ip="203.0.113.9")[0].status, 200)
third = self.get_token(ip="203.0.113.9")[0]
self.assertEqual(third.status, 429)
self.assertEqual(third.headers.get("Cache-Control"), "no-store")
self.assertIn("获取令牌过于频繁", json.loads(third.body)["error"])
# 另一个 IP 不受影响
self.assertEqual(self.get_token(ip="203.0.113.10")[0].status, 200)
# 窗口滑动 60 秒后恢复
self.advance(61)
self.assertEqual(self.get_token(ip="203.0.113.9")[0].status, 200)
# 0 = 关闭限流
self.cfg.update({"token_rate_limit_per_min": 0})
for _ in range(5):
self.assertEqual(self.get_token(ip="203.0.113.9")[0].status, 200)
def test_events_are_aggregated_per_minute(self):
"""签发/校验失败按分钟聚合成**一条**(这个项目很在意日志量)。"""
for _ in range(3):
self.get_token(ip="203.0.113.9")
for _ in range(2):
self.tokens.verify("bogus-token", "203.0.113.9")
self.advance(120) # 跨过分钟边界
self.get_token(ip="203.0.113.10") # 触发汇总
texts = [e["text"] for e in self.store.events(50)]
issued = [t for t in texts if "App 令牌:签发 3 个" in t]
failed = [t for t in texts if "App 令牌:校验失败 2 次" in t]
self.assertEqual(len(issued), 1, texts)
self.assertEqual(len(failed), 1, texts)
self.assertIn("203.0.113.9", failed[0])
# 成功校验**不逐笔**记录:连打 10 次也没有"成功"日志
tok = self.get_token(ip="203.0.113.9")[1]["token"]
for _ in range(10):
self.tokens.verify(tok, "203.0.113.9")
self.assertFalse(any("令牌校验成功" in t for t in [e["text"] for e in self.store.events(50)]))
def test_status_endpoint(self):
self.get_token(ip="203.0.113.9")
self.tokens.verify("nope", "203.0.113.9")
d = json.loads(self.call(req("GET", "/token/status")).body)
self.assertTrue(d["ok"])
self.assertEqual(d["issued"], 1)
self.assertEqual(d["invalid"], 1)
self.assertEqual(d["ttl_sec"], 600)
self.assertEqual(d["paths"], ["/music/do.php", "/push_download"])
self.assertIn("pending_log", d)
# ══════════════════════ ② 校验与兼容(红线) ══════════════════════
class TestTokenEnforce(TokenCase):
def do_php_with_token_param(self, value):
"""老 App 的口径:令牌放在 query 的 `token` 里(= i.php 发给它的那个)。"""
return self.call(req("GET", "/music/do.php", {"musicno": str(TID), "token": value}))
def test_disabled_by_default_old_app_unaffected(self):
"""**红线**:默认不强制 —— 老 App(不带令牌)必须照常可用。"""
self.assertFalse(self.cfg.get("app_token_enforce"))
self.ready_song()
self.assertEqual(self.do_php().status, 200) # 不带令牌
self.assertEqual(json.loads(self.do_php().body)["code"], 200)
# 老协议自己的 token 参数(i.php 发的那个)不是 App 令牌 —— 不能被当成"错误令牌"
self.assertEqual(self.do_php_with_token_param("local").status, 200)
i = self.call(req("GET", "/i.php", {"mac": "x"}))
self.assertEqual(i.status, 200)
self.assertEqual(json.loads(i.body)["code"], 200)
def test_wrong_token_rejected_even_when_not_enforced(self):
"""带了**错误**令牌必须报 401(避免"以为生效了")。"""
self.ready_song()
resp = self.do_php_with_token_param("definitely-wrong")
self.assertEqual(resp.status, 401)
self.assertEqual(json.loads(resp.body), {"ok": False, "error": apptoken.AUTH_ERROR})
resp2 = self.call(req("GET", "/music/do.php", {"musicno": str(TID)},
headers={"X-App-Token": "definitely-wrong"}))
self.assertEqual(resp2.status, 401)
# 过期令牌同样 401
self.cfg.update({"app_token_ttl_sec": 30})
tok = self.get_token(ip="")[1]["token"]
self.advance(31)
self.assertEqual(self.call(req("GET", "/music/do.php", {"musicno": str(TID)},
headers={"X-App-Token": tok})).status, 401)
def test_valid_token_passes_when_not_enforced(self):
self.ready_song()
tok = self.get_token(ip="203.0.113.9")[1]["token"]
r = self.call(req("GET", "/music/do.php", {"musicno": str(TID)},
headers={"X-App-Token": tok}, client_ip="203.0.113.9"))
self.assertEqual(r.status, 200)
self.assertEqual(json.loads(r.body)["code"], 200)
# query 里的 App 令牌(非老协议令牌)也认
r2 = self.call(req("GET", "/music/do.php", {"musicno": str(TID), "token": tok},
client_ip="203.0.113.9"))
self.assertEqual(r2.status, 200)
def test_enforced_requires_token(self):
self.ready_song()
self.cfg.update({"app_token_enforce": True})
resp = self.do_php()
self.assertEqual(resp.status, 401)
self.assertEqual(json.loads(resp.body), {"ok": False, "error": apptoken.AUTH_ERROR})
tok = self.get_token(ip="203.0.113.9")[1]["token"]
self.assertEqual(self.call(req("GET", "/music/do.php", {"musicno": str(TID)},
headers={"X-App-Token": tok},
client_ip="203.0.113.9")).status, 200)
# query 里的老协议令牌 ≠ App 令牌:强制模式下仍要 401(否则等于没有强制)
self.assertEqual(self.do_php_with_token_param("local").status, 401)
def test_i_php_checked_when_token_present(self):
"""`/i.php` 默认不在 app_token_paths 里(不强制),但"带了就校验"。"""
self.assertEqual(self.call(req("GET", "/i.php", {"mac": "x"})).status, 200)
self.assertEqual(self.call(req("GET", "/i.php", {"mac": "x"},
headers={"X-App-Token": "bad"})).status, 401)
tok = self.get_token(ip="")[1]["token"]
self.assertEqual(self.call(req("GET", "/i.php", {"mac": "x"},
headers={"X-App-Token": tok})).status, 200)
def test_custom_paths(self):
self.cfg.update({"app_token_paths": "/i.php", "app_token_enforce": True})
self.assertEqual(self.call(req("GET", "/i.php", {"mac": "x"})).status, 401)
# do.php 不在受管路径里了 → 不强制
self.assertEqual(self.do_php().status, 200)
self.assertIn("/i.php", self.tokens.paths())
class TestPushDownloadAuth(TokenCase):
"""`/push_download`:合法 App 令牌 **或** 合法 push_token 任一即可。"""
def push(self, headers=None, body=None, client_ip="203.0.113.9"):
payload = body if body is not None else form(tid=TID, url=PUSH_URL)
return self.call(req("POST", "/push_download", {}, payload, headers or {},
client_ip=client_ip))
def test_app_token_accepted(self):
self.cfg.update({"app_token_enforce": True})
tok = self.get_token(ip="203.0.113.9")[1]["token"]
resp = self.push(headers={"X-App-Token": tok})
self.assertEqual(resp.status, 200, resp.body)
self.assertTrue(json.loads(resp.body)["ok"])
def test_push_token_still_accepted(self):
"""原有 push_token 通道必须继续通过(老 App / 脚本不受影响)。"""
resp = self.push(body=form(tid=TID, url=PUSH_URL, token="local"))
self.assertEqual(resp.status, 200, resp.body)
resp2 = self.push(headers={"X-Push-Token": "local"})
self.assertEqual(resp2.status, 200, resp2.body)
def test_no_credential_is_401(self):
resp = self.push()
self.assertEqual(resp.status, 401)
self.assertEqual(json.loads(resp.body), {"ok": False, "error": "鉴权失败"})
# 连 enforce=true 时也是 401(走 push 自己的那条判定)
self.cfg.update({"app_token_enforce": True})
self.assertEqual(self.push().status, 401)
def test_wrong_credentials_are_401(self):
self.assertEqual(self.push(headers={"X-Push-Token": "wrong"}).status, 401)
self.assertEqual(self.push(body=form(tid=TID, url=PUSH_URL, token="wrong")).status, 401)
self.assertEqual(self.push(headers={"X-App-Token": "wrong"}).status, 401)
# ══════════════════════ ④ 宿主隔离(双站点) ══════════════════════
class TestMediaHostIsolation(TokenCase):
def setUp(self):
super().setUp()
self.ready_song()
with open(os.path.join(self.media, f"{TID}.ts"), "wb") as fh:
fh.write(b"\x47" * 300) # 300 字节,Range 断言用
self.cfg.update({"media_hosts": ["md.kasugano.cn"],
"api_hosts": ["mdmuse.kasugano.cn"],
"trust_proxy_headers": True,
"trusted_proxies": ["127.0.0.1"]})
def hit(self, path, xfh, **kw):
query = {"musicno": str(TID)} if path.startswith("/music/") else None
return self.call(via_proxy_req(self.cfg, "GET", path, query, xfh=xfh, **kw))
def test_media_host_can_download(self):
r = self.hit(f"/media/{TID}.ts", "md.kasugano.cn:4433")
self.assertEqual(r.status, 200, r.body)
self.assertEqual(r.headers.get("Accept-Ranges"), "bytes")
r2 = self.hit(f"/media/{TID}.ts", "md.kasugano.cn:4433",
headers={"Range": "bytes=100-199"})
self.assertEqual(r2.status, 206)
self.assertEqual(r2.headers.get("Content-Range"), "bytes 100-199/300")
self.assertEqual(r2.file_len, 100)
def test_media_host_is_port_tolerant(self):
"""配置写不带端口,头里带 :4433 必须命中(反之亦然)。"""
self.assertEqual(self.hit(f"/media/{TID}.ts", "md.kasugano.cn:4433").status, 200)
self.assertEqual(self.hit(f"/media/{TID}.ts", "md.kasugano.cn").status, 200)
self.cfg.update({"media_hosts": ["MD.Kasugano.CN:4433"]})
self.assertEqual(self.hit(f"/media/{TID}.ts", "md.kasugano.cn").status, 200)
self.assertEqual(self.hit(f"/media/{TID}.ts", "md.kasugano.cn:4433").status, 200)
def test_media_host_cannot_call_api(self):
self.assertEqual(self.hit("/music/do.php", "md.kasugano.cn:4433").status, 404)
self.assertEqual(self.hit("/healthz", "md.kasugano.cn:4433").status, 404)
self.assertEqual(self.hit("/i.php", "md.kasugano.cn:4433").status, 404)
self.assertEqual(self.hit("/token", "md.kasugano.cn:4433").status, 404)
# 404 的响应体与"路径不存在"长得一样(不透露隔离规则)
body = json.loads(self.hit("/healthz", "md.kasugano.cn:4433").body)
self.assertEqual(body, {"error": "not found", "path": "/healthz"})
def test_api_host_cannot_download(self):
self.assertEqual(self.hit(f"/media/{TID}.ts", "mdmuse.kasugano.cn").status, 404)
self.assertEqual(self.hit("/music/do.php", "mdmuse.kasugano.cn").status, 200)
self.assertEqual(self.hit("/healthz", "mdmuse.kasugano.cn").status, 200)
def test_untrusted_forwarded_host_is_ignored(self):
"""**防伪造**:不可信对端塞 X-Forwarded-Host 一律不采信(只看 Host 头)。"""
self.cfg.update({"trusted_proxies": ["192.168.100.10"]}) # 127.0.0.1 不再可信
forged = req("GET", f"/media/{TID}.ts",
headers={"Host": "127.0.0.1:6677", "X-Forwarded-Host": "md.kasugano.cn:4433"},
client_ip="127.0.0.1")
self.assertEqual(self.call(forged).status, 404, "伪造的媒体宿主不能下文件")
forged2 = req("GET", "/music/do.php", {"musicno": str(TID)},
headers={"Host": "127.0.0.1:6677",
"X-Forwarded-Host": "md.kasugano.cn:4433"},
client_ip="127.0.0.1")
self.assertEqual(self.call(forged2).status, 200, "不可信头不该把 API 也封掉")
# 开关整体关掉时同样不采信
self.cfg.update({"trust_proxy_headers": False})
self.assertEqual(self.call(forged).status, 404)
def test_host_falls_back_to_host_header_when_untrusted(self):
"""不可信时退回 Host 头:直接以媒体域名访问(没经代理)也能下文件。"""
self.cfg.update({"trusted_proxies": ["192.168.100.10"]})
r = req("GET", f"/media/{TID}.ts", headers={"Host": "md.kasugano.cn:4433"},
client_ip="203.0.113.9")
self.assertEqual(self.call(r).status, 200)
r2 = req("GET", "/music/do.php", {"musicno": str(TID)},
headers={"Host": "md.kasugano.cn:4433"}, client_ip="203.0.113.9")
self.assertEqual(self.call(r2).status, 404)
def test_disabled_when_media_hosts_empty(self):
self.cfg.update({"media_hosts": [], "api_hosts": ["mdmuse.kasugano.cn"]})
self.assertEqual(self.hit(f"/media/{TID}.ts", "mdmuse.kasugano.cn").status, 200)
self.assertEqual(self.hit("/music/do.php", "anything.example.com").status, 200)
def test_client_ip_reports_host_decision(self):
r = self.hit("/api/client_ip", "md.kasugano.cn:4433")
d = json.loads(r.body)
self.assertEqual(d["host"], "md.kasugano.cn:4433")
self.assertEqual(d["host_source"], "x-forwarded-host")
self.assertTrue(d["media_host_match"])
self.assertFalse(d["api_host_match"])
self.assertEqual(d["media_hosts"], ["md.kasugano.cn"])
d2 = json.loads(self.hit("/api/client_ip", "mdmuse.kasugano.cn").body)
self.assertFalse(d2["media_host_match"])
self.assertTrue(d2["api_host_match"])
def test_config_warnings(self):
warns = self.cfg.update({"media_hosts": "md.kasugano.cn, 不是域名", "trust_proxy_headers": False})
self.assertEqual(self.cfg.get("media_hosts"), ["md.kasugano.cn", "不是域名"])
self.assertTrue(any("trust_proxy_headers" in w for w in warns))
self.assertTrue(any("看不懂" in w for w in warns))
# ══════════════════════ ⑤ HTTP 层端到端 ══════════════════════
class TestOverTheWire(unittest.TestCase):
"""真起 127.0.0.1 端口:令牌 + 双站点判定 + Range/206 全链路。"""
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.media = os.path.join(self.tmp, "media")
os.makedirs(self.media, exist_ok=True)
self.cfg = Config(os.path.join(self.tmp, "c.json"))
self.cfg.update({"data_dir": self.tmp, "media_dir": self.media,
"public_media_base": "https://md.kasugano.cn:4433/media",
"trust_proxy_headers": True, "trusted_proxies": ["127.0.0.1"],
"media_hosts": ["md.kasugano.cn"], "api_hosts": ["mdmuse.kasugano.cn"]})
self.store = Store(os.path.join(self.tmp, "t.db"))
self.store.import_library([(TID, "测试歌", "国语", "", "歌手", f"{TID}.ts", 1, "")], "v")
with open(os.path.join(self.media, f"{TID}.ts"), "wb") as fh:
fh.write(b"\x47" * 300)
self.router = service.build_router(self.cfg, self.store, None)
self.srv = web.start_server("127.0.0.1", 0, self.router, cfg=self.cfg, backlog=32)
self.port = self.srv.server_address[1]
def tearDown(self):
try:
self.srv.shutdown()
self.srv.server_close()
except Exception:
pass
try:
self.store.conn.close()
except Exception:
pass
shutil.rmtree(self.tmp, ignore_errors=True)
def get(self, path, headers=None, method="GET"):
c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
try:
c.request(method, path, headers=headers or {})
r = c.getresponse()
return r.status, dict((k.lower(), v) for k, v in r.getheaders()), r.read()
finally:
c.close()
def token_for(self, client_ip):
st, hdrs, body = self.get("/token", {"X-Forwarded-For": client_ip})
self.assertEqual(st, 200, body)
self.assertEqual(hdrs.get("cache-control"), "no-store")
return json.loads(body)["token"]
def test_full_app_token_flow(self):
tok = self.token_for("203.0.113.9")
# ① 带 X-App-Token 调 do.php:通过(歌曲没下载完 → JSON code:404,HTTP 仍是 200)
st, _h, body = self.get(f"/music/do.php?musicno={TID}",
{"X-Forwarded-For": "203.0.113.9", "X-App-Token": tok,
"X-Forwarded-Host": "mdmuse.kasugano.cn"})
self.assertEqual(st, 200, body)
self.assertIn(json.loads(body)["code"], (200, 404))
# ② 换了客户端 IP(同一令牌)→ 401
st2, _h2, body2 = self.get(f"/music/do.php?musicno={TID}",
{"X-Forwarded-For": "203.0.113.10", "X-App-Token": tok})
self.assertEqual(st2, 401, body2)
self.assertEqual(json.loads(body2)["error"], apptoken.AUTH_ERROR)
# ③ 错误令牌 → 401(默认不强制也校验)
st3, _h3, _b3 = self.get(f"/music/do.php?musicno={TID}",
{"X-Forwarded-For": "203.0.113.9", "X-App-Token": "bad"})
self.assertEqual(st3, 401)
# ④ 不带令牌(enforce=false)→ 照常可用(老 App)
st4, _h4, _b4 = self.get(f"/music/do.php?musicno={TID}",
{"X-Forwarded-For": "203.0.113.9"})
self.assertEqual(st4, 200)
def test_enforced_over_the_wire(self):
self.cfg.update({"app_token_enforce": True})
st, _h, body = self.get(f"/music/do.php?musicno={TID}",
{"X-Forwarded-For": "203.0.113.9"})
self.assertEqual(st, 401, body)
tok = self.token_for("203.0.113.9")
st2, _h2, body2 = self.get(f"/music/do.php?musicno={TID}",
{"X-Forwarded-For": "203.0.113.9", "X-App-Token": tok})
self.assertEqual(st2, 200, body2)
def test_proxy_split_over_the_wire(self):
"""伪造 `X-Forwarded-Host`(127.0.0.1 可信):媒体站能下文件+Range,其余 404。"""
media_hdrs = {"X-Forwarded-For": "203.0.113.9",
"X-Forwarded-Host": "md.kasugano.cn:4433"}
st, hdrs, body = self.get(f"/media/{TID}.ts", media_hdrs)
self.assertEqual(st, 200, body)
self.assertEqual(hdrs.get("accept-ranges"), "bytes")
self.assertEqual(len(body), 300)
st2, hdrs2, body2 = self.get(f"/media/{TID}.ts", {**media_hdrs, "Range": "bytes=100-199"})
self.assertEqual(st2, 206, body2)
self.assertEqual(hdrs2.get("content-range"), "bytes 100-199/300")
self.assertEqual(len(body2), 100)
# 同一宿主调 do.php → 404(媒体站只下文件)
st3, _h3, _b3 = self.get(f"/music/do.php?musicno={TID}", media_hdrs)
self.assertEqual(st3, 404)
# API 宿主拉媒体 → 404
st4, _h4, _b4 = self.get(f"/media/{TID}.ts",
{"X-Forwarded-For": "203.0.113.9",
"X-Forwarded-Host": "mdmuse.kasugano.cn"})
self.assertEqual(st4, 404)
# 不可信对端伪造宿主 → 只按真实 Host 判定 → 媒体站域名不成立 → 404
self.cfg.update({"trusted_proxies": ["192.168.100.10"]})
st5, _h5, _b5 = self.get(f"/media/{TID}.ts", media_hdrs)
self.assertEqual(st5, 404)
# ══════════════════════ ⑥ IIS 交付物静态校验 ══════════════════════
class TestIisSplitConfigs(unittest.TestCase):
def read(self, path):
with open(path, encoding="utf-8") as fh:
return fh.read()
def test_both_site_configs_parse_and_are_disjoint(self):
for path in (API_CFG, MEDIA_CFG):
self.assertTrue(os.path.isfile(path), f"缺少 {path}")
text = self.read(path)
root = ET.fromstring(text) # 必须能被 ElementTree 解析
self.assertTrue(root.tag.endswith("configuration"))
self.assertNotIn("6678", text, "管理端口不该出现在任何对外配置里")
self.assertIn("192.168.100.232:6677", text) # 反代到服务端口 6677
# IIS 里实际生效的是"服务器变量名"(HTTP_ 前缀那种写法),四个都要传
for name in ("HTTP_X_FORWARDED_FOR", "HTTP_X_REAL_IP",
"HTTP_X_FORWARDED_PROTO", "HTTP_X_FORWARDED_HOST"):
self.assertIn(name, text, f"{path} 缺少 {name}")
self.assertIn("doDynamicCompression=\"false\"", text) # 关压缩(保 Range)
rules = root.findall(".//{*}rule")
self.assertTrue(rules, f"{path} 没有任何规则")
self.assertEqual(rules[-1].find(".//{*}action").get("type"), "CustomResponse")
self.assertEqual(rules[-1].find(".//{*}action").get("statusCode"), "404")
def test_api_site_has_no_media_rule(self):
text = self.read(API_CFG)
self.assertNotIn("media", text.lower(), "API 站里不该出现任何媒体路径")
for needle in ("token", "api.php", "i.php", "music/do.php", "push_download",
"push_download/status", "healthz"):
self.assertIn(needle, text, f"API 站缺少 {needle} 规则")
# 反代规则数:7 条接口规则 + 1 条兜底 404
rules = ET.fromstring(text).findall(".//{*}rule")
self.assertEqual(len(rules), 8, [r.get("name") for r in rules])
self.assertIn("api/client_ip", text) # 排查接口:默认注释,但要有
def test_media_site_is_media_only(self):
text = self.read(MEDIA_CFG)
self.assertIn("media", text)
self.assertNotIn("do.php", text, "媒体站不该出现取址接口")
self.assertNotIn("push_download", text, "媒体站不该出现推送接口")
self.assertIn("4433", text)
self.assertIn("preserveHostHeader", text) # ARR 保留原始 Host
rules = ET.fromstring(text).findall(".//{*}rule")
self.assertEqual(len(rules), 2, [r.get("name") for r in rules])
self.assertEqual(rules[0].find(".//{*}match").get("url"), "^(media/.*)$")
# 目标必须是 `…:6677/media/<文件>`(用 {R:0} 时必须**没有**第二个 media 段)
action = rules[0].find(".//{*}action").get("url")
self.assertEqual(action, "http://192.168.100.232:6677/{R:0}")
self.assertNotIn("/media/{R:0}", action)
# ══════════════════════ ⑦ 管理界面契约(源码级) ══════════════════════
class TestUiContract(unittest.TestCase):
"""界面必须能看到/操作到这些开关(否则后端做了用户也不知道)。"""
def setUp(self):
from ktv import ui
self.html = ui.PAGE
def test_settings_expose_token_and_host_keys(self):
for key in ("app_token_ttl_sec", "app_token_paths", "token_rate_limit_per_min",
"media_hosts", "api_hosts"):
self.assertIn(f"'{key}'", self.html, f"设置页缺少 {key}")
for sel in ("s_app_token_bind_ip", "s_app_token_single_use",
"s_app_token_persist", "s_app_token_enforce"):
self.assertIn(f'id="{sel}"', self.html, f"设置页缺少 {sel}")
# 说明里写明"为什么默认不强制"(老 App 红线)与端口容错
self.assertIn("X-App-Token", self.html)
self.assertIn("老 App 不带令牌照常可用", self.html)
self.assertIn("4433", self.html)
if __name__ == "__main__":
unittest.main(verbosity=2)