合并自建歌曲源支线(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,553 @@
#!/usr/bin/env python3
"""反向代理(IIS / ARR)支持自测:可信代理头、真实客户端 IP、HTTPS 地址、探活、413、HEAD/Range、IIS 配置。
覆盖:
① `trusted_proxies` 判定(开关关闭 / 对端不可信 → **忽略** XFF;可信 → 取 XFF 最左侧非代理 IP);
② 推送限流按**真实客户端 IP** 分桶(反代后不再所有设备共用一份额度),且来源 IP 写进事件日志;
③ `X-Forwarded-Proto/Host` **仅可信时**生效:`/music/do.php` 返回 https 公网地址;
④ `/healthz` 微秒级返回、不查库、不碰媒体目录(打桩断言未被调用);
⑤ 请求体超 `max_body_bytes` → 413 + 中文说明;
⑥ HTTP 层(真起本地端口、走 socket):HEAD/Range 206 + Content-Length + Accept-Ranges、
每个响应都有精确 Content-Length(keep-alive 的前提)、连接可复用;
⑦ `deploy/iis/` 交付物静态校验:web.config 可解析、含 6677 反代规则、**不含 6678**、
含 `push_download`/`healthz`/`media`。
不联网、不起外网连接(socket 用例只绑 127.0.0.1 的临时端口)。
"""
import http.client
import json
import os
import shutil
import sys
import tempfile
import time
import unittest
import urllib.parse
import xml.etree.ElementTree as ET
from unittest import mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ktv import admin
from ktv import health as health_mod
from ktv import proxy
from ktv import service
from ktv import 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")
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()
# ══════════════════════ ① 可信代理判定 ══════════════════════
class TestTrustedProxies(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.cfg = Config(os.path.join(self.tmp, "c.json"))
def test_default_is_off(self):
"""默认:开关关闭 + 可信列表为空 → 代理头一律不采信(安全默认)。"""
self.assertFalse(self.cfg.get("trust_proxy_headers"))
self.assertEqual(self.cfg.get("trusted_proxies"), [])
info = proxy.resolve("203.0.113.7", {"X-Forwarded-For": "198.51.100.9"}, self.cfg)
self.assertEqual(info.ip, "203.0.113.7")
self.assertFalse(info.via_proxy)
self.assertEqual(info.source, "tcp")
def test_switch_on_but_peer_not_trusted_ignores_headers(self):
"""开关开着、但直连对端**不在** trusted_proxies → 仍然一个头都不看(防伪造)。"""
self.cfg.update({"trust_proxy_headers": True, "trusted_proxies": ["192.168.100.10"]})
info = proxy.resolve("203.0.113.7", {"X-Forwarded-For": "198.51.100.9",
"X-Real-IP": "198.51.100.8",
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "evil.example.com"}, self.cfg)
self.assertEqual(info.ip, "203.0.113.7")
self.assertFalse(info.via_proxy)
self.assertEqual(info.proto, "") # Proto/Host 也不采信
self.assertEqual(info.host, "")
def test_trusted_peer_takes_leftmost_non_proxy(self):
"""可信对端 → 取 XFF **最左侧非代理** IP(客户端伪造的可信代理 IP 会被跳过)。"""
self.cfg.update({"trust_proxy_headers": True, "trusted_proxies": ["127.0.0.1"]})
info = proxy.resolve("127.0.0.1",
{"X-Forwarded-For": "127.0.0.1, 203.0.113.9, 127.0.0.1"}, self.cfg)
self.assertEqual(info.ip, "203.0.113.9")
self.assertTrue(info.via_proxy)
self.assertEqual(info.source, "x-forwarded-for")
self.assertEqual(info.peer_ip, "127.0.0.1")
def test_cidr_and_leftmost_fallback(self):
"""CIDR 命中;整条链都是可信代理时退回最左侧(原始客户端),不退化成代理地址。"""
self.cfg.update({"trust_proxy_headers": True,
"trusted_proxies": ["127.0.0.1", "192.168.100.0/24"]})
info = proxy.resolve("192.168.100.5", {"X-Forwarded-For": "10.1.2.3, 192.168.100.7"}, self.cfg)
self.assertEqual(info.ip, "10.1.2.3")
all_trusted = proxy.resolve("192.168.100.5", {"X-Forwarded-For": "127.0.0.1"}, self.cfg)
self.assertEqual(all_trusted.ip, "127.0.0.1")
self.assertTrue(all_trusted.via_proxy)
def test_falls_back_to_x_real_ip(self):
"""没有 XFF 时用 X-Real-IP;两者都没有则退回 TCP 对端。"""
self.cfg.update({"trust_proxy_headers": True, "trusted_proxies": ["127.0.0.1"]})
info = proxy.resolve("127.0.0.1", {"X-Real-IP": "203.0.113.11"}, self.cfg)
self.assertEqual(info.ip, "203.0.113.11")
self.assertEqual(info.source, "x-real-ip")
plain = proxy.resolve("127.0.0.1", {}, self.cfg)
self.assertEqual(plain.ip, "127.0.0.1")
self.assertTrue(plain.via_proxy) # 仍标记"经由可信代理",只是没有头可用
def test_garbage_headers_ignored(self):
"""垃圾/伪造头(unknown、带端口、非 IP、注入字符)不采信。"""
self.cfg.update({"trust_proxy_headers": True, "trusted_proxies": ["127.0.0.1"]})
info = proxy.resolve("127.0.0.1", {"X-Forwarded-For": "unknown", "X-Real-IP": "not-an-ip"}, self.cfg)
self.assertEqual(info.ip, "127.0.0.1")
self.assertEqual(proxy.norm_ip("203.0.113.9:8080"), "203.0.113.9")
self.assertEqual(proxy.valid_host("evil/../x"), "")
self.assertEqual(proxy.valid_host("ktv.example.com:8443"), "ktv.example.com:8443")
self.assertEqual(proxy.valid_proto("javascript"), "")
self.assertEqual(proxy.valid_proto("https"), "https")
def test_config_normalises_and_warns(self):
"""配置层:字符串拆成列表、非法项丢弃并给警告、开关开着却没列表也要警告。"""
warns = self.cfg.update({"trusted_proxies": "127.0.0.1, 192.168.100.0/24 , 不是IP"})
self.assertEqual(self.cfg.get("trusted_proxies"), ["127.0.0.1", "192.168.100.0/24", "不是IP"])
self.assertTrue(any("解析不了" in w for w in warns))
warns2 = self.cfg.update({"trust_proxy_headers": True, "trusted_proxies": []})
self.assertTrue(any("trusted_proxies 为空" in w for w in warns2))
warns3 = self.cfg.update({"http_backlog": 1, "max_body_bytes": 10})
self.assertEqual(self.cfg.get("http_backlog"), 5)
self.assertEqual(self.cfg.get("max_body_bytes"), 4096)
self.assertTrue(any("http_backlog" in w for w in warns3))
# ══════════════════════ ② 推送限流按真实 IP ══════════════════════
class PushCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.cfg = Config(os.path.join(self.tmp, "c.json"))
self.media = os.path.join(self.tmp, "media")
os.makedirs(self.media, exist_ok=True)
self.cfg.update({"media_dir": self.media, "data_dir": self.tmp, "push_token": "local",
"push_rate_limit_per_min": 1, "trust_proxy_headers": True,
"trusted_proxies": ["127.0.0.1"]})
self.store = Store(os.path.join(self.tmp, "t.db"))
self.router = service.build_router(self.cfg, self.store, None)
def tearDown(self):
try:
self.store.conn.close()
except Exception:
pass
def push_from_proxy(self, tid, client_ip_in_xff):
"""模拟 IIS 反代:TCP 对端 127.0.0.1(可信),XFF 带真实客户端 IP。"""
info = proxy.resolve("127.0.0.1", {"X-Forwarded-For": client_ip_in_xff}, self.cfg)
r = req("POST", "/push_download", body=form(tid=tid, url=f"http://cdn.example.com/480p/{tid}.ts",
token="local"),
client_ip=info.ip, peer_ip=info.peer_ip, via_proxy=info.via_proxy, xff=info.xff)
return self.router.dispatch(r)
def test_rate_limit_separate_clients_behind_proxy(self):
"""反代后两个设备各自限流,不再共用代理地址的一份额度。"""
a = self.push_from_proxy(TID, "203.0.113.9")
b = self.push_from_proxy(TID + 1, "203.0.113.10")
self.assertEqual((a.status, b.status), (200, 200), (a.body, b.body))
# 同一设备再来一条 → 触发限流(每分钟 1 条)
again = self.push_from_proxy(TID + 2, "203.0.113.9")
self.assertEqual(again.status, 429)
self.assertIn("推送过于频繁", json.loads(again.body)["error"])
self.assertIn("203.0.113.9", json.loads(again.body)["error"])
def test_rate_limit_logs_real_ip(self):
"""来源 IP 写进事件日志:接受那条(即时)与限流汇总那条(分钟翻页后),都带真实 IP。"""
self.push_from_proxy(TID, "203.0.113.9") # 接受 → 即时 INFO
self.push_from_proxy(TID + 1, "203.0.113.9") # 第二条被限流 → 进"当前分钟桶"
self.assertTrue(any("App 点歌推送:接受" in e["text"] and "203.0.113.9" in e["text"]
for e in self.store.events(20)),
[e["text"] for e in self.store.events(20)])
st = json.loads(self.router.dispatch(req("GET", "/push_download/status")).body)
self.assertEqual(st["rate_limited"], 1)
self.assertIn("203.0.113.9", st["pending_log"].get("sample", ""))
# 分钟翻页(把时间往前拨 2 分钟)→ 汇总成一条 WARN 事件,同样带来源 IP
real_time = time.time
with mock.patch("ktv.pushdl.time.time", side_effect=lambda: real_time() + 120):
self.router.dispatch(req("GET", "/push_download/status"))
self.assertTrue(any("触发限流" in e["text"] and "203.0.113.9" in e["text"]
for e in self.store.events(30)),
[e["text"] for e in self.store.events(30)])
def test_untrusted_peer_still_rate_limits_by_tcp_peer(self):
"""对端不可信时:XFF 完全不同也只按 TCP 对端限流(防伪造,宁粗不松)。"""
self.cfg.update({"trusted_proxies": ["192.168.100.10"]}) # 127.0.0.1 不再可信
first = req("POST", "/push_download", body=form(tid=TID, url=f"http://cdn.example.com/{TID}.ts",
token="local"),
headers={"X-Forwarded-For": "203.0.113.1"}, client_ip="127.0.0.1")
second = req("POST", "/push_download", body=form(tid=TID + 1, url=f"http://cdn.example.com/{TID + 1}.ts",
token="local"),
headers={"X-Forwarded-For": "203.0.113.2"}, client_ip="127.0.0.1")
self.assertEqual(self.router.dispatch(first).status, 200)
self.assertEqual(self.router.dispatch(second).status, 429)
def test_client_ip_debug_endpoint(self):
"""`GET /api/client_ip`:返回认定结果与"为什么没用代理头"。"""
info = proxy.resolve("127.0.0.1", {"X-Forwarded-For": "203.0.113.9",
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "ktv.example.com"}, self.cfg)
r = req("GET", "/api/client_ip", client_ip=info.ip, peer_ip=info.peer_ip,
via_proxy=info.via_proxy, proto=info.proto, host=info.host, xff=info.xff)
d = json.loads(self.router.dispatch(r).body)
self.assertEqual(d["client_ip"], "203.0.113.9")
self.assertEqual(d["peer_ip"], "127.0.0.1")
self.assertTrue(d["via_proxy"])
self.assertEqual(d["source"], "x-forwarded-for")
self.assertEqual(d["x_forwarded_proto"], "https")
self.assertEqual(d["ignored_reason"], "")
# 不可信时给出明确原因
self.cfg.update({"trusted_proxies": []})
d2 = json.loads(self.router.dispatch(req("GET", "/api/client_ip", client_ip="10.0.0.5")).body)
self.assertFalse(d2["via_proxy"])
self.assertIn("trusted_proxies 为空", d2["ignored_reason"])
# ══════════════════════ ③ 协议/Host 感知的媒体地址 ══════════════════════
class TestForwardedSchemeHost(PushCase):
def _ready(self):
self.store.import_library([(TID, "测试歌", "国语", "", "歌手", f"{TID}.ts", 10, "")], "v")
self.store.enqueue([TID])
self.store.claim_one(TID)
self.store.finish(TID, True, bytes_=7 << 20, path=os.path.join(self.media, f"{TID}.ts"))
def _do_php(self, **kw):
r = req("GET", "/music/do.php", {"musicno": str(TID)}, **kw)
return json.loads(self.router.dispatch(r).body)
def test_https_forwarded_when_trusted(self):
self._ready()
d = self._do_php(client_ip="203.0.113.9", peer_ip="127.0.0.1", via_proxy=True,
proto="https", host="ktv.example.com")
self.assertEqual(d["code"], 200)
self.assertTrue(d["data"].startswith("https://ktv.example.com/media/"), d["data"])
self.assertTrue(d["data"].endswith(f"/{TID}.ts"))
def test_forwarded_ignored_when_untrusted(self):
"""不可信(没经代理)→ 用配置里的 public_media_base,绝不被头带偏。"""
self._ready()
self.cfg.update({"public_media_base": "http://ktv.example.com:6677/media"})
d = self._do_php(client_ip="203.0.113.9", peer_ip="203.0.113.9", via_proxy=False,
proto="https", host="evil.example.com")
self.assertEqual(d["data"], f"http://ktv.example.com:6677/media/{TID}.ts")
def test_forwarded_ignored_when_switch_off(self):
self._ready()
self.cfg.update({"public_media_base": "http://ktv.example.com:6677/media",
"trust_proxy_headers": False})
# 走真实认定链路:开关关着 → via_proxy=False,头一个都不看
info = proxy.resolve("127.0.0.1", {"X-Forwarded-For": "203.0.113.9",
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "ktv.example.com"}, self.cfg)
self.assertFalse(info.via_proxy)
d = self._do_php(client_ip=info.ip, peer_ip=info.peer_ip, via_proxy=info.via_proxy,
proto=info.proto, host=info.host)
self.assertTrue(d["data"].startswith("http://ktv.example.com:6677/"), d["data"])
def test_public_base_helper(self):
self.cfg.update({"public_media_base": "http://ktv.example.com:6677/media"})
r = req("GET", "/x", via_proxy=True, proto="https", host="ktv.example.com")
self.assertEqual(proxy.public_base(self.cfg, r), "https://ktv.example.com/media")
self.assertEqual(proxy.public_media_url(self.cfg, r, 42), "https://ktv.example.com/media/42.ts")
self.assertEqual(proxy.absolute_url(self.cfg, r, "/healthz"), "https://ktv.example.com/healthz")
# 只给 proto(没给 Host)→ 保留配置里的主机
r2 = req("GET", "/x", via_proxy=True, proto="https")
self.assertEqual(proxy.public_base(self.cfg, r2), "https://ktv.example.com:6677/media")
# 不可信 → 原样
self.assertEqual(proxy.public_base(self.cfg, req("GET", "/x")),
"http://ktv.example.com:6677/media")
# ══════════════════════ ④ /healthz ══════════════════════
class TestHealthz(PushCase):
def test_healthz_is_fast_and_does_not_touch_db_or_media(self):
"""探活必须微秒级返回:不查库、不列媒体目录(打桩后一旦被调用就失败)。"""
with mock.patch.object(self.store, "stats", side_effect=AssertionError("healthz 不该查库")), \
mock.patch.object(self.store, "keep_stats", side_effect=AssertionError("healthz 不该查库")), \
mock.patch("os.listdir", side_effect=AssertionError("healthz 不该碰媒体目录")), \
mock.patch("os.stat", side_effect=AssertionError("healthz 不该碰媒体目录")), \
mock.patch("shutil.disk_usage", side_effect=AssertionError("healthz 不该碰磁盘")):
t0 = time.perf_counter()
resp = self.router.dispatch(req("GET", "/healthz"))
elapsed = time.perf_counter() - t0
self.assertEqual(resp.status, 200)
self.assertLess(elapsed, 0.05, f"healthz 太慢:{elapsed * 1000:.1f}ms")
d = json.loads(resp.body)
for key in ("ok", "version", "uptime_sec", "library", "keeps", "downloader"):
self.assertIn(key, d)
self.assertTrue(d["ok"])
self.assertIn("snapshot_age_sec", d)
def test_healthz_snapshot_refresh_fills_numbers(self):
"""后台刷新(refresh())才会查库;刷完后探活就能看到数字。"""
self.store.import_library([(1, "A", "国语", "", "s", "1.ts", 1, "")], "v")
snap = health_mod.HealthSnapshot(self.cfg, self.store, None)
self.assertEqual(snap.snapshot()["library"], 0) # 还没刷新 → 占位值
snap.refresh()
router = service.build_router(self.cfg, self.store, None, health=snap)
d = json.loads(router.dispatch(req("GET", "/healthz")).body)
self.assertEqual(d["library"], 1)
self.assertIn("downloader", d)
self.assertGreaterEqual(d["snapshot_age_sec"], 0)
def test_healthz_refresh_failure_keeps_serving(self):
"""刷新失败(库暂时读不到)只记日志 + 沿用上次值,探活照样 200。"""
snap = health_mod.HealthSnapshot(self.cfg, self.store, None)
with mock.patch.object(self.store, "stats", side_effect=RuntimeError("db busy")):
d = snap.refresh()
self.assertEqual(d["library"], 0)
self.assertTrue(snap.snapshot()["ok"])
self.assertTrue(any("探活快照刷新失败" in e["text"] for e in self.store.events(10)))
# ══════════════════════ ⑥ HTTP 层(真起本地端口) ══════════════════════
class TestHttpLayer(unittest.TestCase):
"""socket 级验证:Content-Length / HEAD / Range / keep-alive / 413(IIS 关心的正是这些)。"""
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.cfg = Config(os.path.join(self.tmp, "c.json"))
self.media = os.path.join(self.tmp, "media")
self.logs = [] # 收集访问日志(校验里面用的是真实客户端 IP)
os.makedirs(self.media, exist_ok=True)
self.cfg.update({"media_dir": self.media, "data_dir": self.tmp, "push_token": "local",
"trust_proxy_headers": True, "trusted_proxies": ["127.0.0.1"],
"max_body_bytes": 4096}) # 配置里的最小值会被钳制到 4096
self.store = Store(os.path.join(self.tmp, "t.db"))
self.store.import_library([(TID, "测试歌", "国语", "", "歌手", f"{TID}.ts", 1, "")], "v")
# 媒体文件:长度 300 字节(Range 用例断言精确 Content-Length)
with open(os.path.join(self.media, f"{TID}.ts"), "wb") as fh:
fh.write(b"\x47" * 300)
# 服务端口路由 + 管理端口路由(`/api/keeps/import` 的 413 要在**真实路由**上验证)
self.router = web.Router()
self.router.routes.extend(service.build_router(self.cfg, self.store, None).routes)
self.router.routes.extend(admin.build_router(self.cfg, self.store, None).routes)
self.srv = web.start_server("127.0.0.1", 0, self.router, cfg=self.cfg, backlog=32,
log_fn=self.logs.append)
self.port = self.srv.server_address[1]
def tearDown(self):
try:
self.srv.shutdown()
except Exception:
pass
try:
self.srv.server_close() # 关监听套接字,避免 ResourceWarning 噪音
except Exception:
pass
try:
self.store.conn.close()
except Exception:
pass
shutil.rmtree(self.tmp, ignore_errors=True)
def conn(self):
return http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
def test_head_and_range_with_content_length(self):
# 全部请求都带 XFF(= 经可信代理到达),验证代理场景下 HEAD/Range 与直连一致
xff = {"X-Forwarded-For": "203.0.113.9, 127.0.0.1"}
c = self.conn()
try:
c.request("HEAD", f"/media/{TID}.ts", headers=dict(xff))
r = c.getresponse()
self.assertEqual(r.status, 200)
self.assertEqual(r.getheader("Content-Length"), "300")
self.assertEqual(r.getheader("Accept-Ranges"), "bytes")
self.assertEqual(r.read(), b"")
# HEAD + Range 也要是 206 + 精确长度(App 的探测就是 HEAD)
c.request("HEAD", f"/media/{TID}.ts", headers={**xff, "Range": "bytes=100-199"})
rh = c.getresponse()
self.assertEqual(rh.status, 206)
self.assertEqual(rh.getheader("Content-Length"), "100")
self.assertEqual(rh.getheader("Content-Range"), "bytes 100-199/300")
self.assertEqual(rh.getheader("Accept-Ranges"), "bytes")
self.assertEqual(rh.read(), b"")
# 同一条连接继续发 Range(keep-alive 复用;若上一个响应长度不对这里会错乱/卡住)
c.request("GET", f"/media/{TID}.ts", headers={**xff, "Range": "bytes=100-199"})
r2 = c.getresponse()
self.assertEqual(r2.status, 206)
self.assertEqual(r2.getheader("Content-Length"), "100")
self.assertEqual(r2.getheader("Accept-Ranges"), "bytes")
self.assertEqual(r2.getheader("Content-Range"), "bytes 100-199/300")
self.assertEqual(len(r2.read()), 100)
# 再来一次,确认连接还能用
c.request("GET", "/healthz", headers=dict(xff))
r3 = c.getresponse()
self.assertEqual(r3.status, 200)
self.assertGreater(int(r3.getheader("Content-Length")), 0)
self.assertTrue(json.loads(r3.read())["ok"])
finally:
c.close()
def test_keepalive_requires_exact_length_every_response(self):
"""每个响应都必须有精确 Content-Length(缺了代理会挂住)。"""
c = self.conn()
try:
for path, expect in ((f"/media/{TID}.ts", 300), ("/healthz", None),
(f"/media/{TID}.ts", 300), ("/nope", None)):
c.request("GET", path, headers={"X-Forwarded-For": "203.0.113.9"})
r = c.getresponse()
body = r.read()
cl = r.getheader("Content-Length")
self.assertIsNotNone(cl, f"{path} 没有 Content-Length")
self.assertEqual(int(cl), len(body), f"{path} 的 Content-Length 与实际不符")
if expect is not None:
self.assertEqual(len(body), expect)
finally:
c.close()
def test_proxy_headers_resolve_client_ip_over_the_wire(self):
"""经 socket 发 XFF(对端 127.0.0.1 可信)→ healthz 之外用 /api/client_ip 核对。"""
c = self.conn()
try:
c.request("GET", "/api/client_ip",
headers={"X-Forwarded-For": "203.0.113.9, 127.0.0.1",
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "ktv.example.com"})
r = c.getresponse()
d = json.loads(r.read())
self.assertEqual(d["client_ip"], "203.0.113.9")
self.assertEqual(d["peer_ip"], "127.0.0.1")
self.assertTrue(d["via_proxy"])
finally:
c.close()
def test_access_log_uses_real_client_ip(self):
"""访问日志里的客户端 IP 也必须是**真实客户端 IP**(反代后不能全是代理地址)。"""
c = self.conn()
try:
c.request("GET", "/healthz", headers={"X-Forwarded-For": "203.0.113.77, 127.0.0.1"})
r = c.getresponse()
r.read()
finally:
c.close()
self.assertTrue(any("203.0.113.77" in m for m in self.logs), self.logs[-3:])
self.assertFalse(any('"GET /healthz' in m and m.startswith("127.0.0.1") for m in self.logs),
self.logs[-3:])
def test_oversize_body_returns_413_chinese(self):
c = self.conn()
try:
c.request("POST", "/api/keeps/import", body=b"x" * 6000,
headers={"Content-Length": "6000"})
r = c.getresponse()
self.assertEqual(r.status, 413)
d = json.loads(r.read())
self.assertFalse(d["ok"])
self.assertIn("请求体过大", d["error"])
self.assertIn("max_body_bytes", d["error"])
self.assertEqual(r.getheader("Connection"), "close")
finally:
c.close()
def test_body_within_limit_accepted(self):
c = self.conn()
try:
csv = f"编号,歌名\n{TID},测试歌\n".encode("utf-8-sig")
c.request("POST", "/api/keeps/import", body=csv,
headers={"Content-Length": str(len(csv))})
r = c.getresponse()
self.assertEqual(r.status, 200, r.read())
finally:
c.close()
def test_chunked_request_body_rejected_with_close(self):
"""分块请求体不支持:必须 411 + 关连接(否则 chunk 会被当成下一个请求,经代理时乱响应)。"""
import socket
s = socket.create_connection(("127.0.0.1", self.port), timeout=5)
try:
s.sendall(b"POST /api.php HTTP/1.1\r\nHost: 127.0.0.1\r\n"
b"Transfer-Encoding: chunked\r\nContent-Type: application/x-www-form-urlencoded\r\n\r\n"
b"5\r\nhello\r\n0\r\n\r\n")
data = s.recv(65536)
finally:
s.close()
head = data.split(b"\r\n", 1)[0]
self.assertIn(b"411", head)
self.assertIn(b"Connection: close", data)
def test_backlog_configurable(self):
"""监听队列长度按 `http_backlog` 生效(配置写入 socket 的 listen backlog)。"""
self.assertEqual(self.cfg.get("http_backlog"), 128)
srv = web.start_server("127.0.0.1", 0, self.router, cfg=self.cfg, backlog=32)
try:
self.assertEqual(srv.request_queue_size, 32)
finally:
srv.shutdown()
srv.server_close()
# ══════════════════════ ⑦ IIS 交付物静态校验 ══════════════════════
class TestIisDeployFiles(unittest.TestCase):
def test_web_config_is_index_pointing_to_two_sites(self):
"""原「三合一」web.config 已拆成两个站点,本文件只作**索引**(不再含任何规则)。
两份站点配置的静态校验(可解析、各管各的、不含管理端口)见 `tests/test_app_token.py`
的 `TestIisSplitConfigs`。
"""
path = os.path.join(IIS_DIR, "web.config")
self.assertTrue(os.path.isfile(path), f"缺少 {path}")
with open(path, encoding="utf-8") as fh:
text = fh.read()
root = ET.fromstring(text) # 必须能被 ElementTree 解析
self.assertTrue(root.tag.endswith("configuration"))
self.assertNotIn("6678", text) # 管理端口绝不出现在对外配置里
self.assertNotIn("192.168.100.232", text) # 索引文件里不留后端地址(避免三份配置互相矛盾)
self.assertEqual(root.findall(".//{*}rule"), [], "索引文件里不该再有反代规则")
for needle in ("api-site/web.config", "media-site/web.config"):
self.assertIn(needle, text, f"索引里应指向 {needle}")
def test_application_host_snippet_parses(self):
path = os.path.join(IIS_DIR, "applicationHost.config.snippet.xml")
self.assertTrue(os.path.isfile(path), f"缺少 {path}")
with open(path, encoding="utf-8") as fh:
root = ET.fromstring(fh.read())
proxy_el = root.find(".//{*}proxy")
self.assertIsNotNone(proxy_el, "片段里必须有 ARR 的 <proxy> 配置")
self.assertEqual(str(proxy_el.get("enabled")).lower(), "true")
names = {e.get("name") for e in root.findall(".//{*}allowedServerVariables/{*}add")}
for n in ("HTTP_X_FORWARDED_FOR", "HTTP_X_FORWARDED_PROTO", "HTTP_X_REAL_IP"):
self.assertIn(n, names, f"未放行服务器变量 {n}")
def test_iis_readme_documents_steps_and_faq(self):
path = os.path.join(IIS_DIR, "README.md")
self.assertTrue(os.path.isfile(path), f"缺少 {path}")
with open(path, encoding="utf-8") as fh:
text = fh.read()
for needle in ("URL Rewrite", "ARR", "Enable proxy", "healthz", "Range", "X-Forwarded-For",
"HTTPS", "6678", "maxAllowedContentLength", "trust_proxy_headers"):
self.assertIn(needle, text, f"IIS README 缺少 {needle}")
if __name__ == "__main__":
unittest.main(verbosity=2)