版本号:
- 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 例)
661 lines
34 KiB
Python
661 lines
34 KiB
Python
#!/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)
|