Files
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

661 lines
34 KiB
Python
Raw Permalink 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.
#!/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)