Skip to content

dsh-plugin-mesh

Verified

dsh-plugin-mesh · v0.5.2 · MIT · Web UI

DSH Mesh:局域网自动发现同网段的 DSH 实例、显示每台机器上报的能力,并双向派发任务;可用内置的用户态 Tailscale 节点跨互联网组网。Discovers other DSH instances on the LAN, shows the capabilities each one reports, and dispatches tasks between machines in both directions, with an optional userspace Tailscale n

Install

dsh plugin add dsh-plugin-mesh

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Tags

Creators

Readme

dsh-plugin-mesh

🌐 想先看看它长什么样、怎么用? 打开展示页:https://peng456.github.io/dsh-plugin-mesh/ (面向使用者、从零讲起;本 README 面向开发与排障。)

🤖 给 agent / 英文读者:llms.txt(机器可读的事实:工具、配置项与默认值、端口) · AGENTS.md(改这个仓库前的硬约束与坑)· README.en.md(英文快速上手)· DESIGN.md(设计原理与复盘)

🛠 想参与开发:CONTRIBUTING.md(提交规范、会被挡下的写法)—— 合并进 main 就会自动测试、发 npm 包和 GitHub Release,你不需要碰版本号。

本 README 是完整中文文档,含设计取舍与踩坑记录,比较长。

DSH 的 Mesh 插件:启动后自动发现同一网段的其他 DSH 实例,在侧栏提供一个 操作页面显示各机器的在线状态,并支持把任务派给对端的 agent 执行。 不在同一网段也行 —— 内置用户态 Tailscale 节点,可跨互联网自组网,功能同级。

用起来就是你要的那个效果:在 A 机器上开发,把「部署到 B」这件事丢给 agent, 你只下大命令。

零配置:固定端口 + 自动扫描 + ping,装上就能发现彼此,不需要配对码。 首次打开 Mesh 页面会让你给这台机器起个名字(默认填主机名),别的机器就靠这个名字找你。 跨互联网只要在界面上填一次 Tailscale 授权码,不需要装 Go、不需要装 Tailscale 客户端。

┌──────── A 机器 (DSH) ────────┐          ┌──────── B 机器 (DSH) ────────┐
│  mesh 插件  :45917            │          │  mesh 插件  :45917            │
│   ├ UDP 组播心跳  ◀───────────┼──────────┼──▶  被动发现                  │
│   ├ 主动扫本网段  ────────────┼──────────┼──▶  兜底发现                  │
│   ├ ping 保活     ────────────┼──────────┼──▶  在线状态                  │
│   └ 侧栏 Mesh 页面            │          │                              │
│                               │───任务──▶│  唤醒本机 agent 执行          │
│                               │◀──回派───┤                              │
└───────────────────────────────┘          └──────────────────────────────┘

进程版本 ≠ 磁盘版本 → [WARN] 新代码没被加载,检查 plugins 挂载

(不依赖 node:优先 python3 解 JSON,退回到正则抠 version 字段。)

⚠️ 只许比 version;两条轴互补,缺一不可

① 绝不能把 capabilities 拉进这个比对"求更严"。 能力块(gpu / runtimes / capabilitiesAt)是热刷新的,与重启完全解耦: 实测同一个 pid 在不重启的情况下 capabilitiesAt 一路前进,而 version 纹丝不动。 把能力字段纳入比对会造成反向误诊。对端给过的反例:它进程是 0.3.27 旧码, 但"磁盘代码采出的 gpu"与"进程报的 gpu"都是 null,两者相等 → 检查会 误判成"新码已加载"。能力相等什么都证明不了。

② 两条轴管的是不同的失败:

轴 时机 兜住的失败 工具
① 可加载性 重启前 半加载:index.js/package.json 已是新版,但新 import 的文件没拷全 → 重启即崩 check-loadable.mjs(真 import 一次入口)
② 进程自述版本 重启后 加载了但没重启:磁盘换了、进程还是旧码 /mesh/v1/health 的 .version

version 检查覆盖不到半加载:那种情况下它读到的是新版本号(常量来自 index.js,而 index.js 已经换过了),于是正确地报新版本、检查放行, 可进程 import 时直接 ERR_MODULE_NOT_FOUND 崩。 所以只做 ② 会漏半加载,只做 ① 会漏"加载了但没重启"。

组播的作用范围:比"局域网"更窄

239.255.42.99:45892 是我在私有组播段里挑的(239.0.0.0/8 是 RFC 2365 定义的 管理范围,相当于组播里的"私有地址"),TTL 设为 1,所以:

  • ✅ 同一网段(同一个二层广播域)内有效
  • ❌ 路由器不转发 —— 跨子网/VLAN 到不了,那种情况要配 scanRanges
  • ❌ AP isolation 会连它一起挡(那是把客户端之间的所有流量都隔离,不只是组播)
  • ⚠️ 部分交换机的 IGMP snooping 在没有 querier 时会丢掉组播
  • ⚠️ Windows 防火墙默认拦入站组播,要放行 UDP 45892

正因为组播有这么多坑,才加了扫描那条路:只要 TCP 通,扫描就能发现。


互联网自组网(Tailscale / tsnet)

局域网那套跨互联网时不成立:没有组播,也没有可扫的网段。所以插件内置了一个 用户态 Tailscale 节点,把发现与派发延伸到互联网,功能与局域网完全同级 —— tailnet 上找到的机器和局域网找到的机器出现在同一个列表里,mesh_run 用法一模一样。

终端用户零依赖

不需要装 Go,也不需要装 Tailscale 客户端。插件首次启用时按平台下载一个预编译的 sidecar 二进制(约 9MB gzip),缓存在 ~/.dsh/mesh/bin/。

为什么必须自己编译:tsnet 是 Go 库(tailscale.com/tsnet),npm 上没有可用的绑定 (实测 tsnet 包是个无描述无仓库的空壳,tailscale 包是占位符)。所以由 scripts/build-sidecar.sh 交叉编译各平台产物,随 GitHub Release 发布。

已发布的平台:

平台 适用 ELF 形态
darwin/amd64 Intel Mac Mach-O
darwin/arm64 Apple Silicon Mac Mach-O
windows/amd64 Windows PE
linux/amd64 Linux x86_64、WSL 全静态 ET_EXEC
linux/arm64 ARM64 Linux、树莓派 全静态 ET_EXEC
android/amd64 x86_64 Android / 模拟器上的 Termux PIE(ET_DYN) + /system/bin/linker64
android/arm64 aarch64 Android / 手机上的 Termux PIE(ET_DYN) + /system/bin/linker64

