远程桌面内部实现

内部向文档,不进文档站导航与索引(见 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.jsEMQX HTTP 鉴权里那条 remote/${user}/#(action: 'all')ACL
app/pages/dashboard/remote.vue控制台「远程桌面 → 在线设备」页
public/robots.txtDisallow: /r/(地址即凭证,与 /n/ 同理)

服务端下发的字段

网关只读 GET /api/p2p/connect?key= 响应里的这些:

字段用途未下发时
data.Broker / data.user / data.passwordMQTT s信令地址与账号(账号同时是 topic 前缀与访问凭证原料)缺失即启动失败
data.is_vip1 才允许 -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 / CredentialICE 服务器;-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。

三个已知偏差
  1. webhost 是硬编码的 https://p2pee.cn/r/,不读 runtimeConfig.public.siteUrl —— 换域名时这一行要一起改(/r/ 的页面路由本身按站点走,不受影响)。
  2. README 里约定的 t=3 应用标识服务端没实现:server/api/p2p/connect.js 不读 t(未知参数被静默忽略),三个业务共用同一份 data。真需要按应用分发额度时在这个路由里加分支。
  3. 访问地址只认 data.remote.webhost:顶层 proxy 时代的 proxyhost / 形如 http://p2pee.cn/t/ 的顶层 webhost 一律不读,回落过去等于把用户导到打不开的地址。
  4. 凭证校验失败的码不一样: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)
页面端用 & 前缀带来两个后果
  1. 它会被 server/api/user/clients.js 当成服务端设备算出 markid(取末段抹非数字),于是 app/pages/dashboard/remote.vue 里 serverList(startsWith('&'))会把浏览器标签页也收进被控端列表,而 clientList(/^remote/i)匹配不到任何一条 → 「连接端」子表恒空。
  2. 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() 决定工具栏按钮
iframeboot.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.mjsMODULES 里的 { key: 'remote', repo: 'xnkyn/assets', tag: 'p2pee-remote' };META_FILES 把 manifest.txt / SHA256SUMS 从下载列表剔掉
app/utils/releases.tsReleaseModuleId 含 'remote';releaseDownloads() 对缺失模块返回空数组(tag 被删时不炸构建)
content/changelog/index.tstoRelease() 的 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,全部业务共用),不要在这里加聚合统计。

相关