@hi-dsh/tailscale
v0.1.0
Published
Tailnet-only remote access for a loopback-bound dsh web server: identity-gated loopback proxy plus an automatic tailscale serve HTTPS route. 让 dsh 在不暴露任何对外端口的前提下,从你的 Tailscale 虚拟局域网内安全访问。
Maintainers
Readme
@hi-dsh/tailscale
让 dsh web 只对你的 Tailscale 虚拟局域网开放——装完即可用,不需要任何配置项、不需要复制 token、不需要加 --trusted-host。
装好后打开 https://<你的节点>.<你的tailnet>.ts.net/ 就是 DSH 界面。没有加入你 tailnet 的计算机在网络上根本碰不到它。
它解决的是什么问题
dsh web 默认只监听 127.0.0.1,而且故意拒绝 --host 0.0.0.0:它后面跑的是会执行 shell 命令的 agent,一个可达的源站就等于远程代码执行。所以"安全地远程访问"只有一条路——源站留在环回地址,前面放一道门。
但这道门不能只靠 tailscale serve 裸转发,因为 DSH 自己还有两道门:
- Host/Origin 信任栅栏(
/api):只接受 loopback 的 Host,或显式声明为可信的权威。tailscale serve会把Host: <节点>.<tailnet>.ts.net原样透传,于是/api直接 403——这是"我明明配了 serve 却打不开"的根因。 - 浏览器会话认证:每个进程随机生成一个 launch token,用一个
GET /?token=…换发绑定 authority 的签名 Cookie,之后/、/api、WebSocket 都要靠它。没有它就 401。
本插件把这两件事一起解决,且不修改 DSH 任何内部状态:它在进程内起一个只监听 127.0.0.1 的代理,验证 Tailscale 身份之后才把 Host 重写成环回权威,并复用 DSH 自己的 token 交换流程完成首次配对。
安全模型
四层,逐层独立成立:
| 层 | 机制 | 挡住什么 |
|---|---|---|
| 网络 | tailscale serve 的监听只存在于 tailnet 接口上,且只应答 Host 为本节点 MagicDNS 名的请求(直接用 tailnet IP 访问返回 400) | 未加入 tailnet 的计算机(你要求的那条硬保证) |
| 身份 | 每个请求必须带 tailscaled 注入的 tailscale-user-login;再用 tailscale whois 回查 x-forwarded-for 的归属登录名并核对;默认白名单为本机 Tailscale 账号所有者 | 同一 tailnet 内的其他账号;伪造头(本机之外的进程无法产生该头) |
| 传输 | 代理只绑 127.0.0.1,不新开任何对外端口;TLS 由 Tailscale 用真实证书终止 | 局域网扫描、端口暴露 |
| 应用 | DSH 自己的 Host 栅栏与会话 Cookie 认证原样保留,代理只是替已验证身份重放其配对流程 | CSRF / DNS rebinding;未认证的 API 访问 |
需要显式说明的边界:本机进程仍等同于完全信任(环回监听上的身份头可被本机代码伪造)。这不增加任何新暴露面——本机代码本来就能直连 127.0.0.1:3080。真正的信任边界是"tailnet 成员"和"本机",两者都在你手里。
安装
# 1. 装进 web profile
dsh plugin --profile web add @hi-dsh/tailscale
# (本地开发时改用仓库目录:dsh plugin --profile web add link:/path/to/hi-dsh-tailscale)
# 2. 把包名加入 profile 的 dsh.profile.bundles 数组
# 编辑 ~/.dsh/profiles/web/package.json:
# "dsh": { "profile": { "bundles": [
# "@deepseek-ai/dsh-base",
# "@deepseek-ai/dsh-web-app",
# "@hi-dsh/tailscale"
# ] } }
# 3. 重启 dsh web
dsh web启动日志会出现一行:
dsh tailscale: https://your-node.your-tailnet.ts.net/ (tailnet only; identity: [email protected])前置条件只有两个,都与你是否已经会用 Tailscale 一致:
- 本机装了 Tailscale 并已登录;
- tailnet 已启用 MagicDNS 与 HTTPS 证书(Tailscale 管理台 → DNS → HTTPS Certificates)。
未启用时
tailscale serve --https会失败,插件会记录原因并保持关闭状态,不会影响 DSH 正常启动。
使用
用任何已加入 tailnet 的设备(手机、笔记本)打开上面那行 URL 即可。第一次会自动跳转一次完成配对,之后该浏览器 30 天内免配对。
- 不需要把 token 发到手机上;
- 不需要
--trusted-host; - 不需要改 Tailscale ACL(要按人收窄见下方配置);
- 不需要固定端口。
配置(全部可选)
在 ~/.dsh/profiles/web/cordis.patch.yml 里按 id 覆盖:
- id: hi-dsh-tailscale
config:
httpsPorts: [8443] # 默认 [443, 8443, 4443],按序自检回退
allowLogins: [[email protected], [email protected]]
# listenPort: 3081 # 默认 0 = 自动(优先用 dsh 端口 +1…+8)
# verifyWhois: true # 默认开;关掉只校验身份头
# tailscaleBin: /usr/local/bin/tailscale
# printUrl: true
# replaceDeadRoutes: true # 默认开:接管指向已死环回端口的旧路由
# logRequests: true # 默认开:每次经代理的请求打一行(方法/路径/状态/耗时/字节)
# enabled: false # 临时关闭整个插件allowLogins 不写时自动取本机 Tailscale 账号所有者(tailscale status --json 的 Self.UserID → User[].LoginName),所以个人 tailnet 无需任何配置。
关于端口:为什么不是简单的 --https=443
插件不会盲目占用 443。对每个候选端口它都做一件事:配好之后用真实 HTTPS 请求探测自己的探针路径,只有确认应答来自本代理才采用,否则立刻回滚换下一个。
这不是过度设计。在开发这台机器上,Docker 正监听 *:443:tailscale serve --https=443 会"配置成功",但 TLS 握手被 Docker 接走并重置,远端完全连不上——没有自检就会静默失效。插件实测行为:
port 443 : probe 000 → rolled back
port 8443 : probe 303 → claimed同理,若某端口上已存在指向其他存活服务的 serve 路由,插件会明确跳过并打印冲突对象,绝不覆盖别人的服务。
幂等与生命周期
- 监听端口优先取
dsh端口+1…+8,因此重启后通常复用同一个端口,serve 路由无需改动。 - 进程被强杀(SIGKILL)导致路由残留时,下次启动会识别出"指向已死环回端口"的旧路由并接管(
replaceDeadRoutes)。 - 插件停止时(DSH 退出、profile 重载)会关闭监听,并撤掉本次由它安装的 serve 路由;若复用的是已存在的路由则保持不动。
- 客户端断开会被传导到上游:浏览器放弃一个流式响应(手机上很常见——链路抖动后重连)时,代理立即结束对应的上游请求。没有这一步,每次重连都会在上游留下一个永远在产生数据、却没人读取的流,越积越多。
test/abort-propagation.mjs用一个永不结束的源站锁住这条行为。
故障排查
| 现象 | 原因与处理 |
|---|---|
| 启动日志没有 dsh tailscale: 行 | 未在 web profile 挂载,或 enabled: false;确认 dsh.profile.bundles 里有它 |
| 日志出现 tailscale CLI not found | 没装 Tailscale,或 CLI 不在 PATH;macOS 用 GUI 安装时插件会自动找 /Applications/Tailscale.app/Contents/MacOS/Tailscale |
| this node has no MagicDNS name | Tailscale 未登录/未连接,或 tailnet 未启用 MagicDNS |
| no usable HTTPS port among … | 候选端口都被占用或被自检否决;看上面几行日志里每个端口的探测结果,必要时换 httpsPorts |
| 端口上显示 already serves …; leaving it alone | 该端口有别的服务;换端口,或先 tailscale serve --https=<port> off |
| 浏览器 403 且页面显示 @hi-dsh/tailscale: refused (…) | 身份没通过:换用白名单内的 Tailscale 账号、确认 tailscale whois 能解析来源地址 |
| 浏览器 401 | 走到了源站但没配对成功;清掉该站点 Cookie 后重新打开 URL |
已知边界
- 不做 Funnel:插件只配置
serve(仅 tailnet)。把 DSH 暴露到公网与它的安全前提(源站本应不可达)直接冲突。 - 多实例:同一节点上跑多个
dsh web时,先启动的实例会占用 443/8443/4443;后启动的实例会发现端口上是指向存活服务的路由并跳过,日志会说明冲突对象。可显式给第二个实例配不同的httpsPorts。 - 依赖 DSH 公开契约:
connection.authenticatedUrl()(取 launch token)与/api的 Host 栅栏语义。它不读写私有字段,也不伪造 Cookie。
English summary
@hi-dsh/tailscale gives a loopback-bound dsh web a tailnet-only HTTPS URL with no configuration. It starts an identity-gated proxy on 127.0.0.1, points a tailscale serve --https route at it, and prints https://<node>.<tailnet>.ts.net/. Every request must carry a tailscaled-issued identity header, cross-checked with tailscale whois; only then is Host rewritten to the loopback origin so DSH's own Host/Origin fence and browser-session authentication still apply. First visit pairs itself through DSH's existing token exchange, so there is no token to copy and no --trusted-host to add. Machines outside the tailnet cannot reach it at all.
License
MIT
