欢迎光临
我们一直在努力

云服务器升级 dsh 之后 Web 打不开了:Cloudflare Tunnel + Access 下的认证排查记

/* 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(给赶时间的人)

这次是两个互相独立的故障叠加,而浏览器只把第一个报了出来:

  • 没有会话 cookie。 dsh web 的浏览器认证 = 一次性的「进程 token 换签名 cookie」。升级重建了 cookie 的签名密钥,旧 cookie 全部作废,于是首页返回 401。
  • /api 信任栅栏没放开公网域名。 pm2 启动命令里没有 –trusted-host dsh.example.com,导致所有 /api 调用返回 403。就算你把 cookie 换回来,页面能打开,界面也是废的。
  • 修复方式是:把 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)

    由此得到三条推论,正好解释了现象:

  • cookie 绑定 authority:换域名/端口 = 换 cookie;127.0.0.1:3080 的 cookie 对 dsh.example.com 无效。
  • token 每次重启都变,但 cookie 不是 —— 只要签名密钥不变、authority 不变、没过期,普通重启不会让你重新登录。
  • 升级可能重建签名密钥:这次 .credentials.yaml 的写入时间恰好是升级那一刻,说明新版本创建/重写了 browser-session 记录 → 所有旧 cookie 立即失效 → 首页 401。
  • 换句话说:让你掉登录的不是"版本号变了",而是"升级带来了认证密钥的变更"。这是升级的固有代价,无法完全避免,但可以做到只掉一次登录、不出现 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. 复盘:六条可以带走的经验

  • 先分层,再证伪。 401 与 403 是两件事:403 是"我不认识你这个 Host",401 是"认识你,但你没登录"。一个域名下的两个路由返回不同错误码,往往意味着两个独立故障。
  • 能在源站复现的,就别在浏览器里猜。 curl -H 'Host: <域名>' http://127.0.0.1:<port>/… 是最省时间的探测手法。
  • token ≠ cookie。 token 是进程级的一次性入场券;cookie 是authority 绑定的持久凭据。重启会换 token,但不一定换 cookie;升级如果动了签名密钥,cookie 才会全废。
  • 配置要放在"升级不会覆盖"的地方。 与其争 –trusted-host 有没有写进 pm2 的包装命令,不如写进 profile patch —— 还可以顺带把 cookieMaxAgeDays 从 30 天调到 365 天。
  • 别用 head/tail 盲抓日志。 pm2 的 out 日志是追加的,旧版本的启动行会永久留在文件头;用模式精确匹配。
  • 不要急着降级。 dsh 0.1.5-rc.1 已经把会话格式升到 V3,升级后的会话不支持降级读取;而且升级本身既不会修好 403,也不会修好 cookie 缺失,只会再换一次 token。先分清"版本问题"和"配置问题"。
  • 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 变更)
    赞(0)
    未经允许不得转载:171主机测评 » 云服务器升级 dsh 之后 Web 打不开了:Cloudflare Tunnel + Access 下的认证排查记
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址