# 下载中心「公网远程访问」部署与实测报告

> 部署日期：2026-09-30　|　执行：DSH 会话（自动配置 + 三步实测验证）
> 目标：在**家庭局域网之外**访问 DSH 机的下载中心（8899）
> 结论：**已打通并验证**。手机/任何浏览器打开 `https://111.231.0.226:8899/` 即可（自签证书，首次点一次「继续访问」）。

---

## 一、最终架构（为什么这么设计）

```
手机 / 任意浏览器
  │  HTTPS（自签证书，TLS 加密）
  ▼
腾讯云 CVM 111.231.0.226（ap-shanghai）
  │  dl-tls-proxy.service：Node 零依赖 TLS 终止，监听 0.0.0.0:8899
  │  ↓ 明文仅存在于云端本机回环
  │  http://127.0.0.1:8898
  ▼
SSH 反向隧道（DSH 机 dl-tunnel.service 主动外连）
  │  云端只监听回环 127.0.0.1:8898 —— 公网摸不到
  ▼
DSH 机 127.0.0.1:8899（下载中心 + HTTP Basic 认证）
```

设计要点：

- **不改云上 sshd 配置**。隧道用默认 `GatewayPorts no`，远端转发自动绑到 `127.0.0.1:8898`；公网只有 TLS 代理一个入口。这比"改成 `GatewayPorts clientspecified` 让隧道直接对外"更安全，也少动一台生产机。
- **两段都加密**。手机→云是 TLS；云→家是 SSH。云服务器本身是你自己的机器，明文只在它的回环里出现。
- **隧道密钥被限死用途**。云端 `authorized_keys` 里的条目带
  `restrict,port-forwarding,permitlisten="127.0.0.1:8898"` —— 这把密钥不能开 shell、不能转发别的端口、只能监听那一个回环口。
- **两端 systemd `Restart=always`**，隧道断线自动重连（`ServerAliveInterval=20`、`ExitOnForwardFailure=yes`）。
- 隧道断开时云端返回**可读的 502**（"隧道不可达"），而不是让浏览器白等到超时。

## 二、实测数据（都是实测，不是估算）

### 认证与可用性

| 检查项 | 结果 |
|---|---|
| 公网无凭据 `GET /` | **401** |
| 公网有凭据 `GET /` | **200**，页面标题「文件下载」 |
| 公网旧密码 | **401**（改密后旧密码立即失效） |
| 公网 `GET /healthz` | **200**（**故意免认证**，见踩坑 §6.3） |
| **局域网无凭据 `GET /`** | **200**（内网免密） |
| **局域网无凭据 `POST /api/upload/*`** | 400（**已通过认证闸门**，仅因请求体不合法；证明内网免密对上传同样生效） |
| 内网伪造 `X-DL-Relay: 1` 且无凭据 | **401**（防伪造，见 §三·内网免密） |
| 局域网/本机 `GET /healthz` | 200（巡检脚本不受影响） |
| HTTPS 证书 | `CN=111.231.0.226`，SAN 含 `IP:111.231.0.226`，有效期至 **2036-09-26** |

### 外部视角验证（关键）

| 视角 | 结果 |
|---|---|
| 本机直连（家庭宽带 → 公网） | 401 / 200 ✅ |
| **经 v2ray 境外出口（美国洛杉矶 `176.122.137.2`）→ 上海** | 401 / 200 / healthz 200 ✅ |

第二条是真正的「站外」验证 —— 证明公网可达，而不是只在服务器自己身上自测。

### 速度

| 路径 | 实测速度 |
|---|---|
| 公网（手机/在外） | **~360 KB/s（约 2.9 Mbps）** ← 腾讯云出口带宽上限 |
| 经 v2ray 境外出口 | ~343 KB/s |
| 局域网直连（对照） | 717 MB/s |

换算：10 MB ≈ 30 秒；100 MB ≈ 5 分钟；1 GB ≈ 48 分钟；**5 GB 4K 电影 ≈ 4 小时**。

