局域网访问为何少一半配置页:DSH 的安全边界设计复盘
1. 引言
一个诡异现象困扰了我很久:同一台机器上的同一个应用,用本机地址访问和用局域网地址访问,看到的配置页数量不一样------回环地址 7 个,局域网地址只有 3 个,少的全是敏感能力(终端、Agent 循环、子智能体、网页搜索)。
先后怀疑过缓存、窗口宽度、CSS 样式,最后才发现:这不是 bug,是应用的安全边界设计。本文从源码层复盘这个机制,帮你在以后再遇到"局域网少功能"时少走弯路。
2. 概念/架构:访问地址决定功能面
| 访问地址 | 判定结果 | 持久化策略 | 插件面板「官方」组 |
|---|---|---|---|
http://localhost:3080/(本机) |
回环(isLoopback=true) | host |
7 个:智能体团队/自动授权审查/语音输入/终端/Agent 循环/子智能体/网页搜索 |
http://192.168.x.x:3080/(局域网) |
远程(isLoopback=false) | memory |
3 个 :智能体团队/自动授权审查/语音输入(终端/Agent 循环/子智能体/网页搜索 隐藏) |
反复排查后确认:不是缓存、不是窗口宽度、不是样式插件------是访问地址决定的。
3. 问题/现象:真实对比
同一浏览器、同一会话、同一构建,只换 hostname:
| 检查项 | 回环地址 | 局域网地址 |
|---|---|---|
| 插件面板「官方」组 | 7 个配置页 | 3 个配置页 |
| 终端 / Agent 循环 / 子智能体 / 网页搜索 | 显示 | 隐藏 |
| 已安装插件组 | 正常 | 正常(不受降级影响) |
4. 根因(源码级)
4.1 触发点:persistence 判定
设置客户端(dsh-client-ui-settings/lib/client.js L1509)的关键一行:
js
const persistence = ctx.remote.$host.isLoopback ? "host" : "memory";
isLoopback = true(本机回环)→ persistence =host→ 完整isLoopback = false(局域网 IP)→ persistence =memory→ 降级
4.2 回环判定:只认 localhost / 127.x
连接客户端(dsh-client-connection/lib/client.js L1333-L1337)的判定函数:
js
function isLoopbackHostname(hostname) {
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);
}
局域网 IP 不满足(首段不是 127)→ 判为远程。
⚠️ 关键:trusted-host 不影响此判定。 就算启动时加了
--trusted-host 192.168.x.x:3080,它只影响浏览器信任的 API 权限,不影响 isLoopback 的持久化降级。这是两个独立的机制------我当时就栽在这里。
从「访问地址」到「两套持久化策略」的完整判定流程如下:
text
访问 hostname
│
▼
isLoopbackHostname(hostname) 判定
│
├── "localhost" 或 "[::1]" ────────────────▶ isLoopback = true
├── 4 段且首段为 "127"(如 127.0.0.1)─────▶ isLoopback = true
└── 其余(如 192.168.x.x)───────────────▶ isLoopback = false
│
persistence =
isLoopback ? "host" : "memory"
│
┌──────────────────────────────┴──────────────┐
▼ ▼
host 持久化 memory 持久化
(完整功能面) (敏感配置页隐藏)
4.3 降级效果:敏感配置页按"服务是否 serve"隐藏
设置壳客户端(dsh-client-ui-settings-shell/lib/client.js L182,终端/Agent 循环/子智能体/网页搜索同款):
js
ctx.effect(() => ctx.configForms.whileServed([BASH_NS, PWSH_NS], (served) =>
ctx.slots.inject("plugins.item", () => ctx.slots.register({...}))))
whileServed(dsh-client-ui-settings/lib/client.js L1330-L1349)逻辑:
js
const served = new Set(mirror.getSnapshot().view?.namespaces.map(v => v.ns) ?? [])
const watched = namespaces.some(ns => served.has(ns))
if (watched) off = register(served) // serve 了才注册卡片
else if (!watched && off) { off(); off = undefined } // 没 serve 就卸载
memory(LAN)模式下 bash / pwsh / subagent / web-search 的 namespace 不被 serve → 终端 / Agent 循环 / 子智能体 / 网页搜索 4 个配置页不注册 → 只显示 3 个无需服务条件的。
从「LAN 访问」到「少一半配置页」的降级链路如下:
text
memory 模式(LAN 访问触发降级)
│
▼
bash / pwsh / subagent / web-search 的 namespace 未被 serve
│
▼
whileServed 判定 watched = false → 不注册卡片(已注册则卸载)
│
▼
终端 / Agent 循环 / 子智能体 / 网页搜索 4 个配置页隐藏
│
▼
插件面板「官方」组只剩 3 个:
智能体团队 / 自动授权审查 / 语音输入
5. 安全设计意图:为什么这么设计
| 隐藏的配置页 | 为什么 LAN 下隐藏 |
|---|---|
| 终端(shell) | Bash/PowerShell 执行能力,远程暴露风险高 |
| Agent 循环 | 控制 Agent 派发工具调用,敏感 |
| 子智能体 | 递归层级/模型,敏感 |
| 网页搜索 | 搜索提供方配置,敏感 |
核心设计:默认把"非本机回环访问"视为不可信远程 ,避免 shell 等能力从局域网被滥用。这是刻意的安全护栏,思路与"禁止监听 0.0.0.0"完全一致------能力越危险,暴露面越要收窄。
6. 配置/方案:实际使用指南
| 场景 | 用哪个地址 | 效果 |
|---|---|---|
| 在本机桌面操作 | http://localhost:3080/ |
完整 7 配置页 + 全部功能 |
| 从局域网访问 | http://192.168.x.x:3080/ |
3 非敏感配置页,功能正常 |
| 管理自制插件 | 两个地址都行 | 已安装插件组始终显示,不受降级影响 |
token 与 cookie:
| 项 | 行为 |
|---|---|
| token | 两个地址共用同一 token(进程级) |
| cookie | 首次访问写 30 天 cookie,之后直接开根路径 |
| 失效 | 清浏览器缓存 / 换机器需重新兑换 |
7. 陷阱/根因:常见误区(我的踩坑记录)
| # | 误判 | 排除方法 |
|---|---|---|
| 1 | 以为是缓存问题 | 无痕窗口测试还是 3 个 → 排除 |
| 2 | 以为是窗口宽度 | 调试测矮窗口下回环地址 7 个全可见 → 排除 |
| 3 | 以为是样式插件误伤 | 禁用后单列但仍 3 个 → 排除 |
| 4 | 以为 trusted-host 能解决 | 不影响 isLoopback 判定 → 排除 |
8. 总结/改进
这次排查最大的收获:现象奇怪时,先怀疑设计而非故障。
| 结论 | 内容 |
|---|---|
| 1 | 地址决定身份------应用通过 hostname 判断"你是不是本机",本地 vs 远程两套策略 |
| 2 | 降级是故意的------危险能力在远程访问时隐藏,是安全护栏不是 bug |
| 3 | 两个机制别混淆------trusted-host 管 API 信任,isLoopback 管功能面,互不影响 |
产品层面的思考:这类"按访问来源降级"的设计在本地优先应用里越来越常见(如浏览器扩展的 host 权限)。它把安全决策从"开发者自觉"变成"机制强制"------远程就是低权限,没有例外。理解它,比"修复"它更重要。
9. 参考代码位置
| 文件 | 位置 | 内容 |
|---|---|---|
dsh-client-ui-settings/lib/client.js |
L1509 | persistence = isLoopback ? "host" : "memory" |
dsh-client-connection/lib/client.js |
L1333-1337 | isLoopbackHostname 判定 |
dsh-client-ui-settings-shell/lib/client.js |
L182 | 终端配置页 whileServed 注册 |
dsh-client-ui-settings/lib/client.js |
L1330-1349 | whileServed 实现 |
dsh-web-app/lib/index.js |
L83-89 | resolveLanTrust(trusted-host,与 isLoopback 无关) |