合并自建歌曲源支线(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
+448
View File
@@ -0,0 +1,448 @@
# 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 里的下载域名切过去 —— 分两步上线,出问题最容易定位。
@@ -0,0 +1,245 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
麦动KTV 自建歌曲源 —— IIS **API 站**配置(站点 `mdmuse.kasugano.cn`,HTTPS 443)
把这个文件放到该站点的根目录,文件名必须是 `web.config`。
┌──────────────────────────────────────────────────────────────────────────────┐
│ 本目录一共有三份配置,**这一份只管 API**(另一个站点只管文件下载): │
│ * `api-site/web.config` ← 你正在看的:mdmuse.kasugano.cn(HTTPS 443) │
│ * 同级的「媒体站」目录 md.kasugano.cn(HTTPS **4433**)只提供文件下载 │
│ * `web.config`(上级目录) 只是"该看哪两份"的索引,本身不含任何规则 │
│ 文件名与完整步骤见 `../README.md`(第 5 节「建两个站点」)。 │
└──────────────────────────────────────────────────────────────────────────────┘
作用:把 App 需要的接口反代到内网 Linux 上跑的本服务
(默认 `http://192.168.100.232:6677`,**必须替换成你自己的 IP:端口**)。
前置条件(缺一个就会 500.19 或 502,完整步骤见 `../README.md`):
① IIS 装好 **URL Rewrite 2.1** 与 **Application Request Routing (ARR) 3.0**;
② 启用 ARR 代理(ARR → Server Proxy Settings → 勾 Enable proxy → Apply),
对应 `../applicationHost.config.snippet.xml` 里的 `<proxy enabled="true" ... />`;
③ 放行服务器变量 HTTP_X_FORWARDED_FOR / HTTP_X_FORWARDED_PROTO / HTTP_X_REAL_IP /
HTTP_X_FORWARDED_HOST,否则本文件的 `<serverVariables>` 会让站点报 500.19。
⚠️ 必须替换的占位值:
- 后端地址 `http://192.168.100.232:6677` → 你内网 Linux 服务器的真实 IP:端口
(本文件共 7 条反代规则 + 说明里的示例;用编辑器全局替换即可);
- 域名 `mdmuse.kasugano.cn` → 你的 API 域名(站点绑定里也要一致);
- `HTTP_X_FORWARDED_PROTO` 写死 `https`:只有本站点绑定了 HTTPS 证书才这样写;
若站点同时有 HTTP 绑定,按文件末尾「HTTP 绑定」说明调整。
⚠️ 为什么这个站点里**一个文件下载路径都没有**:文件下载走另一个域名(另一个站点),
这样"批量拉文件"的流量与"调接口"的流量在 IIS 层就分开了,互不影响;
后端还会按 `X-Forwarded-Host` 再做一次宿主隔离(配置项见 `../README.md` 第 10 节),
双重保险:谁也别想用 API 域名批量拖文件、也别想用下载域名调接口。
⚠️ 管理端口(**本文件里没有任何规则指向它**):公网因此访问不到管理界面 —— 这是
"管理端口不对公网暴露"的第一道闸门(另外两道:IIS 里用 IP 限制 / 只绑内网地址,
以及 Linux 侧防火墙;端口号与做法见 `../README.md` 的「管理端口」一节)。
⚠️ 查询串:URL Rewrite 默认 appendQueryString="true",会把原始 ?a=1&b=2 原样转给后端;
这里显式写出来,避免以后有人改成 false 踩坑。{R:0} = 规则 pattern 匹配到的**整段路径**
(不含开头的 /),所以路径原样透传。
⚠️ 请求方法:URL Rewrite 对**所有方法**(GET/HEAD/POST/PUT/DELETE…)都生效,
不需要额外配置;本服务实际只用 GET/HEAD/POST。若站点装了 WebDAV 模块,它会在
Rewrite 之前拦下 PUT/DELETE 并回 405(本项目用不到这两个方法)。
-->
<configuration>
<system.webServer>
<!-- ══════════════ 反向代理规则(URL Rewrite + ARR)══════════════
写法:<action type="Rewrite" url="http://后端/{R:0}" /> —— 目标写成绝对 URL 时,
URL Rewrite 就会交给 ARR 做**反向代理**(不是 302 重定向)。
每个规则都带 <serverVariables>,把真实客户端信息告诉后端:
X-Forwarded-For = 客户端 IP(**有意只写 {REMOTE_ADDR}**:不拼接客户端自己传来的
XFF,后端拿到的就是真实来源 IP,杜绝伪造链;
后端还有 trusted_proxies 白名单兜底)
X-Real-IP = 同上(有些反代/后端只认这一个)
X-Forwarded-Proto = https(让后端把 /music/do.php 返回的下载地址拼成
https://域名/…,而不是 http 内网地址)
X-Forwarded-Host = 原始 Host(域名;既是后端拼绝对地址的依据,
也是后端**宿主隔离**判定的依据 —— 媒体站靠它认出自己)
-->
<rewrite>
<rules>
<!-- ① GET/POST /token(App 令牌:随机串,默认 10 分钟时效,可绑定客户端 IP)
自有 App 的流程:先取令牌,再带请求头 X-App-Token 调 do.php / push_download。
⚠️ 响应是 Cache-Control: no-store,中间层不要缓存它。 -->
<rule name="ktv-app-token" stopProcessing="true">
<match url="^token$" />
<serverVariables>
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
<set name="HTTP_X_REAL_IP" value="{REMOTE_ADDR}" />
<set name="HTTP_X_FORWARDED_PROTO" value="https" />
<set name="HTTP_X_FORWARDED_HOST" value="{HTTP_HOST}" />
</serverVariables>
<action type="Rewrite" url="http://192.168.100.232:6677/{R:0}" appendQueryString="true" />
</rule>
<!-- ② POST /api.php(App 启动配置) -->
<rule name="ktv-api-php" stopProcessing="true">
<match url="^api\.php$" />
<serverVariables>
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
<set name="HTTP_X_REAL_IP" value="{REMOTE_ADDR}" />
<set name="HTTP_X_FORWARDED_PROTO" value="https" />
<set name="HTTP_X_FORWARDED_HOST" value="{HTTP_HOST}" />
</serverVariables>
<action type="Rewrite" url="http://192.168.100.232:6677/{R:0}" appendQueryString="true" />
</rule>
<!-- ③ GET /i.php(老 App 取 token;**不带我们的 App 令牌**,后端默认不强制,
但参数与签名算法一个字都没改,照常可用) -->
<rule name="ktv-i-php" stopProcessing="true">
<match url="^i\.php$" />
<serverVariables>
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
<set name="HTTP_X_REAL_IP" value="{REMOTE_ADDR}" />
<set name="HTTP_X_FORWARDED_PROTO" value="https" />
<set name="HTTP_X_FORWARDED_HOST" value="{HTTP_HOST}" />
</serverVariables>
<action type="Rewrite" url="http://192.168.100.232:6677/{R:0}" appendQueryString="true" />
</rule>
<!-- ④ GET /music/do.php(点歌取址:返回的下载地址由后端按 X-Forwarded-* 拼成
https 公网地址;老 App 带的 token 参数是它自己的老协议令牌,后端认得) -->
<rule name="ktv-music-do-php" stopProcessing="true">
<match url="^music/do\.php$" />
<serverVariables>
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
<set name="HTTP_X_REAL_IP" value="{REMOTE_ADDR}" />
<set name="HTTP_X_FORWARDED_PROTO" value="https" />
<set name="HTTP_X_FORWARDED_HOST" value="{HTTP_HOST}" />
</serverVariables>
<action type="Rewrite" url="http://192.168.100.232:6677/{R:0}" appendQueryString="true" />
</rule>
<!-- ⑤ GET /push_download/status(推送计数,运维核对;注意放在 ⑥ 之前,
虽然两条 pattern 互不重叠,但"长路径在前"是好习惯) -->
<rule name="ktv-push-download-status" stopProcessing="true">
<match url="^push_download/status$" />
<serverVariables>
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
<set name="HTTP_X_REAL_IP" value="{REMOTE_ADDR}" />
<set name="HTTP_X_FORWARDED_PROTO" value="https" />
<set name="HTTP_X_FORWARDED_HOST" value="{HTTP_HOST}" />
</serverVariables>
<action type="Rewrite" url="http://192.168.100.232:6677/{R:0}" appendQueryString="true" />
</rule>
<!-- ⑥ POST /push_download(App 点歌推送:鉴权 = 合法 App 令牌 **或** push_token;
限流按真实客户端 IP,靠上面的 XFF) -->
<rule name="ktv-push-download" stopProcessing="true">
<match url="^push_download$" />
<serverVariables>
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
<set name="HTTP_X_REAL_IP" value="{REMOTE_ADDR}" />
<set name="HTTP_X_FORWARDED_PROTO" value="https" />
<set name="HTTP_X_FORWARDED_HOST" value="{HTTP_HOST}" />
</serverVariables>
<action type="Rewrite" url="http://192.168.100.232:6677/{R:0}" appendQueryString="true" />
</rule>
<!-- ⑦ GET /healthz(探活:给 IIS/负载均衡用;后端是纯内存快照,微秒级返回) -->
<rule name="ktv-healthz" stopProcessing="true">
<match url="^healthz$" />
<serverVariables>
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
<set name="HTTP_X_REAL_IP" value="{REMOTE_ADDR}" />
<set name="HTTP_X_FORWARDED_PROTO" value="https" />
<set name="HTTP_X_FORWARDED_HOST" value="{HTTP_HOST}" />
</serverVariables>
<action type="Rewrite" url="http://192.168.100.232:6677/{R:0}" appendQueryString="true" />
</rule>
<!-- ⑧ 其余路径一律 404(不反代、不读本地文件):管理界面/管理接口/未列出的接口
全部挡在外面。要临时放通别的路径,就在这条规则**之前**再复制一条上面的规则。 -->
<rule name="ktv-block-everything-else" stopProcessing="true">
<match url=".*" />
<action type="CustomResponse" statusCode="404" statusReason="Not Found"
statusDescription="404 Not Found" />
</rule>
<!-- ⑨【可选·仅排查时启用】GET /api/client_ip:看服务端认定的客户端 IP / 是否来自
可信代理 / 宿主判定结果(host、是否命中媒体宿主/API 宿主)。
默认**故意不放通**(它会返回内网代理地址列表,属于内部信息);
排查 X-Forwarded-For 是否生效时,把下面这段的注释去掉、排完再注释回去。
另一种不改配置的做法:在 Linux 上直连服务端口自测(见 ../README.md「验证」)。
<rule name="ktv-optional-client-ip" stopProcessing="true">
<match url="^api/client_ip$" />
<serverVariables>
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
<set name="HTTP_X_REAL_IP" value="{REMOTE_ADDR}" />
<set name="HTTP_X_FORWARDED_PROTO" value="https" />
<set name="HTTP_X_FORWARDED_HOST" value="{HTTP_HOST}" />
</serverVariables>
<action type="Rewrite" url="http://192.168.100.232:6677/{R:0}" appendQueryString="true" />
</rule>
-->
<!-- ⑩【可选·仅运维时启用】GET /token/status:令牌计数(签发/校验失败/限流)。
同样是内部信息,默认不放通。临时启用时用请求头 X-App-Token 调(开了强制的话)。
<rule name="ktv-optional-token-status" stopProcessing="true">
<match url="^token/status$" />
<serverVariables>
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
<set name="HTTP_X_REAL_IP" value="{REMOTE_ADDR}" />
<set name="HTTP_X_FORWARDED_PROTO" value="https" />
<set name="HTTP_X_FORWARDED_HOST" value="{HTTP_HOST}" />
</serverVariables>
<action type="Rewrite" url="http://192.168.100.232:6677/{R:0}" appendQueryString="true" />
</rule>
-->
</rules>
</rewrite>
<!-- ══════════════ 上传/请求体限制 ══════════════
maxAllowedContentLength 是 IIS 自己先卡的一道(单位**字节**,默认 30MB)。
本站点公开的接口请求体都很小(推送是一条表单),这里仍给到 32MiB 作为余量。
⚠️ 它必须 **≥ 后端配置 max_body_bytes(默认 8MiB)**,否则超限时是 IIS 先返回它自己的
HTML 413,而不是后端的 JSON + 中文说明,两边行为就不一致了。 -->
<security>
<requestFiltering>
<requestLimits maxAllowedContentLength="33554432" />
</requestFiltering>
</security>
<!-- 整站关掉静态/动态压缩:本站点没有可压缩的大响应,保持与另一个站点一致,
避免以后误加规则时把分块/长度搞乱(压缩会让 Content-Length 与 Range 语义失真)。 -->
<urlCompression doStaticCompression="false" doDynamicCompression="false" />
<!-- 站点若装了 WebDAV,它会在 URL Rewrite 之前拦下 PUT/DELETE 并回 405。
本项目只用 GET/HEAD/POST,一般无需处理;确实遇到 405 就取消下面的注释。
<modules>
<remove name="WebDAVModule" />
</modules>
<handlers>
<remove name="WebDAV" />
</handlers>
-->
</system.webServer>
</configuration>
<!--
══════════════ 若站点同时绑定了 HTTP(不带证书)══════════════
上面每条规则的 HTTP_X_FORWARDED_PROTO 都写死了 "https"。如果这个站点**同时**有 http 和
https 两个绑定,需要让 http 走 http、https 走 https。两种做法:
做法 A(推荐):把 proto 写成条件分支 —— 删掉每条规则里的
<set name="HTTP_X_FORWARDED_PROTO" value="https" />
改成在规则里加条件与两条设置(URL Rewrite 不支持条件赋值,所以要拆成两条规则):
<conditions><add input="{HTTPS}" pattern="^ON$" /></conditions> → value="https"
<conditions><add input="{HTTPS}" pattern="^ON$" negate="true" /></conditions> → value="http"
做法 B(更省事):干脆只保留 HTTPS 绑定(HTTP 绑定删掉或改成 301 跳转到 HTTPS),
那么写死 "https" 就是对的。
⚠️ 忘记改的后果:HTTP 访问时会话里返回的下载地址是 https://…,或者 HTTPS 站点返回
http://内网主机名(App 拒收)。定位办法:看后端的 `GET /api/client_ip`
(默认不对外暴露;临时放通 / 内网直连两种方式见 ../README.md),
它会给出服务端认定的真实客户端 IP / proto / host,一眼就能看出是谁给的。
-->
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
麦动KTV 自建歌曲源服务器 —— IIS applicationHost.config 片段(**服务器级**配置)
⚠️ 这是一个"片段",不是可以直接替换整份 applicationHost.config 的完整文件:
请把这几个节点**手工合并**进真正的
%windir%\System32\inetsrv\config\applicationHost.config
的 <configuration><system.webServer> 里面(已有同名节点就合并子元素,别写重复节点)。
⚠️ 需要**管理员权限**:applicationHost.config 默认只有 Administrators / SYSTEM 可写;
改之前先备份(复制成 applicationHost.config.bak),改错会导致整个 IIS 起不来。
也可以**完全不动这个文件**,改用 IIS 管理器 UI 完成同样的三件事(推荐新手):
* ARR 代理: ARR → Server Proxy Settings → 勾 Enable proxy → Apply
(= 下面 <proxy> 节点;Timeout/Response buffer 也在这一页)
* 放行服务器变量: URL Rewrite → View Server Variables → Add
(= 下面 <allowedServerVariables>;四个都要加)
* 上传缓冲: IIS 管理器 → 站点 → Configuration Editor → system.webServer/serverRuntime
→ uploadReadAheadSize(= 下面 <serverRuntime>,也可留默认)
三块配置各自解决的问题(**两个站点共用**:API 站 `api-site/web.config` +
媒体站 `media-site/web.config`):
① <proxy> —— 让 ARR 真的当反向代理(不开 = 站点 502);
preserveHostHeader 保留原始 Host,避免后端看到内网主机名;
proxyTimeout/connectTimeout/sendTimeout 给到 600 秒
(媒体是大文件,默认 120 秒不够);
② <allowedServerVariables> —— 放行两份 web.config 里 <serverVariables> 用的四个头,
**不放行则站点直接 500.19 打不开**;
③ <serverRuntime> —— 大请求体(CSV 导入)读入缓冲,避免代理层把上传截断。
-->
<configuration>
<system.webServer>
<!-- ① ARR:启用代理(不开这一项,web.config 里的 Rewrite 到绝对 URL 会 502.3)
enabled="true" = 启用反向代理(IIS 管理器里的 Enable proxy)
preserveHostHeader="true" = 把原始 Host(ktv.example.com)转给后端,
后端就不会看到内网主机名(HTTPS/域名场景必须)
reverseRewriteHostInResponseHeaders="false" = 不要改写响应里的 Location/Host,
避免把内网地址/端口暴露或改错
⚠️ 下面两行是**可选**的超时设置(默认 120 秒对媒体下载偏短;媒体是大文件、
慢客户端会拖很久,建议给到 10 分钟 = 600)。若你的 ARR 版本不接受这两个属性
(配置一保存就报错),请删掉它们,改用 IIS 管理器 → ARR Cache → Server Proxy
Settings → Time-out (seconds) = 600 来设置,效果一样。
-->
<proxy enabled="true" preserveHostHeader="true" reverseRewriteHostInResponseHeaders="false"
proxyTimeout="600" connectTimeout="600" sendTimeout="600" />
<!-- ② 放行服务器变量:web.config 里的 <serverVariables> 用到这四个头。
不放行 → 站点报 500.19("This configuration section cannot be used at this path")。
也可以不加这一段,改用 IIS 管理器 → URL Rewrite → View Server Variables → Add 逐个添加。
⚠️ 若你的 ARR 已经自动追加 X-Forwarded-For(部分版本会),仍建议保留本段:
后端的 trusted_proxies 白名单才是最终依据,重复设置不会有副作用。 -->
<rewrite>
<allowedServerVariables>
<add name="HTTP_X_FORWARDED_FOR" />
<add name="HTTP_X_FORWARDED_PROTO" />
<add name="HTTP_X_REAL_IP" />
<add name="HTTP_X_FORWARDED_HOST" />
</allowedServerVariables>
</rewrite>
<!-- ③ 请求体读入缓冲(单位字节,默认只有 48KB):
反代上传(CSV 导入等)超过缓冲时会出现 413/截断类报错,这里给到 32MiB,
与 web.config 的 maxAllowedContentLength 保持一致;后端 max_body_bytes 默认 8MiB。 -->
<serverRuntime uploadReadAheadSize="33554432" />
</system.webServer>
</configuration>
@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
麦动KTV 自建歌曲源 —— IIS **媒体站**配置(站点 `md.kasugano.cn`,HTTPS 端口 **4433**)
把这个文件放到该站点的根目录,文件名必须是 `web.config`。
┌──────────────────────────────────────────────────────────────────────────────┐
│ 本目录一共有三份配置,**这一份只管文件下载**: │
│ * `media-site/web.config` ← 你正在看的:md.kasugano.cn(HTTPS **4433**) │
│ * `api-site/web.config` mdmuse.kasugano.cn(HTTPS 443)只提供 API │
│ * `web.config`(上级目录) 只是"该看哪两份"的索引,本身不含任何规则 │
│ 完整步骤见 `../README.md`(第 5 节「建两个站点」/ 第 5.3 节「4433 端口怎么绑」)。│
└──────────────────────────────────────────────────────────────────────────────┘
本站点**只有一条反代规则**:`media/<编号>.ts` → 内网服务的同名路径,其余路径一律 404。
目的:让这个域名(端口)**只能下文件**,不能调接口;反过来,API 域名也不能批量拖文件
(后端还会按 X-Forwarded-Host 做一次宿主隔离,见 `../README.md` 第 10 节)。
⚠️ 必须替换的占位值:`http://192.168.100.232:6677` → 你内网 Linux 服务器的真实 IP:端口;
域名 `md.kasugano.cn` 与端口 `4433` → 你实际的媒体域名/端口(IIS 站点绑定里也要一致)。
⚠️ 端口 4433 怎么来的:IIS 站点 → Bindings → Add → Type `https` → Port 填 **4433** →
SSL certificate 选你的证书(一张证书带 SAN 同时签 `md.kasugano.cn` 与
`mdmuse.kasugano.cn` 最省事)→ OK。之后访问就是 `https://md.kasugano.cn:4433/...`。
非 443 端口下 IIS 的 `{HTTP_HOST}` 会带端口(`md.kasugano.cn:4433`)——
后端判定宿主时对端口容错,配 `media_hosts=["md.kasugano.cn"]` 也能命中。
⚠️ **Range/206 必须原样透传**(App 的 OkDownload 靠它断点续传/边下边播):
* 本站点关掉压缩(见下面 urlCompression):压缩会破坏 Content-Length/Range 语义;
* 不要在 media/* 上加任何缓存/出站改写规则;
* ARR 侧不要开响应缓冲(Server Proxy Settings → Response buffer threshold 设为 0 更稳);
* ARR 超时给足(媒体是大文件,默认 120 秒不够,见下面注释里的 600 秒)。
前置条件与 API 站完全相同:URL Rewrite 2.1 + ARR 3.0 已装、已启用代理、
已放行四个服务器变量(HTTP_X_FORWARDED_FOR / HTTP_X_FORWARDED_PROTO /
HTTP_X_REAL_IP / HTTP_X_FORWARDED_HOST)—— 不放行就是 500.19,不会给你 404。
-->
<configuration>
<system.webServer>
<!-- ══════════════ 反向代理规则(URL Rewrite + ARR)══════════════
pattern `^(media/.*)$` 的 {R:0} = 整段匹配(`media/<编号>.ts`),
所以目标是 `http://192.168.100.232:6677/media/{R:0}`,
例如 `/media/7300123.ts` → `http://192.168.100.232:6677/media/7300123.ts`。
⚠️ 别写成 `url="…/media/{R:0}"`(那会变成 /media/media/…,必 404)。 -->
<rewrite>
<rules>
<!-- ① 唯一的一条:GET/HEAD /media/<编号>.ts(支持 Range/206) -->
<rule name="ktv-media-only" stopProcessing="true">
<match url="^(media/.*)$" />
<serverVariables>
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
<set name="HTTP_X_REAL_IP" value="{REMOTE_ADDR}" />
<set name="HTTP_X_FORWARDED_PROTO" value="https" />
<!-- 原始 Host 交给后端:既是后端拼绝对地址的依据,也是它**宿主隔离**判定的依据
(非 443 端口时这里会带 `:4433`,后端对端口做了容错) -->
<set name="HTTP_X_FORWARDED_HOST" value="{HTTP_HOST}" />
</serverVariables>
<action type="Rewrite" url="http://192.168.100.232:6677/{R:0}" appendQueryString="true" />
</rule>
<!-- ② 其余路径一律 404:本站点**不提供任何接口**(接口在 API 站)。
想临时放开别的路径?别放 —— 需要接口就用 API 域名。 -->
<rule name="ktv-media-block-everything-else" stopProcessing="true">
<match url=".*" />
<action type="CustomResponse" statusCode="404" statusReason="Not Found"
statusDescription="404 Not Found" />
</rule>
</rules>
</rewrite>
<!-- 媒体是 TS 流(video/MP2T),**压缩它没有任何意义**,反而会把 206 分块/长度搞乱、
还可能撑爆 CPU。整站关掉静态/动态压缩,让字节原样透传。 -->
<urlCompression doStaticCompression="false" doDynamicCompression="false" />
<!-- ══════════════ ARR 侧的三项(保留 Host / 超时 / 缓冲)══════════════
⚠️ `system.webServer/proxy` 这一节在 IIS 里**默认是锁定的**(overrideModeDefault="Deny"),
直接写进站点 web.config 会让站点报 500.19。所以默认走**服务器级**:
`../applicationHost.config.snippet.xml` 的
<proxy enabled="true" preserveHostHeader="true"
reverseRewriteHostInResponseHeaders="false"
proxyTimeout="600" connectTimeout="600" sendTimeout="600" />
或者在 IIS 管理器里:服务器节点 → Application Request Routing Cache →
Server Proxy Settings → Enable proxy + Time-out 600 + Response buffer threshold 0。
若你的 IIS 明确放开了这一节(applicationHost.config 里把 proxy 的
overrideModeDefault 改成 Allow),也可以就地取消下面这段注释,效果一样:
<proxy preserveHostHeader="true" proxyTimeout="600" connectTimeout="600" sendTimeout="600" />
-->
<!-- 站点若装了 WebDAV,它会在 URL Rewrite 之前拦下 PUT/DELETE 并回 405。
本站点只用 GET/HEAD(媒体下载),一般无需处理;遇到 405 就取消下面的注释。
<modules>
<remove name="WebDAVModule" />
</modules>
<handlers>
<remove name="WebDAV" />
</handlers>
-->
</system.webServer>
</configuration>
<!--
══════════════ 验证 Range/206(部署完必做)══════════════
下面这些命令在任意能访问 IIS 的机器上跑(把域名/编号换成你的):
# ① 只有 /media/* 通:其余必须 404(拿 API 域名上的任意接口路径来试都行)
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/token' # → 404
# ② 媒体 HEAD:必须 200 + Content-Length + Accept-Ranges: bytes
curl -sI 'https://md.kasugano.cn:4433/media/<编号>.ts' | grep -Ei 'HTTP/|content-length|accept-ranges'
# ③ 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
# Accept-Ranges: bytes
# ④ 反查宿主隔离是否生效(后端侧,可选):临时放通 API 站的 /api/client_ip,
# 看 host=md.kasugano.cn:4433、media_host_match=true、api_host_match=false。
⚠️ 常见坑:③ 返回 200 且整文件 = Range 被吃掉了。检查顺序:
压缩是否真的关了 → ARR 响应缓冲是否开着 → 是否还有别的出站规则改写了响应。
-->
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
麦动KTV 自建歌曲源 —— IIS 反代配置**索引**(本文件已不再是可用的反代配置)
⚠️ 2026-10 起,对外反代**拆成两个站点**(原因:让"批量拉媒体文件"与"调接口"在 IIS 层
就分开,互不影响,也便于分别限速/限流/看日志):
* `api-site/web.config` → 站点 **mdmuse.kasugano.cn**(HTTPS 443)
反代 `token`(App 令牌)、`api.php`、`i.php`、
`music/do.php`、`push_download`、`push_download/status`、
`healthz`;可选(默认注释)`api/client_ip` / `token/status`;
其余路径 404。
* `media-site/web.config` → 站点 **md.kasugano.cn**(HTTPS 端口 **4433**)
只反代 `media/*`(必须保留 Range/206);其余路径 404。
两份文件各自独立、可以分别复制到两个站点的根目录;**不要再回到"一个站点全包"的老写法**,
否则三份配置会互相矛盾(改了这份忘了那份是这类事故的典型来源)。
⚠️ 为什么保留本文件而不是删掉:IIS 站点根目录里如果**残留**旧的三合一 `web.config`,
它会优先于上面两份生效(同一目录只能有一个 web.config),结果是"照新文档做了却没生效"。
所以这里放一个**只含说明、不含任何规则**(也不含任何反代目标地址)的索引文件:
谁把它复制进站点也不会转发任何流量,只会 404 —— 一眼就能看出"配置放错了文件"。
⚠️ 部署方式(二选一):
① 建两个站点,分别把 `api-site/web.config`、`media-site/web.config` 放进各自根目录;
② 如果只想先跑一个站点做验证,就直接用 `api-site/web.config`(媒体站可以稍后再建,
此时 App 的下载地址域名需要指向 API 站 —— 见 `README.md` 第 12 节 FAQ)。
⚠️ 服务器级前置条件(两个站点共用):URL Rewrite 2.1 + ARR 3.0、已启用 ARR 代理、
已放行四个 X-Forwarded-* 服务器变量 —— 见 `applicationHost.config.snippet.xml`
与 `README.md` 第 2~4 节。
⚠️ 管理端口**不出现在本目录任何一份配置里**(这是"管理界面不对公网暴露"的第一道闸门)。
-->
<configuration>
<system.webServer>
<!-- 这里**故意不放任何 rewrite 规则**:
反代规则在 `api-site/web.config` 与 `media-site/web.config` 里,
本站点(如果它被当成了真实站点)应当只返回 404。 -->
</system.webServer>
</configuration>