> ⚠️ **这条路的定位是「传文档、随手取文件」，不是「在外拉大视频/模型」**。要全速请走 Windows 笔记本的 Tailscale 直连（家宽上行 ~48 Mbps，快 15 倍，且不暴露任何端口）。

## 三、三种入口一览

| 入口 | 地址 | 速度 | 加密 | 适用 |
|---|---|---|---|---|
| 局域网 | `http://192.168.31.76:8899/` | 本地 | ❌ | 家里 |
| **公网中继** | `https://111.231.0.226:8899/` | ~2.9 Mbps | ✅ TLS（自签） | **手机/在外应急** |
| Tailscale 私网 | `http://100.67.143.34:8899/` 或 `http://dsh-76:8899/` | **~48 Mbps** | ✅ WireGuard | Windows 笔记本 |
| ~~Tailscale Funnel~~ | ~~`https://dsh-76.tailcbc4d1.ts.net/`~~ | — | ✅ 真证书 | ❌ **已判定不可用**，见踩坑 §6.1 |

**认证（2026-09-30 二次调整：改为「内网免密、公网要密码」）**：

| 入口 | 是否要密码 |
|---|---|
| 局域网 `192.168.31.76:8899` | **不要**（含本机回环；上传也免密） |
| 公网 `https://111.231.0.226:8899/` | **要**，账号 `zyw` |
| Tailscale 私网 `100.67.143.34:8899` | **要** |

密码存放于 **`/home/zyw/Downloads/.dl-hub-auth`**（权限 600，被 systemd 单元以 `EnvironmentFile` 挂载），改完 `systemctl restart dl-server` 即生效。

### ⚠️ 内网免密的实现有个必踩的坑（务必看懂再改）

**不能只按来源 IP 判断内网**。公网请求是经 SSH 反向隧道进来的，`remoteAddress` 同样是 `127.0.0.1`，与本机浏览器完全无法区分 —— 若写成「私网/回环就免密」，**公网入口的认证会被静默关掉**。

正确做法（已实现）：

1. 云上的 `dl-tls-proxy.js` 转发时**强制覆盖**写入 `X-DL-Relay: 1`；
2. 下载中心见到该头 → **一律要认证**（不看来源 IP）；没有该头且来源在免密网段内 → 免密；
3. 因为代理是**覆盖**写入，外部伪造该头只会让自己被要求输密码，**无法绕过认证**（已实测：内网伪造该头 + 无凭据 → 401）。

免密网段可用环境变量调整（默认 `127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`）：

```bash
# 想让 Tailscale 私网也免密，就在 dl-server.service 里加：
Environment=DL_AUTH_EXEMPT=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10
# 想回到「谁都要密码」，设成 none
Environment=DL_AUTH_EXEMPT=none
```

## 四、运维命令

```bash
# 公网链路总控（本机 + 云端一起看）
bash ~/Downloads/dl-remote-ctl.sh status     # 两端服务 + 端口 + 地址
bash ~/Downloads/dl-remote-ctl.sh test       # 链路自测（认证校验）
bash ~/Downloads/dl-remote-ctl.sh ext        # 境外出口验证公网真可达
bash ~/Downloads/dl-remote-ctl.sh restart    # 两端一起重启
bash ~/Downloads/dl-remote-ctl.sh log 50     # 本机隧道日志
bash ~/Downloads/dl-remote-ctl.sh cloud-log  # 云端 TLS 代理日志

# Tailscale 私网（Windows 用）
bash ~/Downloads/tailscale-ctl.sh status|url|test|funnel-on|funnel-off

# 下载中心本身
systemctl status dl-server
bash ~/Downloads/install-dl-service.sh       # 重装单元（已同步认证配置，不会冲掉）
```

## 五、组件清单（回滚用）

