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

31 KiB
Raw Permalink Blame History

IIS 反向代理:拆成两个站点(API 站 + 媒体站),对外只暴露 IIS

适用场景:KTV 场所里有一台 Windows + IIS 的机器(对外有域名/固定 IP), 内网另有一台 Linux 跑本服务(默认 192.168.100.232:6677)。 对外只开放 IIS(HTTPS + 域名),由它把 App 需要的接口反代到内网服务; 管理端口 6678 不对公网暴露。

0. 先理解四件事(不然后面所有排错都是盲猜)

  1. 反代之后,后端看到的"客户端"是 IIS。所以后端必须知道"谁才是真正的客户端": 靠 IIS 传 X-Forwarded-For / X-Real-IP,并且只在可信前提下采信 —— 后端配置 trust_proxy_headers + trusted_proxies(值填 IIS 机器的内网 IP)。 不开这一项的后果:/push_download 的限流会把全场设备算成同一个来源(一台刷满、其余全 429), 日志里也看不到是哪台设备,排查设备问题等于没日志。
  2. 反代之后,后端看到的协议是 http(内网 IIS→Python 是明文)。所以 HTTPS 站点必须靠 X-Forwarded-Proto: https + trust_proxy_headers,否则 /music/do.php 会把 http://内网主机:6677/media/xxx.ts 返回给 App(App 拒收,还泄露内网拓扑)。
  3. 媒体必须支持 Range。App 的下载器(OkDownload)靠 206 Partial Content 断点续传/边下边播。 所以 IIS 侧不要开压缩/不要缓存改写,后端已经支持 HEAD/Range(两份 web.config 都已关压缩)。
  4. 为什么要拆两个站点:让"批量拉媒体文件"与"调接口"在 IIS 层就分开 —— 各自的绑定/证书/限速/日志互不影响;后端再加一道宿主隔离(media_hosts / api_hosts), 于是 md.kasugano.cn:4433 只能下文件、mdmuse.kasugano.cn 不对外提供批量下载。

后端健康检查与排错接口(务必记住这两个):

接口 用途
GET /healthz 探活:纯内存快照、微秒级返回(给 IIS/负载均衡用),返回 ok/version/uptime_sec/library/keeps/downloader
GET /api/client_ip 排错:服务端认定的客户端 IP + 是否来自可信代理 + 命中了哪个头 + 宿主判定结果(host / media_host_match / api_host_match)。反代配置对不对,看一眼就知道

1. 准备:后端(Linux,6677)先跑起来

# 在 192.168.100.232 上
cd /opt/ktv-source && python3 run.py --config /etc/ktv-source/server-config.json
# 本机自测(不经过 IIS)
curl -s http://127.0.0.1:6677/healthz
# → {"ok":true,"version":"1.0.0","uptime_sec":12,...,"library":0,"keeps":0,"downloader":{...}}

改 /etc/ktv-source/server-config.json(或管理界面「设置」页),反代部署必改这些项:

