caddy 反代 dsh web,让局域网用户也能访问
八月 24, 2026 [网络与安全] #caddy #dshdsh(DeepSeek Harness)的 Web UI 用一条命令就能起:
npx @deepseek-ai/dsh --profile web
默认监听 127.0.0.1:3080,只有本机能访问。想实现三个目标:局域网设备(手机/其他电脑)能访问、有用户名密码认证、传输加密(basicauth 明文走 HTTP 等于裸奔)。
方案:本机 caddy 反代,tls internal 内部证书 + basicauth。最终链路:
浏览器(局域网设备) ──https──> caddy:3080 (tls internal + basicauth) ──> 127.0.0.1:3081 (dsh web)
这篇文章记录完整配置和一路踩过的坑。
一、端口规划
caddy 要占 3080,dsh web 就得让位。用 --port 挪到 3081:
npx @deepseek-ai/dsh --profile web --host 127.0.0.1 --port 3081 --no-open
用一个 systemd-run 临时服务包起来(dotfiles/.my_local/bin/dsh-web.sh,start/stop/restart/status/log 子命令):
systemd-run --user --unit=dsh-web --collect \
--working-directory="$HOME" \
--setenv=PATH="$PATH" \
--property=Restart=on-failure --property=RestartSec=2 \
-- "$(command -v npx)" -y @deepseek-ai/dsh --profile web \
--host 127.0.0.1 --port 3081 --no-open \
--trusted-host 127.0.0.1:3080 --trusted-host localhost:3080 \
--trusted-host 192.168.5.68:3080 # 局域网 IP,见第四节
--trusted-host 是 dsh 的 /api 浏览器信任围栏配置,后面单独讲。
二、caddy 配置(Debian 版踩坑最多)
系统:Debian 13,apt install caddy(版本 2.6.2)。Caddyfile:
https://127.0.0.1:3080, https://localhost:3080, https://192.168.5.68:3080 {
tls internal
basicauth {
peter $2a$14$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
}
reverse_proxy 127.0.0.1:3081
}
密码哈希用 caddy hash-password --plaintext '你的密码' 生成。
坑 1:指令名是 basicauth 不是 basic_auth
上游 caddy 2.9 才把 basic_auth 改名为 basicauth,但 Debian 打包的 2.6.2 已经把改名打了进去。用老名字直接报 unrecognized directive: basic_auth。二进制里明明有模块、其他指令都正常,唯独认证指令对不上——排查时一度很迷惑。
坑 2:站点地址必须写 https://host:port 完整形式
这是最隐蔽的坑。写 :3080 { tls internal }(裸端口 + tls 指令)时配置校验能过,但自动 HTTPS 不会为它签发任何证书——握手直接 tlsv1 alert internal error,浏览器报 ERR_SSL_PROTOCOL_ERROR。
原因:caddy 的自动证书管理只覆盖「站点地址里写了主机名/IP 的站点」。裸端口站点没有标识符,自动 HTTPS 无从下手。改成 https://127.0.0.1:3080 这种带 scheme + 地址的形式,caddy 就会为每个地址签发内部证书。
坑 3:内部根证书装不进系统信任库
caddy 服务以 caddy 用户运行,首次启动想自动把内部根证书装进系统信任库,但该用户不在 sudoers,静默失败:
pki.ca.local: failed to install root certificate: failed to execute sudo: exit status 1
手动装(注意 HOME 必须指向 caddy 用户的存储目录,否则会用 root 的身份另建一套 CA):
sudo env HOME=/var/lib/caddy caddy trust
装完后本机 curl https://127.0.0.1:3080/ 无需 -k,浏览器也不报证书错误。
三、为什么必须配 --trusted-host
dsh web 的 /api 是特权接口(能驱动 agent 干活),它自身没有认证,只绑 127.0.0.1——这防得住远程,但防不住本机恶意网页的跨站请求(CSRF/驱动式攻击连 127.0.0.1 不需要任何权限)。所以 dsh 加了「浏览器信任围栏」:校验浏览器请求的 Origin 头,只信任自己监听地址 127.0.0.1:3081。
关键点:Origin 是浏览器按地址栏 URL 生成的,反向代理不会、也不该改写它。所以经过 caddy 后:
直连: 浏览器访问 http://127.0.0.1:3081 → Origin: http://127.0.0.1:3081 ← 与 dsh 一致,放行
经反代: 浏览器访问 https://127.0.0.1:3080 → Origin: https://127.0.0.1:3080 ← 不一致,403
dsh 从 TCP 视角看不出区别(请求都来自本机),但围栏只认 Origin。实测证据:
| 请求 | 结果 |
|---|---|
| 直连 3081,无 Origin(curl/非浏览器) | 放行(围栏只管浏览器请求) |
直连 3081,Origin=127.0.0.1:3080(未放行) | 403 |
经 caddy,Origin=127.0.0.1:3080(已放行) | 放行 |
经 caddy,Origin=evil.example | 403 |
--trusted-host 127.0.0.1:3080 --trusted-host localhost:3080 就是把这些「反代入口地址」加进白名单。症状也很有迷惑性:UI 能打开、页面渲染正常,但所有 /api 请求 403,功能全废。
顺带一提:恶意网页请求 https://127.0.0.1:3080 时浏览器会自动带上缓存的基本认证凭据,caddy 那关拦不住它——围栏是最后一道防线(origin 不对直接 403)。
定位:这是 CSRF 防护,不是 CORS
容易混淆,「校验 Origin」和「CORS 配置」是两回事,区别在浏览器管什么:
| 管什么 | 谁执行 | dsh 案例 | |
|---|---|---|---|
| CORS | 响应可不可读 | 浏览器(看服务器回不回的 Access-Control-Allow-* 头) | 恶意页面 fetch /api 即使被 CORS 拒,请求本身也已在服务器执行,只是读不到响应 |
| CSRF 防护(dsh 的 Origin 围栏) | 请求该不该被执行 | 服务端(校验请求自带的 Origin 头) | 请求到 dsh,Origin 不在白名单 → 403,不执行 |
CORS 通常用于「开放 API 给第三方域」场景,方向相反;想拦住执行只能靠服务端校验。
为什么 CORS 拦不住 CSRF:预检只对一类请求生效
浏览器 fetch 分两类:
| 请求 | 是否预检 | CORS 拦得住吗 |
|---|---|---|
简单请求:GET/POST、content-type 只有 form-urlencoded / multipart / text/plain、无自定义头 | 不预检,直接发出 | 拦不住——请求在服务器执行,只是 JS 读不到响应(SOP) |
非简单请求:application/json、自定义头、Authorization 等 | 先发 OPTIONS 预检 | 拦截:服务器没回 CORS 头,浏览器连正式请求都不发,也读不到 |
所以「随便 fetch application/json 打任意网站」被预检拦死,但「用 <form> 或 text/plain 的 POST/GET」畅通无阻——这正是经典 CSRF 的载体,必须服务端自己判断。
三种真实攻击:围栏如何拦截
攻击 A:表单 CSRF(经典形态,不需要点击)
恶意网页放一个隐藏表单 + 一段自动提交脚本——用户打开页面即发起请求,无感知、无需点击:
<form id="evil" method="POST" action="https://127.0.0.1:3080/api/run-agent">
<input name="prompt" value="rm -rf ~ && ...">
</form>
<script>
document.getElementById('evil').submit() // JS 自动提交,不依赖用户点击
</script>
表单的跨站提交不受 CORS/SOP 限制(CORS 只管 fetch/XHR 的响应可读性),浏览器会带上 Caddy 缓存的 Basic 凭据。无围栏:dsh 执行,rm -rf 跑起来。有围栏:浏览器自动带的 Origin: https://evil.example 不在白名单 → 403。
攻击 B:text/plain 伪 JSON
攻击者知道后端收 JSON,但 fetch 用 text/plain 发 JSON.stringify 的 body 绕过预检——很多 API 只看内容不看 content-type,照样执行。围栏同样以 Origin 一票否决。
攻击 C:DNS rebinding(最隐蔽)
攻击者域名 evil.com 先解析到外网,JS 建立连接后再解析到 127.0.0.1——此时 fetch https://evil.com/api/... 在浏览器眼里是同源请求:不预检、响应可读、直连 dsh。但 Origin 头仍是 https://evil.com,围栏 403。没有服务端 Origin 校验的话,这个攻击连 CORS 都拦不住(它根本不同源)。
为什么 caddy 的 basicauth 挡不住、围栏是最后一道
攻击者的请求确实会带着你浏览器缓存的 Basic 凭据(你访问 https://127.0.0.1:3080 时输过的密码,浏览器记忆后会自动附上 Authorization 头),caddy 认证天然放行——caddy 认不出「这是恶意页面发的,用户没点」。所以防线落在 dsh:Origin 头永远只有地址栏来源意义上的值,攻击者无法伪造页面来源,他永远过不了这关(无 Origin 的请求 dsh 视为非浏览器、放行,但恶意网页的跨站请求一定带 Origin)。
为什么普通网站不靠 Origin 校验当主防线
Origin/Referer 校验是少量站点会用的补充手段(GitHub 曾用「Origin 校验 + token 兜底」),但主流主防线是:
- SameSite Cookie:浏览器默认
SameSite=Lax(2020 年 Chromium 默认),恶意页面发起的跨站 POST 连登录 Cookie 都不带,CSRF 自动失效——大多数站点就靠这一条 - CSRF token:表单嵌服务端签发的 token,提交校验
- CORS 配置:管读取不挡执行,
Access-Control-Allow-Origin: *的站一抓一大把,从不是 CSRF 防线
dsh 是特殊形态:无认证、无 Cookie、无会话的本地特权 API——SameSite 没 Cookie 可保护,token 没有登录仪式可签发,只剩 Origin 头本身可校验。所以「裸 API + 本机监听」才不得不用 Origin 围栏。
四、局域网 IP 自动收集
caddy 只给站点地址列表里的 IP 签发证书,DHCP 换 IP 就断。我让 install 脚本渲染时自动收集 hostname -I 的全部非回环 IPv4(局域网/tailscale/docker bridge),拼进站点地址:
# 渲染 Caddyfile 时
for h in $(hostname -I | tr ' ' '\n' | grep -E '^[0-9.]+$' | grep -v '^127\.'); do
SITES="$SITES, https://$h:3080"
done
dsh-web.sh 启动时做同样的事,把每个 IP 都加进 --trusted-host——否则通过局域网 IP 访问时 /api 照样 403。换 IP 后重跑安装脚本 + 重启 dsh web 即可。
完整实现(模板 + 幂等安装脚本 + 服务脚本)在工具仓库:homelab/caddy/(Caddyfile.tmpl、install.sh,密钥不入库)和 dotfiles/.my_local/bin/dsh-web.sh。
五、局域网其他设备访问
本机浏览器直接可用(根证书已装系统信任库)。其他设备(手机/别的电脑)需要先信任 caddy 的根证书,否则握手成功但报 ERR_CERT_AUTHORITY_INVALID:
# 本机把根证书复制出来,起个临时 HTTP 服务供设备下载
sudo cp /usr/local/share/ca-certificates/Caddy_Local_Authority_*.crt /tmp/caddy-root.crt
python3 -m http.server 8000 -d /tmp
设备浏览器访问 http://192.168.5.68:8000/caddy-root.crt 下载并安装:
- Android:设置 → 安全 → 安装 CA 证书(网络与互联网 → 加密与凭据)
- iOS:下载 → 设置 → 已下载描述文件 → 安装,再在「证书信任设置」开启完全信任
- 桌面:导入系统信任库(Debian/Ubuntu:拷到
/usr/local/share/ca-certificates/后update-ca-certificates)
装完后访问 https://192.168.5.68:3080,输入用户名密码即可。
六、模型配置为什么必须从 127.0.0.1 访问
第五节做完后,UI 打开正常、聊天正常,但点开「模型配置」页却报错:
加载提供方目录失败: settings are unavailable in this browser
--trusted-host 192.168.5.68:3080 明明加了(见第一节),为什么 /api 还是不放行?——因为它没坏,这是 dsh 0.1.1-rc.2 起的设计:/api 被分成两类方法:
| 方法类 | 例子 | 围栏白名单 |
|---|---|---|
| 普通 | 会话创建、llm.providers 目录、聊天 | trustedHosts(--trusted-host)全部生效 |
| 配置平面 | settings.describe/update/replace/mutate、credentials.*、agentPreset.*、host.*、llm.discoverModels | 固定传「空列表」——只有 loopback 来源才放行 |
设计文档原话(大意):trustedHosts 只是 DNS-rebinding 围栏,不是认证层;配置平面(读也不行——settings.describe 会原样返回全部配置和密钥状态)在真正的认证层出现前,永远保持 loopback-same-origin。--host 0.0.0.0 也因此被官方禁用。
为什么反代了还是不行:dsh 不看 TCP 来源,只看 Host 头
反代后 dsh 收到的连接确实来自 127.0.0.1(caddy 转发),但 dsh 故意不信任 TCP 回环——因为 DNS rebinding 攻击的经典形态就是「socket 打到 loopback、Host 却是攻击者域名」,回环连接完全不能证明来源可信。而 caddy 的 reverse_proxy 默认把客户端原始 Host 原样透传(192.168.5.68:3080),dsh 看到的权威是非回环 → 配置平面 403。
实测证据(直接打 3081,模拟反代过来的请求):
# Host=局域网 IP,Origin=局域网 IP → 配置平面 403
curl -s -X POST -o /dev/null -w '%{http_code}\n' \
-H 'Host: 192.168.5.68:3080' -H 'Origin: https://192.168.5.68:3080' \
http://127.0.0.1:3081/api/settings.describe
# → 403
# 同一 TCP 回环、Host 换回 127.0.0.1 → 通过围栏(后面的 415 只是没带 content-type)
curl -s -X POST -o /dev/null -w '%{http_code}\n' \
-H 'Host: 127.0.0.1:3081' -H 'Origin: https://127.0.0.1:3081' \
http://127.0.0.1:3081/api/settings.describe
# → 415
解法:让浏览器 origin 回到 127.0.0.1
两个方案,都不需要改 dsh/caddy 上游:
方案 A(最推荐):本机配一次,全局域网共享。 在本机访问 https://127.0.0.1:3080 配置模型/凭证——配置存进 dsh 的共享配置文件,其他设备的浏览器 UI 读同一份,聊天照常。大多数场景到这里就结束了。
方案 B(其他设备上也要配模型):SSH 隧道,dotfiles/.my_local/bin/dsh-proxy.sh。 客户端起一条隧道,把远端 127.0.0.1:3080(caddy)转发成本地 127.0.0.1:3080,浏览器地址栏仍是回环,origin 回到 https://127.0.0.1:3080,一切放行(caddy basicauth 依然生效):
# 客户端(任何 Linux,systemd --user + ssh + iproute2)
dsh-proxy.sh start # 默认 peter@192.168.5.68,可用 $DSH_REMOTE / 参数覆盖
# 然后本机浏览器开 https://127.0.0.1:3080
dsh-proxy.sh [stop|restart|status|log]
内部实现就是一个 systemd-run --user 包起来的 ssh:
systemd-run --user --unit=dsh-proxy --collect \
--setenv=GPG_TTY=/dev/nonexistent \
--property=Restart=on-failure --property=RestartSec=5 \
-- ssh -N -L 3080:127.0.0.1:3080 \
-o ExitOnForwardFailure=yes -o StrictHostKeyChecking=accept-new \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
peter@192.168.5.68
几个细节:
GPG_TTY=/dev/nonexistent:ssh 可能触发 gpg-agent 口令提示,隧道环境没有终端,pinentry 会挂起无输出;失效时让它快速失败(报agent refused operation),自己在客户端终端解锁一次 ssh 密钥即可ExitOnForwardFailure:本地端口被占(如本机也在跑 caddy 3080)直接失败退出,不静默挂起ServerAliveInterval + Restart=on-failure:网络断了 ssh 快速退出(exit 255),systemd 自动重连StrictHostKeyChecking=accept-new:首次连接自动接受 host key,居家环境免交互
为什么不干脆让 caddy 改写 Host
加 header_up Host 127.0.0.1:3081 确实能骗过 Host 检查,但浏览器对同源 fetch 的 POST 会带 Origin: https://192.168.5.68:3080,Host 换成 127.0.0.1:3081 后 Origin 与 Host 不匹配 → 依旧 403。再 header_up -Origin 删掉 Origin 倒能过,但这就等于把 dsh 的配置平面围栏整体拆除——服务器对「任意来源的配置平面请求」放行,只剩 basicauth 一层(而 basicauth 本就挡不住浏览器自动附带的缓存凭据,参考第三节)。dsh 官方把这标为设计特性等着「真正的认证层」出现,不建议硬拆。
七、容器场景:监听「所有地址」,证书到底发给谁?
本节实测(caddy:2.10 容器 + -p 8000:8000)。结论一句话:证书发给「站点地址」,与监听地址无关。caddy 对同一端口自动以通配地址监听([::]:8000,也就是 0.0.0.0 的效果),但证书 SAN 只按你写进站点地址列表的标识符签发。所以「监听 0.0.0.0 证书是不是还能正确」答案取决于你写了什么:
| 站点地址 | 结果(实测) |
|---|---|
:8000 | 不签发任何证书,握手直接 internal error(同坑 2,2.6/2.10 行为一致) |
https://0.0.0.0:8000 | 危险:caddy 真的会签发证书,但 SAN 是字面 IP Address:0.0.0.0,真实 IP 访问全部验证失败 |
https://<容器IP>:8000 | SAN=容器 IP(如 172.17.0.2),签出来的是「容器内地址的证书」;宿主机/局域网用户访问用的是宿主 IP,至少 SNI 对不上、容器重建 IP 还变,永远用不上 |
https://<宿主LAN IP>:8000 | SAN=宿主 LAN IP(正确),但还差最后一公里,见下 |
为什么 https://IP 这种访问会失败——SNI 才是领证书的钥匙。 证书按站点地址签发,但 TLS 握手时客户端要发一个 SNI(Server Name Indication)告诉服务端「我要哪张证书」。实测各客户端对 IP 地址的行为并不一致:
| 客户端 | 访问 https://IP:8000 时发 SNI 吗 | 无 default_sni 时结果 |
|---|---|---|
| Chromium 系(Chrome/Edge/Android) | 不发(对域名才发) | ERR_SSL_PROTOCOL_ERROR |
| curl | 不发(对域名才发) | 000(TLS internal error),和 Chrome 站一边,最容易误导排查 |
| openssl s_client | 默认不发;-servername 显式发 | 不发 SNI 会被拒;显式 -servername IP 能拿到证书 |
caddy 2.10 对无 SNI 的 ClientHello 直接拒绝;老版 2.6.2 反而有兜底(发给首域证书——本机浏览器直接访问 https://127.0.0.1:3080 能工作正是靠它),2.7 引入 default_sni 后移除了这个兜底,所以容器版必须显式配置。
解法:全局 default_sni 指定无 SNI 时该给哪张证书:
{
default_sni 192.168.5.68
}
https://127.0.0.1:8000, https://localhost:8000, https://192.168.5.68:8000 {
tls internal
basicauth {
peter $2a$14$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
}
reverse_proxy 127.0.0.1:3081
}
加 default_sni 前后对照(同样配置、同一容器):
| 客户端 | 无 default_sni | 有 default_sni |
|---|---|---|
| curl(无 SNI) | 000 / internal error | 200,证书 SAN 正确 |
| chromium 打开 IP URL | ERR_SSL_PROTOCOL_ERROR | ERR_CERT_AUTHORITY_INVALID(握手已成功,只差 CA 信任) |
ERR_CERT_AUTHORITY_INVALID 意味着"证书和域名都对,只差设备信任根证书"。容器里的 CA 这样导出:
docker cp <容器名>:/data/caddy/pki/authorities/local/root.crt ./root.crt
openssl x509 -in root.crt -noout -subject -dates
# subject=CN=Caddy Local Authority - 2026 ECC Root,有效期 10 年
(容器内证书以容器用户写入,卷目录在宿主机侧常是 uid 1000/root 权限,直接 ls 可能没权限,用 docker cp 最省事;挂卷前 mkdir -p ./data && sudo chown 1000:1000 ./data 也能解决。)
容器场景几个经验:
- 别拿容器 IP 当站点地址——每次重建都会变,还会让 LAN 用户 SNI 对不上。固定写宿主机 LAN IP,或配内部域名更稳(浏览器对域名一定发 SNI,证书按域名签,不受 default_sni 限制)。
default_sni只能设一个值:LAN / tailscale 多个 IP 里只有它指定的那个能被"裸 IP 无 SNI"访问;其余 IP 要么换域名,要么接受 hostname 不匹配。- 内部证书叶证书默认只有 12h 有效期,长跑要拉长。注意 caddy 2.10 的全局块里没有
tls选项(实测报unrecognized global option: tls),lifetime 写在站点级:
https://192.168.5.68:8000 {
tls {
issuer internal {
lifetime 8760h # 默认仅 12h
}
}
basicauth { ... }
reverse_proxy 127.0.0.1:3081
}
验证
curl -s -o /dev/null -w '%{http_code}\n' https://192.168.5.68:3080/ # 无凭据 → 401
curl -s -u peter:密码 -o /dev/null -w '%{http_code}\n' https://192.168.5.68:3080/ # → 200
curl -s -u peter:密码 -H 'Origin: https://evil.example' https://192.168.5.68:3080/api/ # → 403
总结
- 端口让位:caddy 占 3080,dsh web 挪到 3081
- Debian 版 caddy 两个差异:
basicauth指令名、站点地址必须https://host:port完整形式 - 内部根证书手动信任:
sudo env HOME=/var/lib/caddy caddy trust - dsh 的
/api围栏校验浏览器 Origin(服务端 CSRF 防护,不是 CORS),代理不改 Origin,必须用--trusted-host显式放行反代入口(包括局域网 IP);围栏也是 basicauth 拦不住的本机 CSRF 的最后防线 - 局域网设备装一次根证书即可永久使用,换 IP 重跑脚本重新签发
- 配置模型/凭证永远走 loopback origin:本机
https://127.0.0.1:3080,或客户端dsh-proxy.shSSH 隧道转发;--trusted-host帮不了配置平面(dsh ≥0.1.1-rc.2 设计如此),也别用 caddy 改写 Host/删 Origin 硬拆围栏 - 容器场景:证书按「站点地址」(勿用容器 IP/0.0.0.0),配全局
default_sni兜底 Chromium 系的无 SNI 请求,CA 从卷目录/data/caddy/pki/authorities/local/root.crt导出