| 位置 | 文件 | 说明 |
|---|---|---|
| 本机 | `/etc/systemd/system/dl-tunnel.service` | SSH 反向隧道单元 |
| 本机 | `~/.ssh/id_ed25519_dltunnel{,.pub}` | 隧道专用密钥 |
| 本机 | `~/Downloads/.dl-hub-auth` | Basic 认证凭据（600） |
| 本机 | `~/Downloads/dl-remote-ctl.sh` | 链路控制脚本 |
| 本机 | `/etc/systemd/system/dl-server.service.bak-20260930-preauth` | 加认证前的单元备份 |
| 云端 | `/root/dl-tls-proxy.js` | TLS 终止代理（零依赖 Node） |
| 云端 | `/root/dl-tls/{cert,key}.pem` | 自签证书（key 600） |
| 云端 | `/etc/systemd/system/dl-tls-proxy.service` | 代理单元 |
| 云端 | `/root/.ssh/authorized_keys` | 含受限隧道公钥（原为空文件） |
| 本机 | `~/Downloads/tailscale-ctl.sh` | Tailscale 控制脚本 |

### 回滚（彻底撤掉公网入口）

```bash
# 1) 本机停隧道
sudo systemctl disable --now dl-tunnel
# 2) 云端停代理并收回密钥
~/Downloads/ssh_cloud.sh 'systemctl disable --now dl-tls-proxy; rm -f /root/.ssh/authorized_keys'
# 3) 腾讯云控制台删除 TCP 8899 入站规则
# 4) 若要连 Basic 认证一起撤：sudo rm /home/zyw/Downloads/.dl-hub-auth
#    && sudo systemctl daemon-reload && sudo systemctl restart dl-server
```

## 六、踩坑记录（都是真踩过的，别再走一遍）

### 6.1 Tailscale Funnel 在国内不可用（重要）

`https://dsh-76.tailcbc4d1.ts.net/` 看起来完美（真证书、无需装客户端），实测**约一半请求失败**。逐 IP 排查结论：

| Funnel 入口 IP | 结果 |
|---|---|
| `208.111.34.11` | 5/5 成功 |
| `208.111.35.209` | **0/5 成功** |

失败报文是 `GnuTLS, handshake failed: TLS 链接非正常地终止了` —— **TLS 握手被异常终止，GFW 按 IP 重置**。DNS 在这两个 IP 间轮询，所以表现是"刷一下能开、再刷就超时"。**手机浏览器无法固定 IP，故这条路放弃。**

> 如果哪天要救 Funnel：只能在客户端侧把域名解析钉到可用 IP，浏览器做不到；不要浪费时间反复开关 Funnel（Let's Encrypt 有速率限制）。

### 6.2 家宽直连被「双层 NAT」堵死

排查过程与结论：

- netcheck 显示 `MappingVariesByDestIP: false`（全锥型 NAT）+ UPnP/NAT-PMP/PCP 全部可用，看起来直连大有希望。
- 但小米路由器的 WAN 口 IP 是 **`192.168.1.2`（内网地址）**，而 **`192.168.1.1` 是"中国电信智能网关"**（HTTP 200，标题可辨）。→ 双层 NAT，公网 IP `14.145.49.106` 挂在电信网关上。
- 在小米上做 UPnP 映射（实测用 Python 直接调 SOAP 接口，脚本 `/tmp/upnp-igd.py`）后，**从腾讯云打回 `14.145.49.106:18899` 仍然超时**。
- 佐证：小米上**已有的**一条 TCP 映射 `37419`（别的设备建的）从公网同样不通 → 是双层 NAT 的普遍现象，不是我们配错。
- 电信智能网关**不提供 UPnP**（只在 80/8080 有网页管理口）。要修只能在它上面手工加端口映射或 DMZ，需要光猫管理员密码。

> 附带发现：家宽**没有可用的 IPv6**（本机唯一全局 v6 地址 `fd7a:115c:a1e0::` 是 Tailscale 自建的 ULA），所以走 IPv6 直连这条捷径也不成立。

### 6.3 `/healthz` 必须对认证豁免

