/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
云服务器升级 dsh 之后 Web 打不开了:Cloudflare Tunnel + Access 下的认证排查记
环境:阿里云 ECS(Alinux,root 运行)+ pm2 + dsh 0.1.5-rc.1 + Cloudflare Tunnel(本地托管)+ Cloudflare Access 症状:升级 dsh 之后,访问 https://dsh.example.com/ 只返回一行纯文本: dsh web authentication required; reopen the URL printed by dsh web.
文中域名、IP、密钥均已匿名化:请把 dsh.example.com 换成你自己的隧道域名。
TL;DR(给赶时间的人)
这次是两个互相独立的故障叠加,而浏览器只把第一个报了出来:
修复方式是:把 trustedHosts(顺带 cookieMaxAgeDays)写进 ~/.dsh/profiles/web/cordis.patch.yml —— 升级不会覆盖它,而且 profile 是 patchReload: live,热生效、不用重启;然后再用一次带 ?token= 的 URL 换取 cookie。
另外两个坑值得单独记住:
- 别把带 token 的 URL 写进 cloudflared 的 service: —— ingress 的 service 只接受 scheme://host:port,带 path/query 会让 cloudflared 启动失败并无限重启。
- 别用 tail -1 之类的方式在 pm2 日志里抓 token —— 日志第一行是旧版本留下的、不带 token 的 URL,很容易抓错并误以为"dsh 不再打印 token 了"。
1. 部署架构
浏览器 ──HTTPS──▶ Cloudflare Access ──▶ Cloudflare Edge ──(隧道)──▶ cloudflared ──▶ dsh web
(身份认证,外层) dsh.example.com 127.0.0.1:3080
关键组件:
| Cloudflare Tunnel | 本地托管(/etc/cloudflared/config.yml + 凭据 JSON),systemd 托管 |
| Cloudflare Access | 挂在 dsh.example.com 上,登录后才放行(外层认证) |
| dsh web | 由 pm2 托管:bash -c "dsh web –host 127.0.0.1 –port 3080",只监听 loopback |
| 证书 | Cloudflare 边缘终止 TLS,源站是明文 HTTP,所以 service: http://127.0.0.1:3080 |
/etc/cloudflared/config.yml:
tunnel: 00000000–0000–0000–0000–000000000000
credentials-file: /etc/cloudflared/00000000–0000–0000–0000–000000000000.json
ingress:
– hostname: dsh.example.com
service: http://127.0.0.1:3080 # 只能到 host:port
– service: http_status:404 # catch-all 必须放最后
2. 现象
升级 dsh 后:
- 浏览器访问 https://dsh.example.com/ → 一行纯文本 dsh web authentication required; reopen the URL printed by dsh web.
- Cloudflare Access 是正常放行的(能看到 dsh 的响应,说明已经穿过了 Access 与隧道)。
- 服务本身是活着的:pm2 显示 online、restarts=0,cloudflared 日志没有任何源站不可达的错误。
3. 排查:先分层,再逐层证伪
这类"外部访问异常"的排查,我习惯先把链路切成四层:边缘(域名/DNS/TLS)→ 隧道 → 源站进程 → 应用内认证,然后用最小代价的探测逐层排除。
| 边缘 | 公网 curl https://dsh.example.com/ | 302 → xxx.cloudflareaccess.com | DNS/TLS/Access 都正常 |
| 隧道 | systemctl status cloudflared、日志 | active,precheck 全 pass,无 ERR | 隧道没断,源站可达 |
| 源站 | curl -H 'Host: dsh.example.com' http://127.0.0.1:3080/ | 401 + 那行文本 | 请求确实到达了 dsh |
| 应用认证 | 见下 | 发现两个独立问题 | ← 真凶在这层 |
3.1 关键手法:用 Host 头在源站本地复现公网行为
不用真去点浏览器,直接在服务器上模拟公网请求,成本极低、结论极硬:
H='Host: dsh.example.com'
# 首页:不带 cookie
curl -sS -o /dev/null -D – -H "$H" http://127.0.0.1:3080/
# /api:这是 dsh 的 RPC/WebSocket 网关
curl -sS -o /dev/null -w '%{http_code}\\n' -H "$H" http://127.0.0.1:3080/api
# 对照:把 Host 换成 loopback,看差异
curl -sS -o /dev/null -w '%{http_code}\\n' -H 'Host: 127.0.0.1:3080' http://127.0.0.1:3080/api
实测结果就是这次问题的"指纹":
/ (Host: dsh.example.com,无 cookie) → 401
/api(Host: dsh.example.com) → 403 ← 注意!和首页不是同一个错误码
/api(Host: 127.0.0.1:3080) → 401
首页 401、/api 403 —— 两个不同状态码,说明是两件不同的事:
- 401:请求进了应用,但没通过 dsh 自己的浏览器认证 → 缺 cookie。
- 403:请求被 dsh 的信任栅栏在认证之前就挡掉了 → 公网域名不在受信列表里。
3.2 确认 token 本身是好的
dsh 启动时会打印一条带进程 token 的 URL。用它换一次 cookie,验证认证机制本身没问题:
TOKEN=$(grep -ho 'token=[A-Za-z0-9_-]\\+' /root/.pm2/logs/dsh-out*.log | tail -1 | cut -d= -f2)
curl -sS -o /dev/null -w '%{http_code}\\n' -H 'Host: dsh.example.com' "http://127.0.0.1:3080/?token=$TOKEN"
# → 303(并带上 Set-Cookie)
303 + Set-Cookie 说明 token 机制完好 —— 所以 401 的原因只能是"浏览器侧没有 cookie"。
3.3 一个把人带偏的假象:日志里"URL 不带 token"
期间遇到一个很有迷惑性的现象:在 pm2 日志里看启动 URL,第一行是
dsh web: http://127.0.0.1:3080 ← 没有 token!
于是怀疑"新版本不再打印 token 了"。实际原因是 pm2 的 out 日志是追加的,这行是升级前旧版本留下的历史记录,新版启动时打印的行在它后面:
dsh web: http://127.0.0.1:3080/?token=…
dsh web: http://127.0.0.1:3080/?token=…
dsh web: http://127.0.0.1:3080/?token=… ← 这行才是当前进程的
教训:不要用 head/tail -1 盲抓,用模式精确匹配。
4. 根因一:dsh web 的浏览器认证模型
要理解"升级为什么会把登录搞掉",得先看清它的认证模型(读 dsh-client-connection 源码可以确认):
| 启动 token | 每条启动 URL 上的 ?token=…,进程级随机,只存在于内存(PROCESS_LAUNCH_TOKENS 弱引用表),不落盘 |
| token 的作用 | 仅用于换取 cookie:GET /?token=<当前进程 token> → 303 跳 / + Set-Cookie |
| 会话 cookie | 名称为 dsh-auth- + base64url(sha256(authority)),值是 v1.<payload>.<HMAC-SHA256 签名> |
| cookie 载荷 | {version, authority, issuedAt, expiresAt},其中 authority = 请求的 host[:port] |
| cookie 属性 | HttpOnly; SameSite=Strict; Path=/,寿命 = cookieMaxAgeDays(默认 30 天) |
| 签名密钥 | 持久化在 $DSH_HOME/.credentials.yaml 的 records["client-connection/browser-session"] |
| 普通访问 | 只看 cookie;不会续期(只有 token 交换才重新种 cookie) |
由此得到三条推论,正好解释了现象:
换句话说:让你掉登录的不是"版本号变了",而是"升级带来了认证密钥的变更"。这是升级的固有代价,无法完全避免,但可以做到只掉一次登录、不出现 403。
5. 根因二:/api 的 Host/Origin 信任栅栏
dsh 的 /api(RPC + WebSocket 网关)前有一道防 DNS rebinding / 防跨站的栅栏,规则是:
- Host 必须是 loopback,或者匹配 trustedHosts 里的条目(带端口则精确匹配,不带端口则匹配任意端口);
- 如果带了 Origin,必须与该 Host 相等;
- Sec-Fetch-Site: cross-site 一律拒绝。
- 失败返回 403;通过栅栏但未认证返回 401。
而 pm2 里的启动命令是:
bash -c "dsh web –host 127.0.0.1 –port 3080" # 没有 –trusted-host!
trustedHosts 只在绑 0.0.0.0 时会自动派生 LAN IP 字面量;这里是 loopback 绑定 + 公网域名访问,所以列表为空 → 公网 Host 不被信任 → /api 一律 403。
这条不修,登录进去也是废的:页面 HTML 能出来,但每一次 /api 调用、每一条 WebSocket 都会被 403 挡掉。
6. 插曲:cloudflared 的 service: 不能带 path/query
排查早期,曾把"带 token 的完整 URL"直接写进了隧道配置:
ingress:
– hostname: dsh.example.com
service: http://127.0.0.1:3080/?token=XXXX # ❌
结果:
$ cloudflared –config /etc/cloudflared/config.yml tunnel ingress validate
Validating rules from /etc/cloudflared/config.yml
Validation failed: http://127.0.0.1:3080/?token=XXXX is an invalid address,
ingress rules don't support proxying to a different path on the origin service.
The path will be the same as the eyeball request's path
原因:cloudflared 会把访客请求的 path 原样转发到源站,service 只描述"协议 + 主机 + 端口"。这个校验失败会让 cloudflared tunnel run 以退出码 1 结束,systemd 于是进入 activating (auto-restart) 无限重启:
Active: activating (auto-restart) (Result: exit-code)
Process: … ExecStart=/usr/local/bin/cloudflared … tunnel run (code=exited, status=1/FAILURE)
正确写法就是 service: http://127.0.0.1:3080;token 应该由浏览器带着访问,而不是写进隧道配置(写进去也等于把凭据落在服务器文件里)。
7. 修复
7.1 cloudflared:service 回到纯地址
ingress:
– hostname: dsh.example.com
service: http://127.0.0.1:3080
– service: http_status:404
cloudflared –config /etc/cloudflared/config.yml tunnel ingress validate # → OK
sudo systemctl restart cloudflared
systemctl is-active cloudflared
7.2 dsh:把 trustedHosts 放进 profile patch(关键)
有两条路可以放开公网域名:
- A. CLI flag:dsh web –port 3080 –trusted-host dsh.example.com
- B. profile patch(推荐):写进 ~/.dsh/profiles/web/cordis.patch.yml
选 B 的理由:这次 pm2 里是 bash -c "…" 包装的启动命令,重建命令容易出错;更重要的是 patch 文件不会被升级覆盖,属于"放一次、长期有效"。
# ~/.dsh/profiles/web/cordis.patch.yml
# 这是一个 patch 层:按 row id 覆盖 bundle 里的配置。
– id: connection
config:
# 保留 bundle 里原有的 LAN/–trusted-host 表达式,追加本部署的公网域名。
# (这个写法就是 dsh-web-app 自带 patch 注释里给出的推荐形态。)
trustedHosts: ['dsh.example.com', …ctx.webRuntime.trustedHosts]
# cookie 默认只有 30 天,而且普通访问不续期;调长可以少折腾。
cookieMaxAgeDays: 365
这个 profile 的 manifest 是 patchReload: live,所以改完即热生效,不需要重启(本次实测:PID 未变、当前 token 继续有效):
# 改之前先备份
cp -a ~/.dsh/profiles/web/cordis.patch.yml ~/.dsh/profiles/web/cordis.patch.yml.bak-$(date +%Y%m%d-%H%M%S)
# 写入上面的内容后验证:/api 应该从 403 变成 401(栅栏放行、仅未认证)
curl -sS -o /dev/null -w '%{http_code}\\n' -H 'Host: dsh.example.com' http://127.0.0.1:3080/api
7.3 完成一次「token → cookie」交换
# 取当前进程的 token(精确匹配,避免抓到旧行)
grep -ho 'token=[A-Za-z0-9_-]\\+' ~/.pm2/logs/dsh-out*.log | tail -1 | cut -d= -f2
然后用浏览器打开(只把 origin 换成公网域名,token 原样保留):
https://dsh.example.com/?token=<TOKEN>
如果你像我一样在隧道前面套了 Cloudflare Access,也不用担心:Access 登录跳转的 redirect_url 会原样保留 ?token=…(可在 302 响应的 meta 里看到),登录回来后会正常完成交换。
交换成功后,cookie 的寿命就是配置值,本次实测:
HTTP/1.1 303 See Other
set-cookie: dsh-auth-…=…; Max-Age=31536000; Path=/; Expires=Mon, 13 Sep 2027 11:12:12 GMT; HttpOnly; SameSite=Strict
7.4 别忘了"残留旧 cookie"的浏览器
修完之后我发现:普通窗口仍然报 401,无痕窗口一次就通过。这是因为普通窗口里还留着升级前那版留下的 dsh-auth-* 旧 cookie / 缓存的 401 响应,以及可能用过期的 token URL。
处理:在 dsh.example.com 的站点数据里清一次(Chrome/Edge:地址栏左侧图标 → Cookie 和网站数据 → 删除;或 F12 → Application → Storage → Clear site data),然后再走一次 7.3。
排查时优先用无痕窗口,可以瞬间把"服务端问题"和"浏览器脏状态"分开。
8. 验证:一张判据表
| 502 / 1033 / 1034 | 边缘→源站不通 | 查 cloudflared 日志、service 地址、端口 |
| 403(dsh 返回 forbidden) | /api 信任栅栏没过 | 检查 –trusted-host / trustedHosts;检查有没有 originRequest.httpHostHeader 覆盖了 Host |
| 401(dsh web authentication required…) | 栅栏过了,但没有有效 cookie | 用当前 token 的 URL 打开一次;检查 authority 是否一致、cookie 是否过期/被清 |
| 200 但界面空白/转圈 | 前端或实时通道问题 | 看浏览器 Console / Network,确认 /api 与 WebSocket |
端到端自检(在服务器上跑,绕过浏览器干扰):
H='Host: dsh.example.com'
JAR=$(mktemp)
TOKEN=$(grep -ho 'token=[A-Za-z0-9_-]\\+' ~/.pm2/logs/dsh-out*.log | tail -1 | cut -d= -f2)
curl -sS -o /dev/null -w 'exchange -> %{http_code}\\n' -H "$H" "http://127.0.0.1:3080/?token=$TOKEN" # 303
curl -sS -c "$JAR" -o /dev/null -H "$H" "http://127.0.0.1:3080/?token=$TOKEN"
curl -sS -b "$JAR" -o /dev/null -w 'index -> %{http_code}\\n' -H "$H" http://127.0.0.1:3080/ # 200
curl -sS -b "$JAR" -o /dev/null -w 'api -> %{http_code}\\n' -H "$H" http://127.0.0.1:3080/api # 非 403/401
curl -sS -b "$JAR" -o /dev/null -w 'ws mux -> %{http_code}\\n' -H "$H" \\
-H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Origin: https://dsh.example.com' \\
-H 'Sec-Fetch-Site: same-origin' -H 'Sec-WebSocket-Version: 13' \\
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \\
http://127.0.0.1:3080/api/remote.mux # 101
rm -f "$JAR"
本次最终结果:303 / 200 / 401(栅栏放行、未认证) / 101,公网侧 302 → Access 登录。全绿。
9. 复盘:六条可以带走的经验
10. 命令速查
# —— 隧道 ——
cloudflared –config /etc/cloudflared/config.yml tunnel ingress validate
cloudflared –config /etc/cloudflared/config.yml tunnel ingress rule https://dsh.example.com
sudo systemctl restart cloudflared && systemctl is-active cloudflared
journalctl -u cloudflared -n 50 –no-pager
# —— pm2 / 进程 ——
pm2 list –no-color
pm2 describe dsh | grep -Ei 'script path|script args|exec cwd|interpreter|restarts'
tr '\\0' ' ' < /proc/$(pm2 pid dsh)/cmdline; echo
grep -ho 'token=[A-Za-z0-9_-]\\+' ~/.pm2/logs/dsh-out*.log | tail -1 | cut -d= -f2
# —— 源站自检 ——
H='Host: dsh.example.com'
curl -sS -o /dev/null -w '%{http_code}\\n' -H "$H" http://127.0.0.1:3080/
curl -sS -o /dev/null -w '%{http_code}\\n' -H "$H" http://127.0.0.1:3080/api
# —— 配置与回滚 ——
cat ~/.dsh/profiles/web/cordis.patch.yml
ls -l ~/.dsh/profiles/web/cordis.patch.yml*
# 回滚:把 .bak 拷回原名即可(live 热生效)
# —— 认证相关文件 ——
ls -l ~/.dsh/.credentials.yaml # cookie 签名密钥所在(属主必须是 dsh 的运行用户)
参考
- Cloudflare Tunnel 配置文件(ingress / service 语法)
- Cloudflare Tunnel 作为 Linux systemd 服务
- PM2 Ecosystem File(args / interpreter / cwd)
- dsh v0.1.5-rc.1 release notes(含会话格式 V3 变更)

