页面能打开,但 API 全是 403——这几乎总是主机栅栏不认识浏览器用的那个 authority,和证书没关系。 反过来也常被搞混:公告了 https://app.example/ui/ 就以为已经信任 app.example,其实这是两个独立开关。
按官方公开部署指南的说法,外部链路、地址公告、请求信任是三件分开的事;Web 服务器参考也把 HTTP 载体和认证划给不同层。先把这个分工记住,后面所有排查都是围绕它。
结论先行:三个开关各管什么
| 部件 | 作用 | 不代表 |
|---|---|---|
--public-url |
公告外部根地址和可选的挂载前缀 | 不接纳该主机,也不完成认证 |
--trusted-host |
把浏览器实际使用的 authority 加入允许范围 | 不建立公网路由,也不配置 TLS |
| 监听地址 | 决定哪些机器能连上这个端口 | 不是访问控制,也不等于认证 |
| 反向代理 | 处理外部连接、改写路径、转发升级 | 不能代替 DSH 自己的浏览器认证 |
| 启动 token + 签名 cookie | 认证浏览器会话 | 不隔离监听端口 |
--public-url 接受 HTTP 或 HTTPS 根地址并规范化末尾斜杠,它影响的是客户端看到什么:打印和打开的启动地址、DSH_WEB_URL、以及 agent 运行时上下文里的 Web GUI 地址。后端本身仍然按 origin-root 提供路由,它并不知道外面还挂了一个 /ui/。
最小可用的启动命令
dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example
把这个示例域名换成浏览器地址栏里真正出现的那个 authority。两个参数一起给,是因为它们解决的是两件不同的事:前者告诉浏览器去哪,后者允许那个地址进来。
代理必须完成的六个动作
这一节是行为要求,不是某款代理产品的配置模板。
- 限制后端监听端口:只允许可信代理或所在网络访问。公告地址和主机栅栏都不保护端口本身。
- 原样转发浏览器的
Host:如果代理把 Host 改写成后端地址,栅栏看到的 authority 就不再是浏览器用的那个值。 - 转发前剥掉挂载前缀:外部
/ui/api/...应以/api/...抵达后端;同时转发 WebSocket 升级所需的Upgrade、Connection头。 - 把裸
/ui重定向到/ui/:只有带斜杠的入口会把启动 token 换成会话 cookie;已经有 cookie 的浏览器也可以通过这个入口或/ui/index.html拿文档。 - 改写 cookie 路径到外部挂载:后端是明文 HTTP 时,在面向 HTTPS 的代理侧补
Secure;后端自己用 TLS 时 cookie 已经带了这个属性。 - 打开公告地址实测:检查 API 请求和 WebSocket,而不只是首页。
第 4 条最容易被跳过:代理剥完前缀之后,后端已经无法重建外部路径,所以「带不带斜杠」这件事必须在代理这一层处理掉。这和 配置层优先级 里讲的分层是同一类问题——决定行为的那一层,往往不是你以为在改的那一层。
为什么地址全对还是 403
主机栅栏接受三类 authority:loopback、监听器自己的绑定地址、以及 --trusted-host 明确列出的值。所以只公告不信任,必然被拒;代理转发成功也不改变这个结论,API 照样返回 403。
但 403 并不只有这一种来源,至少四种要分开:
| 现象 | 最可能的原因 | 怎么确认 |
|---|---|---|
| 首页正常,API 全 403 | 浏览器 authority 不在栅栏里 | 对比浏览器地址、代理转发的 Host、已配置的可信条目 |
| 文档也 404 / 反而 403 | 前缀没剥,或裸 /ui 被当成文件路径 |
看后端实际收到的路径 |
| 偶发 403,换端口就好 | 可信条目带了端口,而隧道端口会变 | 不带端口的条目匹配任意端口 |
| 某个具体路径 403 | 可能是越过前端目录的路径遍历防护 | 记录失败路径和返回响应的那一层 |
最后一行值得单独说:状态码相同不代表原因相同。遇到 403 先把「请求路径 + 响应来自哪一层」记下来,再决定要不要扩大信任范围——扩大信任范围是最贵的一种修法。
TLS 应该放在哪一层
指南允许在代理、监听器、或两者上终止 TLS:
- 代理上终止:保护浏览器到代理这段;代理到监听器是否加密,取决于监听器配置。
- 监听器原生 TLS:
--tls-cert给完整证书链,--tls-key给不带口令的 PEM 私钥,路径相对进程工作目录解析。 - 监听器只读一次文件,换证书后必须重新加载或重启;文件缺失或无效直接启动失败,不会悄悄退回 HTTP。
- 浏览器还会校验证书信任和它访问的那个主机名 / IP。
常见误判是「浏览器到代理的证书有效,就说明整条链路是安全的」。这两段是两条独立链路,浏览器侧证书有效,说明不了代理怎么连后端。
逐段验收:分别记录文档、API 和 WebSocket
同一个「打不开」可能来自三个完全不同的环节,所以验收记录要分开:
- 浏览器 → 代理:记浏览器 authority 和外部路径。
- 代理 → 监听器:记后端收到的
Host和路径。挂载部署下,外部/ui/api/...应变成/api/...。前缀没剥、或 Host 被改写,是两个独立故障,分别记。 - 入口与 cookie 交换:确认浏览器先到带斜杠的入口,再看后续请求是否在改写后的路径上带着 cookie。
- 升级请求:普通请求转发成功,不代表升级能成功——
Upgrade/Connection头有没有转发,要单独确认。 - 最终才看状态码:页面已经渲染出来、后续 API 被拒,说明问题发生在文档返回之后。
每次只改一个条件,然后重跑同一套检查,才有可比对的证据。这部分和 排查企业代理下的 TLS 证书错误 是同一套方法论:先定位是哪一层,再动配置。
几个容易想当然的地方
- 改绑定地址 ≠ 加可信主机。WebServer 载体会拒绝未指定绑定地址,而不是一次监听所有网卡;Web profile 默认 loopback。指定一个非 loopback 地址,等于向那块网卡所在的网络提供服务,这是监听层面的事,和浏览器信任无关。要从端口层面看这些区别,可以对照 设置 Web 监听主机和端口。
- 证书和进程凭据是两种东西。证书要向浏览器证明服务器身份;启动 URL 里带的是进程凭据。哪怕 HTTPS 已经建立,token → cookie 的流程一样要走。
- 多应用共用域名时,cookie 路径决定浏览器把 cookie 发给谁。路径要改写到外部挂载,
Secure只在面向 HTTPS 的那段加。 - 本机 loopback 就够了的时候,不要发布。公告地址只影响客户端看到的入口,它不会替你创建任何保护。
安全边界(这段不能省)
- 启动链接就是凭据,不要出现在公开日志、工单、变更记录或截图里。
- TLS 握手成功只证明连接建立,不能代替访问控制。
- 官方文档没有给出适用于所有代理产品的通用配置;部署之后必须自己验证完整链路,包括失败路径(比如在受控环境里确认未列入白名单的 authority 确实被拒)。
- 隧道域名或端口会变化时,把它纳入维护流程,并确认可信配置与实际浏览器地址一致。
- 证书轮换要规划维护窗口:验证新证书的名称、信任链和连接结果,重载监听器之后再验收一遍。
检查清单
- 浏览器地址栏里的 authority,和
--trusted-host写的是不是同一个? --public-url带没带挂载前缀和末尾斜杠?- 代理有没有原样转发
Host? - 外部
/ui/api/...到后端是不是变成了/api/...? - 裸
/ui有没有重定向到/ui/? - cookie 的路径改写到外部挂载了吗?
Secure加在正确的那一段了吗? - WebSocket 的
Upgrade/Connection头转发了吗? - TLS 终止在哪一层?换证书之后重载了吗?
- 首页、API、升级请求,三条链路分别验过吗?
- 启动链接有没有被写进任何会留存的地方?
相关阅读
本文依据 DeepSeek Harness 官方公开部署指南与 Web 服务器参考整理,由 DSH Plugin 社区(dsh-plugin.com)维护者撰写,首发于阿里云开发者社区。
插件与文档收录于:https://dsh-plugin.com/zh/blog