Android 是独立平台,不是 linux 的别名(真机实测,2026-10)。

第一层坑是平台识别:Node 在 Termux 下 process.platform 返回的是 android, 不是 linux。早期 detectTarget() 只认 darwin/win32/linux,于是抛 「不支持的系统:android」。这个抛发生在 binaryInfo()→binaryPath() 里,而 binaryInfo() 又被 TsnetNode.status() → /mesh/api/state 一路调用,于是整个 Mesh 页面变成 500,表现为「本机 undefined · undefined:undefined」且互联网组网 卡片消失。这也是为什么 binaryInfo() 被改成绝不抛异常。

⚠️ Android 端必须手动加白名单,否则会被系统杀掉

实测结论,不是猜测。 一台 Android 设备:up 7 days、MemAvailable 2.6 GB、 load average 0.00 —— 设备和内存都好好的,但它的 sidecar 进程 etime 只有 2 分钟。DSH 进程在反复被重建。

不是 OOM,是 Android 的省电策略在主动冻结/杀掉后台 App。 对 mesh 的后果:

机制 触发条件 后果
Doze(打盹) 屏幕关闭一段时间、未充电、未移动 暂停后台网络访问与定时任务 → 心跳发不出、ping 收不到,对端看它就像掉线
App Standby App 长期未被使用 同上,限制更严
厂商后台清理 小米 / 华为 / OPPO / vivo 自有策略 直接杀进程,比原生激进得多 → 会话被中途打断

会话被打断不算故障:manager 会走"原会话已结束 → 新建会话并重绑同一会话键" 这条路径(见 设备身份与多通道设计.md)。只是调试噪声变多 —— 同一个会话键 可能先后绑定过几个 session。

必须手动加白名单,路径因厂商而异:

厂商 设置路径
原生 Android 设置 → 应用 → DSH → 电池 → 不受限制
小米 / Redmi 设置 → 应用设置 → 应用管理 → DSH → 省电策略 → 无限制;再到「自启动」打开;最近任务里长按卡片加锁(防一键清理)
华为 / 荣耀 设置 → 电池 → 启动管理 → DSH → 关掉「自动管理」→ 手动勾选 允许自启动 / 关联启动 / 后台活动
OPPO / vivo / 一加 设置里找「允许后台运行」「自启动」「电池优化白名单」

代价是多耗一点电;长期插电或常驻在线的设备几乎无感。

注意:持久化 instanceId 已经让"重启"不再产生重名(见「身份与命名」), 所以白名单要解决的是会话连续性,不是重名。

第二层坑更隐蔽:当时把 android 映射到 linux 资产就以为完事了,但 Android 从 5.0 起只允许 PIE 可执行文件,而给桌面准备的是全静态 ET_EXEC,真机 exec 阶段会被 bionic 直接拒掉,原话就一句(下载、解压、sha256 全部正常,一跑就退):

error: "/data/data/com.termux/files/home/.dsh/mesh/bin/sidecar-v1/dsh-mesh-tsnet" has unexpected e_type: 2

