Bundle
dsh-sakurafrp
手机 ↔ 电脑互通(SakuraFrp 隧道 + dsh-mobile 移动网关)的图形化管理插件
- Source
- ArimaKana-Akane
- License
- MIT
- Updated
- Updated 3 hours ago
Readme
# dsh-sakurafrp
> [!WARNING]
> **本仓库是 vibe coding 产物。** 代码由作者与 AI(DeepSeek Harness 会话)对话生成、
> 多轮迭代而来,**没有经过人工逐行审计**,也只在作者本机环境(WSL2 + dsh `0.1.5-rc.1` 系列)
> 实测过。请自行审阅后再使用;**不要直接用于生产或安全敏感场景**。
> 代码按 MIT「按原样(AS IS)」提供,不附带任何担保,风险自负。
> A vibe-coded satellite plugin for [`dsh-mobile`](https://github.com/saya-ch/dsh-mobile):
> manage the **phone ↔ desktop** link (SakuraFrp TCP tunnel + dsh-mobile gateway) entirely
> from DSH's own UI — status, gateway on/off, one-time pairing QR, device revoke, self-heal.
把「手机通过 SakuraFrp 隧道访问电脑上的 DSH」这条链路**图形化**:
原先要跑脚本、改 YAML、盯 systemd 才能完成的事,现在在 DSH 会话头部点一个手机图标就能做。
---
## 1. 它做什么
| 能力 | 说明 |
|---|---|
| **链路状态一屏可见** | 网关是否在跑、监听端口、公网入口、四个公网节点的**实测延迟**、已配对设备数、外部守护服务状态 |
| **一键开关手机访问** | 直接控制 dsh-mobile 的移动网关(等价于 `POST /api/mobile-access/lan/control`);**关闭会留下抑制标记**,自愈不再把它拉回来 |
| **配对二维码** | 申请一次性配对窗口(默认 5 分钟有效),二维码与链接**内联返回**,手机相机/浏览器扫码即可 |
| **设备管理** | 逐台撤销、一键清空(清空会带上游要求的 `confirm:true`);配合 `maxDevices` 实现「只信任某一台设备」 |
| **网关自愈** | 每 15 秒检查移动网关,发现被静默关闭(已知问题)就重新拉起 —— **但用户主动关闭时让路**(见下方说明) |
| **公网可达性探测** | 每 60 秒并发探测主入口 + 三个运营商前缀节点,面板直接显示谁通谁不通 |
| **外部守护开关** | 可启停 `dsh-mobile-lan-watchdog.service`(冗余层,插件自带自愈已能独立兜底;它同样尊重「用户已关闭」标记) |
> **「关得掉」是硬要求。** 早期版本的自愈是无条件的:你关掉手机访问后,15 秒内它就会被
> 自动拉起来,连 `dsh-mobile` 自己持久化的 `enabled:false` 偏好也一并被覆盖——表现为
> 「根本关不掉」。现在点「关闭手机访问」会写一个抑制标记(默认
> `$DSH_HOME/mobile-access/.gateway-user-disabled`),插件自愈与外部 watchdog 都会跳过;
> 面板也会显示「你已主动关闭…」。点「开启手机访问」即删除标记、恢复自愈。
入口:DSH 会话头部工具区的**手机图标按钮**(`order: -15`,位于「重启」与「打开文件管理器」之间)。
手机端也能打开这个面板,但**只能看状态**(原因见第 6 节)。
---
## 2. 依赖声明(重要)
### 2.1 运行期硬依赖:`dsh-mobile` 插件
本插件**不自己实现任何网关**,它只是 `dsh-mobile` 的移动访问网关的图形化管理壳。
没有 `dsh-mobile` 时:`/status` 会显示网关无响应,`/pair` 会报「网关未启动或 dsh-mobile 未启用」。
它依赖 `dsh-mobile` 提供的这些**本机(loopback)管理接口**:
| 方法 | 路径 | 用途 |
|---|---|---|
| GET/POST | `/api/mobile-access/lan/control` | 网关开关(`{running: bool}`) |
| GET | `/api/mobile-access/lan/status` | 网关/配对/设备数/资源占用 |
| GET | `/api/mobile-access/lan/devices` | 设备列表 |
| POST | `/api/mobile-access/lan/devices/revoke` | 撤销设备(`{deviceId}`,32 位小写 hex) |
| POST | `/api/mobile-access/lan/devices/reset` | 清空设备 |
| POST | `/api/mobile-access/lan/pairing/open` | 申请一次性配对窗口,返回 `pairUrl` + `qrSvg` |
### 2.2 运行期硬依赖:SakuraFrp 隧道(非 npm)
公网入口由 **SakuraFrp 启动器常驻的 `frpc`** 提供。本插件**不启动、不配置、也绝不接触访问密钥**。
面板预建一条 **TCP 隧道**,并且:
- 隧道**远程端口必须等于** profile 里 `mobile-access.listenPort`(插件硬约束:显式 authority 的端口必须等于监听端口);
- 隧道**不要设置「访问密码」**——那会让 frpc 接管 TLS 去渲染认证页,破坏端到端自签证书与 Host 校验;
- Windows 端 frpc 经 WSL2 `networkingMode=mirrored` 的 localhost 直通,回连 WSL 回环监听。
### 2.3 运行期软依赖
| 项目 | 必需 | 用途 |
|---|---|---|
| `dsh-mobile-lan-watchdog.service`(本仓库 `scripts/dsh-mobile-lan-watchdog.sh`) | 否 | 网关被静默关闭时的第二层自愈;插件自带 15s 自愈已可独立兜底 |
| `systemctl --user` | 否 | 外部守护服务的启停 |
### 2.4 平台与版本
| 项目 | 要求 |
|---|---|
| 操作系统 | Linux / WSL2(插件用 `systemctl --user`、路径按 POSIX 拼) |
| Node | `>= 20`(用到全局 `fetch`、`AbortSignal.timeout`) |
| dsh 核心 | 在 `0.1.5-rc.1` 系列的 web profile 上验证;其它版本自测 |
| dsh profile | `web`(`$DSH_HOME/profiles/web`) |
### 2.5 `dsh-dependencies` 声明
除 README 外,`package.json` 里也带了机器可读的声明:
```json
"peerDependencies": { "dsh-mobile": "*" },
"dshDependencies": {
"core": ">=0.1.5-rc.1",
"plugins": [{ "name": "dsh-mobile", "required": true, "provides": "..." }],
"programs": [{ "name": "SakuraFrp 启动器 / frpc", "required": true, "platform": "Windows" }],
"services": [{ "name": "dsh-mobile-lan-watchdog.service", "required": false }],
"platform": ["Linux", "WSL2"], "node": ">=20"
}
```
---
## 3. 安装
### 3.1 一键安装(推荐)
```bash
git clone https://github.com/ankhishtar2-lang/dsh-sakurafrp.git
cd dsh-sakurafrp
bash scripts/install.sh # 拷贝到 profile + 注册 bundles + 静态自检
systemctl --user restart dsh-web-profiled.service # 生效(由你手动执行)
```
`scripts/install.sh` 只做三件事:把包拷进 `$DSH_HOME/profiles/web/node_modules/`、
把 `dsh-sakurafrp` 追加进 profile `package.json` 的 `dsh.profile.bundles`、做静态自检
(host 是否有 `apply` 导出是硬红线:缺了会让**整棵插件树 boot 失败**)。它也**不会**替你重启。
### 3.2 手动安装
```bash
cp -r dsh-sakurafrp "$DSH_HOME/profiles/web/node_modules/"
# 再把 "dsh-sakurafrp" 加进 $DSH_HOME/profiles/web/package.json 的 dsh.profile.bundles
dsh --profile web --dump-config >/dev/null && echo 配置可解析
systemctl --user restart dsh-web-profiled.service
```
### 3.3 前置:配置 dsh-mobile 的移动网关
在 `$DSH_HOME/profiles/web/cordis.patch.yml` 里加一段(**值都要换成你自己的**):
```yaml
- id: mobile-access
config:
initiallyEnabled: true
maxDevices: 1 # 只信任一台设备就写 1
pairingTtlMs: 300000 # 配对窗口 5 分钟
listenHost: 127.0.0.1
listenPort: 33782 # 必须等于 SakuraFrp 隧道的远程端口
publicAuthorities:
- node.example.com:33782 # 手机访问时用的 Host,必须与隧道入口一致
- yd.node.example.com:33782 # SakuraFrp 运营商前缀节点(可选)
allowedCidrs: ['127.0.0.0/8', '::1/128'] # 隧道过来的源地址恒为回环
upstreamOrigin: http://127.0.0.1:3080
tls:
mode: provided
certFile: !!js dshHomePath('mobile-access/tls/server.crt')
keyFile: !!js dshHomePath('mobile-access/tls/server.key')
pairingCaFile: !!js dshHomePath('mobile-access/tls/ca.crt')
instanceId: '<你的自签 CA 的 SHA-256 指纹,小写去冒号>' # 必须与 CA 指纹完全一致
```
> ⚠️ **两个会让你起不来 dsh 的坑(作者踩过)**
> 1. `pairingCaFile` 配了却漏了 `instanceId`:插件会拿 `sha256(stateFile 路径)` 当 instanceId,
> 永远对不上 CA 指纹 → `start()` 抛错 → loader fail-fast → **整个 dsh web 起不来**。
> 2. `maxDevices` 在「配对准入」时只数活跃设备,但在**启动加载**时数**全部记录(含已撤销)**。
> 撤销过设备后忘记清理、又把 `maxDevices` 改小,重启即崩。本仓库的
> `scripts/dsh-mobile-security-audit.sh` 会自动清理已撤销记录。
---
### 安装方式补充:从 npm 安装(可选)
本包的 `package.json` 已按 npm 发布要求准备好(去掉 `private`、用 `files` 白名单控制内容)。
发布到 npm 后即可用 dsh 自己的命令安装(`dsh plugin add` 本质就是 `pnpm add`):
```bash
dsh plugin --profile web add dsh-sakurafrp
```
自己发布(**需要你自己的 npm 账号**;`scripts/publish.sh` 不接触也不保存任何 token):
```bash
npm login
bash scripts/publish.sh --dry # 自检 + 列出将要发布的文件,不发布
bash scripts/publish.sh # 真正发布(改过代码要先升 version,同版本号不可覆盖)
```
> 发布后请同步更新 README 与上游收录表的描述,保持「描述属实」这一条成立。
## 4. 配置(环境变量)
不想改 profile 的话,全部行为都可以用环境变量覆盖(在 systemd unit 或启动脚本里设):
| 变量 | 默认值 | 说明 |
|---|---|---|
| `DSH_SAKURA_ORIGIN` | `https://node.example.com:33782`(占位符) | **必填**:你自己的公网入口。占位符不改的话探测与配对都会失败 |
| `DSH_SAKURA_AUTHORITIES` | 空 | 额外探测的公网权威(逗号分隔),主入口自动取自 origin;SakuraFrp 的 yd./dx./lt. 前缀节点写在这里 |
| `DSH_SAKURA_SELFHEAL_MS` | `15000` | 网关自愈轮询间隔 |
| `DSH_SAKURA_PROBE_MS` | `60000` | 公网节点探测间隔 |
| `DSH_SAKURA_LOG` | `$HOME/dsh/memory/dsh-sakurafrp.log` | 事件日志(自动以 0600 创建) |
| `DSH_SAKURA_WATCHDOG_UNIT` | `dsh-mobile-lan-watchdog.service` | 外部守护服务单元名 |
| `DSH_SAKURA_SUPPRESS_FILE` | `$DSH_HOME/mobile-access/.gateway-user-disabled` | 「用户已关闭手机访问」的抑制标记路径;存在时自愈与 watchdog 都让路 |
| `DSH_SAKURA_ADMIN_TOKEN` | 未设 | 设了之后变更类路由的标识头必须是这个值(把公开的 `1` 换成真凭证) |
| `DSH_SAKURA_ALLOW_REMOTE_ADMIN` | 未设 | 设为 `1` 时**取消「仅电脑端」闸门**(见第 6 节,不建议) |
> 📌 **与本机实际运行版本的差异**:作者本机那份把 `DSH_SAKURA_ORIGIN` 默认值写成了自己的隧道入口
> (省去配置环境变量)。为了不公开个人隧道地址,**本仓库把默认值改成了占位符**
> `https://node.example.com:33782`,并且默认不再探测 yd./dx./lt. 前缀节点。
> 因此从本仓库安装后,**必须**显式设置 `DSH_SAKURA_ORIGIN`(必要时再设 `DSH_SAKURA_AUTHORITIES`)。
---
## 5. HTTP 路由
全部注册在 dsh web(默认 3080)上,并且**只接受回环来源**(非回环一律 403):
| 方法 | 路径 | 仅电脑端 | 说明 |
|---|---|---|---|
| GET | `/dsh-sakurafrp/status` | 否(手机端会脱敏) | 聚合状态:网关(含 `suppressed`)/探测/设备/守护/自愈计数 |
| POST | `/dsh-sakurafrp/control` | ✅ | `{running: bool}` 开关手机访问;关闭会写抑制标记,开启会清除 |
| POST | `/dsh-sakurafrp/pair` | ✅ | 申请配对窗口,返回 `pairUrl` + 内联 `qrDataUrl` |
| GET | `/dsh-sakurafrp/qr.svg` | ✅ | 最近一次二维码 SVG(调试用;前端已改用内联 data URL) |
| POST | `/dsh-sakurafrp/revoke` | ✅ | `{deviceId}` 撤销设备 |
| POST | `/dsh-sakurafrp/devices/reset` | ✅ | 清空全部配对设备(向 dsh-mobile 发送 `confirm: true`) |
| POST | `/dsh-sakurafrp/selfheal` | ✅ | 立刻执行一次「确保网关在跑」;用户已关闭则跳过 |
| POST | `/dsh-sakurafrp/probe` | ✅ | 立刻探测全部公网节点 |
| POST | `/dsh-sakurafrp/watchdog` | ✅ | `{active: bool}` 启停外部守护服务 |
---
## 6. 安全说明(请务必读)
### 6.1 为什么有「仅电脑端」闸门
`dsh-mobile` 的移动网关是一个**带特权上游 cookie 的反向代理**:它把除 `/mobile-access/*` 以外的
一切路径转发到 dsh web(3080),并附上它用 DSH 进程内 launch token 换来的认证 cookie。
后果是:**任何已配对设备都能触达 3080 上注册的全部插件路由**,而来源地址看上去都是 `127.0.0.1`。
作者在安全审查中实测过(用真实配对设备从公网):
| 请求 | 加固前 | 加固后 |
|---|---|---|
| `POST /dsh-sakurafrp/pair` | 200(手机可**自行铸造配对码**、再拉设备进来) | 403 `desktop_only` |
| `POST /dsh-sakurafrp/watchdog {"active":false}` | 200(守护服务被停 → 自愈失效) | 403 `desktop_only` |
| `POST /dsh-whale-tools/restart` | 200(**dsh 真的被重启**) | 403 `desktop_only` |
**闸门怎么工作(三道,全部要过)**:
1. 来源地址必须是回环;
2. `Origin` / `Referer` 若存在,必须是回环(`127.0.0.1` / `::1` / `localhost`)。
网关转发时会用白名单**重建请求头**(只放行
`accept* / content-* / if-* / range / user-agent / origin / sec-fetch-site`)——
注意 `origin` **是**在白名单里的,所以手机经隧道访问时服务端能看到的 `Origin` 是
**隧道域名**,而不是回环;恶意网页则是攻击者域名。两者都会被拒。
这两个头由浏览器自己填,页面脚本改不了;
3. 请求头 `x-dsh-sakurafrp-desktop: 1`。设了 `DSH_SAKURA_ADMIN_TOKEN` 时,
这里必须是那个令牌的值(真凭证)。
**它是什么、不是什么(请如实理解)**:第 3 条那个头是**区分器**,不是认证——它的值公开写在
本 README 与源码里。真正的边界是:3080 只绑 `127.0.0.1` + 网关会剥掉自定义头 +
**能在本机发请求的进程本就拥有同等用户权限**(可以直接调 loopback 管理接口)。
所以本闸门挡的是浏览器跨站请求与经公网隧道的设备,不是把本机进程沙箱化。
- 逃生舱:`DSH_SAKURA_ALLOW_REMOTE_ADMIN=1` 会让手机端也能管理(**等于把上面三道全关掉**,不建议)。
- 本插件**不接触任何密钥**:SakuraFrp 访问密钥始终只在 Windows 启动器手里;
前端只处理**一次性、短时有效**的配对 token。
- 事件日志以 `0600` 创建,内容不含密钥与 token。
### 6.2 安全边界在哪
**真正的安全边界是「设备配对」本身**:配对成功后,那台设备就拥有完整的 DSH 会话
(能驱动 agent、读写会话)——这是移动访问的功能本意。本插件收紧的是**控制面**
(铸造配对码、开关网关、撤销设备、停守护、重启 dsh),不是会话面。
手机丢失/借人 ≈ 把 DSH 交出去;要收回就在电脑端面板撤销该设备。
---
## 7. 仓库脚本
| 脚本 | 用途 |
|---|---|
| `scripts/install.sh` | 幂等安装(拷贝 + 注册 bundles + 静态自检) |
| `scripts/uninstall.sh` | 卸载(移除 bundles + 删目录) |
| `scripts/dsh-mobile-lan-watchdog.sh` | 移动网关守护:发现 `running:false` 就拉起(systemd user 服务用) |
| `scripts/dsh-mobile-security-audit.sh` | **可复跑的审查脚本**:匿名面 / 文件权限 / 「已配对设备越权」实测。⚠️ **它并不是完全非破坏性的**:为了实测越权,它会**真的在本机配对一台临时设备**,因此会改写 `~/.dsh/mobile-access/devices.json`(0600)。脚本结束时会清理该记录,但**中途被打断会留下一条有效设备记录**——跑完请用面板确认设备列表,或直接跑 `devices/reset` |
| `scripts/legacy/dsh-mobile-pair.sh` | 早期命令行配对脚本,已被插件面板取代,留作备用 |
审计脚本用法:
```bash
bash scripts/dsh-mobile-security-audit.sh [公网origin]
# 关键项全过 → 退出码 0;发现越权/暴露 → 1
```
---
## 8. 已验证 / 已知限制
**已在作者本机验证**
- 4 个公网节点探测全部 `200 {"ok":true}`,延迟 161–235 ms(真实隧道,自签证书校验关闭);
- 真实配对 → 带会话拉取手机端首页 200;手机端所有控制面请求 403;
- host 半桩测试 37 项 + 浏览器半 bundle 复核;`dsh --profile web --dump-config` 退出码 0;
- 网关被静默关闭后 15 秒内自愈(多次实测)。
**已知限制**
- 只做「管理壳」,**不提供隧道**:隧道断了要去看 SakuraFrp 启动器/面板;
- 依赖 `dsh-mobile` 的内部管理接口,**上游改路径就会失效**(接口非公开契约);
- 公网探测会周期性向公网节点发 HTTPS 请求(每 60 秒 4 条,可 `DSH_SAKURA_PROBE_MS` 调大或关掉插件);
- 面板为原生 DOM + 内联样式,深色/浅色主题都做了变量兜底,但主题差异大的皮肤下观感可能一般;
- 手机端只能读状态,这是**设计**而非缺陷。
---
## 9. 卸载
```bash
bash scripts/uninstall.sh
systemctl --user restart dsh-web-profiled.service
```
卸载本插件**不会**影响:SakuraFrp 隧道、`mobile-access` 配置与自签证书、设备状态文件、
外部守护服务。这些要单独处理(见第 3.3 节的配置段)。
---
## 10. 目录结构
```
dsh-sakurafrp/
├── package.json # dsh.bundle / dsh.client / peerDependencies / dshDependencies
├── cordis.patch.yml # loader 条目:id=sakurafrp, name=dsh-sakurafrp
├── lib/
│ ├── index.js # host 半:9 条路由 + 网关自愈(尊重用户关闭)+ 公网探测 + 桌面闸门
│ └── client.js # 浏览器半:头部手机按钮 + 管理面板 + 每秒倒计时
├── scripts/ # install.sh / uninstall.sh / publish.sh / 守护 / 安全审计 / 早期配对
├── LICENSE # MIT
└── README.md
```
---
## 11. 许可
MIT © 2026 ankhishtar2-lang —— 见 [LICENSE](LICENSE)。
再次提醒:**vibe coding 产物,未经人工逐行审计,按「原样」提供,风险自负。**
Install
dsh plugin --profile web add github:ArimaKana-Akane/dsh-sakurafrp
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-sakurafrp from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.