背景

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
2
3
4
5
$ curl -s -X POST https://192.168.4.50:8443/api/settings.describe \
-k -H "Origin: https://192.168.4.50:8443" -H "Content-Type: application/json" \
-d '{"type":"client-request","rpcId":"t","method":"settings.describe","payload":{}}'

{"type":"server-response","rpcId":"t","result":{"ok":true,"value":{"writable":true,...,"namespaces":[12 个]}}}

后端 100% OK。错误信息来自前端。

根因定位

前端 client bundle (dsh-client-ui-settings-models/client.js) 里有一段 catch block:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
try {
const [providersResponse] = await Promise.all([
this.api.llm.providers({}),
this.describeFace.ensure(),
])
if (!providersResponse.result.ok) throw new Error(providersResponse.result.error.message)
const mirrored = this.describeFace.getSnapshot()
if (mirrored.view === undefined) {
throw new Error(mirrored.error ?? 'settings are unavailable in this browser')
}
...
} catch (error) {
state.status = 'error'
state.error = error instanceof Error ? error.message : String(error)
}

mirrored.view === undefined → 抛 'settings are unavailable in this browser' 字面错误。

describeFace.ensure() 走的是 SettingsDescribeMirror 的 settings cache (来自 packages/client/ui-settings/src/client/settings-mirror.ts):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
constructor(
private readonly api: SettingsFace,
private readonly persistence: 'host' | 'memory' = 'host',
) {
this.store = createSnapshotStore<SettingsMirrorSnapshot>({
status: persistence === 'host' ? 'idle' : 'unavailable', // ← memory → status = 'unavailable'
view: undefined,
error: null,
})
}

ensure(): Promise<void> {
if (this.persistence === 'memory') return Promise.resolve() // ← memory 直接返回, 不 load
if (this.inFlight !== undefined) return this.inFlight
if (this.getSnapshot().status === 'idle') return this.load()
return Promise.resolve()
}

persistence === 'memory' 时, ensure 直接返回, 永不调 settings.describe。这个 mirror 在 init 时通过 connection.isLoopback 决定 persistence:

1
2
3
4
const mirror = new SettingsDescribeMirror(
connection.api,
connection.isLoopback ? 'host' : 'memory', // ← 非 loopback → memory
)

connection.isLoopback 判断走 isLoopbackHostname:

1
2
3
4
5
6
7
export function isLoopbackHostname(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '[::1]') return true
const parts = hostname.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}

只识别 localhost / [::1] / 127.*.*.*

192.168.4.50 完全不是 loopbackisLoopback = falsepersistence = '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
2
if (method !== void 0 && PRIVILEGED_METHODS.has(method) && !isTrustedApiRequest(request, []))
return new Response("forbidden", { status: 403 });

[] 是 trustedHosts 默认值。改成 trustedHosts 闭包变量:

1
2
if (method !== void 0 && PRIVILEGED_METHODS.has(method) && !isTrustedApiRequest(request, trustedHosts))
return new Response("forbidden", { status: 403 });

修了之后 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
2
3
手机浏览器 → localhost:3080 → TabSSH SSH Local转发
→ SSH over wlan → Termux sshd 8022
→ 127.0.0.1:3080 → DSH webserver

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 端准备

  1. sshd 已在跑 (Termux 自带):

    1
    2
    $ pgrep -af sshd
    5801 sshd
  2. 生成 ed25519 key (TabSSH 端生成, public key 给 Termux):

    1
    2
    # 在 TabSSH 内: Settings → Keys → Generate Key (ed25519)
    # 复制 public key 给 Termux 加进 authorized_keys
  3. Termux 加 public key:

    1
    echo "ssh-ed25519 AAAA...(TabSSH 给你的一行)" >> ~/.ssh/authorized_keys
  4. 测试 SSH 登录:

    1
    ssh -i ~/.ssh/dsh-mobile-tunnel -p 8022 u0_a323@127.0.0.1

TabSSH 配置

  1. 新建连接:

    • Host: 192.168.4.50
    • Port: 8022
    • Username: u0_a323
    • Authentication: Public Key (选刚生成的)
  2. 新增 Forwarding (关键):

    • Type: Local
    • Saved connection: (刚才建的)
    • Host/IP: 127.0.0.1 ← 不能写 “local” 占位符
    • Local port: 3080
    • Remote port: 3080
  3. 连接 + 浏览器开 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
2
3
4
5
6
7
8
9
$ ssh -o StrictHostKeyChecking=no -i ~/.ssh/dsh-mobile-tunnel -p 8022 \
u0_a323@127.0.0.1 'whoami; uptime'

debug1: Remote: /home/u0_a323/.ssh/authorized_keys:2: key options:
agent-forwarding port-forwarding pty user-rc x11-forwarding

u0_a323
09:33:10 up 21 days, 20:00, load average: 2.15, 2.27, 2.49
debug1: Exit status 0

但 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 编辑连接 → AdvancedEnable Agent Forwarding (默认 OFF, 必须手动开):