与 glibc/bionic、CGO_ENABLED=0 与否、权限、防火墙都无关,就是 ELF 类型不对。 所以 android/* 单独出一份产物:-buildmode=pie 拿到 ET_DYN,再用 -ldflags=-I /system/bin/linker64 把解释器指到 bionic。

只加 -buildmode=pie 不够:Go(实测 go1.25.5)内部链接器产的 linux/arm64 PIE 带 PT_INTERP=/lib/ld-linux-aarch64.so.1,这个路径在 Android 上不存在,内核会以 ENOENT 收场。必须显式改解释器路径。好在纯 Go 的 PIE 里 DT_NEEDED 为空、动态 符号表无未定义符号、重定位全是 R_AARCH64_RELATIVE(实测 113089 条),bionic 加载时只需刷一遍相对重定位,不依赖任何 libc —— 正是 Android 自家 PIE 的形态。

桌面 linux/* 则刻意保持全静态 ET_EXEC:给它加 PIE 会把解释器钉成 /lib64/ld-linux-x86-64.so.2,Alpine 这类 musl 发行版没有该路径,等于用一点 安全加固换掉一整类系统。构建脚本里对 android 产物有硬校验(ET_DYN + 解释器 + TLS 对齐,任一不满足就直接拒绝发布),免得这些坑再溜出去。

第三层坑是 TLS 段对齐。Go 的内部链接器产 PIE 时把 PT_TLS 的 p_align 硬编码成寄存器宽度(cmd/link/internal/ld/elf.go: ph.Align = ctxt.Arch.RegSize, arm64 上是 8),而 ARM64 bionic 要求 ≥64:

error: "…/dsh-mesh-tsnet": executable's TLS segment is underaligned:
       alignment is 8, needs to be at least 64 for ARM64 Bionic

外部链接器(NDK 的 lld)会按符号对齐算这个值,用内部链接器就得自己补,所以 scripts/fix-android-tls-align.py 在编译后把 p_align 抬到 64(x86_64 抬到 16)。 这是安全的:TLS 块按 variant II 布局、块尾贴着线程指针,p_align 只影响块起点 留多少填充,符号相对块尾的偏移不变,Go 运行时算出来的地址一样。

第四层坑是 argv 错位。Android/Termux 上 exec 经常经 /system/bin/linker64 转发(linker64 <程序> -hostname …,这就是为什么一个没有 PT_INTERP 的静态 ET_EXEC 也会收到 bionic 的报错 —— 内核根本不会去调 bionic)。bionic 只在 __libc_init 里把 linker64 <程序> 这两段摘掉,而纯 Go 程序从不调用 __libc_init:它直接按内核初始栈读 argc/argv,于是 os.Args[1] 是程序路径。 flag.Parse() 一遇到非选项就停止解析,后面所有开关被静默丢掉,看起来像 "收不到任何参数"(-version 和故意写错的 -zzz 得到同一句报错;对照组 node/bash 正常,因为它们是 C 程序、走 __libc_init)。所以 sidecar/main.go 的 normalizeArgs() 在 flag.Parse() 前把第一个以 - 开头的元素之前的东西丢掉。

第四层坑是 Android 不给 netlink。修好前两层后 sidecar 能启动了,但死在 tsnet 自己的启动阶段:

{"event":"error","message":"tsnet: route ip+net: netlinkrib: permission denied","where":"start"}

Android 对 app UID(untrusted_app)关掉了 NETLINK_ROUTE 与 /proc/net(同一身份下 /proc/net/route、/proc/net/if_inet6 都是 EACCES,而 bionic 的 getifaddrs 正常), 而 Go 标准库的 net.Interfaces() 在 linux(android 也算 linux)上一律走 netlink, 于是 netmon.New() 直接失败。Tailscale 源码里对这件事有原话(net/netmon/state.go): "It exists because Android SDK 30 no longer permits Go's net.Interfaces to work"。 官方 Android app 靠 Java 注册接口表,裸二进制没有 Java 可依赖,所以官方提供了 tailscale.com/feature/androidbin(包注释第一句就是"makes Tailscale binaries work when run as raw (non-GUI app) executables on Android, such as under Termux"): 注册 netmon 的接口回退(合成一个 android 接口,地址靠向内核要一条出站 UDP 路由拿 源地址,不发包)、拉进 androiddns、并把 CA 根证书指到 /system/etc/security/cacerts。最后这条是我们还没走到但一定会踩的坑:GOOS=linux 的二进制在 Android 上系统证书池是空的,控制面 TLS 会全挂。 tsnet 默认不引入它("tsnet apps and other programs opt in with a blank import"), 所以 sidecar 加了 sidecar/androidbin_linux.go(必须带 linux 构建约束,否则 mac/win 构建会报 "build constraints exclude all Go files")。它在普通 Linux 上是 空操作(onAndroid() 为假)。

顺带说明为什么不改成 GOOS=android:androidbin 官方就支持 GOOS=linux 形态 ("static linux binaries are commonly run under Android kernels"),而我们的 GOOS=linux 产物已在真机上实测能加载、能解析参数;换 GOOS 会同时动掉运行时这一层, 收益不明。

第五层坑是 DNS。前四层修完后 sidecar 能起来、控制 API 可用,但节点一直停在 NeedsLogin、authUrl 为空。原因:Android 没有 /etc/resolv.conf,Go 的纯 Go 解析器读不到配置就回退到 127.0.0.1:53(没人在听),于是每个查询都失败;而 curl 走的是 bionic 的 getaddrinfo(经 netd),所以"网络明明是通的"。 tailscale 的 dnsfallback(控制面解析兜底)走 DoH 打 dns.google/quad9, 在国内多半也不通,最后就表现为"控制面怎么都连不上"。

tailscale 为此提供了 feature/androiddns(androidbin 会自动拉进来):它不读系统 属性,而是直接跟 netd 的 /dev/socket/dnsproxyd 说协议 —— 和 bionic 的 getaddrinfo 同一条路。它更好(顺带拿到 per-network 配置与 Private DNS), 所以它接管了就不动。但它的前提是 dnsproxyd 连得上,连不上时它静默什么都不做。

于是 sidecar/androidnet_linux.go 补三件事:① 自检 —— 先解析 controlplane.tailscale.com(建链最需要的那个名字),结果写进 /status 的 diag; ② 兜底 —— 解析失败时扫系统属性找 DNS(刻意不只看 net.dns1..4:实测那台华为 ROM 上它们全空,只有 net.rmnet0.dns1 有值,所以按"键以 dnsN 结尾"匹配); ③ 再兜底 —— 属性里的都不通就用公共解析器(223.5.5.5 / 119.29.29.29 / …)。

候选集要逐个自检着换,不能在 Dial 里轮询:UDP "连接"一台不可达的服务器不会 失败(只会让查询超时),Dial 永远返回第一个候选、后面的根本轮不到。自检本身跑在 "控制 API 已监听之后、srv.Up() 之前",所以不会拖慢握手。

注意:androiddns 不读系统属性,它走 /dev/socket/dnsproxyd(与 bionic 的 getaddrinfo 同一条路)。二进制里 net.dns* 那些串是 Go 运行时的符号名 (net.dnsReadConfig、net.dnsStreamRoundTrip…),跟 Android 属性无关。

真正的坑在解析器对象上(真机日志定位):tailscale 的控制面解析不走 net.DefaultResolver —— controlclient 用的是 net/dnscache 的 Forward,而 dnscache 在包初始化时自己 new 了一个 &net.Resolver{PreferGo: …} (dnscache.go 的 single),和 net.DefaultResolver 是两个对象。于是无论 androiddns 还是我们,只要改的是 net.DefaultResolver,控制面就一点没沾上: 自检(走 net.DefaultResolver)通过,TryLogin(走 dnscache.Forward)却报 failed to resolve "controlplane.tailscale.com",随后退到 DERP bootstrap (裸 IP,被出口代理挡掉)→ 节点永远 NeedsLogin。

所以 applyResolver() 把 dial 原地装到两个解析器上(net.DefaultResolver 与 dnscache.Get().Forward,后者是导出字段),而自检也改成打在 controlResolver()(即控制面真正用的那个)上 —— 否则就是自欺欺人。 另外只能原地改字段、不能替换 net.DefaultResolver 指针:有代码在 init 时 就把那个指针存下来了,替换指针它们看不到。

安装时机同样致命:ensureDNS() 必须早于任何可能启动 tsnet 的代码。真机 diag 的先后顺序是 [52] StartLoginInteractiveAs → [61] trying bootstrapDNS → [62] dns: …,即第一次 TryLogin 已经失败,我们才装好解析器。原因是 watchStatus 每秒调 currentStatus(),而它调 srv.LocalClient() —— tsnet 的 LocalClient() 内部就调 s.Start(),整套后端(含第一次登录)会在 一秒内起来。等装好时早已退到 DERP 裸 IP 的 bootstrap,在那台机器"按域名放行" 的出口代理下必然全灭。现在 ensureDNS() 在 sc 建好之后立刻执行,位于 go sc.watchStatus(ctx) 之前;srv.Up() 之前还会再打一行 dnsUpCheck() 复查 控制面解析器的 Dial 状态与自检结果,下次一眼能看出有没有抢跑。

顺带把 tsnet 的日志口径放宽了:原来只放 tsnet: 前缀且含 error/fail/warning 的行, 结果 DNS 卡住时 stdout 上一行线索都没有、只能靠 /status 猜。现在全部进 diag (容量 40→200 行),其中建链关键路径(control/dns/login/auth/derp/magicsock)实时推 到 stdout。

另外两件诊断工具(0.3.9 起):dnscache.SetDebugLoggingEnabled(true) 会把 tailscale 自己的 dnscache 决策日志("using fallback resolver due to error/no returned IPs"、"not using bootstrap DNS: DNS was trustworthy")经 tsnet 的 Logf 流进 diag; probeControlPlane() 异步复刻"解析(ip/ip4) → 逐地址直连 :443 → TLS(SNI=域名) → GET /key?v=148"整条链,每步单独记一行。这样下次就能一锤定音地分清是解析失败、 连接失败还是 TLS 失败 —— 前几轮只能看到"退到 DERP 裸 IP",分不清这三者。

0.3.10 起再加一道真正的兜底:feature/dnsresolvecache。这是 tailscale 为 "开机时 DNS 坏掉"设计的机制(dnscache 在主解析失败或返回空时会去读 <dir>/dns-<host>.json),但官方明说 tsnet 默认不链接它、"tsnet apps must both blank-import this package and configure the cache directory themselves"。所以 sidecar 现在 blank import 它、用 dnscache.HookSetCacheDir 把目录指到 <stateDir>/dnscache,并在自检通过后把我们自己解析出来的 controlplane.tailscale.com 写进 dns-controlplane.tailscale.com.json。 这样即使控制面的主解析再出问题,它也能从落盘缓存拿到 IP,不必退回 DERP bootstrap。 自己写这份文件是安全的:里面只是本进程刚解析并自检通过的 IP,连接时的证书校验 照旧(最坏情况与今天一样连不上)。格式已用 tailscale 自己的读入口 (dnscache.HookLookupDiskCache)在本机验证过。

最后一跳的根因(0.3.11):tailscale/net/netns 的 linux 与 android 实现是 两个文件 —— netns_linux.go 的约束是 linux && !android,netns_android.go 是 android。GOOS=linux 编出来的产物走 linux 版 controlC:它对每个 socket 调 netmon.DefaultRouteInterface() 拿默认路由接口名,而 Android 上 /proc/net/route 与 netlink 都是 EACCES,于是退化去绑 "lo"(或设 SO_MARK),拨号因此出不去。 真机判据很干净:sidecar 内部用裸 net.Dialer 直连 192.200.0.x:443 成功、完整 HTTPS 拿到 200,而 tailscale 自己的拨号路径全灭 —— 两者唯一的差别就是这个 Control 钩子。netns_android.go 的 controlC 在没有 Java 回调时什么都不做, 所以 android 资产改成 GOOS=android 编译(CGO_ENABLED=0 即可,不需要 NDK): 顺带把 netmon 换成 android 的 polling 实现、x509 走 goos.IsAndroid 分支读 /system/etc/security/cacerts,而且该构建不发 PT_TLS 段,TLS 对齐那个坑也消失。

只堵平台映射还不够:#spawnOwn() 的第一行 binaryInfo()/looksExecutable() 在任何 try 之外,而 start() 是先把 phase 置成 "starting" 才调它 —— 异常 reject 到 调用方只留一条 warning,phase 就永远停在 "starting",页面显示「正在组网中」永不结束 (真机实测:POST /mesh/api/tsnet/start 返回 {"ok":false,...,"node":{"phase":"starting"}})。 所以 start() 与 #spawnOwn() 各自再兜一层:任何早期失败都先落 phase:"error" 再抛。 自动重启路径(#onExit 的退避重试)直接调 #spawnOwn()、不经过 start(),这层兜底是它唯一的保障。

另外 #spawnOwnInner() 现在无条件调 ensureBinary():只有它知道 PINNED_CHECKSUMS 变没变(比对解压时旁挂的 source-sha256 标记)。原先外面套了 !installed || !looksExecutable(),于是 sidecar 重新发布时那一步永远走不到, 老装置会一直复用旧二进制,只有手动删缓存才会下载(真机实测踩到)。

补新平台只要跑一次 scripts/build-sidecar.sh --targets ... --publish。

构建戳取的是 sidecar 源码的 git 提交哈希,而不是时间戳。因为 Go 构建是可复现的 (实测同输入连编两次逐字节相同),所以未改动的平台重编后 sha256 不变,补新平台 不会让已装的机器白重下 8.5MB;用时间戳则每次发版全平台哈希都变。

用法

侧栏 Mesh → 「互联网组网」:

  1. 打开开关
  2. 粘贴 Tailscale 授权码(tskey-auth-...)—— 只需输入一次,存进 state.json (权限 0600),重启不用再填
    • 还没有授权码:先注册 / 登录 Tailscale(免费),再到 https://console.tailscale.com/admin/settings/keys 生成。
    • 生成时必须勾 Reusable —— 一次性码只能用一次,重启就会报 invalid key。
    • 后台开了设备审批的,还要勾 Pre-approved。
    • 插件里的「设置」直接给了这个地址,不用自己去后台翻。
  3. Headscale 用户另填「控制服务器地址」,官方用户留空

节点名自动取机器名(立朋-mac → dsh-mac,文超-win → dsh-win),不用你填。

安全边界

进 tailnet 要授权码,进局域网要物理接入 —— 两者信任模型不同。所以留了 tsnetPermissionPreset:留空 = 与局域网完全一致(默认),想只收紧互联网那一侧 就填 workspace-write 之类。因为一旦进网,对方就能对你所有机器下 mesh_run。

网络要求(实测本机)

目标 结果
controlplane.tailscale.com ✅ 0.65s
login.tailscale.com ✅ 0.63s
derp1/2/3.tailscale.com ✅ ~0.8s
github.com 直连 ❌ HTTP 000

所以 sidecar 二进制默认走镜像下载(ghfast.top → gh-proxy.com → … → 直连兜底), 实测 1.24MB/s,9MB 约 8 秒。完整性校验用的是钉在插件源码里的 sha256 (lib/tsnet/checksums.js),而不是从同一个 release 拉 checksums.txt —— 否则恶意镜像可以同时替换两者,校验就成了摆设。


身份与命名

每台机器有三个标识,分工不同:

标识 是什么 稳定性 用途
instanceId 持久 UUID(存在 state.json) 同一 profile 内不变 内部用:去重、识别"这条是我自己发的"
machineName 人可读的名字,如 客厅的Mac 持久保存 你和 agent 用它指代这台机器
fingerprint OS 机器 ID 的 sha256 截断(见 lib/identity.js) 按物理机不变 只用来认领:state.json 丢了/插件重装后,认出"这还是同一台机器"

为什么身份用 UUID 而不是 MAC:一台笔记本同时有 Wi-Fi / 有线 / Thunderbolt 网桥 / VPN,换网 MAC 就变;iOS / Android / macOS 现在默认按 SSID 使用随机 MAC;Android 上 也拿不到稳定 ID。用 MAC 当身份会让"切个网"变成"多出一台新设备"。指纹经过 sha256 截断后才上网,不可逆推硬件 ID。

名字解析优先级:插件配置 machineName > 用户首次启动时填的 > 系统主机名。

首次启动时(还没命名过、落在主机名上)Mesh 页面会主动问你一次:输入框预填主机名, 点「就这个」直接用,或改成你想要的名字。名字存在 $DSH_HOME/mesh/state.json (和配对码同一个文件,权限 0600),改完立刻重新广播,对端不用重启就能看到。

指纹认领的边界(同一台机器上跑多个 profile)

指纹是按物理机算的,所以同一台 Mac 上的每个 DSH profile 指纹都相同。认领 因此有个必须的条件,否则两个都活着的实例会互相删掉对方(连自己都被删):

情况 是否认领
指纹相同 + 机器名相同 ✅ 认领(重装/丢 state.json,名字一般不变)
指纹相同 + 机器名不同 + 旧记录已离线 ✅ 认领(重启残留,正是认领要解决的场景)
指纹相同 + 机器名不同 + 两台都在线 ❌ 不认领(同机两个 profile,各自合法)
指纹不同 ❌ 绝不认领(不同物理机,重名交给重名告警)

重名怎么办

名字是人手填的,可能撞。页面会检测并告警,派发时也不会随便挑一台:

  • 从 A 看:mesh_run 的 peer 填 winbox,只要在 A 的上游对端里唯一就能用(自己不算)
  • 从第三台机器看:如果它发现有两台都叫 winbox,mesh_run 会拒绝并列出候选, 让你改用 <ip>:<port> 精确定位

地址也接受实例 id 或 ip:port,所以撞名时总有办法精确指定。


安装

两台机器都装。装完必须重启 DSH Desktop 才会加载。

方式 A:从 npm 装(推荐,一条命令)

dsh plugin add dsh-plugin-mesh                  # desktop profile ← 要应用自带的终端
dsh plugin --profile web add dsh-plugin-mesh    # 其它 profile ← 普通终端即可

为什么推荐这条:它不碰 git clone —— 而 github.com 的 git over https 在国内 会被 Connection reset(首次安装最常见的失败原因)。npm 走的是 registry,国内镜像 (npmmirror)有同步。实测在全新 profile 上 4.8 秒装完,48 个文件。

⚠️ 只有 desktop 这一个 profile 例外:dsh plugin --profile desktop ... 在 普通终端里会被拒绝(bin.js:29:profile "desktop" is managed exclusively by the Electron application),必须用应用自带的那一个(托盘/菜单 → 终端)。 其它 profile 用普通终端就行(上面第二条已实测)。

方式 B:一键脚本(从源码装)

git clone https://github.com/peng456/dsh-plugin-mesh.git
cd dsh-plugin-mesh
./install.sh

脚本做两件事,且可重复执行(已装就跳过):

  1. 把插件复制到 ~/.dsh/profiles/desktop/node_modules/dsh-plugin-mesh/
  2. 把它加进该 profile package.json 的 dsh.profile.bundles (改前自动备份为 package.json.bak)

常用选项:

./install.sh --dry-run              # 只打印会做什么,不落盘
./install.sh --profile web          # 装到别的 profile
./install.sh --home /path/to/.dsh   # 指定 DSH_HOME
./install.sh --uninstall            # 卸载(删目录 + 从 bundles 移除)

方式 C:手动装

# macOS
cp -R dsh-plugin-mesh ~/.dsh/profiles/desktop/node_modules/dsh-plugin-mesh

# Windows (PowerShell)
Copy-Item -Recurse dsh-plugin-mesh "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-plugin-mesh"

然后在 ~/.dsh/profiles/desktop/package.json 的 bundles 里加上 "dsh-plugin-mesh":

{
  "dsh": {
    "profile": {
      "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-plugin-mesh"],
      "patchReload": "live"
    }
  }
}

⚠️ 必须是真实目录,不能是软链。 DSH 的 profile 包解析器会拒绝 realpath 落在 <profile>/node_modules 之外的包(module-resolution-*.js 里那条判断, 目的是防止陈旧的外部状态覆盖已封装安装)。把插件软链到你的工作目录会在启动时 报 ERR_MODULE_NOT_FOUND。install.sh 已经处理好了这一点。

方式 D:官方命令 + 本地路径

托盘/菜单 → 终端:

dsh plugin add /path/to/dsh-plugin-mesh

注意 dsh plugin --profile desktop 在普通终端里会被拒绝 (bin.js:29:profile "desktop" is managed exclusively by the Electron application), 必须用应用自带的那个终端。

升级已安装的插件

scripts/release-plugin.sh --publish 发完版会自动同步到局域网镜像并打印一段可直接 粘贴到对端的命令。对端(尤其是 Android,见下面的 /tmp 坑)执行后必须重启 DSH 才会加载新版本。

手工步骤就是:下载 → 解压 → 备份 → 覆盖 → 重启。

# 对端可写临时目录(Android 用 $TMPDIR,见下)
T="${TMPDIR:-$HOME/tmp}"; mkdir -p "$T"
curl -fL -o "$T/mesh-0.3.26.tar.gz" http://192.168.1.6:8791/dsh-plugin-mesh-0.3.26.tar.gz

mkdir -p "$T/mesh-new" && tar xzf "$T/mesh-0.3.26.tar.gz" -C "$T/mesh-new"
grep -m1 '"version"' "$T/mesh-new/package.json"     # 应显示目标版本

PLUGIN="$HOME/.dsh/profiles/<profile>/node_modules/dsh-plugin-mesh"
cp -R "$PLUGIN" "$PLUGIN.bak"
rm -rf "$PLUGIN/lib" "$PLUGIN/client" "$PLUGIN/scripts" "$PLUGIN/sidecar"
cp -R "$T/mesh-new/lib" "$T/mesh-new/client" "$T/mesh-new/scripts" "$T/mesh-new/sidecar" "$PLUGIN/"
cp "$T/mesh-new/package.json" "$T/mesh-new/cordis.patch.yml" "$PLUGIN/"
# 然后重启 DSH

先 rm -rf 那几个子目录再拷,是为了不让上一版删掉的文件残留下来。

⚠️ 重启前必须先验「可加载」,不能只验「文件对得上」

升级是先落盘、后重启两段式的,中间有一段半态窗口:磁盘已是新版本, 进程还是旧版本(VERSION 是启动时读入的常量,不跟着磁盘变)。这个窗口里如果 新代码新增了一个 import 而落盘漏了那个文件,重启会在 DSH 加载插件时当场崩。

两种常规自检都兜不住这个失败模式:

自检 为什么兜不住
find + 逐字节 cmp 被漏掉的文件根本不在磁盘上,没有"不一致"可比对,只会报全部一致
node --check <file> 只做单文件语法解析,不解析 import 图,缺文件照样通过

唯一能精确复现"重启即崩"的,是从安装路径实际 import 一次入口:

PLUGIN="$HOME/.dsh/profiles/<profile>/node_modules/dsh-plugin-mesh"

# ① 一条命令版(不依赖安装路径里有脚本,任何机器都能跑)
node -e "(async()=>{const m=await import('file://$PLUGIN/lib/index.js');console.log('OK',Object.keys(m))})()"

# ② 仓库里的封装命令(打印版本/exports,退出码可直接当门禁)
npm run check:deploy -- "$PLUGIN"          # 严格模式:面向"落盘后的安装路径"
npm run check:loadable                     # 仓库自检:忽略"缺运行时依赖"这一类误报

退出码 0 = 可加载,非 0 = 别重启,先回滚。check:loadable 已挂在 npm run check 末尾,发版时就会跑到。

⚠️ check-loadable.mjs 不能 cd "$PLUGIN" 再跑 —— 升级只覆盖 lib/client/sidecar/scripts,但你手上这份安装目录未必带 scripts/(我的就没有), 于是 node scripts/check-loadable.mjs 会报 Cannot find module。那是"脚本不在 那儿",不是半态。所以校验脚本要放在仓库里、把安装路径当参数传;或者用 上面 ① 那行 node -e,它不需要安装目录里有任何脚本。

⚠️ 校验仓库副本时缺 @deepseek-ai/dsh-brand 是正常的(那是 DSH 运行时提供的 依赖,只有安装路径才解析得到),别误判成半态 —— 这就是 check:loadable 要加 --allow-missing-runtime-dep 的原因。

这一步是拿真实事故换来的:0.3.28 新增了 lib/prompt.js 并由 index.js 顶层 import 它,磁盘一度处于"新 index.js + 缺 prompt.js"的半态 —— cmp 全绿、 --check 全绿,差点带着它重启。由对端 hos 复现并给出这条做法,实测有效。

回滚就是 cp -R "$PLUGIN.bak" "$PLUGIN",再重跑一次上面的校验。

⚠️ Android 上没有 /tmp(根文件系统只读,mkdir /tmp 报 Read-only file system)。给 Android 的命令必须用 $TMPDIR,否则你会看到 curl: (23) client returned ERROR on write —— 一个看起来像网络故障、 实际是"盘不存在"的错误。

Windows 防火墙

New-NetFirewallRule -DisplayName "DSH Mesh" -Direction Inbound -Protocol TCP -LocalPort 45917 -Action Allow
New-NetFirewallRule -DisplayName "DSH Mesh (multicast)" -Direction Inbound -Protocol UDP -LocalPort 45892 -Action Allow

只放行 TCP 也行 —— 那时靠主动扫描发现,不依赖组播。

使用

侧栏会出现一个 Mesh 图标(三个节点连成的网状),点开是主页面:

  • 本机信息:机器名、地址、端口、当前发现方式与配对要求
  • 发现的机器:每台一行,在线绿点/离线灰点,显示地址、系统、用户、最后心跳; 每行「探测」和「派发任务」
  • 扫描本网段 / 重新广播 按钮
  • 派发任务:选目标机器 → 填任务 → 派发
  • 任务记录:收到 / 派出的任务
  • 配对码(折叠,默认不需要)

agent 多出四个工具:

工具 作用
mesh_peers 列出发现的机器
mesh_scan 主动扫本网段,找还没发现的机器
mesh_run 把任务派给某台机器的 agent(异步)
mesh_tasks 查看收到/派出的任务记录

所以你可以直接说:

「看看局域网里有哪些机器,然后让 B 把 web 服务重新部署一下,跑完告诉我。」


配置项

默认已经可用。要改就在 profile 的 cordis.patch.yml 里按 id 覆盖。

⚠️ patch 是「整体替换 config」,不是按 key 合并。 @deepseek-ai/cordis-plugin-include 对非 insert patch 走的是浅赋值 (target[key] = value,见该包 src/index.ts L121-124)。config 只是 一个 key,所以只写 enabled: / tsnetEnabled: 这几行,会把原本写在 这一块里的 permissionPreset / workspaceRoot / port / 限速参数 全部抹掉,回落 schema 默认值。

实测过的后果(对端 文超-win 2026-10-16 拦下):Windows 上 permissionPreset 回落 workspace-write(L139)+ workspaceRoot 回落 '' → 主目录(L141),ACL 沙箱直接 fail-closed (Windows ACL temp root must be outside the workspace), 该机所有入站 mesh 任务的命令执行全挂 —— 包括刚从 tailnet 进来的, 因为 tsnet 读的是同一个值(tsnetPermissionPreset || permissionPreset,L692/L714)。

所以:目标机器上已经有 - id: mesh 时,永远不要整块替换,只合并要改的键。 用 scripts/mesh-tsnet-merge.py(文本级键合并,保注释/缩进/行尾/BOM,幂等):

python3 scripts/mesh-tsnet-merge.py --file <cordis.patch.yml> \
    --download-source http://192.168.1.6:8791/            # 预览,不写盘
python3 scripts/mesh-tsnet-merge.py --file <cordis.patch.yml> \
    --download-source http://192.168.1.6:8791/ --apply    # 落地,自动留 .bak

参考全量键列表(照抄前请先确认目标机上没有既有的 - id: mesh 自定义):

- id: mesh
  config:
    machineName: ''            # 对端列表里的名字,留空用主机名
    port: 45917                # 固定端口
    useMulticast: true         # 组播被动发现
    scanOnStart: true          # 启动扫描(带退避重试)
    scanIntervalMs: 60000      # 周期扫描;0 = 关闭
    scanRanges: []             # 额外网段,如 ["192.168.1.0/24"];空则扫本机 /24
    scanPorts: []              # 除本机端口外额外要扫的端口
    scanConcurrency: 64
    scanTimeoutMs: 800
    pingIntervalMs: 10000      # ping 保活;0 = 关闭
    announceIntervalMs: 5000
    peerTtlMs: 20000           # 多久没消息算离线
    requirePairing: false      # 见下(也可以用页面上的开关,不必写配置)
    agentPreset: standard
    permissionPreset: workspace-write
    workspaceRoot: ''
    # ── 互联网自组网(Tailscale / tsnet)──
    tsnetEnabled: false        # 也可以在 Mesh 页面上的开关里改(存 state.json)
    tsnetAuthKey: ''           # 建议只在页面填;配置里填则优先且钉死
    tsnetControlUrl: ''        # 留空 = 官方 Tailscale;Headscale 填自己的
    tsnetHostname: ''          # 留空 = 由机器名派生(立朋-mac → dsh-mac)
    tsnetDownloadSources: []   # 自定义下载源前缀,可用 {url} 占位符
    tsnetPermissionPreset: ''  # 留空 = 与 permissionPreset 完全一致;填了就只对 tailnet 生效
    # ── 循环限速 ──
    maxWakesPerThread: 30      # 每窗口内最多自动唤醒几次;0 = 不限制
    threadWindowMs: 3600000    # 窗口长度(毫秒),默认 1 小时
    threadCooldownMs: 5000     # 两次自动唤醒的最小间隔;0 = 不限制
    minTaskChars: 24           # 正文短于此长度只入箱不唤醒(挡 ping/ack)

循环限速(重要)

为什么需要:入站任务会自动新建会话并立刻让 agent 开跑,而每条消息都由 远端决定。两个 agent 互相回执时会形成自持的 ping-pong —— 实测一个互回执线程 在 2.5 小时内自动开了 54 个会话、约 800 万 token,全部是重复探索。

四道闸门,任何一道触发都只入箱、不唤醒(任务仍可在 /mesh/v1/tasks 和 Mesh 页面看到,不会被丢弃):

闸门 默认 作用
minTaskChars 24 太短的内容不开会话
寒暄识别 内置 ping / ack / 收到 / 好的 等整条即寒暄的,不开会话
threadCooldownMs 5s 同一 thread 不会连续秒回,管突发
maxWakesPerThread 30 同一 thread 在 threadWindowMs 内的唤醒上限,管持续速率

maxWakesPerThread 是滑动窗口限速,不是终身配额:只统计最近 threadWindowMs(默认 1 小时)内的唤醒。窗口内的唤醒点会随时间自然过期, 该 thread 自动恢复,不需要重启。

⚠ 限速只会让循环变慢,不会让它停。 如果循环的自然节奏低于上限, 窗口永远填不满,限速就永不触发 —— 实测事故节奏约 21 轮/小时,即使把上限 压到 10/小时,24 小时也仍会放行 276 轮。要真正止住请把上限设得低于 循环节奏,或配合会话复用把单价降下来。

撞上限时交给用户决定

限速触发时不静默丢弃,而是在 Mesh 页面出现一张提示卡:

⚠ 有 thread 撞到唤醒上限,已暂停自动唤醒
  diet-reconcile   上限 30 次 / 60 分钟 · 已挡下 12 条
  [提高上限并放行最新一条]  [忽略]
  • 提高上限 → 把该 thread 的上限翻倍(至少 +10),并立刻放行最新那一条 (对话的最新一环)。其余被挡的仍留在收件箱 —— 一次性全放会造成新突发, 反而失去限速的意义。覆盖值落盘,重启后仍有效。
  • 忽略 → 只清提示,上限不变,任务仍留在收件箱。

对应的 API:POST /mesh/api/throttle/raise {thread, cap?}、 POST /mesh/api/throttle/dismiss {thread};被挡的 thread 列表在 GET /mesh/api/state 的 throttled 字段里。

账本与覆盖值落盘在 ~/.dsh/mesh/threads.json,重启不清零 —— 否则对端 只要等到你重启就能重新灌满窗口。

另外两点配套改动:

  • 同 thread 复用会话:同一 thread 的后续消息注入同一个会话,不再每条 从零重新探索。这是省 token 的最大一项,也让 agent 记得前几轮结论。
  • noWake: true:发送方可以显式声明"这条只回执,别开跑"。mesh_run 工具已带这个参数 —— 纯回执/确认请用它,否则会凭空开一个 agent 会话。

已知绕过:限速只认 thread 名字,而名字由发送方自由填写。对端若每次都 换 thread 名或不带 thread,就不受限速约束(此时只有 threadCooldownMs 的 突发冷却在起作用)。它是刹车,不是安全边界。


安全

默认不需要配对 —— 同一内网里任何发现到本机的 DSH 都能派发任务。 对本机 agent 而言,这些任务会被当成一条用户消息(默认权限 workspace-write: 能读写工作区、能执行命令)。

这在自己家的可信内网里是合理取舍,也正是你要的"零配置"。但如果你在 不完全可信的网络里(合租、公司、宿舍、有访客设备、IoT 设备多),请打开:

- id: mesh
  config:
    requirePairing: true

然后在两台机器的 Mesh 页面里互相对一下配对码(首次启动自动生成,页面上可直接复制)。 打开后所有控制接口都要过配对码,用 timingSafeEqual 比较。

其他:

  • 发现是公开的:同一网段任何人都能看见机器名、地址、系统、用户名。这是有意的取舍。
  • 明文 HTTP:局域网内可接受;跨不可信网络请套 SSH 隧道或 TLS 反代。

已知限制

  1. 同 thread 复用会话,但跨 thread 不复用:同一 thread 的后续消息会注入 同一个会话;没带 thread 的任务仍然每次新建。(早于 2026-09 的版本无此复用, 每条消息都新建会话。)
  2. 去重只在进程生命周期内(内存),重启后同 id 会重复入箱。
  3. 没有离线排队:对端离线时 mesh_run 直接失败,不暂存重投。 (要这个能力可以用工作区里的 lanmsg.py。)
  4. 同机多实例:第二个实例端口被占会退回随机端口,主动扫描扫不到它(组播仍能发现)。 真要同机多实例,给它们配不同 port 和对方的 scanPorts。
  5. sidebar.panellist 槽位官方插件没人用过(翻遍 58 个客户端 bundle,只有官方侧栏 自己声明它)。我按源码契约接的,实测注册和渲染都正常,但不如 sidebar.footer.action 那样有第三方先例。
  6. Android 上 sidecar 需要专用资产,且下载源仍是拦路虎:detectTarget() 现在把 android 映射到 android 资产(PIE + /system/bin/linker64,见上文 Termux 的坑)。 原先那份全静态 linux/arm64 在真机上 exec 会被 bionic 拒掉(has unexpected e_type: 2), 所以老版本插件 + 新 sidecar 或新插件 + 老 sidecar 都不行,两边要一起升。 剩下的真正拦路虎是下载源:实测那台 Android 上 ghfast.top / gh-proxy.com / ghproxy.net / github.com 全部不通,只能靠同网段的镜子(见下一节)。装好之后 sidecar 本身可以正常跑。 平台不支持不该拖垮页面:binaryInfo() 只返回 {installed:false, error} 从不抛异常, TsnetNode.status() 外再兜一层 try/catch,/mesh/api/state 在 Android 上仍为 ok:true。

逃生通道:用同网段的机器当 sidecar 镜子

当一台机器的所有下载源都不通(被墙、DNS 污染、无外网)时,把已经拿到资产的机器 当镜子即可 —— 校验和钉在 checksums.js 里,明文 http 改不了内容:

# 在能拿到资产的机器上(仓库内 dist/sidecar/ 已有构建产物时)
python3 ../lan-sidecar-mirror.py --host 0.0.0.0 --port 8791   # 见仓库外的 lan-sidecar-mirror.py
# 先验证这台机器「按对方的平台」真的装得上(不碰本机真实缓存)
node scripts/verify-lan-mirror.mjs --url http://192.168.1.6:8791/ --platform android --arch arm64

对端(被墙的那台)在插件配置里加上:

"tsnetDownloadSources": ["http://192.168.1.6:8791/"]

💡 只有 tsnetDownloadSources 是「非写进 config 不可」的键。 tsnetEnabled / tsnetAuthKey / tsnetControlUrl / tsnetHostname 都有 state.json 兜底(effectiveTsnetConfig,L328-339):在 Mesh 页面填一次就行, 存 state.json(0600),重启不用再填,也不碰 patch 文件 —— 自然绕开了 「整块替换」这个坑。tsnetDownloadSources 没有兜底(manager.js 只读 this.#config),所以它必须合并进 config。

于是新机器接入 tailnet 的最小变更是:

  1. Mesh 页面开开关 + 填授权码(→ state.json,无需重启、无需改文件)
  2. 只有需要局域网镜子时,才用 scripts/mesh-tsnet-merge.py 合并 tsnetDownloadSources 这一个键

历史教训:曾给对端发过一份只含 5 个 tsnet 键的「贴进去就行」片段, 对端识别出它会抹掉自己 22 个键里的 permissionPreset: danger-full-access 而改成手工合并,避免了一次入站任务全挂。别再发片段式模板。

源按顺序试:用户配置的源 → 内置镜像 → 直连 GitHub;http:// 与 https:// 都支持(只 import node:https 的话,http 源会直接 ERR_INVALID_PROTOCOL, 而被迫用局域网源的机器恰恰最没法自己排查)。镜像按「完整 GitHub URL 拼在前缀后」 的形状请求,所以镜子只要忽略路径、按文件名给文件就行。


开发与测试

# 搭隔离环境(把插件真实复制进 profile,不碰你的 desktop profile)
MACHINE=macbox PORT=45917 MULTICAST=false SCAN_PORTS="[45918]" \
  ./dev-setup.sh meshtest /tmp/mesh-dsh

# 启动(dev-boot.mjs 复现真实主进程的模块解析行为)
DSH_HOME=/tmp/mesh-dsh ELECTRON_RUN_AS_NODE=1 \
  "/Applications/DSH Desktop 2.app/Contents/MacOS/DSH Desktop" \
  dev-boot.mjs --profile meshtest --port 8877

dev-setup.sh 支持的环境变量:MACHINE PORT MULTICAST SCAN_ON_START SCAN_PORTS PING_INTERVAL_MS REQUIRE_PAIRING。

为什么需要 dev-boot.mjs:desktop-cli.js:78 只在 app.asar 内才安装 profile 包解析器,而这个安装是未打包的 Resources/app。真实 Electron 主进程 (main.js:4478)是无条件安装的,所以真实应用没这个问题,只有 CLI 路径有。

发现模块可以脱离 DSH 单独测:

node lib/_selftest-discovery.mjs alpha 45900 9000   # 两个进程同时跑可验证互相发现

参与开发与发版

合并进 main 就是发版:CI 会跑全量测试、按提交信息决定版本号(0.x 阶段 feat: / fix: 都只升 patch,只有 breaking 才升 minor;1.0 之后恢复标准 semver; docs:/chore:/ci: 不发版)、发 npm 包(带 provenance 签名)、打 plugin-v<版本> tag,并建带源码包的 GitHub Release。贡献者不需要手改版本号、打 tag 或跑 npm publish —— 手改反而会和流水线打架(npm run check 会在两处版本号不一致时 直接拦下)。

想知道 看
提交信息怎么写、PR 会被哪些门禁检查 CONTRIBUTING.md
流水线怎么运作、失败了怎么重跑、怎么撤回 docs/RELEASE.md
改代码前必须知道的硬约束与坑 AGENTS.md
版本升级 / 新功能后的回归测试怎么跑 docs/TEST-CASES.md
功能需求、架构逻辑、多版本通信协议、跨 IDE 实现契约 docs/PLUGIN-SPEC.md

本地预演"如果现在合并会发生什么"(不联网、不改文件):

npm run release:plan

文件结构

dsh-plugin-mesh/
├── package.json           # dsh.bundle.patch + dsh.client 声明
├── cordis.patch.yml       # 只插入本插件(含默认配置)
├── lib/
│   ├── index.js           # 主机侧:发现编排 + 对端 HTTP + agent 工具 + UI 接口 + 入站唤醒
│   ├── discovery.js       # UDP 组播被动发现 + 对端注册表 + 过期清理 + 指纹认领
│   ├── identity.js        # 持久 UUID 身份 + OS 机器指纹(只用于认领)
│   ├── conversation.js    # 会话键 + 回信地址(两端各自算,不协商)
│   ├── capabilities.js    # 能力采集/净化/摘要(Placement 第 1 步)
│   ├── scan.js            # 主动扫描(固定端口 + CIDR 展开)
│   ├── tsnet/             # 用户态 tailscale 节点(sidecar 下载/管理/桥接)
│   └── _selftest-*.mjs    # 回归脚本(默认不碰网络)
├── sidecar/               # Go 源码:tsnet 用户态节点
├── client/
│   └── client.js          # 浏览器侧:手写 bundle,无需打包工具
├── dev-setup.sh           # 隔离测试环境搭建
├── dev-boot.mjs           # 带模块解析器的测试启动器
├── DESIGN.md              # 设计原理与复盘(为什么这么设计、踩过哪些坑)
├── Placement设计.md        # 步骤 ③ 设计 + 实施记录
├── 设备身份与多通道设计.md   # 步骤 ① 设计
└── README.md