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

449 lines
31 KiB
Markdown
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.
# 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)先跑起来
```bash
# 在 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. 下载并安装两个模块(官方下载页):
- **URL Rewrite 2.1**:<https://www.iis.net/downloads/microsoft/url-rewrite>
- **Application Request Routing 3.0 (ARR)**:<https://www.iis.net/downloads/microsoft/application-request-routing>
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(管理员)
```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 站
```bash
# ① 探活:必须 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 媒体站
```bash
# ① 只有 /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)
```bash
# ① 反代头与宿主判定是否按预期被采信(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 的流程(三步):
```bash
# ① 取令牌(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 侧只让内网访问(推荐一起做)**
```bash
# 只允许运维网段访问 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 里的下载域名切过去 —— 分两步上线,出问题最容易定位。