1
2
3
4
5
6
[Edit Connection]
├─ General
├─ Authentication
├─ Forwarding
└─ Advanced
└─ [✓] Enable Agent Forwarding ← 这里

打开后, SSH session 不再几秒断, 浏览器稳定看 DSH。

教训

TabSSH 的 agent forwarding 默认 OFF 是有道理的 (agent forwarding 有安全风险)。但跟 port forwarding 配合时, 必须打开, 否则 sshd 行为奇怪 (可能是 sshd 等 agent 握手, 几秒 timeout 后断开)。


第五章: 全链路验证

按 SSH 隧道 ready 后, 全链路验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Termux 这边
$ curl -s -X POST http://localhost:3080/api/settings.describe \
-H "Content-Type: application/json" \
-d '{"type":"client-request","rpcId":"t","method":"settings.describe","payload":{}}' | python3 -c "
import json,sys
d = json.loads(sys.stdin.read())
print('ok:', d['result']['ok'])
print('writable:', d['result']['value'].get('writable'))
print('namespaces:', len(d['result']['value']['namespaces']))
"

ok: True
writable: True
namespaces: 12

手机浏览器开 http://localhost:3080:

  • ✅ DSH UI 完整加载
  • ✅ 设置 → 模型 显示完整模型列表 (38 providers)
  • ✅ Agent 预设能编辑
  • ✅ Settings 配置面板能写
  • ✅ 不再 “settings are unavailable”

第六章: 备份方案——HTTPS 8443 + nginx $http_host

虽然 SSH 隧道是日常方案, HTTPS 8443 仍保留作为 fallback:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
server {
listen 8443 ssl;
server_name 192.168.4.50 _;

ssl_prefer_server_ciphers on;

location / {
proxy_pass http://dsh_backend;
proxy_set_header Host $http_host; # ← 不是 $host
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

# WebSocket 支持
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

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 公网文档:


总结

  1. DSH 设计: remote browser 不能用 settings (前端 isLoopback 判断短路), 这是设计意图不是 bug
  2. SSH 隧道是唯一干净的方案: 让浏览器走 localhost, DSH 看到 127.0.0.1 直接放行
  3. TabSSH 是 Android 端最佳: MIT 开源 + Forever Free + Port Forwarding 全免费
  4. 隐藏坑: TabSSH agent forwarding 默认 OFF, 必须开, 否则 SSH session 几秒断

教训: 遇到 “前端报错, 后端 curl 测 OK” 的情况, 永远先去前端代码 grep 错误字符串, 99% 是前端主动 fail 而非后端问题。


附录: 关键代码

Termux sshd_config (Termux 默认已 work)

1
2
3
4
# /data/data/com.termux/files/usr/etc/ssh/sshd_config
Port 8022
AuthorizedKeysFile .ssh/authorized_keys
Subsystem sftp /data/data/com.termux/files/usr/libexec/sftp-server

SSH 端口转发测试命令

1
2
ssh -o StrictHostKeyChecking=no -i ~/.ssh/dsh-mobile-tunnel -p 8022 \
-L 3080:127.0.0.1:3080 -N -f u0_a323@127.0.0.1 'sleep 600'

-L 3080:127.0.0.1:3080 = 本地 3080 转发到远程 127.0.0.1:3080。

TabSSH Forwarding 配置 (Android UI 截图描述)

1
2
3
4
5
6
7
8
9
10
11
12
[Edit Connection]
├─ General: Name=termux-dsh, Host=192.168.4.50, Port=8022, User=u0_a323
├─ Authentication: Type=Public Key, SSH Key=tabssh (ED25519)
├─ Forwarding:
│ └─ Add Forward:
│ ├─ Type: Local
│ ├─ Saved connection: termux-dsh
│ ├─ Host/IP: 127.0.0.1
│ ├─ Local port: 3080
│ └─ Remote port: 3080
└─ Advanced:
└─ [✓] Enable Agent Forwarding ← 关键

Termux 端健康检查脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/bin/bash
# ~/dsh-tunnel-server.sh
DSH_PORT=3080
DSH_HOST=127.0.0.1
TERMUX_HOST="192.168.4.50"
SSH_PORT=8022
SSH_USER="u0_a323"

echo "[1/4] DSH 进程:"
pgrep -af "apps/cli/lib/bin.js web" | grep -v grep | head -1

echo "[2/4] DSH HTTP:"
curl -s -o /dev/null -w " HTTP %{http_code}\n" --connect-timeout 3 "http://${DSH_HOST}:${DSH_PORT}/"

echo "[3/4] settings.describe:"
curl -s -X POST "http://${DSH_HOST}:${DSH_PORT}/api/settings.describe" \
-H "Content-Type: application/json" \
-d '{"type":"client-request","rpcId":"t","method":"settings.describe","payload":{}}' | \
python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(' ok:', d['result']['ok'], 'namespaces:', len(d['result']['value']['namespaces']))"

echo "[4/4] SSH key:"
ls -la ~/.ssh/dsh-mobile-tunnel ~/.ssh/dsh-mobile-tunnel.pub