dsh-tailscale-access
Verifieddsh-tailscale-access · v0.1.0 · MIT · Web UI
DeepSeek Harness plugin: one switch that puts the Web GUI on your phone — Tailscale identity or a cloudflared quick tunnel — behind a loopback-rewriting front door that leaves the harness bound to 127.0.0.1.
Install
dsh plugin add dsh-tailscale-access Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
dsh-tailscale-access
[!IMPORTANT] 早期版本 —— 打开开关前,值得先花两分钟读这一段。
本插件目前是
0.1.x,仍在持续测试中:有自动化测试覆盖,也在作者自己的机器上日常使用,但接口、默认值和行为仍可能在版本之间调整,也没有针对恶意网络或多用户共享主机做加固。它做的事正如其名:为"能在本机执行命令的 GUI"开一个入口。请把这个入口当作 SSH 端口来对待 —— 只在自己的 tailnet 内使用,先读下面的安全模型,确认这个取舍是你愿意承担的。如果今天还不想承担,把开关留着不动就好,没有任何代价。
欢迎提问、报 bug,也欢迎告诉我"这里的行为出乎意料"。
一个 DeepSeek Harness(dsh)插件:在插件页打开一个开关,就能用手机或任何电脑通过浏览器访问这台机器上跑的 harness —— 不需要公网 IP、不需要端口映射、不改 dsh 的监听地址。dsh 自始至终只监听 127.0.0.1。
手机 / 任何电脑
│ tailscale serve(tailnet 内,端到端加密)
│ 或 cloudflared quick tunnel(公网 HTTPS)
▼
本地重写代理 127.0.0.1:8787
│ Host → 127.0.0.1:3080;丢弃 Origin/Referer/Sec-Fetch-Site;
│ 注入 harness 自己的浏览器 cookie
▼
dsh web 127.0.0.1:3080(不知道外面有人)
npm 包名是 dsh-tailscale-access;插件的 settings section、HTTP 路由与状态文件名仍沿用 remote-access。
环境要求
- DSH 0.1.5-rc.2(
next)或 0.1.6-alpha.2+(alpha)。卡片同时注册进两个 slot:0.1.6 的侧边栏「插件」页用plugins.item,0.1.5-rc.2 的「设置 → 插件」页用按 namespace 作 key 的settings.plugin.item;两个页面都不会渲染对方的 slot,所以只会出现一张卡。更老的版本(如 npm 上仍标latest的0.0.1-rc.1)没有本插件能用的卡片 slot:此时打开开关会明确报DSH_VERSION_UNSUPPORTED,而不是跑一个没人看得见的面板。包内已用engines.dsh声明。 - Node >= 20(
engines.node)。插件自身只用 Node 内置模块。 tailscale模式:装好并登录 tailscale,tailnet 打开 HTTPS 证书,且有配置tailscale serve的权限(Linux 上即 tailscale operator)。quick模式(实验性,面板暂不提供):cloudflared。allowDownload为 true 时首次使用会自动下载到$DSH_HOME/cache/cloudflared,也可以用cloudflaredPath指向已有二进制。none模式:不需要额外组件。
安装
用「插件」页安装(推荐)
在 DSH 0.1.6-alpha.2+ 里打开侧边栏的 插件(Plugins) 页,点 添加插件,填入包名并安装(0.1.5-rc.2 走「设置 → 插件」):
dsh-tailscale-access
装完点 立即启用 即可。这一步会同时写好 profile 依赖和 dsh.profile.bundles 条目。
手动安装
把包装进 web profile(profile 位于 ~/.dsh/profiles/web):
cd ~/.dsh/profiles/web
pnpm add dsh-tailscale-access
再把包名加进 profile manifest 的 bundle 列表:
// ~/.dsh/profiles/web/package.json
{
"dependencies": {
"dsh-tailscale-access": "^0.1.0"
},
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"@deepseek-ai/dsh-web-app",
"dsh-tailscale-access"
]
}
}
}
profile 里 "patchReload": "live" 时改 manifest 会自动重载;否则重启 dsh web。在 profile 目录外也可以用 dsh plugin --profile web add dsh-tailscale-access,它做的是同一个 pnpm 步骤。
打开面板
侧边栏 → 插件 → Remote Access(官方分组)。它是配置页,不在「设置 → 插件」里 —— 后者是只读的插件清单。
三种模式
| 模式 | 入口 | 认证 | 说明 |
|---|---|---|---|
tailscale(默认) |
tailscale serve --https=443 + 本地代理 |
tailnet 身份(Tailscale-User-Login + 允许名单) |
域名固定 https://<machine>.<tailnet>.ts.net、端到端加密、第三方看不到明文;不需要口令 |
quick(实验性) |
cloudflared quick tunnel | 口令(+ 可选来源白名单) | 仍在测试中,面板暂不提供 —— 想试就在设置段里写 mode: quick。公网 HTTPS、零安装、域名每次变;必须显式确认风险 |
none |
只开 loopback 本地代理,不开隧道 | 口令 | 调试用:验证代理链路与登录流程;本身不对外发布任何东西 |
同时只允许一个模式生效;切换模式会先拆掉旧入口再建新入口。tailscale 模式的入口地址来自节点的 MagicDNS 名(https://<machine>.<tailnet>.ts.net);本地代理默认端口 8787,dsh 默认 3080。
Funnel 与 tagged node 不带身份头,一律拒绝。
安全模型
这个插件是故意提供 dsh 自己拒绝提供的外网入口:上游不允许绑定网络接口,harness 始终只监听 loopback。入口默认关闭,请按需开启 —— 入口对面是一个能执行任意命令的 GUI。
- 身份优先。
tailscale模式下唯一凭据是Tailscale-User-Login,且只在请求来自 loopback(tailscaled 代理)且启用代理头时才被采信。客户端伪造的Tailscale-User-*一律忽略;缺身份头的请求返回403并计入denied。 - 允许名单 fail-closed。
allowedUsers为空时默认只允许你自己(启用时从tailscale status --json的Self.UserID+User映射解析 login);解析不出来就拒绝启用。不允许"空 = 放行所有人"。 - 口令模式。
quick与none都要求口令(至少 8 位),quick还要求acknowledgeRisk: true。会话是 HMAC 签名的 cookie(HttpOnly、SameSite=Lax、HTTPS 时Secure),有效期由sessionHours控制(默认 72 小时)。登录失败按 IP 限速:5 分钟内 8 次失败封禁 15 分钟。quick还可以用allowedCidrs按隧道传来的CF-Connecting-IP限制来源。 - 只绑 loopback。 本地代理只监听
127.0.0.1,dsh 始终只监听127.0.0.1:3080;不需要--trusted-host,浏览器全程不接触 harness token。 - 代理层做什么。 把
Host改写为127.0.0.1:<dsh 端口>,丢弃Origin/Referer/Sec-Fetch-Site,用服务端换来的 harness 浏览器 cookie 替换Cookie(上游 401 时自动重换一次),响应方向剥掉Set-Cookie与逐跳头,HTTP 与 WebSocket 升级都转发,客户端地址由代理自己解析。tailscale模式下通过白名单校验的身份头会转发给上游;none/quick模式下一律剥离。 - 代理层不信什么。 客户端自带的
CF-Connecting-IP、X-Forwarded-*、Forwarded、True-Client-IP一律丢弃后重建(只在隧道模式且请求来自 loopback 时才采信);伪造的Tailscale-User-*一律忽略;tailscale模式下本地会话 cookie 永远不作为身份。 - 关闭即收口:
tailscale serve reset、关代理、清会话,不留监听。 - 能进这个 GUI 的人 = 能批准 = 能授权命令执行。 所以
allowedUsers白名单 / 口令就是"谁有权批准"的边界。
配置
Remote Access 卡片是本插件唯一的 UI,没有单独的设置表单,也没有 CLI。卡片每 1.5 秒轮询 GET /remote-access/status.json,状态、入口 URL 与客户端列表无需刷新页面即可更新。
| 字段 | 类型 | 默认 | 作用 |
|---|---|---|---|
enabled |
boolean | false |
总开关。为 true 时每次 harness 启动自动恢复这个入口(不是操作系统开机自启)。 |
mode |
tailscale | quick | none |
tailscale |
入口方式。 |
port |
number | 8787 |
本地代理端口。 |
sessionHours |
number | 72 |
口令模式的会话有效期。 |
allowedUsers |
string[] | [] |
tailscale 身份白名单;空 = 只允许本节点自己的 login。 |
allowedCidrs |
string[] | [] |
quick / none 的来源地址白名单。 |
cloudflaredPath |
string | "" |
指定 cloudflared 二进制;空则先搜 PATH,再搜缓存目录。 |
allowDownload |
boolean | true |
允许 quick 模式首次使用时自动下载 cloudflared。仅 settings section。 |
acknowledgeRisk |
boolean | false |
quick 模式必须为 true 才允许启用。 |
audit |
boolean | true |
写审计日志(登录成功/失败、被拒、被踢)。 |
statusPage |
boolean | true |
提供只读状态页。 |
password |
string(secret) | — | quick / none 必需,至少 8 位。仅 settings section。 |
bind |
string | "" |
高级项:覆盖本地代理监听地址。留空即 loopback;其它取值会扩大监听面、失去 loopback 保证。仅 settings section。 |
password、allowDownload、bind 在卡片里没有控件,需要直接改 settings section。
总开关与模式下拉立即写入;文本、数字、列表字段暂存 + Save(空值 = 回默认值)。卡片会标注用户层是否覆盖了默认值,并显示运行阶段(已停止 / 启动中 / 运行中 / 失败);前置检查失败时直接显示可复制的修复命令。
口令要写进插件的 settings section。卡片里没有口令输入框,也不会读回口令;状态接口只报告是否已设置(passwordSet):
# ~/.dsh/settings.yaml
remote-access:
mode: quick
password: "<至少 8 位,建议 20+ 随机>"
acknowledgeRisk: true
卡片还会显示机器名、tailnet 名、tailscale 版本与 BackendState,已连接客户端列表(身份或 IP、首次连接、最后活跃、WebSocket 数、请求数)以及每行的踢掉,并保留一份有界的最近事件。重启隧道 不重启 harness 即可重建入口。关闭开关立即生效,不留监听。
远端页面:DSH 的设置限制
DSH 把"页面是否拥有 Host"绑定在页面 authority 上(isLoopback = localhost / [::1] / 127.0.0.0/8)。在非 loopback 页面上设置镜像跑在 memory 模式:不读设置文档,写入被丢弃且静默成功。所以手机经 tailscale / cloudflared 访问时,DSH 自身的语言、主题、各设置页都会显示默认值、改动不生效(这是 DSH 的既有设计,不是本插件的转发问题 —— 转发层已逐项验证:主页注入逐字节相同、SSE/WS/POST 均正常、零失败请求)。
本插件因此自带配置读写接口,这些 host 路由由代理从 loopback 转发,所以在远端页面上同样可用:
- 读:
GET /remote-access/status.json的config(host 的有效配置,永不含口令,只有passwordSet布尔;另给configOverridden表示是否偏离默认)。 - 写:
POST /remote-access/action的{action:"set",patch}/{action:"unset",fields}/{action:"reset"}。校验失败返回 400 + code,不是 500。
settings section 仍是唯一真源,接口只是另一个写入口。结果:Remote Access 卡片在手机上和在本机一样可用(显示真实状态、能真正改配置、刷新不丢)。DSH 其它设置页仍受上面的限制。
远端页面的「读取宿主设置」按钮
非 loopback 页面上,卡片会显示 远端页面:读取宿主设置 一节(本机页面上隐藏 —— 本机页面本来就有设置文档)。点 读取宿主设置并应用 会请求 GET /remote-access/host-settings.json(宿主设置文档,敏感值已脱敏),把宿主的语言、外观与字号应用到当前页面。文档里的其它设置只报告、不应用 —— 那些偏好属于各自的插件。读取失败会显示原因。
远端审批(重要)
需要审批的操作(例如写工作区外的文件)可以在手机上批准:审批走的是同一条被代理的通道(host 侧 approval/request waterfall → 浏览器侧 ui-approval 的 remote event),待审批状态是会话状态,任何跟随该会话的客户端都能看到面板。三条必须知道的规则:
- 至少要有客户端在线:没有任何 answerer 时请求 fail-closed(直接拒绝),不会排队等你回来。本机浏览器开着也算。
- 谁先答谁生效:本机与手机会同时看到同一个请求,晚答被丢弃;请求被 turn 取消后晚答同样作废。
- 手机被挂起/断连就不会等你:要保活(页面留在前台)。
tailscale模式是 HTTPS(secure context),浏览器能力完整;本版本没有明文回退。
推论:能进这个 GUI 的人 = 能批准 = 能授权命令执行。所以 allowedUsers 白名单 / 口令就是"谁有权批准"的边界。
排错
卡片提示 "not operator"
Linux 上默认只有 root 能改 tailscale serve 配置。把当前用户设为 tailscale operator 即可(一次即可,之后无需 sudo)。卡片会显示确切命令:
sudo tailscale set --operator=$USER
tailnet 没开 HTTPS 证书
tailscale 模式始终发布 HTTPS 443,没有明文 tailscale serve --tcp 回退:明文 HTTP 页面不是 secure browser context,DSH 客户端插件里调用 crypto.randomUUID() 的地方会直接坏掉。去 Tailscale admin console → DNS → HTTPS Certificates 打开,等证书签发后重新打开开关。实在开不了就用 quick 模式(cloudflared 自带 HTTPS)。卡片会显示失败原因,提示里指明 admin console 的设置项,并给出可复制的 tailscale serve --bg --https=443 http://127.0.0.1:<port> 命令。
tailscale 未安装 / 未运行 / 未登录
curl -fsSL https://tailscale.com/install.sh | sh # Linux
brew install tailscale # macOS
winget install --exact --id Tailscale.Tailscale # Windows
sudo systemctl enable --now tailscaled # 守护进程没跑
sudo tailscale up # 没登录
tailscale status # 确认节点状态
tailscale ip -4 # 节点的 tailnet 地址
tailscale 给出登录链接时,卡片显示的提示里会带上该 URL。卡片能区分"未安装 / 未运行 / 未登录 / 不是 operator",前置条件不满足时拒绝启用。每种失败都会带上一句人话结论和一条可复制命令。
cloudflared 找不到或下载失败
allowDownload 为 true 时,quick 模式会把 cloudflared 下载到 $DSH_HOME/cache/cloudflared。下载失败(无网络、没有对应平台的构建、代理问题)时,自己装一个 cloudflared,并把 cloudflaredPath 指向它。
本地端口被占用
配置的 port 报 EADDRINUSE。在卡片里换一个 port,或停掉占用者:
ss -ltn | grep 8787
有残留规则 / 启用后没有发布
上一次运行可能留下 serve 规则。清掉再启用:
tailscale serve status
tailscale serve reset
收集诊断信息
curl -s http://127.0.0.1:8787/remote-access/status.json | head -c 2000
tailscale serve status
cat ~/.dsh/remote-access.json
tail -40 ~/.dsh/remote-access-audit.log
ss -ltn | grep -E '3080|8787'
dsh 控制台里 remote-access: 开头的日志行有完整错误。只读状态页 /remote-access/status 在手机上经隧道也能打开。
文件与接口
| 路径 | 内容 |
|---|---|
$DSH_HOME/remote-access.json |
运行状态快照(enabled、mode、url、port、客户端数、denied、lastError、updatedAt)。 |
$DSH_HOME/remote-access-audit.log |
审计日志(JSON 行,权限 0600,超过 5 MB 轮转为 .1),audit 为 true 时写入。 |
$DSH_HOME/cache/cloudflared/ |
quick 模式自动下载的 cloudflared 二进制。 |
$DSH_HOME/settings.yaml 里的 settings section remote-access |
配置,唯一真源。 |
所有 host 路由都要求 loopback 来源,所以即使 dsh 将来绑得更宽也不会泄漏到网络;代理从 loopback 转发,因此远端页面能用到它们。
| 路由 | 用途 |
|---|---|
GET /remote-access/status.json |
状态 + 不含口令的有效 config。 |
POST /remote-access/action |
kick、restart、set、unset、reset。需要 content-type: application/json 与 x-remote-access-action: 1 头,随机网页无法驱动它(CSRF)。 |
GET /remote-access/status |
只读状态页(statusPage: true)。 |
GET /remote-access/host-settings.json |
宿主设置文档(已脱敏),供远端页面按钮使用。 |
动作请求体:
{"action": "kick", "id": "<clients[].id>"}
{"action": "restart"}
{"action": "set", "patch": {"port": 9000}}
{"action": "unset", "fields": ["port"]}
{"action": "reset"}
已知限制
- 本插件提供的是 dsh 自己拒绝提供的外网入口,默认关闭,请有意开启。
quick模式的 URL 公网可解析、每次启动都变,扫描器会发现它;唯一防线是口令(外加可选 CIDR),不建议长期开着 quick。固定域名的 named tunnel + Cloudflare Access 未实现。- 口令在 settings 文档里是明文存储,对外只报告
passwordSet布尔值;只存 salt + scrypt 哈希未实现。 none/quick模式下口令在 loopback 这一段是明文 HTTP;对外一段是 HTTPS(tailscale / cloudflared)。- 远端页面上 DSH 自身的设置页仍显示默认值;只有语言、外观与字号能通过上面的按钮应用。
- Tailscale Funnel 与 tagged node 不带身份头,一律拒绝。
- 没有账号体系或权限分级:白名单/口令是单一闸门,过了闸门的人就能通过 GUI 执行命令。
- 远端审批要求至少有一个客户端在线,且先答先得;手机被挂起就不会再回答。
- dsh 自身的安全说明依然适用:harness 未做安全审计,沙箱/审批不保证隔离。
开发与测试
npm install # 只依赖 @deepseek-ai/schemastery
npm test # node --test tests/
node --test tests/routes.test.mjs
167 个自动化测试,全部用 node:test,使用假上游/假二进制;需要网络的用例用 DSH_TEST_NETWORK 守卫。
| 测试文件 | 覆盖 |
|---|---|
tests/config.test.mjs |
配置归一化、绑定解析、口令要求、错误结构 |
tests/routes.test.mjs |
host 路由:loopback 守卫、CSRF 头、动作、状态页、host-settings 路由 |
tests/proxy.test.mjs |
转发层:头改写、cookie 注入、WS、限速、身份规则 |
tests/tailscale.test.mjs |
tailscale 状态机与 serve 编排 |
tests/cloudflared.test.mjs、tests/supervisor.test.mjs |
隧道进程、退避、原子写状态 |
tests/orchestration.test.mjs、tests/integration.test.mjs |
生命周期、模式切换、对假上游的端到端接线 |
tests/client-bundle.test.mjs |
客户端卡片 bundle 形状与行为 |
仓库里另有 ACCEPTANCE.zh.md(人工验收清单)。
验证状态
- 卡片已在真实 DSH 0.1.6-alpha.2 上用无头浏览器验证:能注册进
plugins.item、能打开完整页面、轮询状态接口且无控制台报错。 - 尚未在真实 GUI 中跑过:写入路径(开关/下拉 + Save 往返)、踢人、切换语言、窄屏/手机布局。
tailscale serve实机链路尚未在装好 tailscale 的机器上端到端跑过;状态机由假二进制的单元测试覆盖。- 经隧道的远端审批目前是文档所述行为,尚未端到端验证。
许可证
MIT,见 LICENSE。