加 Basic 认证后如果 `/healthz` 也需要凭据，`dl-healthcheck.timer` 每 30s 的无凭据探活就会失败 → 连败 2 次自动重启服务 → **自愈机制变成每 60 秒自毁一次**。已在 `download-server.js` 里显式放行 `/healthz`，并实跑 `dl-healthcheck.sh` 验证退出码 0。

### 6.4 Deepin 25 的 `/usr` 是只读 overlay

装 Tailscale 时 `cp` 到 `/usr/sbin/tailscaled` 报 **`只读文件系统`**：Deepin 25 把 `/usr` 挂成只读 overlay（系统保护 + OSTree）。
**`/usr/local/bin` 是可写的**（它实际落在 `/var` 下的一个 bind 挂载），所以二进制装那里、systemd 单元里的 `ExecStart` 路径也相应改写。
另外官方 apt 源里没有 tailscale 包（Deepin 不是 Ubuntu），用官方**静态二进制 tarball** 最稳。

### 6.5 别用 `pkill -f "http.server 8899"` 清理云端探针

`pkill -f` 会匹配到**这条 ssh 命令自身的命令行**（里面就含该字符串），结果把自己的 SSH 会话杀掉、清理动作半途而废。
正确写法（正则打断自匹配）：`pkill -f "http\.serve[r] 8899"`。同样的坑在 `llmste[r]` 那次已经踩过一回。

### 6.6 `dl-hub` 加了认证后的连带影响

- `install-dl-service.sh` 里原先是**旧版单元**（缺 `DL_MERGE_CONCURRENCY`/`DL_UP_PARALLEL`/`UV_*`），谁跑一次就会把线上配置冲掉。**已同步为线上版本**并加入认证挂载。
- `restart-dl-server.sh` / `install-dl-service.sh` 末尾那条 `curl http://127.0.0.1:8899/` 从回环发起，属内网免密范围，**返回 200 是正常的**；只有公网入口才是 401。
- 全机扫描确认：**没有脚本会程序化抓取 8899 的内容**（只有 `/healthz` 探活），其余引用都只是打印 URL。所以加认证没有破坏任何自动化。

### 6.7 Tailscale 登录参数固定 `--accept-dns=false`

本机是 v2ray + 内网混合环境，若让 Tailscale 接管 DNS（`/etc/resolv.conf`）有搞坏内网解析与代理的风险。故固定不接管 DNS；需要 MagicDNS 名时用 `tailscale-ctl.sh url` 查看，或直接用 `100.67.143.34`。

## 七、可选升级 / 待办

| 项 | 说明 | 收益 |
|---|---|---|
| **换成真证书（推荐）** | 注册免费 DuckDNS 子域 → 解析到 `111.231.0.226` → 用 DNS-01 签 Let's Encrypt 证书 | 去掉浏览器的证书警告，不用域名备案 |
| 攻双层 NAT | 登录电信智能网关（`192.168.1.1`，需管理员密码）加端口映射或 DMZ | 家宽直连，~48 Mbps（但运营商可能仍封入站，有白折腾风险） |
| 关掉 Funnel | `bash ~/Downloads/tailscale-ctl.sh funnel-off` | 少一个公网入口（当前它有一半概率可用，且受 Basic 认证保护） |
| 证书到期监控 | 自签证书 10 年有效（2036 到期），实际不用管；若换 Let's Encrypt 需自动续期 | — |
| 公网入口来源限制 | 腾讯云安全组把 8899 来源从 `0.0.0.0/0` 收窄到常用出口 IP | 减少暴露面（代价：出门换网络就连不上） |

## 八、附：本次新增的账号/凭据位置速查

| 内容 | 位置 |
|---|---|
| 下载中心 Basic 认证 | `~/Downloads/.dl-hub-auth`（600）；账号 `zyw` |
| 隧道专用 SSH 密钥 | `~/.ssh/id_ed25519_dltunnel`（600） |
| Tailscale 账号 | `zhouyuwenprc@gmail.com`；本机节点 `dsh-76` = `100.67.143.34` |
| 腾讯云 SSH | 沿用 `~/Downloads/ssh_cloud.sh`（root / 密码方式） |
