背景
2026-08-15 我写过一篇 DeepSeek Harness 完整指南, 介绍了 DSH 在 ECS + Termux 上的部署。但部署完之后, 我发现通过 HTTPS 远程访问 DSH 时遇到一个诡异的问题:
点 “设置 → 模型” 时, 报 “加载提供方目录失败: settings are unavailable in this browser”。
这个问题让我折腾了 2 天, 从 nginx 反代到 DSH 服务端 patch, 最后才意识到根因根本不在后端, 而在前端。本文是这个完整修复过程的记录, 顺便把 TabSSH 这个隐藏坑也挖出来。
最终方案 (TL;DR)
结论: SSH 隧道 + 端口转发是最干净的方案
- DSH 后端: 保持出厂 loopback-only 设计, 不动
- HTTPS 8443: 保留作为 fallback (llm.providers / host.describe 工作, 但 settings 受限)
- SSH 隧道: 日常用法
- Termux 端: sshd listen 8022 (已跑)
- 手机端: TabSSH (MIT 开源, Forever Free, github.com/tabssh/android)
- 端口转发: Local 3080 → 127.0.0.1:3080
- 浏览器开
http://localhost:3080
第一章: 现象与诊断
现象
访问 https://192.168.4.50:8443/ → 加载 DSH UI → 看到对话面板, 模型选择, Agent 预设都能加载。
但点 “设置 → 模型” → 报:
1 | 加载提供方目录失败: settings are unavailable in this browser |
curl 测试后端:
1 | $ curl -s -X POST https://192.168.4.50:8443/api/settings.describe \ |
后端 100% OK。错误信息来自前端。
根因定位
前端 client bundle (dsh-client-ui-settings-models/client.js) 里有一段 catch block:
1 | try { |
mirrored.view === undefined → 抛 'settings are unavailable in this browser' 字面错误。
describeFace.ensure() 走的是 SettingsDescribeMirror 的 settings cache (来自 packages/client/ui-settings/src/client/settings-mirror.ts):
1 | constructor( |
persistence === 'memory' 时, ensure 直接返回, 永不调 settings.describe。这个 mirror 在 init 时通过 connection.isLoopback 决定 persistence:
1 | const mirror = new SettingsDescribeMirror( |
connection.isLoopback 判断走 isLoopbackHostname:
1 | export function isLoopbackHostname(hostname: string): boolean { |
只识别 localhost / [::1] / 127.*.*.*。
192.168.4.50 完全不是 loopback → isLoopback = false → persistence = 'memory' → ensure 立即返回 → view 永远是 undefined → “settings are unavailable”。
这是 DSH 设计意图: “remote browsers stay process-local because settings RPCs are loopback-only”。settings./agentPreset./credentials.* 都强制走 loopback, 不通过 HTTP 网关暴露。
第二章: 失败的尝试
尝试 1: nginx $http_host 修复
一开始以为是 nginx 反代丢 header, 改了 proxy_set_header Host $http_host;。没用——nginx header 是对的, 问题在前端 JS 不发起 RPC。
尝试 2: 暴力 patch lib/index.js
DSH 编译产物 packages/client/connection/lib/index.js:538:
1 | if (method !== void 0 && PRIVILEGED_METHODS.has(method) && !isTrustedApiRequest(request, [])) |
[] 是 trustedHosts 默认值。改成 trustedHosts 闭包变量:
1 | if (method !== void 0 && PRIVILEGED_METHODS.has(method) && !isTrustedApiRequest(request, trustedHosts)) |
修了之后 settings.describe 返回 200 + 12 namespaces。但前端还是 unavailable——因为前端根本不发起 RPC, 后端再通也没用。
尝试 3: 改 client.js isLoopbackHostname
想直接 sed client.js 加 192.168.4.50 到 loopback 列表。但 client bundle 是 minified, 改起来风险高 (354KB), 而且改完下次 build 还是会被覆盖。
真相
DSH 设计就是让 remote browser 不能用 settings。这是安全设计, 不是 bug。
第三章: SSH 隧道方案
按 DSH 设计, 唯一干净的方案是让浏览器走 loopback。SSH 隧道是标准做法:
1 | 手机浏览器 → localhost:3080 → TabSSH SSH Local转发 |
DSH 看到请求来自 127.0.0.1 → 信任 → settings.describe 工作 → 前端 isLoopbackHostname('localhost') === true → mirror persistence=’host’ → 调 settings.describe → 全功能可用。
TabSSH 选择
试过 4 个 Android SSH app:
| App | 开源 | 免费 | 端口转发 | 备注 |
|---|---|---|---|---|
| ConnectBot | ✅ Apache 2.0 | ✅ | ❌ | 不支持转发, 排除 |
| Termux | ✅ BSD | ✅ | ✅ ssh -L | 命令行, 体验差 |
| JuiceSSH | ❌ | ✅ + Pro $3.99 | ✅ (Pro) | 端口转发收费 |
| TabSSH | ✅ MIT | ✅ Forever Free | ✅ Local+Remote+SOCKS5 | 首选 |
TabSSH 在 GitHub: https://github.com/tabssh/android
README 完整, 117k 行 Kotlin, Material Design 3, 22 themes, AES-256-GCM key store, Port Forwarding + X11 + SOCKS5 + ProxyJump 全免费。
Termux 端准备
sshd 已在跑 (Termux 自带):
1
2$ pgrep -af sshd
5801 sshd生成 ed25519 key (TabSSH 端生成, public key 给 Termux):
1
2# 在 TabSSH 内: Settings → Keys → Generate Key (ed25519)
# 复制 public key 给 Termux 加进 authorized_keysTermux 加 public key:
1
echo "ssh-ed25519 AAAA...(TabSSH 给你的一行)" >> ~/.ssh/authorized_keys
测试 SSH 登录:
1
ssh -i ~/.ssh/dsh-mobile-tunnel -p 8022 u0_a323@127.0.0.1
TabSSH 配置
新建连接:
- Host:
192.168.4.50 - Port:
8022 - Username:
u0_a323 - Authentication: Public Key (选刚生成的)
- Host:
新增 Forwarding (关键):
- Type: Local
- Saved connection: (刚才建的)
- Host/IP:
127.0.0.1← 不能写 “local” 占位符 - Local port:
3080 - Remote port:
3080
连接 + 浏览器开
http://localhost:3080
第四章: TabSSH 隐藏坑——agent forwarding 没开
按 README 描述, TabSSH 默认开启 keepalive:
❤️ Always-on Keepalive — 60s serverAliveInterval; idle sessions survive carrier NAT and Wi-Fi sleep
但实际SSH session 几秒就断——远小于 60s。
调试
Termux 端用 Termux 自己生成的 private key 测试, 0.2 秒稳定完成, 完全没问题:
1 | $ ssh -o StrictHostKeyChecking=no -i ~/.ssh/dsh-mobile-tunnel -p 8022 \ |
但 TabSSH 连接几秒就断。
根因
按 sshd authorized_keys line 2 key options 显示 sshd 允许 agent-forwarding —— 但TabSSH 客户端没请求。
OpenSSH 行为:
- sshd authorized_keys 写允许 options
- 但客户端必须主动发起请求 (ssh -A 或 config ForwardAgent yes)
- 客户端没请求 → sshd 不启用
- 某些 sshd 实现会在客户端没发 agent forwarding 但配置了 port forwarding 的情况下, 主动断开 session (因为两者一般配套使用)
修复
TabSSH 编辑连接 → Advanced → Enable Agent Forwarding (默认 OFF, 必须手动开):
1 | [Edit Connection] |
打开后, SSH session 不再几秒断, 浏览器稳定看 DSH。
教训
TabSSH 的 agent forwarding 默认 OFF 是有道理的 (agent forwarding 有安全风险)。但跟 port forwarding 配合时, 必须打开, 否则 sshd 行为奇怪 (可能是 sshd 等 agent 握手, 几秒 timeout 后断开)。
第五章: 全链路验证
按 SSH 隧道 ready 后, 全链路验证:
1 | # Termux 这边 |
手机浏览器开 http://localhost:3080:
- ✅ DSH UI 完整加载
- ✅ 设置 → 模型 显示完整模型列表 (38 providers)
- ✅ Agent 预设能编辑
- ✅ Settings 配置面板能写
- ✅ 不再 “settings are unavailable”
第六章: 备份方案——HTTPS 8443 + nginx $http_host
虽然 SSH 隧道是日常方案, HTTPS 8443 仍保留作为 fallback:
1 | server { |
Host $http_host 关键——$host 不带端口, DSH trust fence 检查 Origin.host===Host.host 失败 → 403。$http_host 带端口 → 通过。
HTTPS 8443 上的限制:
- ✅ llm.providers / host.describe / agentPreset.list 都能用
- ❌ settings.* / credentials.* / agentPreset.write 受限 (DSH 设计)
日常对话 + 模型选择 + Agent preset 选择够用。写配置走 SSH 隧道 localhost:3080。
第七章: fact 落档 + ECS 文档
按 memory “事不过夜” 原则:
| fact | 内容 |
|---|---|
| #121 | DSH 升级 v0.1.1-rc.2 |
| #122 | HTTPS 8443 self-signed 配置 |
| #123 | nginx $http_host 修复 |
| #124 | C 暴力 patch lib/index.js (回滚后用 SSH 隧道方案) |
| #127 | SSH 隧道初版 (dsh-mobile-tunnel key) |
| #128 | TabSSH agent forwarding 修复 |
ECS 公网文档:
- https://ai.bloodysky.top/dsh-tunnel-guide.md (完整 SSH 隧道配置)
- https://ai.bloodysky.top/dsh-tunnel-server.sh (Termux 端健康检查脚本)
总结
- DSH 设计: remote browser 不能用 settings (前端 isLoopback 判断短路), 这是设计意图不是 bug
- SSH 隧道是唯一干净的方案: 让浏览器走 localhost, DSH 看到 127.0.0.1 直接放行
- TabSSH 是 Android 端最佳: MIT 开源 + Forever Free + Port Forwarding 全免费
- 隐藏坑: TabSSH agent forwarding 默认 OFF, 必须开, 否则 SSH session 几秒断
教训: 遇到 “前端报错, 后端 curl 测 OK” 的情况, 永远先去前端代码 grep 错误字符串, 99% 是前端主动 fail 而非后端问题。
附录: 关键代码
Termux sshd_config (Termux 默认已 work)
1 | # /data/data/com.termux/files/usr/etc/ssh/sshd_config |
SSH 端口转发测试命令
1 | ssh -o StrictHostKeyChecking=no -i ~/.ssh/dsh-mobile-tunnel -p 8022 \ |
-L 3080:127.0.0.1:3080 = 本地 3080 转发到远程 127.0.0.1:3080。
TabSSH Forwarding 配置 (Android UI 截图描述)
1 | [Edit Connection] |
Termux 端健康检查脚本
1 |
|