键 建议值 为什么
trust_proxy_headers true 不开 = 代理头一律不采信(限流会串、日志看不到设备 IP、HTTPS 地址会退化成 http、宿主隔离判定会拿不到 X-Forwarded-Host)
trusted_proxies ["192.168.100.10"](IIS 机器的内网 IP;多个用逗号分隔,也可写 CIDR) 只有这些地址发来的 X-Forwarded-* 才被采信(否则谁都能伪造)
public_media_base https://md.kasugano.cn:4433/media 兜底地址:即使代理头没生效,返回给 App 的也是域名 + 端口 + https(绝不能是裸 IP)
public_host md.kasugano.cn:4433 与上面保持同一个域名(含端口)
media_hosts ["md.kasugano.cn"] 媒体站宿主。非空才启用宿主隔离:/media/* 之外的路径来自媒体宿主 → 404;/media/* 来自非媒体宿主(且 api_hosts 非空)→ 404。写域名即可,端口不用写(见下方"端口容错")
api_hosts ["mdmuse.kasugano.cn"] API 站宿主。配了它之后,媒体站的域名(以及任何未知宿主)请求 /media/* 一律 404
app_token_enforce 先 false,自有 App 上线后再 true false = 只有"带了令牌才校验"(老 App 不带令牌照常可用);true = 受管路径必须带令牌
max_body_bytes 8388608(默认 8MiB) 请求体上限;超限后端回 413 + 中文说明
http_backlog 128(默认) 监听队列:IIS 探活 + 设备突发请求时不会被拒连

改完重启服务:systemctl restart ktv-source,然后确认日志里有:

反向代理头已启用:trusted_proxies=['192.168.100.10'](只有这些地址发来的 X-Forwarded-* 才被采信)
App 令牌:有效期 600 秒、绑定客户端IP=True、单次=False、持久化=False、限流=60/分钟/来源IP、强制=False、受管路径=['/music/do.php', '/push_download'](强制=false 时老 App 不带令牌照常可用)
双站点宿主隔离已启用:媒体宿主=['md.kasugano.cn']、API 宿主=['mdmuse.kasugano.cn']

⚠️ trusted_proxies 填的是 IIS 机器的 IP(即"直连后端的那台机器"),不是手机/设备的网段。 如果你把整个内网网段都写进去(比如 192.168.100.0/24),那么设备直连后端时的伪造头也会被采信 —— 只在"内网绝对干净、设备不可能直连 6677"时才这么写。

1.1 宿主判定与"端口容错"(很重要)

  • 宿主判定只看两个来源,优先可信的 X-Forwarded-Host,不可信时退回 HTTP 的 Host 头 (见 ktv/proxy.effective_host)。好处:任何直连客户端自己塞 X-Forwarded-Host: md.kasugano.cn:4433 不会被采信(它没经过可信代理名单)。
  • ⚠️ X-Forwarded-Host 常常带端口:媒体站绑的是 4433(非默认端口), IIS 的 {HTTP_HOST} 会是 md.kasugano.cn:4433。所以后端的宿主比较对端口容错: 配置里写 ["md.kasugano.cn"] 能命中 md.kasugano.cn:4433,反过来写 ["md.kasugano.cn:4433"] 也能命中不带端口的 md.kasugano.cn(两侧都按"去端口"再比一次,大小写不敏感)。
  • 判定结果可以直接看:GET /api/client_ip 里的 host、host_source、media_host_match、api_host_match。

2. 装 IIS 模块:URL Rewrite + ARR

两个模块缺一不可:URL Rewrite 负责改写/转发规则,ARR 负责真正发起代理请求。

  1. 打开 Server Manager → Add Roles and Features → 安装 Web Server (IIS)。
    • 必须包含:Common HTTP Features → Static Content(否则站点根目录连静态文件都 404)。
    • 建议勾掉/不装 WebDAV Publishing(它会在 URL Rewrite 之前拦 PUT/DELETE)。
  2. 下载并安装两个模块(官方下载页):
  3. 装完重启 IIS 管理器(iisreset 更彻底),确认左侧能看到服务器级的 URL Rewrite 与 Application Request Routing Cache 两个图标。

命令行(管理员 PowerShell)也可以装,但上面两个包是 MSI,用 msiexec /i rewrite_amd64.msi 之类即可。 装完再确认一次:IIS 管理器 → 你的服务器 → Features View → 应同时有 URL Rewrite 和 ARR Cache。


3. 启用 ARR 代理(Enable proxy)—— 不做这步必然 502

做法 A(图形界面,推荐)

  1. IIS 管理器 → 选中服务器节点(不是站点)→ 双击 Application Request Routing Cache;
  2. 右侧 Server Proxy Settings...;
  3. 勾选 Enable proxy;
  4. Time-out 建议改成 600(秒,10 分钟):媒体是大文件、慢客户端会拖很久,默认 120 秒不够; 同页的 Connect time-out / Send time-out 也一并给到 600;
  5. 「Response buffer threshold」保持默认即可;⚠️ 若发现大文件下载卡顿或 IIS 内存占用高: 把它设为 0(不做响应缓冲,直接流式转发)。媒体是 75MB 级的 TS 文件,不需要在代理层缓存。
  6. Apply。

做法 B(配置文件):把 applicationHost.config.snippet.xml 里的 <proxy ... /> 合并进 %windir%\System32\inetsrv\config\applicationHost.config,需要管理员权限; 改前先 copy applicationHost.config applicationHost.config.bak。

验证:%windir%\System32\inetsrv\config\applicationHost.config 里应能看到 <proxy enabled="true" preserveHostHeader="true" ... />。看不到就是没生效,后面一定 502.3。 preserveHostHeader="true" 是媒体站的关键项之一(让 ARR 把原始 Host 转给后端)。


4. 放行服务器变量(不放行 → 站点 500.19 打不开)

两份 web.config 里都用 <serverVariables> 设置 X-Forwarded-For 等头。IIS 默认不允许站点设置 服务器变量,所以必须先放行(一次放行、全服通用,两个站点共用):

做法 A(图形界面):IIS 管理器 → 服务器节点 → URL Rewrite → 右侧 View Server Variables → Add → 依次加入:

HTTP_X_FORWARDED_FOR
HTTP_X_FORWARDED_PROTO
HTTP_X_REAL_IP
HTTP_X_FORWARDED_HOST

做法 B(配置文件):合并 snippet 里的 <rewrite><allowedServerVariables>…。

验证:随便访问一下站点,若是 500.19("This configuration section cannot be used at this path"), 就是这一段没做。也可以临时把 web.config 里所有 <serverVariables> 块删掉看站点是否恢复 —— 但那样代理头就传不过去了(见第 11 节 FAQ 的替代方案)。

关于 ARR 自带的行为:ARR 在多数版本里不会自动追加 X-Forwarded-For(它只负责把请求转给后端), 所以这里用 <serverVariables> 显式设置。两份 web.config 里 XFF 的值有意只写 {REMOTE_ADDR} (不拼接客户端自己传来的 XFF):这样后端拿到的 XFF 只有一个值 = IIS 看到的真实客户端 IP, 客户端再怎么伪造也没用(后端还有 trusted_proxies 白名单兜底)。 如果你在 IIS 前面还挂了一层代理(比如又一层 nginx/CDN),才需要把值改成 {HTTP_X_FORWARDED_FOR},{REMOTE_ADDR} 以保留上游链路。


5. 建两个站点、两个绑定、(两张证书或)一张 SAN 证书

5.1 API 站 mdmuse.kasugano.cn(HTTPS 443)

  1. 建站点:IIS 管理器 → Sites → Add Website
    • Site name:ktv-api
    • Physical path:C:\inetpub\ktv-api(把 api-site/web.config 复制进来,并改名为 web.config)
    • Binding:https / 端口 443 / Host name mdmuse.kasugano.cn
  2. 应用程序池(Application Pools → ktv-api → Advanced Settings):
    • .NET CLR version:No Managed Code(全走 URL Rewrite + ARR,不需要 .NET)
    • Managed pipeline mode:Integrated(默认即可)
    • Identity:默认 ApplicationPoolIdentity 就行(纯反代不写磁盘)
    • Idle Time-out:默认 20 分钟即可;担心被回收可以设为 0
  3. 该站点提供:token(App 令牌)、api.php、i.php、music/do.php、 push_download、push_download/status、healthz;可选(默认注释)api/client_ip、token/status。 其余路径 404(包括 /media/*:文件下载只走媒体站)。

5.2 媒体站 md.kasugano.cn(HTTPS 4433)

  1. 建站点:Add Website
    • Site name:ktv-media
    • Physical path:C:\inetpub\ktv-media(把 media-site/web.config 复制进来,并改名为 web.config)
    • Binding:https / 端口 4433 / Host name md.kasugano.cn(端口怎么填见 5.3)
  2. 应用程序池设置同上(No Managed Code)。
  3. 该站点只有一条反代规则:media/<编号>.ts;其余路径一律 404(接口不在这个域名上)。

5.3 HTTPS 端口 4433 怎么绑(IIS 站点绑定里改端口)

IIS 支持任意 HTTPS 端口,做法:

  1. IIS 管理器 → 左侧 Sites → ktv-media → 右侧 Bindings... → Add;
  2. Type 选 https;Port 填 4433(不是 443);IP 地址留 All Unassigned(或只绑内网网卡);
  3. Host name 填 md.kasugano.cn;
  4. SSL certificate 选你的证书(见 5.4);若需要 SNI,勾选 Require Server Name Indication;
  5. OK → Close。之后访问就是 https://md.kasugano.cn:4433/media/<编号>.ts。
  • 端口 4433 必须同时在网络层放行:Windows 防火墙 New-NetFirewallRule -DisplayName ktv-media-4433 -Direction Inbound -Protocol TCP -LocalPort 4433 -Action Allow; 如果前面还有路由器/云防火墙/NAT,也要把 4433 映射出去(App 侧用的下载地址由后端 public_media_base / X-Forwarded-Host 决定,必须是域名 + 4433)。
  • 非 443 端口下 IIS 的 {HTTP_HOST} 会带端口(md.kasugano.cn:4433),并原样传进 X-Forwarded-Host; 后端对端口容错(第 1.1 节),所以 media_hosts=["md.kasugano.cn"] 就够。
  • 若你的环境只允许 443,也可以把媒体站建成"另一个 443 上的站点"(用不同域名 + SNI 区分), 此时把 public_media_base、public_host、以及 App 里的下载域名一起改成不带端口的形式即可; 后端配置(media_hosts)不用改。

5.4 证书:一张带 SAN 最省事

  • 一张证书带 SAN(推荐):申请/签发时把 md.kasugano.cn 与 mdmuse.kasugano.cn 都写进 SAN,两个绑定选同一张证书。
  • 两张证书:各自签发,分别绑到两个站点。
  • 自签(内网测试):PowerShell(管理员)
    New-SelfSignedCertificate -DnsName md.kasugano.cn,mdmuse.kasugano.cn -CertStoreLocation cert:\LocalMachine\My
    
    自签证书要导入到设备的信任库,否则 App 会因证书校验失败而连不上(这也是为什么更推荐公网域名 + 正式证书)。
  • Let's Encrypt 类工具(win-acme)默认只处理 443;4433 需要手动建绑定并选证书 (续期后证书指纹变了要重新选一次,或改用"一张 SAN 证书 + 手动导入"的方式)。

5.5 HTTP → HTTPS 跳转(可选)

给任一站点再加一个 http 绑定,然后在该站点的 URL Rewrite 里加一条 301 到 https 的规则。 ⚠️ 若同时保留 http 与 https 绑定,web.config 里写死的 HTTP_X_FORWARDED_PROTO=https 需要按文件末尾的说明调整。


6. 把占位值换成你自己的(逐条核对)

位置 占位值 改成
api-site/web.config 里 7 条规则的 url="http://192.168.100.232:6677/{R:0}" 192.168.100.232:6677 你内网 Linux 服务的真实 IP:端口
media-site/web.config 里 1 条规则的 url 同上 同上
两份配置里的 HTTP_X_FORWARDED_PROTO value="https" https 站点只用 HTTPS 就保持;同时有 HTTP 绑定就按文件末尾说明改
IIS 站点绑定 mdmuse.kasugano.cn:443 / md.kasugano.cn:4433 你的两个域名/端口
后端 server-config.json trusted_proxies / public_media_base / public_host / media_hosts / api_hosts IIS 机器 IP / https://md.kasugano.cn:4433/media / md.kasugano.cn:4433 / ["md.kasugano.cn"] / ["mdmuse.kasugano.cn"]
后端 push_token 与 App 侧 pushToken local 改成你自己的强随机串(可选但推荐)

7. 验证(可直接复制)

下面 mdmuse.kasugano.cn = API 站,md.kasugano.cn:4433 = 媒体站; 这些命令在任何能访问 IIS 的机器上跑(Windows 上可用 curl.exe / PowerShell 的 curl 别名)。

7.1 API 站

# ① 探活:必须 200 + JSON(后端是内存快照,应该"秒回")
curl -s -o /dev/null -w '%{http_code} %{time_total}s\n' https://mdmuse.kasugano.cn/healthz
curl -s https://mdmuse.kasugano.cn/healthz
# → {"ok":true,"version":"1.0.0","uptime_sec":…,"library":…,"keeps":…,"downloader":{…},"snapshot_age_sec":…}

# ② App 令牌:必须 200 + token + expires_in:600 + Cache-Control: no-store
curl -s -D - https://mdmuse.kasugano.cn/token
# → {"ok":true,"token":"<43 位随机串>","expires_in":600,"expires_at":<unix>}
#   响应头里必须有 Cache-Control: no-store

# ③ 老 App 取 token(i.php):必须 200 + code:200(**不带**我们的 App 令牌)
curl -s 'https://mdmuse.kasugano.cn/i.php?appid=ktv&mac=800b510bf4fd_2d7bb4a7d7f7&sn=2d7bb4a7d7f7&time=1&ver=2.0&vn=4.1.3.03281430&sign=x'
# → {"code":200,"msg":"ok","retry":5,...,"token":"..."}

# ④ 点歌取址(把 <编号> 换成歌曲表里"已下"的编号):返回的 data 必须是 https://md.kasugano.cn:4433/media/…
curl -s 'https://mdmuse.kasugano.cn/music/do.php?musicno=<编号>&ls=1&resolution=720&token=local'
# → {"code":200,"msg":"SUCCESS","data":"https://md.kasugano.cn:4433/media/<编号>.ts","limit":2000}
#   ⚠️ 注意 token=local 是**老协议自己的**令牌(i.php 返回的那个),不是 App 令牌;
#      后端认得它,不会当成"错误的 App 令牌"(见第 8 节)

# ⑤ 推送接口:不带任何凭证必须 401
curl -s -o /dev/null -w '%{http_code}\n' -X POST 'https://mdmuse.kasugano.cn/push_download' -d 'tid=1&url=http://x/y.ts'
# → 401
# 用 push_token:
curl -s -X POST 'https://mdmuse.kasugano.cn/push_download' -H 'X-Push-Token: local' \
  --data-urlencode 'url=http://cdn.example.com/480p/7300123.ts?token=abc&t=1' -d 'tid=7300123'
# → {"ok":true,...}(或 duplicate;期望 200/400,不是 401/404/502)
# 或用 App 令牌(第 8 节流程):-H "X-App-Token: <第②步拿到的 token>"

# ⑥ 未列出的路径必须 404(管理端口/管理界面不对外;媒体也不在 API 站)
curl -s -o /dev/null -w '%{http_code}\n' https://mdmuse.kasugano.cn/api/stats      # → 404
curl -s -o /dev/null -w '%{http_code}\n' https://mdmuse.kasugano.cn/               # → 404
curl -s -o /dev/null -w '%{http_code}\n' https://mdmuse.kasugano.cn/media/1.ts     # → 404

# ⑦ 宿主隔离(后端侧):API 域名请求 /media/* → 404;这一条"通过"说明 api_hosts 生效

7.2 媒体站

# ① 只有 /media/* 通,其余必须 404(接口不在这个域名上)
curl -s -o /dev/null -w '%{http_code}\n' 'https://md.kasugano.cn:4433/healthz'          # → 404
curl -s -o /dev/null -w '%{http_code}\n' 'https://md.kasugano.cn:4433/music/do.php?musicno=1'  # → 404

# ② 媒体 HEAD:必须 200 + Content-Length + Accept-Ranges: bytes
curl -sI 'https://md.kasugano.cn:4433/media/<编号>.ts' | grep -Ei 'HTTP/|content-length|accept-ranges|content-type'

# ③ Range:必须 206 + Content-Range(这一步不过 = App 断点续传/边下边播会出问题)
curl -s -D - -o /dev/null -r 0-1023 'https://md.kasugano.cn:4433/media/<编号>.ts' \
  | grep -Ei 'HTTP/|content-range|content-length|accept-ranges'
# → HTTP/1.1 206 Partial Content / Content-Range: bytes 0-1023/<总长> / Content-Length: 1024

# ④ 宿主隔离(后端侧):媒体域名请求接口路径 → 404(IIS 这一层已经 404 了,这里验证的是"绕过 IIS 也 404")

7.3 后端侧自测(在 Linux 上直连 6677)

# ① 反代头与宿主判定是否按预期被采信(trusted_proxies 含 127.0.0.1 时才 via_proxy=true)
curl -s 'http://127.0.0.1:6677/api/client_ip' \
  -H 'X-Forwarded-For: 203.0.113.9' -H 'X-Forwarded-Proto: https' \
  -H 'X-Forwarded-Host: md.kasugano.cn:4433'
# → host=md.kasugano.cn:4433、host_source=x-forwarded-host、media_host_match=true、api_host_match=false

# ② 媒体站的宿主能下文件、不能调接口(把 media_hosts 配成 ["md.kasugano.cn"] 后)
curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:6677/media/1.ts' \
  -H 'X-Forwarded-For: 203.0.113.9' -H 'X-Forwarded-Host: md.kasugano.cn:4433'   # → 200/404(文件不存在)
curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:6677/music/do.php?musicno=1' \
  -H 'X-Forwarded-For: 203.0.113.9' -H 'X-Forwarded-Host: md.kasugano.cn:4433'   # → 404(宿主隔离)

# ③ 令牌流程(App 侧将来要走的路)
TOKEN=$(curl -s http://127.0.0.1:6677/token | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
curl -s 'http://127.0.0.1:6677/music/do.php?musicno=1' -H "X-App-Token: $TOKEN"    # → 200(或 code:404 未下载)
curl -s http://127.0.0.1:6677/token/status                                        # → 令牌计数

# ④ 推送限流是否"按设备分开":从两台设备各推一首,观察 /push_download/status 的计数
curl -s 'http://127.0.0.1:6677/push_download/status'

7.4 排查"服务端到底认哪个客户端 IP / 哪个宿主"

默认不对外暴露 /api/client_ip(属于内部信息)。两种做法二选一:

  • A. 临时放通:取消 api-site/web.config 里第 ⑨ 条规则的注释(排完记得注释回去),然后 curl -s https://mdmuse.kasugano.cn/api/client_ip → {"ok":true,"client_ip":"203.0.113.9","peer_ip":"192.168.100.10","via_proxy":true,"source":"x-forwarded-for","host":"mdmuse.kasugano.cn","host_source":"x-forwarded-host","media_host_match":false,"api_host_match":true,...}
  • B. 不改 IIS 配置:在 Linux 后端上直连自测(7.3 的第 ① 条)。

判读要点:client_ip = 设备 IP、peer_ip = IIS 机器 IP、via_proxy = true、ignored_reason 为空 = 代理头生效;host = 判定用的宿主、host_source = 它是从哪个头来的。


8. App 令牌(默认 10 分钟)与 App 侧流程

目的:给未来的自有 App一个短时效随机凭证,避免地址被随意抓取/转发。 兼容红线:已发布的官方 App 不带我们的令牌,所以默认 app_token_enforce=false —— 它只"带了令牌才校验",老 App 一个字节都不用改(/i.php、/music/do.php 的参数与签名算法完全没动)。

自有 App 的流程(三步):

# ① 取令牌(GET /token,也接受 POST);响应禁止缓存(Cache-Control: no-store)
curl -s https://mdmuse.kasugano.cn/token
# → {"ok":true,"token":"…43 位随机串…","expires_in":600,"expires_at":1750000000}

# ② 带上令牌取地址(请求头 X-App-Token;过期/换 IP 都是 401)
curl -s 'https://mdmuse.kasugano.cn/music/do.php?musicno=<编号>&ls=1&resolution=720' \
  -H "X-App-Token: <①的 token>"

# ③ 点歌推送同样可以只带 App 令牌(或继续用 push_token,二者任一)
curl -s -X POST https://mdmuse.kasugano.cn/push_download -H "X-App-Token: <①的 token>" \
  --data-urlencode 'url=https://cdn.example.com/480p/<编号>.ts?token=abc&t=1' -d 'tid=<编号>'

后端口径(配置项见 server/README.md「App 令牌」一节):

键 默认 说明
app_token_ttl_sec 600 有效期(秒),钳制 30~86400
app_token_bind_ip true 绑定签发时的真实客户端 IP(换 IP 用同一令牌 → 401)
app_token_single_use false 用过即失效;媒体地址是多次调用,别开
app_token_paths /music/do.php,/push_download 受管路径前缀(/i.php 是"带了就校验",默认不强制)
app_token_enforce false 什么时候打开:自有 App 已上线、且确认全部客户端都会先取令牌之后
token_rate_limit_per_min 60 同一来源 IP 每分钟最多签发几个(防刷)

⚠️ 老协议自己的 token 参数不算 App 令牌:/i.php 返回的 token(默认 local)会出现在 do.php?...&token=local 里,/push_download 的 body token 则是 push_token 口径。 后端对这两处做了区分,不会把它们当成"错误的 App 令牌"。 自有 App 请统一用请求头 X-App-Token(没有任何歧义)。

⚠️ app_token_enforce=true 之后,app_token_paths 命中的路径(含 /push_download/status) 都要带令牌;运维临时核对可以直连内网 6677,或先 GET /token 再带上。


9. 管理端口(运维入口)怎么保护

管理端口(默认 6678)承载管理界面与管理 API:绝对不要做反代、不要映射到公网。 本目录的配置里没有任何规则指向它,两个站点都把其余路径 404,所以公网根本走不到它。 在此之上再加两道:

做法一:IIS 侧限制(不改后端)

  • 不给 6678 建任何站点/绑定(默认就没有);
  • 如果出于运维便利想通过 IIS 访问管理界面,就单独建一个站点 + IP 限制: IIS 管理器 → 该站点 → IP Address and Domain Restrictions → Add Allow Entry (只允许你的运维网段,如 192.168.100.0/24)→ 再把默认行为设为 Deny; 同时在绑定时只绑内网网卡地址(不要 *),这样公网连不到。
  • 更简单:干脆不做反代,运维时用内网直连 http://192.168.100.232:6678/(配合下面 Linux 防火墙)。

做法二:Linux 侧只让内网访问(推荐一起做)

# 只允许运维网段访问 6678,其余一律拒绝
ufw allow from 192.168.100.0/24 to any port 6678 proto tcp
ufw deny 6678/tcp
# 或者把管理端口只绑内网网卡(更彻底)
# 配置里 admin_host 改成 192.168.100.232,重启服务

也可以给管理接口加令牌:admin_token 非空后,/api/* 需要 X-Auth-Token: <token>(或 ?token=<token>)。


10. 与后端配置的对应关系(一页速查)

本目录配置 后端配置 说明
两份 web.config 的 HTTP_X_FORWARDED_FOR / HTTP_X_REAL_IP trusted_proxies(IIS 机器 IP)+ trust_proxy_headers=true 真实客户端 IP → 推送限流分桶 + 日志
两份 web.config 的 HTTP_X_FORWARDED_PROTO 同上(同一个开关) /music/do.php 返回 https://域名/media/…
两份 web.config 的 HTTP_X_FORWARDED_HOST media_hosts / api_hosts 宿主隔离:媒体宿主只能下文件、API 宿主不能批量下载(端口容错)
API 站的 7 条规则 app_token_paths / app_token_enforce App 令牌只在 API 站需要;enforce=false 时老 App 不受影响
<proxy preserveHostHeader="true">(服务器级/snippet) —— 后端看到真实 Host(也可靠 X-Forwarded-Host)
<requestLimits maxAllowedContentLength="33554432"> max_body_bytes(默认 8MiB) IIS 的限制要 ≥ 后端,否则 413 由 IIS 先返回
<serverRuntime uploadReadAheadSize> —— 大请求体读入缓冲,避免上传被截断
<urlCompression …false /> —— 保证 Range/206 原样透传
ARR Time-out = 600 timeout_sec(下载用,另一回事) 媒体是大文件,代理层超时要给足
—— http_backlog=128 监听队列(IIS 探活 + 突发请求)

11. 常见问题(现象 → 原因 → 处置)

现象 原因 处置
站点报 500.19(config error,打不开) ① 服务器变量没放行(第 4 节);② web.config 放错/内容冲突(比如把旧的"三合一"web.config 又复制进了站点根目录) 先做第 4 节;确认站点根目录里的 web.config 来自 api-site/ 或 media-site/,而不是上级目录那个只含说明的索引
502.3 / 502 Bad Gateway ARR 代理没启用;或后端地址/端口写错;或 Linux 侧 6677 没监听/被防火墙拦 IIS 管理器 → ARR Cache → Server Proxy Settings → 勾 Enable proxy;web.config 里后端地址改成真实 IP:端口;在 IIS 机器上 curl http://192.168.100.232:6677/healthz 直连试一下
504 / 请求超时(大文件下载中断) ARR 默认 Time-out 120 秒;媒体下载会超过 ARR → Server Proxy Settings → Time-out = 600(秒),Connect/Send 也给 600
请求返回 404,但"明明配了规则" ① 路径不在该站点的白名单里(API 站没有 /media/*,媒体站只有 /media/*);② 宿主隔离命中(媒体宿主调接口 / API 宿主拉媒体);③ 报错的是另一份配置 先确认打的是哪个域名/端口;再看 GET /api/client_ip 的 host/media_host_match/api_host_match;最后核对站点根目录里的 web.config 是哪一份
媒体站能下文件,但 API 域名拉媒体 404 这是设计如此(api_hosts 生效) 正常现象;要让 API 域名也能下文件,就把 api_hosts 留空(但那样就没有隔离了)
媒体站只能下文件,调接口 404 同上(媒体宿主 + media_hosts 生效) 正常现象;接口请用 API 域名
X-Forwarded-For 没生效:/api/client_ip 里 via_proxy=false、source=tcp ① 后端 trust_proxy_headers=false;② trusted_proxies 里没有 IIS 机器的 IP;③ IIS 侧服务器变量没放行/没设 看 /api/client_ip 的 ignored_reason,它会直接说明卡在哪一步;后端改成 trust_proxy_headers=true + trusted_proxies=["<IIS 内网 IP>"](改完重启)
HTTPS 下 do.php 返回 http://… X-Forwarded-Proto 没传/没被采信,后端只能按 public_media_base 的 scheme 拼 修好代理头;同时把 public_media_base 改成 https://md.kasugano.cn:4433/media 兜底。⚠️ 绝不能留裸 IP(App 会拒收)
Range 失效(curl -r 0-1023 返回 200 且整文件) 中间层把 Range 吃掉了/改写了:开了动态压缩、装了会缓存/改写的模块,或还有一条出站规则把响应改成 200 两份 web.config 里已 <urlCompression …false />;ARR 的 Response buffer threshold 设 0;curl -sI 确认 Accept-Ranges: bytes,curl -r 确认 206 + Content-Range
/healthz 慢 / 超时 后端探活走的是内存快照,正常是毫秒级 Linux 本机 curl -w '%{time_total}' http://127.0.0.1:6677/healthz 验证后端确实快;若本机快、经 IIS 慢,检查是否有额外重写规则/模块介入
站点根目录 / 返回 404 这是故意的(未列路径全 404) 正常现象;要加白名单就在 404 规则之前复制一条反代规则
推送全部 429("推送过于频繁") 反代头没生效 → 所有设备共用 IIS 的 IP 一份额度 修 X-Forwarded-For;用 /push_download/status 与事件日志核对来源 IP
取令牌返回 429("获取令牌过于频繁") 同一来源 IP 每分钟签发超过 token_rate_limit_per_min(默认 60) 正常防刷;确认不是"把 4433/443 后面的真实 IP 判成了代理地址",或按需调大该项
自己 App 带令牌仍然 401「令牌无效或已过期,请重新获取」 ① 令牌过期(默认 10 分钟);② app_token_bind_ip=true 但客户端 IP 变了(换了网络/NAT);③ 头名写错(必须 X-App-Token) 重新 GET /token;确认走的是同一个来源 IP(看 /api/client_ip);核对头名
App 提示"源地址无效/拒收" 返回给 App 的媒体地址是裸 IP,或域名/端口与证书不匹配 public_media_base 必须是域名(建议带 :4433);用 getent hosts 你的域名 确认内网 DNS 能解析

12. 只想先跑一个站点(最小验证)

  1. 先只建 API 站(用 api-site/web.config),把 public_media_base / public_host 指向 https://mdmuse.kasugano.cn(不带 4433),此时下载地址也落在 API 域名上;
  2. 后端 media_hosts 留空(= 不启用宿主隔离),api_hosts 也留空;
  3. 按第 7.1 节验证通过后,再建媒体站、配 media_hosts=["md.kasugano.cn"]、 api_hosts=["mdmuse.kasugano.cn"],把 public_media_base 改成 https://md.kasugano.cn:4433/media, 并把 App 里的下载域名切过去 —— 分两步上线,出问题最容易定位。