caddy 反代 dsh web,让局域网用户也能访问

八月 24, 2026 [网络与安全] #caddy #dsh

dsh(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.example403

--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 兜底」),但主流主防线是:

  1. SameSite Cookie:浏览器默认 SameSite=Lax(2020 年 Chromium 默认),恶意页面发起的跨站 POST 连登录 Cookie 都不带,CSRF 自动失效——大多数站点就靠这一条
  2. CSRF token:表单嵌服务端签发的 token,提交校验
  3. 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 下载并安装:

装完后访问 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

几个细节:

为什么不干脆让 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>:8000SAN=容器 IP(如 172.17.0.2),签出来的是「容器内地址的证书」;宿主机/局域网用户访问用的是宿主 IP,至少 SNI 对不上、容器重建 IP 还变,永远用不上
https://<宿主LAN IP>:8000SAN=宿主 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 error200,证书 SAN 正确
chromium 打开 IP URLERR_SSL_PROTOCOL_ERRORERR_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 也能解决。)

容器场景几个经验:

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

总结