远程桌面内部实现
内部向文档,不进文档站导航与索引(见
docs-nav.ts的UNLISTED_SECTION_IDS)。用户向内容在 远程桌面文档。
被控端网关是独立仓库的 Go 程序,交付名 p2pee-remote;Go module 路径是 gitee.com/xnkyn/p2pee-remote-desktop(仓库私有,嵌入方配 GOPRIVATE=gitee.com/xnkyn/* + git 凭据后 go get,或直接拷源码)。本仓库(控制台 + 站点)只负责三件事:下发额度与信令凭证、服务入口页、托管前端静态资源。
本仓库里的落点
| 位置 | 职责 |
|---|---|
server/core/ResUserInfo.js | 唯一写 data.remote 的地方:webhost / peers / connect / min_version / notice |
server/api/p2p/connect.js | 免 JWT 的凭证接口(网关用它换 MQTT 账号 + ICE + remote 额度),内部会 isVipCheckExpire |
server/api/user/clients.js | 给控制台列 & 前缀被控端,并算出 url(双重 base64 凭证) |
server/routes/r/[...].js | 入口页 GET /r/<凭证>/:校验凭证 → 读 assets:page 的 remote.html 原样返回 |
server/page/remote.html | 入口页模板(手写在 server/page 根,不在 PAGE_THEMES 体系里,也没有 .htm 源) |
public/remote/ | 前端静态资源整棵树(app.js / boot.js / pipe.js / rdp.js / vnc.js + vendored noVNC) |
server/api/mqtt/auth.js | EMQX HTTP 鉴权里那条 remote/${user}/#(action: 'all')ACL |
app/pages/dashboard/remote.vue | 控制台「远程桌面 → 在线设备」页 |
public/robots.txt | Disallow: /r/(地址即凭证,与 /n/ 同理) |
服务端下发的字段
网关只读 GET /api/p2p/connect?key= 响应里的这些:
| 字段 | 用途 | 未下发时 |
|---|---|---|
data.Broker / data.user / data.password | MQTT s信令地址与账号(账号同时是 topic 前缀与访问凭证原料) | 缺失即启动失败 |
data.is_vip | 1 才允许 -mark 固定 markID | 按非会员,强制随机 1000-9999 |
data.remote.webhost | 访问地址前缀(可带 /r/,网关会剥掉再统一补) | 回落内置 https://p2pee.cn |
data.remote.connect | 最大 -connect 目标数 | 保底 2 |
data.remote.peers | 最大并发桌面会话数 | 保底 2 |
data.remote.min_version | 客户端最低版本,低于则终止启动 | 回落顶层 version2;两边都缺则跳过校验 |
data.remote.notice | 提示文案:只在网关启动时打到控制台(黄色高亮)并进运行日志,不进页面协议 | 不打印 |
data.serve.stun / turn / Username / Credential | ICE 服务器;-turn 可覆盖 TURN,STUN 不可 | 无 STUN/TURN,仅本机候选 |
当前数值(server/core/ResUserInfo.js):peers / connect = 会员 10 / 免费 2,min_version = 1.0.0。额度不复用 proxy 的 target / peers(两个产品的订阅语义不同);<= 0 与缺字段同义,都落保底值 —— 要禁止某个账号用远程桌面,请让接口返回 code != 0,不要靠下发 0。
webhost是硬编码的https://p2pee.cn/r/,不读runtimeConfig.public.siteUrl—— 换域名时这一行要一起改(/r/的页面路由本身按站点走,不受影响)。- README 里约定的
t=3应用标识服务端没实现:server/api/p2p/connect.js不读t(未知参数被静默忽略),三个业务共用同一份data。真需要按应用分发额度时在这个路由里加分支。 - 访问地址只认
data.remote.webhost:顶层 proxy 时代的proxyhost/ 形如http://p2pee.cn/t/的顶层webhost一律不读,回落过去等于把用户导到打不开的地址。 - 凭证校验失败的码不一样:Go 侧 README 写的是 403,本站
server/routes/r/[...].js抛的是 404「地址无效」(刻意与"受限/额度用完"的 429 区分开,不给探测者"这里有个地址"的信号)。另外 README 说「尾斜杠必须带」,本路由用/^\/r\/(.*)$/捕获后replace(/^\/+|\/+$/g,''),带不带都能打开 —— 控制台列表拼出来的地址就不带尾斜杠。
入口路由与凭证编码
https://p2pee.cn/r/<凭证>/
└─┬─┘└──┬──┘
固定段 双重 base64(user:password:markID) 的 URL-safe 形式
- 凭证段按最后两个冒号切(口令与 markID 之间、user 与口令之间),外层同时兼容标准 base64 与无 padding 的 URL-safe 形式;含
/的整段会被路径捕获一起取走([...]匹配多段),所以路径段不能被 CDN 规范化。 password段就是users.id + 40000,与/api/user/clients.js和ResUserInfo同源;校验不过(格式非法 / 用户不存在 / 已注销 / 口令段不等)一律 404「地址无效」,不查 EMQX、不暴露"这个地址存在但没开"。- 用户查询走
userCache,键remoteUser:<user>,TTL 60s。 - 页面自身不调本站接口(信令走 MQTT、桌面走 P2P),所以入口路由刻意不注入
__ICE_SERVERS__/__MAX_UPLOAD_SIZE__,也不写cookie_page—— 那套注入是middleware/3.t.js给/t/**分享页用的。 - 响应带
X-Robots-Tag: noindex, nofollow;网关自带的-debug静态服务同样校验凭证,但不通过返回 403。
静态资源映射
仓库目录与站点 URL 一一对应,整份拷走即可部署,不需要改名:
web/remote/ → <站点根>/remote/ app.js、boot.js、pipe.js、rdp.js、vnc.js、vnc/ 整棵树
web/index.html → <站点根>/r/<凭证>/ 入口页(一条通配路由把 /r/ 下的请求都指向这份 HTML)
index.html里引用资源一律写/remote/xxx绝对路径:页面自己在/r/<凭证>/下,相对./app.js会解析成/r/<凭证>/app.js而 404。- 模块之间的
import "./boot.js"不用改:ES module 的相对说明符按模块自身 URL 解析。 - Go 侧
gorw/web.go用go:embed内嵌同一份资源,-debug本机服务按同样的对应关系发,形态与线上一致(还带 ETag / no-cache)。
信令 topic、ACL 与 clientId
顶级命名空间 remote/,与穿透的 p2p/{user}/ 分开,便于服务端按命名空间独立审计:
remote/{user}/{markID}/offer | answer | ice | gw-health | gw-probe
EMQX 授权里那条与穿透同构(server/api/mqtt/auth.js):
{ permission: 'allow', action: 'all', topic: `remote/${user.user}/#` }
action: 'all' 同时覆盖发布与订阅 —— 网关和页面用的是同一个 MQTT 账号(都来自 Key 换来的凭证),一条规则就够。缺这条规则时网关会在订阅阶段直接失败并打印 topic,不会静默连上却收不到 offer。
MQTT clientId 形状(README 约定 vs 本仓库现状,待对齐):
| 端 | README 约定 | 现状实现 |
|---|---|---|
| 网关 | &{主机名}_{GOOS}_{毫秒尾数}_{markID} | 与约定一致(& 前缀 = 服务端设备) |
| 浏览器页面 | remoteweb_{markID}_{浏览器}_{系统}_{随机6位} | public/remote/rtc.js 实际写的是 "&web_" + markID + "_" + randomID(4) |
- 它会被
server/api/user/clients.js当成服务端设备算出markid(取末段抹非数字),于是app/pages/dashboard/remote.vue里serverList(startsWith('&'))会把浏览器标签页也收进被控端列表,而clientList(/^remote/i)匹配不到任何一条 → 「连接端」子表恒空。 - broker 侧无法按前缀区分「哪个项目 / 哪一端」,也就没法按页面端限流。
改法二选一:把 rtc.js 的 clientId 换成 remote_web_ 前缀(同时保持 remote.vue 的 /^remote/i 筛选),或者反过来把 clientList 的判据改成 &web_。注意远程桌面的连接端只按 remote 前缀筛,不能收全部非服务端设备 —— web_ / p2pweb_ 与 & 被控端的 markid 号段可能同值,混进来就是错关联(见 agents.md 的 clientid markid 取法)。
前端链路细节
| 细节 | 值 / 位置 |
|---|---|
| MQTT broker 端点 | 按页面协议固定,页面无配置入口:https → wss://app.navjs.cn/mqtt,http → ws://1.94.136.247:8083/mqtt(boot.js) |
| 浏览器侧 ICE | 只配 STUN:stun:vip.navjs.cn:3478、stun:stun.navjs.cn:3478(boot.js 写死),中继由网关侧 TURN 兜底 |
| DataChannel | 发 offer 之前创建两条:control(JSON)+ desktop(协议字节),SDP 一次谈定、不重新协商 |
| 控制协议 | 收 config | ready | error | closed,发 select | release(app.js ↔ gorw/session.go) |
| 分片 | 每条 DC 消息 = [1 字节续传标志][≤16KB 载荷],0x01 表示还有后续分片;浏览器侧 pipe.js 的 CHUNK_SIZE = 16*1024 与 gorw/pipe.go 对称 |
| 重组上限 | 16MB,超限整帧作废(宁可丢一帧也不把残帧交给协议层错位解析) |
| 发送流控 | 水位高 4MB 暂停 / 低 1MB 恢复;关闭前最多冲刷 1 秒 |
| 为什么要两条通道 | VNC 通道一旦交给 noVNC,它的 onmessage 被 RFB 解析器独占;双通道顺带解决时序 —— 桌面通道在 select 之前不注册任何业务回调,预置口令与就绪信号都走控制通道 |
| 信令时序 | ANSWER_TIMEOUT 12000、MAX_RETRIES 3、RETRY_DELAY 3000、PROBE_TIMEOUT 3000、PROBE_TRIES 3、DISCONNECT_GRACE 8000(rtc.js) |
| 能力位 | RDP_CAPS = { sas:true, clipboard:true, resize:true },VNC_CAPS 全 false → app.js 的 applyCaps() 决定工具栏按钮 |
| iframe | boot.js 的 embedded() 直接拒绝:一条 P2P 连接会占一份并发额度 |
协议层范围
RDP:TPKT / X.224 / TLS / CredSSP(NTLMv2 + MIC + v6 哈希绑定)、标准安全层(服务端证书解析、会话密钥推导、RC4、MAC、4096 包密钥更新、自动登录)、MCS / GCC / 能力协商 / 激活、静态虚拟通道(rdpdr / cliprdr / rdpsnd / drdynvc / display)、24bpp 位图与 Interleaved RLE、fastpath 分片重组、脏区合并 + 跨帧 zlib 流、鼠标 / 键盘(扫描码 + 扩展键)/ 中文 IME(Unicode 键盘事件)/ 远端光标、剪贴板文本双向、SAS、会话中切分辨率(失败自动带新尺寸重连)。未实现:音频播放、rdpdr 文件重定向、多显示器、RemoteFX / H264。
VNC:RFB 3.3–3.8,认证与编码全部由 vendored noVNC 承担,因此任意 RFB 服务皆可;画面本地缩放,不请求远端改尺寸。
系统要求 Windows 10 / Server 2016 及以上;TLS + CredSSP 与标准 RDP 安全层自动协商回退。
诊断子命令与测试
.\p2pee-remote.exe probe 192.168.10.7 # RDP 协商诊断,不需要凭据、不走 P2P 与信令
.\p2pee-remote.exe shot 192.168.10.7 -out shot.png # RDP 无头截图:出图即说明协议链路正常
.\p2pee-remote.exe -key <key> -connect rdp:192.168.10.7 -debug -listen 127.0.0.1:8081
go build -o p2pee-remote.exe . # 唯一交付物(前端已 go:embed 进去)
go build -o fakevnc.exe ./cmd/fakevnc # 测试用迷你 VNC 服务,不交付
go test ./... && go vet ./...
$env:RDP_PROBE=1; go test ./internal/rdp # 真机测试默认跳过
Windows + Git Bash 上构建要先 export GOTOOLCHAIN=go1.25.1:PATH 上的 go1.22.5 低于 go.mod 要求的 1.24.11,GOTOOLCHAIN=auto 会去下载新 toolchain 而外网被重置,命令会挂死几分钟。-race 在这台机器上对所有包都返回 exit status 0xc0000139(测试二进制起不来),不要用它做验证。
服务端侧已知限制:部分机器的「登录会话」(无凭据连接)可能 CSR 初始化失败(LocalSessionManager 事件 36,0x80004005),填凭据直连即可、重启 Remote Desktop Services 可能修复;同一台 Windows 上无人完成登录的会话会在激活后约 30 秒被主机断开(Error Info 0x0000000C,事件 40 原因代码 12)。两条限制都只在目标机上。
发行管线接入
p2pee-remote 的下载入口与其它业务共用同一套管线(Gitee 仓库 xnkyn/assets,tag p2pee-remote):
| 位置 | 接线 |
|---|---|
scripts/fetch-releases.mjs | MODULES 里的 { key: 'remote', repo: 'xnkyn/assets', tag: 'p2pee-remote' };META_FILES 把 manifest.txt / SHA256SUMS 从下载列表剔掉 |
app/utils/releases.ts | ReleaseModuleId 含 'remote';releaseDownloads() 对缺失模块返回空数组(tag 被删时不炸构建) |
content/changelog/index.ts | toRelease() 的 downloadsFrom === 'release' 白名单含 remote |
content/changelog/remote.json | "downloadsFrom": "release" —— 下载地址不手写,跟着发行版走 |
产物命名沿用穿透的规则(带架构段),1.0.0 共 11 个包:windows-{amd64,arm64,386}.exe、linux-{amd64,arm64,arm,386}、darwin-{amd64,arm64}、freebsd-{amd64,arm64},另附 SHA256SUMS 与 manifest.txt(构建元数据,不进下载列表)。抓取失败只 warn、不阻断构建,此时 releases.json 里不会有 remote 键。
两处刻意不做的事
- 设备主页
/n/<nav_code>不列远程桌面:fetchServeDevices只取@(文件共享)与#(穿透)。远程桌面的地址带凭证且每次重启可能变化,放进免登录的固定入口等于把凭证外发到公开页。 - 控制台不显示远程桌面额度:额度数值由服务端下发给网关,页面状态行里的「并发上限 N」就是它。控制台列表只提供在线被控端与地址,
/api/user/clients本身是 EMQX 的一次查询(limit: 30,全部业务共用),不要在这里加聚合统计。
